Start free
Developers

Webhooks

OneForm can POST an event to your URL the moment something happens: a form is submitted, a report is approved, a call is booked. Every request is JSON and signed with your firm's secret.

Two ways to receive events

REST hooksWorkflow step
Set up byYour code or a platform such as Zapier, with an API key: POST /hooksSomeone at the firm, in a workflow: add a “Send a webhook” step
RequestPOST with the standard payloadPOST, PUT or PATCH; the standard payload, your own fields, or a JSON template; extra headers
Around itOne subscription per trigger (and optionally per form)Conditions, delays and other steps before or after
Ends whenYou unsubscribe, the key is revoked, or your URL answers 410 Gone 2 times in a rowThe workflow is switched off or deleted

Both sign their requests the same way, with the same secret, so one receiver can take both.

Triggers

TriggerFires when
form_submitted
Form is submitted
Runs as soon as someone finishes the form.
partial_submission
Form is left unfinished
Runs once when someone answers some questions, then stops for an hour without submitting. Stops if they finish later.
report_approved
Report is approved
Runs when a reviewer approves the report.
report_sent
Report is sent
Runs after the report email goes out.
report_opened
Report is opened
Runs the first time the client opens the report email or their report page (once per report).
payment_received
Payment received
Runs when someone pays for a report or a consultation (Stripe).
booking_made
Call is booked
Runs when someone books a call on the thank-you screen (once per submission).
booking_rescheduled
Call is rescheduled
Runs each time a booked call moves to a new time, by the client or your team.
booking_cancelled
Call is cancelled
Runs when a booked call is cancelled, by the client or your team.
lead_hot
Lead becomes Hot
Runs once per person, the first time their lead score reaches Hot (checked when they submit a form, or open or click an email).

GET /triggers lists the same, so a new trigger reaches your code without a change on your side. “Lead becomes Hot” only fires on plans that include Leads in full.

The request

A POST to your URL with a JSON body and these headers. Your endpoint has 10 seconds to answer; any 2xx counts as delivered.

HeaderExampleWhat it is
Content-Typeapplication/jsonAlways JSON.
X-OneForm-Eventform_submittedThe trigger.
X-OneForm-Delivery<delivery id>Unique per delivery (per run, for a workflow step). Dedupe on it.
Idempotency-Key<delivery id>The delivery id again (a workflow step sends <run id>:<step id>), for receivers that dedupe on this header.
X-OneForm-Hook<subscription id>REST hooks only: which subscription this is for.
X-OneForm-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>HMAC-SHA256 of "<t>.<raw body>", keyed with the firm's webhook signing secret.
X-OneForm-Test1Workflow test runs only.

Payload

The same shape for every trigger. report is null when there is no report yet; booking appears only with a booked call, and partial only for partial_submission. For a report sold on the thank-you screen and not yet paid for, the summary and highlights are withheld (null and []).

