Webhooks API
Overview
The Webhooks API lets you register an outbound HTTP subscription that Stack9 calls whenever a specific event happens, so an external system (a CRM, a lead router, a mailbox handler) is notified in real time instead of having to poll the Stack9 API.
Today there is exactly one webhook event: form.submission, bound to a single form via form_code. A webhook cannot yet be applied to "all forms," and the delivered payload does not yet carry a business_unit field. See Roadmap below.
Each webhook has a name, a target URL, the event it listens for, and optional custom HTTP headers (useful for authenticating your endpoint, e.g. an API key or bearer token). When a website visitor submits the form the webhook is registered against, Stack9 POSTs a JSON payload containing the submission to the webhook's target URL. Every webhook registered for that form is called — a form with multiple webhooks fans out to multiple deliveries.
Authentication
All endpoints require API key authentication:
X-API-Key: your-api-key-here
The webhook object
| Field | Type | Notes |
|---|---|---|
id | string | Server-generated. |
name | string (1–200 chars) | Required. |
event | object { event_type, form_code } | event_type is currently always "form.submission". Immutable after creation — update requests cannot change it. |
target.url | string (URI, http/https) | Required. Destination for deliveries. |
target.headers[] | array of { name, value, is_secret } | Custom headers sent with every delivery. value max 500 chars. Defaults to []. |
active | boolean | Default true. Set false to stop deliveries without deleting the webhook. |
test_data | object | Free-form JSON body used by the test endpoint. |
last_attempt_at | date-time | Read-only. Timestamp of the most recent delivery attempt (any recipient). |
last_attempt_status | "success" | "failed" | Read-only. Outcome of the most recent attempt only — this is a per-webhook status, not a per-submission history. |
Secret headers
Set is_secret: true on a header to have its value encrypted at rest. Responses from GET/LIST never return the value of a secret header — only name and is_secret come back. To rotate a secret header on update, send a new value; omit value to keep the existing secret unchanged.
There is no HMAC / payload-signature mechanism for these webhooks. The only way a receiver can authenticate a delivery is a secret custom header you configure yourself (e.g. Authorization: Bearer … or X-Api-Key: …). Require HTTPS and treat the header value as the sole credential — there is nothing else to verify the call came from Stack9. See Security considerations.
Create a webhook
POST /api/webhooks
Request body
{
"event": { "event_type": "form.submission", "form_code": "form_Xk9mNp2Q" },
"name": "CRM notification",
"target": {
"url": "https://example.com/hooks/stack9",
"headers": [
{ "name": "X-Api-Key", "value": "secret-123", "is_secret": true }
]
},
"active": true,
"test_data": { "email_address": "example@april9.com.au", "question1": ["Item 1"] }
}
Example response
{ "id": "wh_9kL3mP5n" }
List webhooks
GET /api/webhooks
Optional query parameter form_code filters to webhooks registered for one form.
Example response
{
"results": [
{
"version": 1,
"created_at": "2026-01-01T00:00:00.123Z",
"id": "wh_9kL3mP5n",
"name": "CRM notification",
"event": { "event_type": "form.submission", "form_code": "form_Xk9mNp2Q" },
"active": true,
"last_attempt_at": "2026-02-14T09:12:00.000Z",
"last_attempt_status": "success",
"target": {
"url": "https://example.com/hooks/stack9",
"headers": [
{ "name": "X-Api-Key", "is_secret": true }
]
}
}
]
}
Get a webhook by ID
GET /api/webhooks/{id}
Returns a single webhook using the same shape as the list items above.
Update a webhook
PUT /api/webhooks/{id}
event cannot be included — the event type and form_code are fixed at creation.
Request body
{
"name": "CRM notification (prod)",
"active": true,
"target": {
"url": "https://example.com/hooks/stack9",
"headers": [
{ "name": "X-Api-Key", "is_secret": true }
]
}
}
Omit a secret header's value to keep the current secret; include a new value to rotate it.
Example response
{ "id": "wh_9kL3mP5n" }
Delete a webhook
DELETE /api/webhooks/{id}
Example response
{ "id": "wh_9kL3mP5n" }
Send a test payload
POST /api/webhooks/{id}/test
Sends the webhook's stored test_data to its configured target.url (with its configured headers) and reports what happened — useful for validating a receiver before it goes live, without waiting for a real form submission.
Example response
{
"success": true,
"status_code": 200,
"response_body": "OK",
"duration_ms": 184
}
If the request could not complete, success is false and an error string is returned instead of status_code/response_body.
Delivery payload
When a visitor submits the form a webhook is registered against, Stack9 POSTs this envelope to target.url:
{
"data": {
"version": 1,
"created_at": "2026-01-01T00:00:00.123Z",
"updated_at": null,
"id": "sub_9kL3mP5n",
"form_code": "form_Xk9mNp2Q",
"email_address": "visitor@example.com",
"submission_date": "2026-01-01T00:00:00.000Z",
"form_data": { "firstName": "John", "interests": "Product Updates" },
"events": [
{ "type": "string", "message": "string", "isError": false, "timestamp": "2026-01-01T00:00:00.100Z" }
]
}
}
data is the same object returned by GET /api/form_submissions/{id} (see Forms API). The delivered content type is JSON. Custom headers configured on target.headers[] are sent with the request.
business_unit is not present on the delivered submission. If you need to route deliveries by Business Unit, register a separate webhook per form for now.
Delivery semantics
- Fan-out: a submission is delivered once per webhook registered for that form. A form with three webhooks produces three separate delivery attempts.
- Asynchronous, queue-based: delivery happens after the submission is accepted, not inline with the visitor's request.
- Retries: failed deliveries are retried, up to a ceiling of 5 attempts. The retry backoff schedule and which failures are considered retryable are not part of the public contract — treat retries as best-effort.
- Duplicate deliveries are possible by design (e.g. two webhooks pointed at the same URL, or a retry after an ambiguous failure). Your receiver should be idempotent on
data.id. - Status visibility: the webhook's
last_attempt_at/last_attempt_statusreflect only the most recent delivery attempt across all submissions for that webhook — there is no per-submission delivery history in the API today.
Security considerations
- Authenticate deliveries with a secret custom header (
is_secret: true) — there is no signature to verify instead. - Always use HTTPS target URLs in production.
- De-duplicate on
data.id— retries and multi-webhook fan-out can both cause the same submission to arrive more than once. - Secret header values are never returned by the API after creation; rotate by sending a new
valueon update.
Roadmap
The following are described in draft internal specs but are not available today. Do not build against them until they ship:
- Apply a webhook to all forms (including forms created later), instead of a single
form_code. business_unitin the delivered payload, so one shared endpoint can route submissions from many forms/sites.- Per-submission delivery status (
Delivered/Failed/Processing) and a webhook delivery history with captured response bodies, viewable per form submission.
Related
- Forms API — create the form a webhook listens to, and inspect submissions directly via
/form_submissions. - Email Settings API — a separate, SparkPost-backed email-event "webhooks" concept for delivery/bounce/open/click notifications; unrelated to the
form.submissionwebhooks on this page. - Entity Versioning & Audit API —
webhookis a supported entity type, so you can retrieve version history or recover a deleted webhook registration.