Skip to main content

Webhooks

Webhooks let an external system be notified the moment something happens in Stack9 Experience, instead of polling an API for changes. You register a webhook in the back office — a name, a target URL, the event to listen for, and any custom HTTP headers — and the platform POSTs a JSON payload to that URL when the event fires.

One event ships today

The only webhook event available today is form.submission, registered against one form at a time. Everything in the Roadmap section is specified but not yet shipped. Build against what is described in the sections above it.

What webhooks are for

The canonical case is lead capture. A visitor submits an enquiry form on a website; a CRM, a lead-routing service, or a dealership mailbox handler needs to know immediately. Without webhooks, that integration polls the submissions API on a timer and inherits every problem polling brings — latency, wasted calls, and duplicate-detection logic.

With a webhook:

  • Push, not poll — the receiver is called as the submission lands.
  • Per-target authentication — custom headers carry the receiver's own API key or bearer token.
  • Asynchronous and retried — delivery runs through a queue, so a slow or briefly unavailable receiver does not affect the visitor submitting the form.
  • Testable before go-live — a built-in test call exercises the real URL and headers and reports exactly what came back.
Webhooks list screen in the Stack9 Experience back office showing registered webhooks with columns for name, event, target URL, active state and last attempt status, plus a create webhook button

Not to be confused with

Three different mechanisms in the platform are called "webhooks". They are unrelated, and mixing them up is the most common integration mistake.

MechanismDirectionWhat it is
DXP webhooks (this page)OutboundRegistered against a platform event — today, a form submission — and delivered to your URL.
Email event webhooksOutboundConfigured under Email Settings and backed by the email delivery provider. Reports delivery, bounce, open, and click events with a different schema and a different management surface. See Email & Communications.
Stack9 Core automation webhook triggerInboundAn HTTP endpoint the platform exposes so an external system can trigger an automation. The opposite direction, documented in the Stack9 Core guides.
subscriber.added and email.received are not DXP webhook events

You may see these named in older product summaries. They do not exist in the DXP webhook event list. Subscriber and email-delivery events are served by the email event webhooks under Email Settings, which is a separate mechanism. Do not build a /webhooks registration expecting them.

Supported events

EventFires whenExtra configuration
form.submissionA visitor submits the form identified by form_code.form_code — required, and immutable after creation.

The event key is a namespaced string, and the field is designed to accept further values over time. Today the list has exactly one entry.

The registration model

A webhook registration carries:

FieldNotes
nameHuman label, for example "CRM notification". Required.
eventThe event type plus its form_code. Immutable after creation — to point a webhook at a different form, create a new one.
target.urlDestination URL. Must be http or https; use https. Required.
target.headersCustom HTTP headers sent on every delivery. Each header has a name, a value, and an is_secret flag.
activeDefaults to true. An inactive webhook is retained but does not deliver.
test_dataA sample payload used by the test call. The interface seeds a sensible default for form submissions.
last_attempt_atRead-only. Timestamp of the most recent delivery attempt.
last_attempt_statusRead-only. success or failed for the most recent attempt — per webhook, not per submission.

Secret headers. Marking a header as secret means its value is encrypted at rest and omitted from API responses; the response returns the header's name and its secret flag, but never the value. To rotate a secret, submit a new value on update; to keep the current one, omit the value entirely.

Fan-out is allowed. Several webhooks may target the same form, and several may point at the same URL. Each is delivered independently.

Webhook create drawer showing name field, event type select fixed to form submission, form select, target URL field, a custom headers table with an X-Api-Key row marked as secret, and an active toggle

Delivery semantics

Delivery is asynchronous and queue-based. A submission is recorded first and delivery is handed to a queue, so a slow receiver never slows down the visitor.

  • Fan-out — one submission produces one delivery per applicable webhook, each with its own attempt record.
  • Retries — a delivery carries a retry counter bounded at five, so a failing delivery is retried up to five times.
  • Observability today — the webhook record's last_attempt_at and last_attempt_status reflect the most recent attempt only. There is no per-attempt history and no stored response body for live deliveries yet.
Retry backoff and timeouts are not part of the published contract

The retry ceiling of five is confirmed. The backoff schedule, which HTTP statuses and timeouts are treated as retryable, and the delivery timeout value are not specified in the public contract. Design your receiver to be tolerant: respond quickly, acknowledge with a 2xx before doing slow work, and do not rely on a specific retry cadence.

Payload envelope

The delivered body wraps the submission in a data envelope. The submission object is identical to what the submissions API returns for the same record.

{
"data": {
"version": 1,
"created_at": "2026-01-01T00:00:00.123Z",
"updated_at": null,
"id": "01HZX9K7QK8M4V2S6T3N5Y7B1C",
"form_code": "abc12",
"email_address": "visitor@example.com",
"submission_date": "2026-01-01T00:00:00.000Z",
"form_data": {
"first_name": "Alex",
"enquiry_type": "Test drive",
"message": "Interested in the hybrid model."
},
"events": [
{
"type": "created",
"message": "Submission received",
"isError": false,
"timestamp": "2026-01-01T00:00:00.200Z"
}
]
}
}

Notes for integrators:

  • form_code identifies which form produced the submission, so one receiver can route by form even though each webhook is bound to a single form today.
  • form_data is free-form: its keys are whatever the form's schema defines. Treat it as an open map and tolerate added fields.
  • events is an optional lifecycle log on the submission.
  • The delivery body is JSON, plus whatever custom headers the registration defines.
