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 hooks | Workflow step | |
|---|---|---|
| Set up by | Your code or a platform such as Zapier, with an API key: POST /hooks | Someone at the firm, in a workflow: add a “Send a webhook” step |
| Request | POST with the standard payload | POST, PUT or PATCH; the standard payload, your own fields, or a JSON template; extra headers |
| Around it | One subscription per trigger (and optionally per form) | Conditions, delays and other steps before or after |
| Ends when | You unsubscribe, the key is revoked, or your URL answers 410 Gone 2 times in a row | The workflow is switched off or deleted |
Both sign their requests the same way, with the same secret, so one receiver can take both.
Triggers
| Trigger | Fires when |
|---|---|
form_submittedForm is submitted | Runs as soon as someone finishes the form. |
partial_submissionForm is left unfinished | Runs once when someone answers some questions, then stops for an hour without submitting. Stops if they finish later. |
report_approvedReport is approved | Runs when a reviewer approves the report. |
report_sentReport is sent | Runs after the report email goes out. |
report_openedReport is opened | Runs the first time the client opens the report email or their report page (once per report). |
payment_receivedPayment received | Runs when someone pays for a report or a consultation (Stripe). |
booking_madeCall is booked | Runs when someone books a call on the thank-you screen (once per submission). |
booking_rescheduledCall is rescheduled | Runs each time a booked call moves to a new time, by the client or your team. |
booking_cancelledCall is cancelled | Runs when a booked call is cancelled, by the client or your team. |
lead_hotLead 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.
| Header | Example | What it is |
|---|---|---|
Content-Type | application/json | Always JSON. |
X-OneForm-Event | form_submitted | The 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-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> | HMAC-SHA256 of "<t>.<raw body>", keyed with the firm's webhook signing secret. |
X-OneForm-Test | 1 | Workflow 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 []).
{ "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}{ "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" }}{ "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:
- Read the raw body as bytes, before any JSON parsing.
- Compute HMAC-SHA256 of
<t>.<raw body>with the secret, and compare it withv1in constant time. - Reject a
tmore than 5 minutes from now, so an old request can't be replayed.
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");// }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"])<?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']);}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)}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"])endKeep 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 hooks | Workflow step | |
|---|---|---|
| Tries in all | 8 | 5 |
| Waits between tries | 1 min, 2 min, 4 min, 8 min, 16 min, 32 min, 60 min | 30 seconds, 2 minutes, 8 minutes, 32 minutes, each ±20% |
| After the last try | The delivery is marked failed | The 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(orIdempotency-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 https://app.oneform.si/api/v1/triggers/form_submitted/sample \ -H "Authorization: Bearer $ONEFORM_API_KEY"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();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$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);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))}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.