Skip to content

Webhooks

Webhooks push events to your server so you don’t have to poll. Managing webhook subscriptions requires the webhooks:manage scope.

Subscribe to one or more of these six events:

EventFires when
response.createdA form receives a new response.
contact.created_or_updatedA CRM contact is created or changed.
contact.unsubscribedA contact unsubscribes.
ticket.createdA support ticket is opened.
ticket.repliedA message is added to a ticket.
ticket.status_changedA ticket’s status changes.
POST /v1/webhooks
FieldRequiredNotes
nameyesA label for the subscription.
urlyesYour HTTPS endpoint.
eventsyesArray of event names from the table above (at least one).
activenoDefaults to true.
secretnoHMAC signing secret — set this so you can verify signatures.
Terminal window
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.

GET /v1/webhooks # list (unpaginated)
PUT /v1/webhooks/{id} # update
DELETE /v1/webhooks/{id} # delete → 204

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

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

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
});
  • Respond 2xx within 30 seconds. Delivery attempts time out at 30s; a timeout counts as a failure.
  • Any non-2xx response, 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 eventId to dedupe, since a retry can redeliver an event you already processed.
POST /v1/webhooks/{id}/test

Sends a sample delivery to the subscription’s URL and reports what your endpoint returned:

Terminal window
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).