Webhooks and signing
Webhooks push events to your server as they happen — use them instead of polling. Create subscriptions in the dashboard under Developers → Webhooks, choosing which events each endpoint receives.
To send events into Blueticked from another system, use Receive events from your app instead. The connection address and authorization are different.
Events
| Event | Fires when |
|---|---|
message.received | A customer sent an inbound message into a connected channel. |
message.sent | An outbound message was accepted by the provider. |
message.delivered | A provider confirmed delivery to the recipient. |
message.read | A provider confirmed the recipient read the message. |
message.failed | A provider or Blueticked failed to send the message. |
email.opened | A recipient opened an email (first open per message). |
email.clicked | A recipient clicked a link in an email (first click per message). |
email.bounced | An email bounced for good (a permanent, undetermined or unclassified bounce) and the address was suppressed. A temporary bounce sends nothing. |
email.complained | A recipient marked an email as spam; the address was suppressed. |
contact.created | A contact was created through the API or workspace UI. |
contact.updated | A contact profile or channel flag changed. |
suppression.created | An identifier was added to the do-not-contact list (STOP, unsubscribe, manual or API add). |
suppression.removed | A do-not-contact entry was removed (START resubscribe, manual or API delete). |
suppression.imported | A bulk opt-out import finished — one digest with counts, not one event per row. |
flow.submitted | A customer completed a WhatsApp Flow. |
channel.connected | A channel was connected to a business — including when your customer completed a connect link you sent them. |
agent.run.completed | An AI agent completed a handled run. |
ticket.created | A ticket was raised — by a flow's create-a-ticket step, by hand from a conversation, or via the API. |
ticket.assigned | A ticket was put on someone's desk (assignee set or changed). |
ticket.updated | A ticket's status, priority or title changed (excluding resolve/reopen, which have their own events). |
ticket.reopened | A resolved ticket was reopened. |
ticket.resolved | A ticket was marked resolved in Control Centre (or via the API). |
commerce.order.paid | A shop order was paid (confirmed by the signed payment webhook). |
commerce.order.fulfilled | A paid order was marked handed over — shipped, collected, or courier-booked. |
commerce.order.refunded | A paid order was refunded in full. |
commerce.order.abandoned | A checkout was started but never paid — the recovery hook for a follow-up flow. |
table_row.created | A row was added to a table — by a form, the Inbox, an import, the API or by hand. |
table_row.status_changed | A table row moved to another status. |
Verify every delivery
Each request carries x-blueticked-event (the event name) and x-blueticked-signature-v2: t=<unix seconds>,v1=<signature>. The signature is the lowercase hex HMAC-SHA256 of <t>.<raw body> keyed with your endpoint secret, with no sha256= prefix. Refuse a timestamp more than 5 minutes from now, so a captured delivery cannot be replayed. During a secret rotation the header carries a second v1 for the previous secret; accept either. Always verify against the raw body; re-serializing parsed JSON breaks the signature.
The older x-blueticked-signature (sha256= HMAC of the body only) is still sent, but it cannot refuse a replay, so do not rely on it.
const event = await bt.webhooks.constructEvent(
rawBody,
req.headers, // the whole header set: it checks x-blueticked-signature-v2
process.env.BLUETICKED_WEBHOOK_SECRET!,
); // throws on a bad or stale signature, so return 401 in your catchimport { createHmac, timingSafeEqual } from "node:crypto";
// Refuse a timestamp further than this from now, so a captured delivery cannot be replayed.
const TOLERANCE_SECONDS = 300;
function verify(rawBody: string, headers: Headers, secret: string): boolean {
const v2 = headers.get("x-blueticked-signature-v2"); // t=<unix seconds>,v1=<hex>
if (!v2) return false;
const parts = v2.split(",").map((part) => part.split("="));
const stamp = parts.find(([key]) => key === "t")?.[1] ?? "";
if (!/^\d{1,12}$/.test(stamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(stamp)) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
createHmac("sha256", secret).update(`${stamp}.${rawBody}`).digest("hex"),
);
// During a secret rotation a second v1 carries the previous secret's signature: accept either.
return parts
.filter(([key]) => key === "v1")
.some(([, v1]) => {
const got = Buffer.from(v1 ?? "");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}Delivery and retries
- Respond with any 2xx within 10 seconds; do slow work asynchronously.
- Failed deliveries are retried with backoff; every attempt is visible (and replayable) on Developers → Webhooks, in its delivery log. Incoming connection events have their own Deliveries tab under Connected apps → Integrations.
- Sandbox (
blu_test_) traffic fires the same events with"test": truein the payload — branch on it to keep test data out of production side effects. - Deliveries can arrive out of order or more than once; treat handlers as idempotent.