Back to Docs

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.

Create a webhook
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);
Or with curl
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

FieldRequiredDescription
urlYesPublic endpoint to receive events. Private and internal addresses are refused, and redirects are not followed: use the final URL.
eventsYesEvent types to subscribe to, or * for all of them
secretYesYour signing secret, used to verify deliveries

Event Types

EventTriggered When
ticket.createdA ticket is created, from any channel (API, email, form, portal, chat)
ticket.updatedTicket fields change: status, priority, assignee, company, custom fields
ticket.comment.createdA comment is added: a customer message, an agent reply, or an internal note (check isInternal)
ticket.overdueA ticket passes its SLA due time
ticket.awaiting_replyA 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.startedA website visitor starts a chat (the opening message creates the ticket)
chat.messageA 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:

Webhook payload
{
  "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

data
{
  "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

data
{
  "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

data
{
  "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

data
{
  "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

data
{
  "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, ![name](attachment:att_id). Fetch the file through the attachments API.

ticket.awaiting_reply

data
{
  "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

data
{
  "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_abc123

The 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

Verify webhook signature
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

SDK signature verification
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.

Async processing pattern
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.

Deduplication
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:

List deliveries
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:

Test with ngrok
# Start ngrok tunnel
ngrok http 3000

# Use the ngrok URL as your webhook endpoint
# https://abc123.ngrok.io/webhooks/dispatch

To trigger an event, create a ticket or add a comment in a test brand.