Webhooks
Webhooks push events to your server so you don’t have to poll. Managing webhook subscriptions
requires the webhooks:manage scope.
Events
Section titled “Events”Subscribe to one or more of these six events:
| Event | Fires when |
|---|---|
response.created | A form receives a new response. |
contact.created_or_updated | A CRM contact is created or changed. |
contact.unsubscribed | A contact unsubscribes. |
ticket.created | A support ticket is opened. |
ticket.replied | A message is added to a ticket. |
ticket.status_changed | A ticket’s status changes. |
Create a subscription
Section titled “Create a subscription”POST /v1/webhooks| Field | Required | Notes |
|---|---|---|
name | yes | A label for the subscription. |
url | yes | Your HTTPS endpoint. |
events | yes | Array of event names from the table above (at least one). |
active | no | Defaults to true. |
secret | no | HMAC signing secret — set this so you can verify signatures. |
curl -X POST "https://publicapi.shout.com/v1/webhooks" \ -H "Authorization: Bearer $SHOUT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Prod events", "url": "https://example.com/hooks/shout", "events": ["response.created", "ticket.created"], "secret": "whsec_a_long_random_string" }'{ "id": "13ce5c05-ba3a-f793-c5fe-3a2265f87caf", "name": "Prod events", "url": "https://example.com/hooks/shout", "active": true, "events": ["response.created", "ticket.created"], "hasSecret": true}The secret is never returned (only hasSecret). An unknown event name returns 400 invalid_request
listing the valid names.
Manage subscriptions
Section titled “Manage subscriptions”GET /v1/webhooks # list (unpaginated)PUT /v1/webhooks/{id} # updateDELETE /v1/webhooks/{id} # delete → 204PUT takes the same body as create. The secret field is tri-state on update:
- omit /
null— keep the existing secret. ""(empty string) — clear the secret (stop signing).- a value — replace the secret.
Delivery format
Section titled “Delivery format”Events are batched: events occurring close together are collected over a short window
(~20 seconds) and delivered together in one POST to your url. The body is a batch envelope:
{ "batchId": "b7e1c2a3-...", "subscriberId": "13ce5c05-ba3a-f793-c5fe-3a2265f87caf", "timestamp": "2026-07-12T09:15:00Z", "events": [ { "eventId": "e1a2b3c4-...", "event": "ticket.created", "eventType": 4, "timestamp": "2026-07-12T09:14:58Z", "payload": { /* event-specific, see below */ } } ]}Always iterate events — a single delivery can carry more than one. Each event carries an
event field: the event name string (one of the six events above, e.g.
ticket.created). Switch on this to route the event.
Ticket event payload
Section titled “Ticket event payload”ticket.created, ticket.replied and ticket.status_changed carry a PII-light ticket payload —
identifiers, workflow state, and (on replies) a short plain-text excerpt. It never includes full
message HTML.
{ "ticketId": "66746f32-a74f-0900-b4fa-3a22628b2e12", "ticketReference": "SUPPORT-123", "sequentialNumber": 123, "teamInboxId": "0c1f6873-35f9-d9ed-df62-3a206f1f1af1", "status": "open", "previousStatus": "snoozed", "priority": "medium", "contactId": "8768931a-6136-79ef-cc73-3a22628a1e06", "subject": "Help with my export", "createdAt": "2026-07-11T08:49:47Z", "messageId": "769119d7-e4ea-8f82-3115-3a22628b2e17", "direction": "inbound", "source": "email", "excerpt": "Hi, I can't download my results…"}previousStatus is only present on ticket.status_changed; messageId, direction, source
and excerpt are only present on ticket.replied. Status, priority, direction and source cross the
wire as stable lowercase strings.
Contact event payload
Section titled “Contact event payload”contact.created_or_updated and contact.unsubscribed carry the contact:
{ "id": "1dcc380c-8076-2a37-5fb8-3a2265f83a67", "name": "Jordan Lee", "email": "jordan@acme.com", "groups": [ { "id": 218617, "name": "Form Sign-Ups", "consentWording": null, "consentGroupType": null } ], "customValues": [ { "fieldId": "…", "fieldName": "Plan", "stringValue": "Pro", "numberValue": null, "dateValue": null } ]}Response event payload
Section titled “Response event payload”response.created carries the full submission — the survey response with its answers, any linked
contact, computed scores, and the form’s title and launch code. Match answers to questions using the
same question IDs you get from GET /v1/forms/{id}. (For a
narrower, stable shape, you can instead treat the webhook as a trigger and fetch the response via
GET /v1/forms/{id}/responses/{responseId}.)
Verifying signatures
Section titled “Verifying signatures”If the subscription has a secret, every delivery includes an X-Signature header:
- Algorithm: HMAC-SHA256.
- Key: your subscription
secret. - Signed content: the exact raw request body bytes (verify before parsing JSON).
- Encoding: Base64 (standard, not URL-safe).
Compute base64(HMAC_SHA256(secret, rawBody)) and compare it, in constant time, to X-Signature.
// Node.js (Express). Verify against the RAW body — do not re-serialize the JSON first.const crypto = require('crypto');const express = require('express');
const app = express();app.use(express.raw({ type: 'application/json' })); // req.body is a Buffer
app.post('/hooks/shout', (req, res) => { const signature = req.get('X-Signature') || ''; const expected = crypto .createHmac('sha256', process.env.SHOUT_WEBHOOK_SECRET) .update(req.body) // the raw bytes, exactly as received .digest('base64');
const ok = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).send('invalid signature');
const batch = JSON.parse(req.body.toString('utf8')); for (const event of batch.events) { // switch on event.event (e.g. "ticket.created") and handle event.payload } res.sendStatus(200); // 2xx = delivered; anything else triggers a retry});Retries & delivery expectations
Section titled “Retries & delivery expectations”- Respond
2xxwithin 30 seconds. Delivery attempts time out at 30s; a timeout counts as a failure. - Any non-
2xxresponse, or a connection failure, is retried — up to 5 times, with exponential backoff (capped at 30 minutes between attempts). - After 5 failed retries the batch is permanently failed and dropped — build your endpoint to be resilient, and reconcile via the REST API if you suspect a gap.
- Deliveries per subscription preserve order; a retrying batch holds its place.
- Make your handler idempotent — use
eventIdto dedupe, since a retry can redeliver an event you already processed.
Testing your endpoint
Section titled “Testing your endpoint”POST /v1/webhooks/{id}/testSends a sample delivery to the subscription’s URL and reports what your endpoint returned:
curl -X POST "https://publicapi.shout.com/v1/webhooks/13ce5c05-ba3a-f793-c5fe-3a2265f87caf/test" \ -H "Authorization: Bearer $SHOUT_API_KEY"{ "success": true, "statusCode": 200, "responseBody": "" }If the endpoint can’t be reached, success is false and statusCode is 0:
{ "success": false, "statusCode": 0, "responseBody": "Could not connect to the endpoint." }Test deliveries are rate-limited (roughly one every few seconds per subscription).