No business_unit in the payload yet

The originating Business Unit is not present in the delivered payload today. Carrying it is part of the roadmap below. If you need to route by brand or dealership right now, map from form_code on your side.

Testing a webhook

A test call sends the webhook's stored test_data to its configured URL, with its real custom headers, without waiting for a live submission. The response tells you precisely what happened:

FieldMeaning
successWhether the call completed successfully.
status_codeThe HTTP status your endpoint returned.
response_bodyThe body your endpoint returned.
duration_msRound-trip time.
errorPresent when the call could not be completed.

This is the fastest way to diagnose an integration: a wrong path, a missing API key, or a TLS problem shows up as a concrete status code and body rather than a silent non-delivery.

Webhook detail screen with a test panel showing the JSON test payload on the left and a success result on the right reading Success 200 in 148 milliseconds with the response body displayed

Security

The webhook security model is deliberately simple today, and its limits should be understood before you go live.

What you get:

  • Custom header authentication. You configure the header your receiver requires — typically Authorization: Bearer … or X-Api-Key: … — and mark it secret so it is encrypted at rest and never returned by the API.
  • Secret rotation. Update with a new value to rotate; omit the value to keep the existing secret.
  • HTTPS transport. Target URLs must be http or https; receivers should require https.
There is no HMAC signature scheme

The platform does not sign webhook payloads. There is no signature header, no shared signing secret, and no timestamp or nonce header, so a receiver cannot cryptographically verify that a payload came from Stack9.

Compensate with all of the following:

  • Require a secret header on every request and reject anything without it.
  • Restrict the receiving endpoint to Stack9 source addresses where your infrastructure allows it.
  • Serve the receiver over HTTPS only, and treat the URL itself as sensitive.
  • Be idempotent. Deduplicate on data.id. Duplicate deliveries are expected: retries can re-deliver, and two webhooks pointing at the same URL both fire. There is no replay protection and no delivery-id header to key on.

API surface

MethodPathPurpose
POST/webhooksRegister a webhook.
GET/webhooksList webhooks, optionally filtered by form_code.
GET/webhooks/{id}Retrieve one webhook. Secret header values are omitted.
PUT/webhooks/{id}Update name, active state, target, or test data. The event cannot be changed.
DELETE/webhooks/{id}Delete a webhook.
POST/webhooks/{id}/testSend the stored test payload to the target URL and report the result.

API Reference: full request and response schemas for these endpoints are documented in the Webhooks API.

API Reference: submissions delivered by webhooks are the same records served by /form_submissions, /form_submissions/{id}, /form_submissions/count, and /form_submissions/list — useful as a reconciliation path if a receiver was down.

Roadmap

Specified, not shipped

The following are drafted specifications, not current behaviour. They are listed so you can design integrations that will not need rewriting — not so you can build against them today. Confirm release status with your Stack9 delivery team before depending on any of it.

All-forms binding. A webhook will be able to apply to all forms in the tenant, including forms created after the webhook was registered, rather than being bound to one form_code. Today, a new form needs a new webhook registration.

Business Unit in the payload. Every delivered submission will carry its originating Business Unit, so one shared endpoint can route submissions from many sites, brands, and dealerships. See Business Units.

Per-submission delivery status and history. Each submission will carry a delivery status with exactly three values — Delivered when every hand-off succeeded, Failed when at least one attempted hand-off failed, and Processing when nothing has failed and at least one hand-off is still outstanding — with precedence Failed over Processing over Delivered when a submission mixes outcomes. Alongside it, a webhook history per submission will record one line per hand-off with the destination, the attempt time, and the response body the receiver returned. Capturing response bodies on live deliveries is new work; only the test call retains a response body today. Submissions on forms with no webhook will appear with an empty history and a Processing status, never Failed, and submissions predating the feature will not be back-filled.

Best practices

  1. Acknowledge fast, process later. Return 2xx as soon as you have durably accepted the payload, then do downstream work asynchronously. Slow receivers accumulate retries.
  2. Deduplicate on data.id. Treat every delivery as at-least-once.
  3. Tolerate schema growth. form_data keys change whenever the form changes; parse defensively and never fail on an unexpected field.
  4. Use the test call in every environment before pointing production traffic at a receiver.
  5. One receiver, many forms. Route on form_code inside your handler rather than building a bespoke endpoint per form — that also positions you for all-forms binding.
  6. Keep a reconciliation path. Poll /form_submissions on a slow schedule, or after an incident, to catch anything a prolonged receiver outage lost.
  7. Register a secret header on every webhook. It is the only authentication the receiver can rely on today.

Real-world example

A dealer group wires enquiry forms into its CRM:

  1. One receiver per environment, authenticated with a secret X-Api-Key header, validated with the test call before launch.
  2. A webhook per enquiry form, each active and independently monitored via its last-attempt status.
  3. Routing on form_code inside the receiver, mapping each form to a dealership queue.
  4. Idempotent ingest keyed on the submission id, so retries and double registrations cannot create duplicate leads.
  5. A nightly reconciliation job against the submissions API, comparing counts to catch silent gaps.

Next steps


Webhooks turn form capture into a push integration: registered per form, delivered through a queue with retries, authenticated with your own headers, and verifiable with a built-in test call.