Skip to content

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

EventFires when
message.receivedA customer sent an inbound message into a connected channel.
message.sentAn outbound message was accepted by the provider.
message.deliveredA provider confirmed delivery to the recipient.
message.readA provider confirmed the recipient read the message.
message.failedA provider or Blueticked failed to send the message.
email.openedA recipient opened an email (first open per message).
email.clickedA recipient clicked a link in an email (first click per message).
email.bouncedAn email bounced for good (a permanent, undetermined or unclassified bounce) and the address was suppressed. A temporary bounce sends nothing.
email.complainedA recipient marked an email as spam; the address was suppressed.
contact.createdA contact was created through the API or workspace UI.
contact.updatedA contact profile or channel flag changed.
suppression.createdAn identifier was added to the do-not-contact list (STOP, unsubscribe, manual or API add).
suppression.removedA do-not-contact entry was removed (START resubscribe, manual or API delete).
suppression.importedA bulk opt-out import finished — one digest with counts, not one event per row.
flow.submittedA customer completed a WhatsApp Flow.
channel.connectedA channel was connected to a business — including when your customer completed a connect link you sent them.
agent.run.completedAn AI agent completed a handled run.
ticket.createdA ticket was raised — by a flow's create-a-ticket step, by hand from a conversation, or via the API.
ticket.assignedA ticket was put on someone's desk (assignee set or changed).
ticket.updatedA ticket's status, priority or title changed (excluding resolve/reopen, which have their own events).
ticket.reopenedA resolved ticket was reopened.
ticket.resolvedA ticket was marked resolved in Control Centre (or via the API).
commerce.order.paidA shop order was paid (confirmed by the signed payment webhook).
commerce.order.fulfilledA paid order was marked handed over — shipped, collected, or courier-booked.
commerce.order.refundedA paid order was refunded in full.
commerce.order.abandonedA checkout was started but never paid — the recovery hook for a follow-up flow.
table_row.createdA row was added to a table — by a form, the Inbox, an import, the API or by hand.
table_row.status_changedA 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.

verify.ts (SDK)
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 catch
verify.ts (no SDK, Node)
import { 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": true in 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.