Developers
Webhooks
Endpoints you register receive the events you subscribe to, signed, retried and replayable. 33 event types are available.
Delivery
Register an endpoint in the console's Developer page or with the API, and subscribe it to event types or to all of them. Each event is delivered once per endpoint as a JSON POST with CC-Event-Id, CC-Event-Type, CC-Delivery-Id and CC-Signature headers.
Answer with a 2xx status. Anything else is retried with exponential back-off, and after 8 attempts the delivery goes to the dead letter, from where you can replay it. A replay is a new delivery with the same event id, so use the event id to ignore duplicates.
- Endpoint URLs must use https and resolve to public addresses; redirects are not followed.
- Each endpoint has its own secret, shown once when it is created.
- Rotating the secret signs with both the new and the old secret for 24 hours, so you can switch without missing a delivery.
Verifying the signature
CC-Signature is t=<unix seconds>,v1=<hex>, with a second v1 during a rotation. The v1 value is the HMAC-SHA-256, under the endpoint's secret, of the timestamp, a full stop and the raw request body. Check it against the raw bytes before parsing the JSON, reject timestamps more than 5 minutes away, and remember each delivery you accepted by its timestamp and body (not by the signature that matched) until its timestamp is too old, so a replayed request is refused even with one of its two signatures removed. In Node.js:
// Verify a webhook delivery (Node.js 18 or later, no dependencies).
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
// Deliveries already accepted (a digest of the timestamp and the body), each kept until its
// timestamp is too old to be accepted anyway. With several processes, keep them in your database.
const seen = new Map();
/**
* header: the CC-Signature request header
* rawBody: the request body exactly as received (a Buffer or a string)
* secrets: your endpoint secret, or [new, old] while a rotation is under way
* Returns true only for a genuine delivery that has not been seen before.
*/
export function verifyWebhook(header, rawBody, secrets, nowMs = Date.now()) {
let t = null;
const v1 = [];
for (const part of String(header ?? '').split(',')) {
const [k, v = ''] = part.split('=').map((s) => s.trim());
if (k === 't' && /^[0-9]{1,12}$/.test(v)) t = Number(v);
else if (k === 'v1' && /^[0-9a-f]{64}$/.test(v)) v1.push(v);
}
if (t === null || v1.length === 0) return false;
const now = Math.floor(nowMs / 1000);
if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody);
const genuine = [secrets].flat().some((secret) => {
const want = createHmac('sha256', secret).update(`${t}.`).update(body).digest();
return v1.some((got) => timingSafeEqual(want, Buffer.from(got, 'hex')));
});
if (!genuine) return false;
// A replay is the same timestamp and body, whichever of its signatures it still carries:
// during a rotation a delivery has two, and resending it with only one is still a replay.
const key = createHash('sha256').update(`${t}.`).update(body).digest('hex');
for (const [k, until] of seen) if (until < now) seen.delete(k);
if (seen.has(key)) return false;
seen.set(key, t + TOLERANCE_SECONDS);
return true;
}
// With node:http:
// const chunks = [];
// req.on('data', (c) => chunks.push(c)).on('end', () => {
// const raw = Buffer.concat(chunks);
// if (!verifyWebhook(req.headers['cc-signature'], raw, process.env.CC_WEBHOOK_SECRET)) return res.writeHead(401).end();
// res.writeHead(204).end();
// handle(JSON.parse(raw)); // a retry is a new delivery: use the CC-Event-Id header to ignore an event you already handled
// });
Event catalogue
The event types you can subscribe to and the fields in each event's data. Internal events are never sent.
| Event type | Meaning | Data fields |
|---|---|---|
conversation.updated | A conversation changed status (opened, resolved, closed) or was reopened by the customer. | conversationId, status, reason (optional), disposition (optional) |
conversation.transferred | A conversation moved to another queue or agent. | conversationId, queueId (optional), agentId (optional) |
message.created | A message was received or sent on any channel. Internal notes (and replies synced from Slack or Teams) never leave: only inbound and outbound messages are sent. | conversationId, interactionId, messageId, channel, direction, kind |
message.status | An outbound message moved forward: sent, delivered, read or failed. | messageId, conversationId, status, reason (optional) |
routing.enqueued | Work entered a queue. | workItemId, conversationId, interactionId, channel, queueId |
routing.assigned | An agent took a piece of work. | workItemId, conversationId, agentId |
contact.merged | Two contacts were found to be one customer and merged (automatically on verified identifiers, or by a person). | contactId, mergedContactId, mergeId, kind |
contact.unmerged | A contact merge was undone: what it moved is back on the merged contact. | contactId, mergedContactId, mergeId |
contact.opted_in | A contact gave consent on a channel for a purpose. | contactId, channel, purpose |
contact.opted_out | A contact withdrew consent (STOP, an opt-out disposition, an unsubscribe). | contactId (optional), channel, purpose (optional) |
email.bounced | An e-mail address bounced and is suppressed. | address, kind |
sla.warning | A ticket reached its SLA policy's warning share of a target. | conversationId, caseNumber, kind, dueAt |
sla.breached | A first-response, next-response or resolution target passed unmet. | conversationId, kind, dueAt |
ticket.created | A conversation became a ticket with a reference number. | conversationId, caseNumber, priority |
ticket.updated | A ticket's case fields, status, priority, tags or assignee changed. | conversationId, caseNumber, changes |
ticket.merged | A ticket was merged into another. | conversationId, into, caseNumber |
ticket.split | Threads were split off a ticket into a new one. | conversationId, from, caseNumber, interactionIds |
survey.sent | A CSAT or NPS survey went out after a conversation was resolved. | surveyId, conversationId, channel, kind |
survey.answered | A customer answered a survey. | surveyId, conversationId, kind, score, via, followUpConversationId (optional) |
dialler.attempt | An outbound call attempt changed state. | campaignId, attemptId, leadId, status, outcome (optional) |
dialler.disposition | An agent recorded a call outcome. | campaignId, attemptId, leadId, agentId, code |
campaign.changed | A campaign was created, edited, started, paused or finished. | campaignId, status (optional) |
agent.state | An agent's presence changed. | agentId, state, reason (optional) |
alert.fired | A threshold alert rule fired (queue wait, abandon rate, service level or adherence crossed its threshold). | alertId, ruleId, rule, metric, value, threshold, unit, queueId (optional), text |
alert.resolved | A firing alert rule went back inside its threshold. | alertId, ruleId, rule, metric, value, threshold, unit, queueId (optional), text |
export.ready | An analytics export finished; the data is at downloadUrl until it expires. | exportId, dataset, format, rows, hasMore, nextCursor, downloadUrl (optional), downloadExpiresAt (optional) |
outcome.recorded | A deposit or conversion was recorded and attributed. | outcomeId, kind, amountMinor, currency, occurredAt, agentId (optional), campaignId (optional), channel (optional), templateName (optional) |
secureform.submitted | A customer submitted a secure form sent in a conversation. Ids only: what they sent is never in an event. | requestId, conversationId, formId |
change.requested | Someone asked for a change to a flow, routing, a rule or a price book that needs the approval of a second person. | changeId, objectType, objectId, action |
change.decided | A change request was applied, rejected, cancelled, found stale or failed. | changeId, objectType, objectId, status |
emergency.changed | Emergency mode was switched on or off, tenant-wide or for one queue. | emergencyId, active, scope, queueId (optional), divertQueueId (optional), pausedCampaigns, resumedCampaigns (optional) |
monitor.flagged | An AI conversation monitor flagged a conversation for a person to look at. | resultId, monitorId, monitor, conversationId, severity |
wallet.low_balance | The prepaid balance fell below its threshold. | availableMinor, thresholdMinor, currency |