Webhooks
This page covers the two webhooks that are set up on a form or a workflow. To have your organization’s own systems told about forms, publishing, records, audiences and assets from one place, see Receive events from your organization; it is signed the same way.
Two surfaces send webhooks here: a form’s Webhook handler (registered on the form’s Triggers page) and a Content workflow’s Webhook action (attached to a state or a transition on the workflow canvas). Both send the same envelope shape and, once you set a signing secret, sign requests the same way — verify once and you can trust either.
Proving a request came from us
Section titled “Proving a request came from us”A webhook is an ordinary POST to a URL you chose, so on its own it proves nothing about who sent
it. Setting a signing secret on the handler or action closes that: every request is then signed with
it, and you can check the signature before trusting the body.
Leave the secret unset and requests are sent unsigned. Signing is opt-in, not required.
Every request carries two headers:
X-Ebitex-Signature: v1,t=1790000591,s=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08X-Ebitex-Delivery: whd_0123456789abcdef0123456789abcdefX-Ebitex-Signature has three comma-separated parts: the scheme version (v1), t=, the Unix
timestamp the request was signed at, and s=, the signature itself.
The recipe
Section titled “The recipe”- Take the exact bytes of the request body you received. Not a re-serialization of the parsed JSON — the bytes as sent. Re-encoding before verifying is the single most common way this goes wrong, because a re-encoder can reorder keys or change whitespace even when the data is identical.
- Read
tout ofX-Ebitex-Signature. - Build the message: the timestamp as a string, a literal
., then the raw body bytes. - Compute HMAC-SHA256 of that message, keyed with your signing secret (as UTF-8 bytes). Encode the result as lowercase hexadecimal.
- Compare your result against
susing a constant-time comparison, not==orstring.Equals. A short-circuiting comparison leaks how many leading characters matched through timing, which is the whole reason this step exists as its own instruction rather than “compare the strings.” - Reject the request if
tis further from your own clock than you’re willing to tolerate — five minutes in either direction is a reasonable starting point. A signature with no age check at all can be replayed indefinitely by anyone who captures one genuine request.
Worked example
Section titled “Worked example”These exact inputs and output are asserted by our own test suite, so you can use them to check your implementation before pointing it at a live secret.
| Input | Value |
|---|---|
| Body (raw bytes, UTF-8) | {"id":"whd_1"} |
| Timestamp | 1790000591 |
| Secret | whsec_fixed_vector |
Message signed (timestamp + . + body) |
1790000591.{"id":"whd_1"} |
| Expected signature (hex-encoded HMAC-SHA256) | 01a06b335f0323c66e5dd81ce4fdffffe5405739766b5c809b52286d6da24af7 |
If your implementation produces anything else for those inputs, the bug is in your implementation, not in a request you received.
The payload envelope
Section titled “The payload envelope”Every request’s body is a JSON object with the same five top-level members, whichever surface sent it:
| Member | Type | Meaning |
|---|---|---|
id |
string | The delivery id, prefixed whd_. Stable across automatic retries of the same delivery — see Retries, below. |
type |
string | What happened. See Event types, below. |
createdAt |
string (ISO 8601) | When the delivery was created. Two attempts of one delivery carry the same value. |
organizationId |
string (UUID) | Which organization this came from — useful if one receiver serves more than one. |
test |
boolean | true for a request sent by a Send test action, false for a real one. Always present, so you can check it either way rather than only for the case you expect. |
data |
object | The event’s own payload. Shape depends on type. |
A Forms submission:
{ "id": "whd_0123456789abcdef0123456789abcdef", "type": "forms.submission.completed", "createdAt": "2026-09-22T14:03:11.482Z", "organizationId": "8f14e45f-ceea-467a-9f1c-1b2d3e4f5a6b", "test": false, "data": { "submissionId": "3a7c1e88-2b4d-4f6a-8c9e-0d1f2a3b4c5d", "formName": "Contact us", "fieldValues": { "email": "ada@example.com", "company": null }, "submittedAtUtc": "2026-09-22T14:03:11.000Z" }}fieldValues keys are the form’s field keys, not their labels, and a blank answer is null rather
than omitted — an omitted key and an explicitly blank answer would otherwise be indistinguishable to
code reading the object.
A Content workflow transition:
{ "id": "whd_01JC8Z3K7Q2N4V6X8Y0A1B2C3D", "type": "content.workflow.transitioned", "createdAt": "2026-09-22T14:03:11.482Z", "organizationId": "8f14e45f-ceea-467a-9f1c-1b2d3e4f5a6b", "test": false, "data": { "entityKind": "component", "entityId": "2b6a1c3e-9f4d-4a2b-8c1e-0d1f2a3b4c5d", "entityName": "Autumn campaign hero", "definitionName": "Marketing review", "transitionName": "Approve", "fromStateName": "In review", "toStateName": "Approved", "pinnedVersionNumber": 4, "actorExternalUserId": "auth0|64f1a2b3c4d5e6f7a8b9c0d1", "actorDisplayName": "Priya Shah", "comment": "Looks good, ship it.", "runStarted": false }}A run that starts on its own — a translation job finishing, or a proposal arriving from a form or
another system, with nobody transitioning anything — sends content.workflow.runStarted instead,
and data.runStarted is true. There’s no previous state and no transition to name, so
transitionName and fromStateName are empty strings rather than absent:
{ "type": "content.workflow.runStarted", "data": { "entityKind": "proposal", "transitionName": "", "fromStateName": "", "runStarted": true } }Event types
Section titled “Event types”type |
Sent by |
|---|---|
forms.submission.completed |
A form’s Webhook handler, when a submission completes |
content.workflow.transitioned |
A Content Webhook action attached to a transition, or to a state entered by one — including the common case of a state-attached action fired by an ordinary transition |
content.workflow.runStarted |
A Content Webhook action attached to the state a run starts in with no transition |
Retries and deduplication
Section titled “Retries and deduplication”The two surfaces retry differently, but id is what makes either safe to handle more than once.
A Content Webhook action retries automatically: up to two extra attempts if the first fails,
each capped at four seconds, the whole attempt bounded to fifteen seconds. Every attempt of one
delivery carries the same id — deduplicate on it, and a retried delivery that finally lands is
correctly recognised as the same event you may have already seen fail.
A Forms Webhook handler does not retry automatically; one attempt is one delivery. If it fails,
the person who set it up can trigger Resend from the form’s Triggers page. A Resend mints a
new id, deliberately: it’s a distinct action a person asked for, not a repeat of the original
attempt, and a receiver that deduplicated it away on the original’s id would silently drop
something a person is now watching for.
Practical rule: keep a short-lived record of ids you’ve already processed (an hour is more
than enough) and skip a request whose id you’ve already handled. Do this regardless of which
surface you’re receiving from — the cost of checking is negligible, and it’s the only thing that
covers both surfaces’ retry behaviour with one rule.
Testing your receiver
Section titled “Testing your receiver”A Forms Webhook handler has a Send test action on the form’s Triggers page — it sends a real
request, signed exactly like a live delivery if you’ve set a secret, with data.test set to true.
Use that field to keep test traffic out of whatever your receiver does with real deliveries, without
needing a second URL or a second secret. A Content Webhook action has no equivalent test-fire today;
data.test is always false for a delivery a Content workflow sends.