Webhooks
Receive real-time notifications when tickets are created or updated, comments are added, and chats start. Build automations and keep your systems in sync.
Overview
Webhooks let you receive HTTP POST requests to your server when events happen in Dispatch Tickets. Instead of polling the API, your system gets notified instantly.
Real-time
Events delivered within seconds of occurring
Secure
HMAC signatures verify authenticity
Reliable
Automatic retries with exponential backoff
Common use cases:
- Send Slack notifications when tickets are created
- Sync tickets to your CRM or database
- Trigger automations when status changes
- Update customer-facing dashboards in real-time
- Log ticket activity for analytics
Creating Webhooks
Webhooks are created per brand through the API. Choose your own signing secret and keep it on your server: every delivery is signed with it.
const webhook = await dispatch.webhooks.create('ws_abc123', {
url: 'https://your-server.com/webhooks/dispatch',
events: ['ticket.created', 'ticket.updated', 'ticket.comment.created'],
secret: process.env.DISPATCH_WEBHOOK_SECRET!, // Required: you choose it
});
console.log(webhook.id);curl -X POST https://api.dispatchtickets.com/v1/brands/ws_abc123/webhooks \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhooks/dispatch",
"events": ["ticket.created", "ticket.comment.created"],
"secret": "a-long-random-string-you-keep"
}'Webhook Configuration
| Field | Required | Description |
|---|---|---|
| url | Yes | Public endpoint to receive events. Private and internal addresses are refused, and redirects are not followed: use the final URL. |
| events | Yes | Event types to subscribe to, or * for all of them |
| secret | Yes | Your signing secret, used to verify deliveries |
Event Types
| Event | Triggered When |
|---|---|
| ticket.created | A ticket is created, from any channel (API, email, form, portal, chat) |
| ticket.updated | Ticket fields change: status, priority, assignee, company, custom fields |
| ticket.comment.created | A comment is added: a customer message, an agent reply, or an internal note (check isInternal) |
| ticket.overdue | A ticket passes its SLA due time |
| ticket.awaiting_reply | A chat visitor has waited too long for a reply. Sent once at warning (1 minute by default) and once at critical (3 minutes). Each brand can set its own times in Settings → Channels → Chat. |
| chat.started | A website visitor starts a chat (the opening message creates the ticket) |
| chat.message | A visitor sends a follow-up or an agent replies in a chat. Internal notes are never included. |
Subscribe to * to receive every event, including ones added later. A subscription to comment.created also works and receives ticket.comment.created.
Payload Format
Every delivery is a JSON POST with the same envelope:
{
"id": "evt_abc123", // Unique event ID: use it to deduplicate
"event": "ticket.created", // Event type
"brand_id": "ws_xyz789", // Brand where it happened
"timestamp": "2026-01-15T10:30:00.000Z",
"data": {
// Event-specific payload
}
}ticket.created
{
"id": "tkt_def456",
"ticketNumber": 1042,
"title": "Need help with my order",
"status": "open",
"priority": "normal",
"assigneeId": null, // Set when the ticket arrives assigned
"source": "email",
"sourceId": "<[email protected]>", // The channel's own id, or null
"tags": ["to:[email protected]", "billing"], // Names, [] when none
"tagIds": ["tag_mno345", "tag_pqr678"], // Same order as tags
"categoryId": "cat_stu901", // null when uncategorised
"categoryName": "Billing",
"cc": ["[email protected]"], // Copied on customer emails
"customFields": { "orderId": "1001" }, // Current values, {} when none
"createdAt": "2026-01-15T10:30:00.000Z",
"customerId": "cus_ghi789",
"customerEmail": "[email protected]",
"customerName": "Jane Doe",
"companyId": "cmp_jkl012",
"companyName": "Acme"
}ticket.updated
{
"id": "tkt_def456",
"ticketNumber": 1042,
"title": "Need help with my order",
"status": "pending",
"priority": "normal",
"assigneeId": "usr_abc123",
"source": "email",
"sourceId": "<[email protected]>",
"tags": ["to:[email protected]", "billing"], // Names after this update, [] when none
"tagIds": ["tag_mno345", "tag_pqr678"], // Same order as tags
"categoryId": "cat_stu901", // null when uncategorised
"categoryName": "Billing",
"cc": ["[email protected]"], // Copied on customer emails
"customFields": { "orderId": "1001" }, // Values after this update
"updatedAt": "2026-01-15T11:00:00.000Z",
"changes": ["status", "assigneeId"], // Fields set by this update
"customerId": "cus_ghi789",
"customerEmail": "[email protected]",
"customerName": "Jane Doe",
"companyId": "cmp_jkl012",
"companyName": "Acme"
}ticket.comment.created
{
"ticketId": "tkt_def456",
"ticketNumber": "ACME-1042",
"customerId": "cus_ghi789",
"customerEmail": "[email protected]",
"customerName": "Jane Doe",
"comment": {
"id": "cmt_jkl012",
"body": "Thanks for reaching out! Let me look into this.",
"authorId": "usr_abc123",
"authorType": "AGENT", // CUSTOMER, AGENT or SYSTEM
"source": "api",
"isInternal": false, // true = a note for your team: never forward it
"createdAt": "2026-01-15T11:15:00.000Z"
}
}Internal notes: ticket.comment.created fires for internal notes too. If you forward comments to customers, skip any with comment.isInternal set.
chat.started
{
"ticketId": "tkt_def456",
"ticketNumber": 1042,
"sessionId": "chs_mno345",
"title": "Hi, my export is stuck",
"body": "Hi, my export is stuck", // The opening message
"createdAt": "2026-01-15T10:30:00.000Z",
"identity": "verified", // or "anonymous"
"externalUserId": "user_123", // Your user id, when verified
"claimedEmail": null, // Typed by an anonymous visitor: unverified
"visitor": { "pageUrl": "https://app.example.com/exports", "userAgent": "..." },
"customerId": "cus_ghi789",
"customerEmail": "[email protected]", // Only when verified
"customerName": "Jane Doe",
"companyId": "cmp_jkl012",
"companyName": "Acme"
}identity is verified only when your server issued the visitor an identity token. An anonymous visitor can type any email address, so treat claimedEmail as unconfirmed.
chat.message
{
"ticketId": "tkt_def456",
"ticketNumber": "ACME-1042",
"sessionId": "chs_mno345",
"from": "visitor", // or "agent"
"message": {
"id": "cmt_pqr678",
"body": "Still stuck after a refresh",
"authorId": null,
"createdAt": "2026-01-15T10:31:00.000Z"
}
}The opening message arrives in chat.started, not as a chat.message. Images in a message appear as Markdown, . Fetch the file through the attachments API.
ticket.awaiting_reply
{
"id": "tkt_def456",
"ticketNumber": 1042,
"publicId": "ACME-1042",
"title": "Hi, my export is stuck",
"source": "chat",
"assigneeId": null,
"level": "warning", // then "critical"
"awaitingReplySince": "2026-01-15T10:30:00.000Z",
"waitingSeconds": 300
}ticket.overdue
{
"id": "tkt_def456",
"ticketNumber": 1042,
"title": "Need help with my order",
"dueAt": "2026-01-15T14:30:00.000Z",
"overdueBy": 60000 // Milliseconds past due
}Signature Verification
Every delivery is signed with your webhook secret. Always verify the signature before trusting a payload.
Headers
X-Dispatch-Signature: sha256=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9
X-Dispatch-Delivery: wdl_abc123The signature is the hex HMAC-SHA256 of the raw request body, keyed with your secret. Compute it over the exact bytes you received, before parsing the JSON.
Node.js / TypeScript
import crypto from 'crypto';
function verifyWebhookSignature(rawBody: string, header: string | undefined, secret: string): boolean {
if (!header?.startsWith('sha256=')) return false;
const received = Buffer.from(header.slice('sha256='.length), 'hex');
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
// Usage in Express: keep the raw body
app.post('/webhooks/dispatch', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString();
if (!verifyWebhookSignature(rawBody, req.headers['x-dispatch-signature'] as string, process.env.DISPATCH_WEBHOOK_SECRET!)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(rawBody);
// Process event...
res.status(200).send('OK');
});Using the SDK
import { DispatchTickets } from '@dispatchtickets/sdk';
app.post('/webhooks/dispatch', express.raw({ type: 'application/json' }), (req, res) => {
const isValid = DispatchTickets.webhooks.verifySignature(
req.body.toString(),
req.headers['x-dispatch-signature'] as string,
process.env.DISPATCH_WEBHOOK_SECRET!
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Process event...
res.status(200).send('OK');
});Retry Behavior
A delivery succeeds when your endpoint answers with a 2xx status within 10 seconds. Anything else, including a timeout or a refused connection, is retried with exponential backoff: after 30 seconds, then 1, 2, 4, 8, 16 and 32 minutes. That is 8 attempts over about an hour, so a short outage or a deploy on your side doesn't lose events.
Redirects and private addresses are not retried, because they would fail the same way every time.
A webhook whose deliveries keep failing is flagged. A delivery counts as failed once all of its attempts have failed. Your account admins are emailed after 3 and after 5 failed deliveries in a row. After 10 in a row the webhook is disabled. A successful delivery resets the count.
Timeout: Your endpoint must respond within 10 seconds. For long-running work, return 200 immediately and process asynchronously.
Best Practices
Return 200 quickly
Acknowledge receipt immediately, then process the event asynchronously. This prevents timeouts and duplicate deliveries.
app.post('/webhooks/dispatch', express.raw({ type: 'application/json' }), async (req, res) => {
// Verify signature first
if (!verifyWebhookSignature(req.body.toString(), req.headers['x-dispatch-signature'] as string, secret)) {
return res.status(401).send('Invalid signature');
}
// Acknowledge immediately
res.status(200).send('OK');
// Process asynchronously
await queue.add('process-webhook', JSON.parse(req.body.toString()));
});Handle duplicates
The same event may be delivered more than once (retries, network issues), and a retried event can arrive after newer ones. Use the event id to deduplicate.
async function processWebhook(event) {
// Check if we've already processed this event
const exists = await db.webhookEvents.findUnique({
where: { eventId: event.id }
});
if (exists) {
return; // Already processed
}
// Process the event
await handleEvent(event);
// Mark as processed
await db.webhookEvents.create({
data: { eventId: event.id, processedAt: new Date() }
});
}Use HTTPS
Use an HTTPS endpoint so payloads, which include customer names and messages, are encrypted in transit.
Never forward internal notes
If you relay comments to customers, check comment.isInternal. To relay only the conversation in a chat, subscribe to chat.message instead, which never includes notes.
Debugging
Delivery Log
Recent delivery attempts for a webhook are available from the API:
curl https://api.dispatchtickets.com/v1/brands/ws_abc123/webhooks/whk_xyz789/deliveries \
-H "Authorization: Bearer sk_live_..."Each entry shows:
- The event type
- Its status and number of attempts
- Your endpoint's response code
- Your endpoint's response body, truncated
- Any error
- When it was last tried
Test Webhooks Locally
Use a tool like ngrok to expose your local server to the internet:
# Start ngrok tunnel
ngrok http 3000
# Use the ngrok URL as your webhook endpoint
# https://abc123.ngrok.io/webhooks/dispatchTo trigger an event, create a ticket or add a comment in a test brand.