Skip to main content

How to receive form submission webhooks

Webhooks let an external system react the instant a website visitor submits a form — no polling the Form Submissions API. You register a target URL against one form, Stack9 POSTs the submission to it, and your system takes it from there.

What you'll build

A production-shaped receiver for a single form:

  1. An HTTPS endpoint that accepts a form.submission delivery
  2. A shared secret sent as an encrypted custom header, validated on your side
  3. A registered webhook, verified end-to-end with the built-in test endpoint
  4. Idempotent handling so queued retries and fan-out duplicates are harmless

Time to complete: 30-40 minutes

Prerequisites

  • A form published in your Marketing Tenant, and its form_code
  • A DXP API key (X-API-Key) if you want to register the webhook over the API
  • Somewhere to host an HTTPS endpoint (a tunnel to localhost is fine while developing)

What ships today

Before you design anything, read this table carefully — the shipped surface is deliberately narrow.

CapabilityStatus
form.submission eventShipped — the only event type
Binding to one form via form_codeShipped — required, and immutable after creation
Custom headers, with secret values encrypted at restShipped
Test endpointShipped
Queue-based async delivery with retriesShipped
Per-webhook last_attempt_at / last_attempt_statusShipped
HMAC or any payload signatureDoes not exist
One webhook covering all formsRoadmap
business_unit in the delivered payloadRoadmap
Per-submission delivery status and webhook historyRoadmap
There is no signature scheme

Stack9 does not sign webhook payloads. There is no X-Signature header, no shared signing secret, no timestamp or nonce header. Your receiver cannot cryptographically prove a request came from Stack9. Your entire authentication story is the secret custom header you configure — so treat that header like a password: HTTPS only, compared in constant time, rotated on a schedule, and paired with IP allow-listing if your infrastructure supports it.

Do not confuse this with email event webhooks

Email event webhooks (delivery, bounce, open, click) are configured under Email Settings and come from the email provider. They use a different schema and a different mechanism. Likewise, the Stack9 Core automation webhook trigger is an endpoint the platform exposes to you — the opposite direction. This guide is only about outbound form submission webhooks.

Step 1: Find the form's form_code

Every webhook is bound to exactly one form, identified by its form_code (5-40 characters, letters, digits, _ and -). Open the form in the back office and copy the code, or list your forms over the API:

curl -X GET 'https://apis.app.stack9.co/api/forms' \
-H 'X-API-Key: your-api-key-here'
DXP back office Forms list screen with the Form Code column highlighted for the Contact Us form

You cannot change form_code later — the event object is immutable after creation. One webhook per form, per destination; register a second webhook if you need a second form.

Step 2: Build the receiver

Write the receiver before you register the webhook, so your first delivery test is meaningful. The contract is simple: read the JSON body, validate the secret header, respond 2xx quickly.

// server.js — Express receiver for Stack9 form submission webhooks
const crypto = require('crypto');
const express = require('express');

const app = express();
app.use(express.json({ limit: '1mb' }));

const EXPECTED_SECRET = process.env.STACK9_WEBHOOK_SECRET;

// Constant-time comparison: a plain === leaks timing information.
function isValidSecret(received) {
if (typeof received !== 'string' || !EXPECTED_SECRET) return false;
const a = Buffer.from(received);
const b = Buffer.from(EXPECTED_SECRET);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}

app.post('/hooks/stack9-forms', async (req, res) => {
// 1. Authenticate. This is the ONLY authentication available — no signature exists.
if (!isValidSecret(req.get('X-Api-Key'))) {
return res.status(401).json({ error: 'unauthorized' });
}

const submission = req.body?.data;
if (!submission?.id) {
return res.status(400).json({ error: 'malformed payload' });
}

// 2. Acknowledge fast, then process. Slow receivers get retried.
res.status(200).json({ received: true });

// 3. Be idempotent — retries and fan-out can deliver the same submission twice.
try {
await handleSubmissionOnce(submission);
} catch (err) {
console.error('submission processing failed', submission.id, err);
}
});

async function handleSubmissionOnce(submission) {
const alreadySeen = await store.exists(`stack9:submission:${submission.id}`);
if (alreadySeen) return;

await crm.createLead({
email: submission.email_address,
formCode: submission.form_code,
answers: submission.form_data,
submittedAt: submission.submission_date,
});

await store.put(`stack9:submission:${submission.id}`, { processedAt: new Date() });
}

app.listen(3000);

Three rules that matter:

  • Compare the secret in constant time. timingSafeEqual on equal-length buffers, with an explicit length check first.
  • Respond before you process. Acknowledge with 2xx as soon as the payload is validated, then do the slow work. A receiver that blocks on a CRM call invites retries and duplicate work.
  • De-duplicate on data.id. Retries (up to 5) and multiple webhooks pointing at the same URL both produce repeat deliveries. This is expected behaviour, not a bug to report.