Sample
form_submitted
{  "event": "form_submitted",  "form": {    "id": "00000000-0000-4000-8000-000000000001",    "title": "Tax check-up",    "url": "https://app.oneform.si/f/tax-check-up"  },  "response": {    "id": "00000000-0000-4000-8000-000000000002",    "submitted_at": "2026-10-01T15:04:05.000Z",    "device": "mobile",    "lang": "en",    "ending": "default"  },  "respondent": {    "name": "Maya Lee",    "first_name": "Maya",    "last_name": "Lee",    "email": "[email protected]",    "phone": "+1 555 0100",    "company": ""  },  "answers": [    {      "id": "contact",      "question": "Your details",      "type": "contact_group",      "answer": "First name: Maya · Last name: Lee · Email: [email protected] · Phone: +1 555 0100"    },    {      "id": "income",      "question": "Roughly what was your income last year?",      "type": "short_text",      "answer": "$120,000"    },    {      "id": "goal",      "question": "What would you like help with?",      "type": "long_text",      "answer": "Lower my taxes next year."    }  ],  "fields": {    "contact": "First name: Maya · Last name: Lee · Email: [email protected] · Phone: +1 555 0100",    "income": "$120,000",    "goal": "Lower my taxes next year."  },  "variables": {},  "hidden": {    "utm_source": "newsletter"  },  "report": null}
report_approved
{  "event": "report_approved",  "form": {    "id": "00000000-0000-4000-8000-000000000001",    "title": "Tax check-up",    "url": "https://app.oneform.si/f/tax-check-up"  },  "response": {    "id": "00000000-0000-4000-8000-000000000002",    "submitted_at": "2026-10-01T15:04:05.000Z",    "device": "mobile",    "lang": "en",    "ending": "default"  },  "respondent": {    "name": "Maya Lee",    "first_name": "Maya",    "last_name": "Lee",    "email": "[email protected]",    "phone": "+1 555 0100",    "company": ""  },  "answers": [    {      "id": "contact",      "question": "Your details",      "type": "contact_group",      "answer": "First name: Maya · Last name: Lee · Email: [email protected] · Phone: +1 555 0100"    },    {      "id": "income",      "question": "Roughly what was your income last year?",      "type": "short_text",      "answer": "$120,000"    },    {      "id": "goal",      "question": "What would you like help with?",      "type": "long_text",      "answer": "Lower my taxes next year."    }  ],  "fields": {    "contact": "First name: Maya · Last name: Lee · Email: [email protected] · Phone: +1 555 0100",    "income": "$120,000",    "goal": "Lower my taxes next year."  },  "variables": {},  "hidden": {    "utm_source": "newsletter"  },  "report": {    "id": "00000000-0000-4000-8000-000000000003",    "title": "Your tax opportunities",    "status": "approved",    "summary": "Three ways to lower next year's bill.",    "highlights": [      "Max out your 401(k)",      "Open an HSA",      "Bunch charitable gifts"    ],    "review_url": "https://app.oneform.si/reviews/00000000-0000-4000-8000-000000000003"  }}
booking_made
{  "event": "booking_made",  "form": {    "id": "00000000-0000-4000-8000-000000000001",    "title": "Tax check-up",    "url": "https://app.oneform.si/f/tax-check-up"  },  "response": {    "id": "00000000-0000-4000-8000-000000000002",    "submitted_at": "2026-10-01T15:04:05.000Z",    "device": "mobile",    "lang": "en",    "ending": "default"  },  "respondent": {    "name": "Maya Lee",    "first_name": "Maya",    "last_name": "Lee",    "email": "[email protected]",    "phone": "+1 555 0100",    "company": ""  },  "answers": [    {      "id": "contact",      "question": "Your details",      "type": "contact_group",      "answer": "First name: Maya · Last name: Lee · Email: [email protected] · Phone: +1 555 0100"    },    {      "id": "income",      "question": "Roughly what was your income last year?",      "type": "short_text",      "answer": "$120,000"    },    {      "id": "goal",      "question": "What would you like help with?",      "type": "long_text",      "answer": "Lower my taxes next year."    }  ],  "fields": {    "contact": "First name: Maya · Last name: Lee · Email: [email protected] · Phone: +1 555 0100",    "income": "$120,000",    "goal": "Lower my taxes next year."  },  "variables": {},  "hidden": {    "utm_source": "newsletter"  },  "report": null,  "booking": {    "id": "00000000-0000-4000-8000-000000000004",    "title": "Tax strategy call",    "status": "confirmed",    "starts_at": "2026-10-08T14:00:00.000Z",    "ends_at": "2026-10-08T14:30:00.000Z",    "duration_min": 30,    "time_zone": "America/New_York",    "location": "https://zoom.us/j/0000000000",    "join_url": "https://zoom.us/j/0000000000",    "invitee": {      "name": "Maya Lee",      "email": "[email protected]",      "phone": "+1 555 0100",      "time_zone": "America/Chicago"    }  }}

