Webhooks
Receive real-time notifications when tickets are created, updated, or closed. 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
Via the Dashboard
Go to Settings → Brands → [Brand] → Webhooks → Create Webhook. Enter your endpoint URL and select which events to receive.
Via the API
const webhook = await dispatch.webhooks.create('br_abc123', {
url: 'https://your-server.com/webhooks/dispatch',
events: ['ticket.created', 'ticket.updated', 'comment.created'],
secret: 'whsec_your_signing_secret', // Optional, auto-generated if omitted
});
// Save webhook.secret securely - you'll need it to verify signatures
console.log(webhook.id); // 'wh_xyz789'
console.log(webhook.secret); // 'whsec_...'Webhook Configuration
| Field | Required | Description |
|---|---|---|
| url | Yes | HTTPS endpoint to receive events |
| events | Yes | Array of event types to subscribe to |
| secret | No | Signing secret (auto-generated if omitted) |
| enabled | No | Whether webhook is active (default: true) |
Event Types
| Event | Triggered When |
|---|---|
| ticket.created | A new ticket is created (via API, email, or portal) |
| ticket.updated | Ticket fields change (status, assignee, custom fields) |
| ticket.closed | Ticket status changes to closed |
| ticket.reopened | Closed ticket is reopened |
| comment.created | A comment is added to a ticket |
| customer.created | A new customer is created |
Subscribe to * to receive all events:
events: ['*'] // All current and future event typesPayload Format
Webhook payloads are JSON with a consistent structure:
{
"id": "evt_abc123", // Unique event ID
"type": "ticket.created", // Event type
"createdAt": "2024-01-15T10:30:00Z",
"brandId": "br_xyz789", // Brand where event occurred
"data": {
// Event-specific payload
}
}ticket.created
{
"id": "evt_abc123",
"type": "ticket.created",
"createdAt": "2024-01-15T10:30:00Z",
"brandId": "br_xyz789",
"data": {
"ticket": {
"id": "tkt_def456",
"ticketNumber": "TKT-1042",
"title": "Need help with my order",
"status": "open",
"priority": "normal",
"source": "email",
"customer": {
"id": "cus_ghi789",
"email": "[email protected]",
"name": "Jane Doe"
},
"createdAt": "2024-01-15T10:30:00Z"
}
}
}ticket.updated
{
"id": "evt_abc124",
"type": "ticket.updated",
"createdAt": "2024-01-15T11:00:00Z",
"brandId": "br_xyz789",
"data": {
"ticket": {
"id": "tkt_def456",
// ... full ticket object
},
"changes": {
"status": { "from": "open", "to": "pending" },
"assigneeId": { "from": null, "to": "usr_abc123" }
}
}
}comment.created
{
"id": "evt_abc125",
"type": "comment.created",
"createdAt": "2024-01-15T11:15:00Z",
"brandId": "br_xyz789",
"data": {
"comment": {
"id": "cmt_jkl012",
"body": "Thanks for reaching out! Let me look into this.",
"authorType": "STAFF",
"authorName": "Support Team",
"createdAt": "2024-01-15T11:15:00Z"
},
"ticket": {
"id": "tkt_def456",
"ticketNumber": "TKT-1042"
}
}
}Signature Verification
Every webhook request includes a signature header to verify the request came from Dispatch Tickets. Always verify signatures to prevent spoofed requests.
Signature Header
X-Dispatch-Signature: t=1705312200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9The header contains:
t- Unix timestamp when the signature was generatedv1- HMAC-SHA256 signature of the payload
Verification Steps
- Extract the timestamp and signature from the header
- Construct the signed payload:
${timestamp}.${rawBody} - Compute HMAC-SHA256 using your webhook secret
- Compare signatures using constant-time comparison
- Verify timestamp is within tolerance (e.g., 5 minutes)
Node.js / TypeScript
import crypto from 'crypto';
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
tolerance = 300 // 5 minutes
): boolean {
// Parse signature header
const parts = signature.split(',');
const timestamp = parseInt(parts.find(p => p.startsWith('t='))?.slice(2) || '0');
const expectedSig = parts.find(p => p.startsWith('v1='))?.slice(3);
if (!timestamp || !expectedSig) {
return false;
}
// Check timestamp tolerance
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > tolerance) {
return false; // Request too old or from the future
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const computedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(expectedSig),
Buffer.from(computedSig)
);
}
// Usage in Express
app.post('/webhooks/dispatch', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-dispatch-signature'] as string;
const payload = req.body.toString();
if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET!)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(payload);
// Process event...
res.status(200).send('OK');
});Using the SDK
import { verifyWebhookSignature } from '@dispatchtickets/sdk';
app.post('/webhooks/dispatch', express.raw({ type: 'application/json' }), (req, res) => {
const isValid = verifyWebhookSignature(
req.body.toString(),
req.headers['x-dispatch-signature'],
process.env.WEBHOOK_SECRET!
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Process event...
});Retry Behavior
If your endpoint returns a non-2xx status code or times out, we automatically retry delivery:
| Attempt | Delay | Time Since First Attempt |
|---|---|---|
| 1 (initial) | Immediate | 0 |
| 2 | 1 minute | 1 minute |
| 3 | 5 minutes | 6 minutes |
| 4 | 30 minutes | 36 minutes |
| 5 (final) | 2 hours | ~2.5 hours |
After 5 failed attempts, the event is marked as failed. You can view failed deliveries in the dashboard and manually retry them.
Timeout: Your endpoint must respond within 30 seconds. For long-running processing, 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', async (req, res) => {
// Verify signature first
if (!verifySignature(req)) {
return res.status(401).send('Invalid signature');
}
// Acknowledge immediately
res.status(200).send('OK');
// Process asynchronously
const event = JSON.parse(req.body);
await queue.add('process-webhook', event);
});Handle duplicates
The same event may be delivered multiple times (retries, network issues). 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
Webhook endpoints must use HTTPS. We don't deliver to HTTP URLs.
Monitor failures
Set up alerts for webhook failures. The dashboard shows delivery status and allows manual retries.
Debugging
Webhook Logs
View recent deliveries in Settings → Brands → [Brand] → Webhooks → [Webhook] → Logs. Each log entry shows:
- Event type and ID
- Request payload
- Response status code
- Response body (truncated)
- Delivery time
Test Webhooks Locally
Use a tool like ngrok to expose your local server:
# Start ngrok tunnel
ngrok http 3000
# Use the ngrok URL as your webhook endpoint
# https://abc123.ngrok.io/webhooks/dispatchSend Test Events
Use the dashboard or API to send a test event to your webhook:
await dispatch.webhooks.test('br_abc123', 'wh_xyz789', {
type: 'ticket.created' // Event type to simulate
});