The payload you will receive

The body is an envelope with the whole Form Submission under data:

{
"data": {
"version": 1,
"created_at": "2026-01-01T00:00:00.123Z",
"updated_at": null,
"id": "1f0d9a52-7c31-4a7e-9c88-0b1e5a7e93d1",
"form_code": "contact",
"email_address": "visitor@example.com",
"submission_date": "2026-01-01T00:00:00.000Z",
"form_data": {
"first_name": "Jane",
"enquiry_type": "Service booking",
"message": "Please call me back tomorrow morning."
},
"events": [
{
"type": "created",
"message": "Submission received",
"isError": false,
"timestamp": "2026-01-01T00:00:00.100Z"
}
]
}
}

form_data is free-form: its keys are the questions on your form, so it changes whenever an editor changes the form. Read defensively rather than destructuring required fields.

business_unit is not in the payload yet

Carrying the originating Business Unit in every delivery is specified but not shipped. If you need to route by brand or dealership today, register one webhook per form and infer the scope from form_code. Do not build against a business_unit field — it does not exist.

Step 3: Register the webhook

Option A: the back office

Open the Webhooks screen and create a new webhook. You will set a Name, the Event (form.submission) and its Form, the Target URL, and any Custom headers.

DXP webhook registration form drawer showing Name, Event type select set to form.submission, Form select, Target URL field, and the custom headers table with an is-secret toggle DXP Webhooks list screen showing registered webhooks with name, form, target URL, active toggle and last attempt status columns

Note that the event and form selects are disabled when editing an existing webhook — that pair is immutable.

Option B: the API

curl -X POST 'https://apis.app.stack9.co/api/webhooks' \
-H 'X-API-Key: your-api-key-here' \
-H 'Content-Type: application/json' \
-d '{
"name": "CRM lead capture",
"event": {
"event_type": "form.submission",
"form_code": "contact"
},
"target": {
"url": "https://crm.example.com/hooks/stack9-forms",
"headers": [
{ "name": "X-Api-Key", "value": "a-long-random-secret", "is_secret": true }
]
},
"active": true,
"test_data": {
"email_address": "example@april9.com.au",
"first_name": "Jane",
"enquiry_type": "Service booking"
}
}'

Response:

{ "id": "7c9f0f0a-3b1a-4a01-8a2f-2b0f9d5e6c31" }

Field reference:

FieldRequiredNotes
nameyes1-200 characters. Human label.
event.event_typeyesAlways form.submission.
event.form_codeyesThe form to listen to. Immutable.
target.urlyeshttp or https. Use https in production.
target.headers[]noEach is name, value, is_secret. Defaults to an empty list.
activenoDefaults to true. Inactive webhooks do not deliver.
test_datanoFree-form JSON body used by the test endpoint.

Step 4: Secure it with a secret header

is_secret: true changes how the value is handled:

  • It is encrypted at rest.
  • It is omitted from every API response. Reads return { "name": "X-Api-Key", "is_secret": true } with no value. Non-secret headers do return their value.
  • On update, value is optional: omit it to keep the current secret, provide it to rotate.

Generate a high-entropy value — this is the only thing standing between your endpoint and the public internet:

openssl rand -base64 48

Rotating the secret later is a plain update. Deploy your receiver so it accepts both the old and new value, rotate, then drop the old one:

curl -X PUT 'https://apis.app.stack9.co/api/webhooks/7c9f0f0a-3b1a-4a01-8a2f-2b0f9d5e6c31' \
-H 'X-API-Key: your-api-key-here' \
-H 'Content-Type: application/json' \
-d '{
"name": "CRM lead capture",
"active": true,
"target": {
"url": "https://crm.example.com/hooks/stack9-forms",
"headers": [
{ "name": "X-Api-Key", "value": "the-new-rotated-secret", "is_secret": true }
]
}
}'

Note there is no event in the update body — it cannot be changed.

Defence in depth, because signatures are unavailable

Layer whatever else your stack allows: terminate on HTTPS only, allow-list Stack9's egress addresses at your edge if you can obtain them, reject bodies over a sane size, never log the secret header, and never treat webhook input as trusted — validate and sanitise form_data exactly as you would a public form post. Reads of form_data keys should never be interpolated into SQL, shell commands, or HTML without escaping.

Step 5: Test the delivery

The test endpoint sends the webhook's stored test_data to the configured target URL, with the configured headers, and reports exactly what happened — no form submission required:

curl -X POST 'https://apis.app.stack9.co/api/webhooks/7c9f0f0a-3b1a-4a01-8a2f-2b0f9d5e6c31/test' \
-H 'X-API-Key: your-api-key-here'
{
"success": true,
"status_code": 200,
"response_body": "{\"received\":true}",
"duration_ms": 187
}
FieldTypeNotes
successbooleanAlways present
status_codenumberThe HTTP status your receiver returned
response_bodystringYour response body, captured verbatim
duration_msnumberRound-trip time
errorstringPresent when the call could not complete at all
DXP webhook detail screen test panel showing the test payload JSON and a success result reading 200 in 187ms

Work through the failure modes deliberately — it is much cheaper here than in production:

What you seeUsual cause
success: false with error and no status_codeDNS, TLS, or connection failure. The receiver was never reached.
status_code: 401Your validation rejected the secret. Check the header name matches exactly, including case-insensitive lookup on your side.
status_code: 404Path typo in target.url.
status_code: 500Your handler threw. Check response_body for your own error text.
The test payload is not the real envelope

test_data is sent as you stored it. A real delivery wraps the submission in { "data": { ... } }. Set test_data to a realistic envelope if you want the test to exercise your parsing path as well as your authentication path.

Step 6: Submit a real form

Submit the live form and confirm the delivery lands. Then check the webhook record:

curl -X GET 'https://apis.app.stack9.co/api/webhooks?form_code=contact' \
-H 'X-API-Key: your-api-key-here'
{
"results": [
{
"version": 1,
"created_at": "2026-01-01T00:00:00.000Z",
"id": "7c9f0f0a-3b1a-4a01-8a2f-2b0f9d5e6c31",
"name": "CRM lead capture",
"event": { "event_type": "form.submission", "form_code": "contact" },
"active": true,
"last_attempt_at": "2026-01-01T09:15:03.412Z",
"last_attempt_status": "success",
"target": {
"url": "https://crm.example.com/hooks/stack9-forms",
"headers": [{ "name": "X-Api-Key", "is_secret": true }]
}
}
]
}

last_attempt_at and last_attempt_status (success or failed) reflect the most recent attempt only — they are per-webhook, not per-submission, and they are not a history. Your own receiver logs remain your source of truth for what was processed.

Step 7: Handle retries and fan-out

Delivery is queued and asynchronous, not inline with the visitor's form submission:

visitor submits form


FormSubmissionCreated ──► fan out to every active webhook on that form_code

├─► DeliverWebhook (webhook A, retry_count 0..5)
└─► DeliverWebhook (webhook B, retry_count 0..5)

What this means for your receiver:

  • Deliveries are not ordered and not instant. Do not rely on arrival order or on a submission reaching you before the visitor sees the thank-you page.
  • Up to 5 retries per delivery. The backoff schedule and exactly which failures are retried are not part of the published contract, so assume a failure will be retried and make sure a partially processed submission is safe to reprocess.
  • Fan-out is unrestricted. Multiple webhooks may target the same URL; each is its own delivery. Combined with retries, duplicate deliveries are normal — which is why Step 2 keys on data.id.
  • Return 2xx for anything you have accepted, even if downstream processing will happen later. Returning 5xx because your CRM is briefly down is a valid choice only if you actually want the retry.

To stop deliveries without losing the configuration, set active: false:

curl -X PUT 'https://apis.app.stack9.co/api/webhooks/7c9f0f0a-3b1a-4a01-8a2f-2b0f9d5e6c31' \
-H 'X-API-Key: your-api-key-here' \
-H 'Content-Type: application/json' \
-d '{
"active": false,
"target": { "url": "https://crm.example.com/hooks/stack9-forms" }
}'

Delete permanently with DELETE /webhooks/{id}.

Troubleshooting

Nothing arrives after a real submission

Check, in order: the webhook is active; its form_code matches the form the visitor actually submitted; last_attempt_at is being updated at all (if not, the delivery never ran); the test endpoint still succeeds against the same URL. If the test succeeds but real submissions do not arrive, the submission is not landing on the form you think it is — confirm with GET /form_submissions?form_code=....

Every delivery is failed but the test succeeds

Your receiver is probably rejecting the real envelope while accepting the flat test_data shape. Real payloads nest everything under data. Log the raw body once (with the secret header redacted) and compare.

The same lead is created several times

You are missing idempotency, or more than one webhook points at the same URL. De-duplicate on data.id, and list your webhooks for that form_code to check for accidental duplicates.

The secret header stopped matching after an update

You included the header in the update body without a value, intending to rotate it — omitting value keeps the existing secret, while supplying a new one replaces it. Also confirm you did not drop the header from the array entirely: target.headers is replaced wholesale, so any header you omit is removed.

You need a signature, not a shared secret

Not available. Escalate it as a requirement rather than trying to synthesise one — you cannot derive a signature from the current payload, because no key material is shared with the receiver.

Next steps

  • Forms API — form configuration, schema, and submissions
  • Business Units — how forms and submissions are scoped inside a Marketing Tenant
  • Email Settings — the separate email event webhooks for delivery, bounce, open, and click