answers lists each answered question with its title and type; fields has the same answers by question id, for mapping. hidden carries the hidden fields and UTM tags the form took from its link or embed. A REST hook's payload is read when the delivery goes out, normally seconds after the event.

Verify the signature

X-OneForm-Signature is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 of t, a dot, and the raw request body, keyed with your firm's signing secret. To check it:

  1. Read the raw body as bytes, before any JSON parsing.
  2. Compute HMAC-SHA256 of <t>.<raw body> with the secret, and compare it with v1 in constant time.
  3. Reject a t more than 5 minutes from now, so an old request can't be replayed.
Node
import { createHmac, timingSafeEqual } from "node:crypto"; /** Verifies a OneForm webhook. rawBody: the exact bytes received (before JSON.parse). */export function verifyOneForm(rawBody: string, header: string | null, secret: string, toleranceSec = 300): boolean {  const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.trim().split("=") as [string, string]));  const t = Number(parts.t);  if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");  const a = Buffer.from(expected, "hex");  const b = Buffer.from(parts.v1, "hex");  return a.length === b.length && timingSafeEqual(a, b);} // Next.js route handler:// export async function POST(req: Request) {//   const raw = await req.text();//   if (!verifyOneForm(raw, req.headers.get("x-oneform-signature"), process.env.ONEFORM_WEBHOOK_SECRET!)) return new Response("bad signature", { status: 401 });//   const event = JSON.parse(raw); // dedupe on req.headers.get("x-oneform-delivery")//   return new Response("ok");// }
Python
import hashlib, hmac, time def verify_oneform(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:    parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)    try:        t = int(parts["t"])    except (KeyError, ValueError):        return False    if abs(time.time() - t) > tolerance or "v1" not in parts:        return False    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()    return hmac.compare_digest(expected, parts["v1"])
PHP
<?phpfunction verify_oneform(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool {    $parts = [];    foreach (explode(',', $header ?? '') as $p) { [$k, $v] = array_pad(explode('=', trim($p), 2), 2, ''); $parts[$k] = $v; }    $t = (int)($parts['t'] ?? 0);    if (!$t || empty($parts['v1']) || abs(time() - $t) > $tolerance) return false;    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);    return hash_equals($expected, $parts['v1']);}
Go
package oneform import (	"crypto/hmac"	"crypto/sha256"	"encoding/hex"	"strconv"	"strings"	"time") func Verify(rawBody []byte, header, secret string, tolerance time.Duration) bool {	parts := map[string]string{}	for _, p := range strings.Split(header, ",") {		if kv := strings.SplitN(strings.TrimSpace(p), "=", 2); len(kv) == 2 {			parts[kv[0]] = kv[1]		}	}	t, err := strconv.ParseInt(parts["t"], 10, 64)	if err != nil || parts["v1"] == "" || time.Since(time.Unix(t, 0)).Abs() > tolerance {		return false	}	mac := hmac.New(sha256.New, []byte(secret))	mac.Write([]byte(parts["t"] + "."))	mac.Write(rawBody)	want, _ := hex.DecodeString(parts["v1"])	return hmac.Equal(mac.Sum(nil), want)}
Ruby
require "openssl" def verify_oneform(raw_body, header, secret, tolerance = 300)  parts = (header || "").split(",").map { |p| p.strip.split("=", 2) }.to_h  t = parts["t"].to_i  return false if t.zero? || parts["v1"].nil? || (Time.now.to_i - t).abs > tolerance  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")  OpenSSL.fixed_length_secure_compare(expected, parts["v1"])end

Keep the secret in an environment variable, never in code. AI coding agents connected to OneForm's MCP server can write this check with the get_webhook_verification tool.

Retries

Network errors, timeouts and answers of 408, 425, 429 and 5xx are retried. Any other 4xx is final: the delivery fails at once. A Retry-After header is honoured, up to 6 hours.

