# Webhooks | OneForm

> OneForm webhooks: every trigger, the JSON payload, the headers, HMAC-SHA256 signatures with verification code in five languages, retries, 410 Gone…

Source: https://oneform.si/developers/webhooks/

---

1. [Home](https://oneform.si/)
2. [Developers](https://oneform.si/developers/)
3. Webhooks

[View as Markdown](https://oneform.si/developers/webhooks.md)

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 hooks                                                                                                             | Workflow step                                                                                |
| --------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Set up by | Your code or a platform such as Zapier, with an API key: [POST /hooks](https://oneform.si/developers/api/#create-hook) | 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](https://oneform.si/developers/api/#list-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.                                                                                    |

Point at the final URL

Redirects are followed, but a redirect to another host drops every header except `Content-Type`, `Accept` and `User-Agent`, signature included. URLs must be public https; private and local addresses are refused at every delivery.

## 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

```json
{  "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": "maya@example.com",    "phone": "+1 555 0100",    "company": ""  },  "answers": [    {      "id": "contact",      "question": "Your details",      "type": "contact_group",      "answer": "First name: Maya · Last name: Lee · Email: maya@example.com · 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: maya@example.com · Phone: +1 555 0100",    "income": "$120,000",    "goal": "Lower my taxes next year."  },  "variables": {},  "hidden": {    "utm_source": "newsletter"  },  "report": null}
```

report\_approved

```json
{  "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": "maya@example.com",    "phone": "+1 555 0100",    "company": ""  },  "answers": [    {      "id": "contact",      "question": "Your details",      "type": "contact_group",      "answer": "First name: Maya · Last name: Lee · Email: maya@example.com · 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: maya@example.com · 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

```json
{  "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": "maya@example.com",    "phone": "+1 555 0100",    "company": ""  },  "answers": [    {      "id": "contact",      "question": "Your details",      "type": "contact_group",      "answer": "First name: Maya · Last name: Lee · Email: maya@example.com · 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: maya@example.com · 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": "maya@example.com",      "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

```ts
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

```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

```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

```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

```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](https://oneform.si/developers/mcp/) 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` (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](https://oneform.si/developers/api/#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

```bash
curl https://app.oneform.si/api/v1/triggers/form_submitted/sample \  -H "Authorization: Bearer $ONEFORM_API_KEY"
```

Node

```js
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

```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
<?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

```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

```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.

[← PreviousREST API](https://oneform.si/developers/api/)[Next →Embed](https://oneform.si/developers/embed/)

Questions about the API, webhooks or embeds? [Ask a developer](https://oneform.si/contact/?topic=developers).

DevelopersWebhooks
- [Overview](https://oneform.si/developers/)
- [REST API](https://oneform.si/developers/api/)
- [Webhooks](https://oneform.si/developers/webhooks/)
- [Embed](https://oneform.si/developers/embed/)
- [AI agents (MCP)](https://oneform.si/developers/mcp/)
- [OAuth](https://oneform.si/developers/oauth/)
- [Zapier](https://oneform.si/developers/zapier/)
- [Form schema](https://oneform.si/developers/form-schema/)

Developers

- [Overview](https://oneform.si/developers/)
- [REST API](https://oneform.si/developers/api/)
- [Webhooks](https://oneform.si/developers/webhooks/)
- [Embed](https://oneform.si/developers/embed/)
- [AI agents (MCP)](https://oneform.si/developers/mcp/)
- [OAuth](https://oneform.si/developers/oauth/)
- [Zapier](https://oneform.si/developers/zapier/)
- [Form schema](https://oneform.si/developers/form-schema/)

[OpenAPI spec](https://oneform.si/developers/openapi.json)[Ask a developer](https://oneform.si/contact/?topic=developers)
