Skip to content

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.

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=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-Ebitex-Delivery: whd_0123456789abcdef0123456789abcdef

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

  1. 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.
  2. Read t out of X-Ebitex-Signature.
  3. Build the message: the timestamp as a string, a literal ., then the raw body bytes.
  4. Compute HMAC-SHA256 of that message, keyed with your signing secret (as UTF-8 bytes). Encode the result as lowercase hexadecimal.
  5. Compare your result against s using a constant-time comparison, not == or string.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.”
  6. Reject the request if t is 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.

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.

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

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.

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.