REST hooksWorkflow step
Tries in all85
Waits between tries1 min, 2 min, 4 min, 8 min, 16 min, 32 min, 60 min30 seconds, 2 minutes, 8 minutes, 32 minutes, each ±20%
After the last tryThe delivery is marked failedThe step fails, the run stops, and the firm can retry it from the run's page

410 Gone

For REST hooks, answer 410 Gone when a subscription should end (for example, the Zap behind it was deleted). After 2 in a row, OneForm deletes the subscription and records it in the firm's audit log. A successful delivery in between resets the count. A workflow step treats 410 like any other 4xx.

Duplicates and order

  • An event reaches each subscription once, however often it's reported inside OneForm.
  • A delivery can still arrive twice: a server that stops mid-send means a resend after 2 minutes. Dedupe on X-OneForm-Delivery (or Idempotency-Key).
  • A retried delivery can land after a later event. Use the timestamps in the payload rather than arrival order.
  • Answer quickly and do slow work afterwards: queue the event, then return 200.

Testing

  • GET /triggers/{trigger}/sample returns a sample in the exact delivery shape, built from your latest submission.
  • In a workflow, a test run sends the step's request with X-OneForm-Test: 1, so your receiver can tell it apart.
  • Your endpoint must be reachable over public https. To test on your own machine, put a tunnel in front of it.
curl
curl https://app.oneform.si/api/v1/triggers/form_submitted/sample \  -H "Authorization: Bearer $ONEFORM_API_KEY"
Node
const res = await fetch("https://app.oneform.si/api/v1/triggers/form_submitted/sample", {  headers: {    Authorization: `Bearer ${process.env.ONEFORM_API_KEY}`,  },});if (!res.ok) throw new Error(`OneForm answered ${res.status}: ${await res.text()}`);const data = await res.json();
Python
import osimport requests res = requests.get(    "https://app.oneform.si/api/v1/triggers/form_submitted/sample",    headers={"Authorization": f"Bearer {os.environ['ONEFORM_API_KEY']}"},    timeout=30,)res.raise_for_status()data = res.json()
PHP
<?php$ch = curl_init("https://app.oneform.si/api/v1/triggers/form_submitted/sample");curl_setopt_array($ch, [    CURLOPT_CUSTOMREQUEST => "GET",    CURLOPT_HTTPHEADER => [        "Authorization: Bearer " . getenv("ONEFORM_API_KEY"),    ],    CURLOPT_RETURNTRANSFER => true,]);$data = json_decode(curl_exec($ch), true);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
Go
package main import (	"fmt"	"io"	"net/http"	"os") func main() {	req, err := http.NewRequest("GET", "https://app.oneform.si/api/v1/triggers/form_submitted/sample", nil)	if err != nil {		panic(err)	}	req.Header.Set("Authorization", "Bearer "+os.Getenv("ONEFORM_API_KEY"))	res, err := http.DefaultClient.Do(req)	if err != nil {		panic(err)	}	defer res.Body.Close()	out, _ := io.ReadAll(res.Body)	fmt.Println(res.Status, string(out))}
Ruby
require "net/http"require "json" uri = URI("https://app.oneform.si/api/v1/triggers/form_submitted/sample")req = Net::HTTP::Get.new(uri)req["Authorization"] = "Bearer #{ENV.fetch('ONEFORM_API_KEY')}"res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }data = JSON.parse(res.body)

The signing secret

Each firm has its own secret, starting whsec_. Owners and admins see it in OneForm under Integrations → Webhook, where they can also rotate it. After a rotation, requests already being signed can carry the old secret for up to a minute while every OneForm server picks up the new one, so accept both for a minute when you switch.

Every firm has its own secret, so a request signed for one firm never verifies for another.

Questions about the API, webhooks or embeds? Ask a developer.