BluetickedDeveloper docs

Webhooks

Receive delivery and reply events, and verify that they came from us.

Events Blueticked sends you

This page is about events Blueticked sends to your server.

To send events into Blueticked from another system, use Receive events from your app instead: its address and authorization are different.

Delivery and retries

Blueticked POSTs a signed JSON envelope to your endpoint for each subscribed event.

A 2xx within 10 seconds marks the delivery successful; any other status, or no answer in time, is attempted up to 8 times in total (the first try plus retries) with exponential backoff, starting at 10 seconds. These organisation webhooks are a separate delivery system from a form's post-submit “Call a webhook” action (WhatsApp Forms), which retries 3 times. The two retry counts are not a contradiction.

Every attempt of one delivery carries the same delivery_id, also sent as the x-blueticked-delivery-id header so you can check it before parsing the body (verify the signature first: the header itself is not signed). Store it and drop a delivery you have already handled: a retry can arrive after your endpoint did the work but before Blueticked saw your 2xx. The envelope's timestamp is set again on each attempt, so the body and its signature differ from one attempt to the next: dedupe on the delivery id, never on the body. Redirects are not followed: answer at the URL you subscribed, because a 3xx counts as a failed attempt and is retried. Deliveries can also arrive out of order (each is retried on its own schedule), so order events by their occurred_at, never by arrival. A delivery made by a test key carries "test": true in its data: branch on it to keep test traffic out of production side effects.

Paused endpoints

A dead endpoint is paused, not retried forever.

When 15 deliveries in a row fail after all their attempts, the webhook is paused: is_active turns false, your workspace gets an alert, and no more events are sent to it. Any delivery that succeeds resets the count. Fix the endpoint, then turn the webhook back on with PATCH /api/v1/webhooks/{id} { "is_active": true } or Resume in Developers, Webhooks. Resuming starts the count again.

Webhook reference

The envelope, signatures, local testing, payload schemas and every event.

The envelope

Every delivery wraps the event data in this envelope.

The x-blueticked-event header names the event too.

{
  "event": "message.delivered",
  "delivery_id": "702e6079-28a9-48f1-a18a-7ddd140b0b85",
  "organization_id": "5b8ec566-3d60-45a0-b4ad-688c9386f289",
  "timestamp": "2026-07-13T10:30:00.000Z",
  "data": {
    "message_id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
    "business_id": "22222222-2222-4222-9222-222222222222",
    "channel": "whatsapp",
    "status": "delivered",
    "external_id": "wamid.HBg...",
    "occurred_at": "2026-07-13T10:30:00.000Z"
  }
}

Verifying signatures

Check the x-blueticked-signature-v2 header (t=<unix seconds>,v1=<signature>): the timestamp, a full stop, then the raw, unparsed request body (`<t>.<body>`), as lowercase hex HMAC-SHA256 with no sha256= prefix, with your whsec_ secret, compared in constant time.

Refuse a delivery without it, or whose timestamp is older than 5 minutes, so a captured request can't be replayed later. The SDK's verifyWebhook does all of this with only the secret. During a 24h rotation window it carries a second v1 for the old secret.

To check your verifier: secret whsec_test_vector_0137, t 1760281200 and body {"event":"message.delivered","data":{"message_id":"msg_test_vector","channel":"whatsapp","status":"delivered","test":true}} give v1=410e4dd3faefeeb53c2c3c37ba77ccf879fad28cc973c529aeeaaeadeb484eac, bare lowercase hex with no prefix.

The body-only x-blueticked-signature header (sha256=<hex HMAC-SHA256> of the raw body, with x-blueticked-signature-previous during a rotation) stays for older receivers. Accepting it is weaker: a replay with the v2 header stripped passes on it, so the SDK takes it only with requireV2: false, while you migrate. delivery_id is still how you drop a retry you have already handled.

import express from "express";
import { verifyWebhook } from "@blueticked/sdk";

// Only the signing secret is needed: no client, no API key. Keep the RAW body.
app.post("/webhooks/blueticked", express.raw({ type: "application/json" }), async (req, res) => {
  const rawBody = req.body.toString("utf8");
  // It requires the timestamped v2 signature and refuses one older than 5 minutes (a replay). { requireV2: false } as a
  // fourth argument also accepts the body-only signature while you migrate; that is weaker (a replay with v2 stripped).
  if (!(await verifyWebhook(rawBody, req.headers, process.env.BLUETICKED_WEBHOOK_SECRET))) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(rawBody);
  res.sendStatus(200);
});

Receive events on your machine

Blueticked delivers only to a public https address, so run a tunnel to your local server (here on port 3000) and use the address it prints, plus your route, as the webhook's URL:

  • cloudflared tunnel --url http://localhost:3000

    cloudflared prints https://<random>.trycloudflare.com.

  • ngrok http 3000

    ngrok prints https://<name>.ngrok-free.dev.

Then press Send test on the webhook in Developers, Webhooks: a signed test event arrives at your machine. The tunnel address is public, so anyone who learns it can post to your route: the signature check above is what protects it, so verify every delivery before you act.

Payload schemas

message.received

{
  "message_id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
  "conversation_id": "fa3efd47-67b2-428c-b75f-17d1dd2e9790",
  "channel": "sms",
  "from": "+27800000001",
  "body": "BOOK please",
  "media": null,
  "has_media": false,
  "media_type": null,
  "media_mime": null,
  "media_filename": null,
  "media_status": null,
  "occurred_at": "2026-07-13T10:30:00.000Z"
}
  • Fires when a customer replies on a connected channel (SMS, WhatsApp, Telegram); `channel` is sms, whatsapp or telegram.
  • `body` is the inbound reply text. Read it directly, no follow-up call.
  • `media` says what non-text content arrived: null for text-only, or { type, filename } (type is image, video, audio, document or sticker; filename only for documents). A captionless photo has body "" and media.type "image": an empty body with media set is content, not an empty message. Fetch the message via GET /api/v1/conversations/{id}/messages for details once the download completes. Rehearse this shape over the wire without a handset: POST /api/v1/webhooks/{id}/test with media: true delivers the captionless-photo sample.
  • `has_media`, `media_type`, `media_mime`, `media_filename` and `media_status` say the same in the conversations API's own fields. `has_media` is true when a file arrived (image, video, audio, document or sticker) and false for text, a shared location or a contact card; it is null only when the message could not be read when the event fired. `media_mime` is the type the channel declared and `media_filename` is set for documents. The event fires as the message is stored, before the file is fetched, so `media_status` is almost always "pending"; it reads "downloaded" or "failed" on GET /api/v1/conversations/{id}/messages once the fetch finishes.
  • `from` is the sender's phone on SMS and WhatsApp, and the sender's Telegram user id on Telegram; `message_id` is the stored conversation message.

message.sent, message.delivered, message.read, message.failed

{
  "message_id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
  "business_id": "22222222-2222-4222-9222-222222222222",
  "channel": "whatsapp",
  "status": "delivered",
  "external_id": "wamid.HBg...",
  "occurred_at": "2026-07-13T10:30:00.000Z"
}
  • `failure_reason` is added only on message.failed.
  • message.sent and message.failed also carry `conversation_message_id`, the Inbox copy of the message (null when it has none); message.sent carries `cost_zar_cents`, what the send cost in ZAR cents.
  • `channel` is one of whatsapp, sms or email.
  • `business_id` is the business the message was sent for. Route the receipt to that customer without calling back. Null on older messages that predate business attribution.

email.opened, email.clicked, email.bounced, email.complained

{
  "message_id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
  "channel": "email",
  "event": "clicked",
  "to": "sam@example.com",
  "occurred_at": "2026-07-13T10:30:00.000Z"
}
  • `url` is added only on email.clicked.
  • Each fires once per message: the first open, the first click (its url is the first link clicked), the first complaint, and the first bounce that suppressed the address (permanent, undetermined or unclassified). A temporary bounce sends nothing, so a later permanent bounce of the same email still sends email.bounced. A repeat open or click of the same email sends nothing.

contact.created, contact.updated

{
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "business_id": "22222222-2222-4222-9222-222222222222",
  "phone": "+27800000001",
  "email": null
}
  • A contact.updated from a merge also carries `merged_contact_id`, the duplicate merged into `contact_id`, which no longer exists.
  • A contact.updated from an opt-out change also carries `suppression_change`: created when the contact was suppressed, removed when that was lifted.

suppression.created, suppression.removed

{
  "identifier": "+27800000001",
  "channel": "sms",
  "message_type": "all",
  "reason": "stop_keyword",
  "business_id": null,
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "occurred_at": "2026-07-13T10:30:00.000Z"
}

suppression.imported

{
  "channel": "email",
  "message_type": "marketing",
  "reason": "csv_import",
  "business_id": "22222222-2222-4222-9222-222222222222",
  "added": 1250,
  "skipped": 84,
  "invalid": 3,
  "note": "Q2 unsubscribe export",
  "occurred_at": "2026-07-13T10:30:00.000Z"
}

flow.submitted

{
  "flow_id": "2bb7e214-6106-4d25-ba1f-2e28b88dc657",
  "flow_name": "customer_feedback_v1",
  "channel": "whatsapp",
  "contact": {
    "phone": "+27800000001"
  },
  "response": {
    "rating": "5",
    "comment": "Looks good"
  },
  "occurred_at": "2026-07-13T10:30:00.000Z"
}

channel.connected

{
  "channel": "whatsapp",
  "business_id": "22222222-2222-4222-9222-222222222222",
  "via": "connect_link",
  "connect_link_id": "55da18e2-8acb-46cb-9d58-1bc94a287783",
  "reference": "your-customer-id-8842",
  "occurred_at": "2026-07-13T10:30:00.000Z"
}
  • Fires when a channel is connected, including when your customer completes a connect link you sent them, which is the moment you otherwise have no way to learn about.
  • reference is the string you passed when minting the link, echoed back so you can match the connect to your own customer record without storing a map of our ids.
  • via is connect_link or dashboard. reference and connect_link_id are null for a dashboard connect.
  • Fired once per connect: the link is burned by a conditional update and only the winner announces, so a double completion cannot deliver twice.

agent.run.completed

{
  "agent_run_id": "cf5bdc6e-a832-4748-815d-977220db6823",
  "agent_id": "ed3b96f9-4f55-4c07-9b15-df108e1df25d",
  "conversation_id": "fa3efd47-67b2-428c-b75f-17d1dd2e9790",
  "outcome": "completed",
  "occurred_at": "2026-07-13T10:30:00.000Z"
}

ticket.created, ticket.assigned, ticket.updated, ticket.reopened, ticket.resolved

{
  "ticket_id": "89e7776f-f68e-4d24-98de-849d4d296925",
  "number": 1042,
  "category": "Payslip query",
  "title": "Payslip missing for August",
  "status": "open",
  "priority": "normal",
  "assigned_to": null,
  "team_id": null,
  "business_id": "22222222-2222-4222-9222-222222222222",
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "conversation_id": "fa3efd47-67b2-428c-b75f-17d1dd2e9790",
  "channel": "whatsapp",
  "sla_breached_at": null,
  "occurred_at": "2026-08-27T04:00:00.000Z"
}
  • One shape for all five events: every key is always present (null when it does not apply), so a consumer mirroring the queue stores one row and never guesses which keys an event carries. `fields` (the ticket's captured free text) is never sent.
  • `number` is the per-workspace reference the flow can quote back to the customer ({{ticket.number}}); it is unique inside your workspace, not globally.
  • `ticket.created` fires however a ticket is raised: a flow's Create-a-ticket step, a member raising one by hand, or POST /api/v1/tickets. `ticket.assigned` fires when the assignee changes, `ticket.updated` on other changes (priority, title, team, status to in_progress, including the automatic priority bump when a ticket goes overdue), `ticket.reopened` when a resolved ticket reopens, and `ticket.resolved` when it is resolved (dashboard or API).
  • `ticket.resolved` also raises the internal `ticket.resolved` flow event, so a flow with an API-event entry of that name can send a closing message.
  • `channel` is the conversation's channel (whatsapp | sms | email) and null for a ticket with no thread. `sla_breached_at` is stamped once the ticket outlives the workspace's resolution target.

commerce.order.paid, commerce.order.fulfilled, commerce.order.refunded, commerce.order.abandoned

{
  "order_id": "8b43c136-a5d3-4352-99d4-bcf43354c548",
  "reference": "BT-ORD-8H2K1",
  "order_number": 1043,
  "status": "paid",
  "amount_cents": 45900,
  "currency": "ZAR",
  "customer_email": "naledi@example.com",
  "fulfilment_method": "delivery",
  "tracking_reference": null,
  "occurred_at": "2026-08-03T09:14:00.000Z",
  "order": {
    "id": "8b43c136-a5d3-4352-99d4-bcf43354c548",
    "reference": "BT-ORD-8H2K1",
    "number": 1043,
    "status": "paid",
    "amount_cents": 45900,
    "currency": "ZAR",
    "description": "2 items from Aloe Ridge Guest House",
    "customer": {
      "name": "Naledi Dlamini",
      "email": "naledi@example.com",
      "phone": "+27800000001",
      "sms_marketing_opt_in": false
    },
    "fulfilment": {
      "method": "delivery",
      "delivery_address": "12 Protea Rd\nNewlands\nCape Town\n7700",
      "delivery_zone": "Southern Suburbs",
      "delivery_fee_cents": 6000,
      "fulfilled_at": null,
      "tracking_reference": null,
      "waybill_id": null,
      "note": null
    },
    "discount": {
      "code": "WINTER10",
      "amount_cents": 4590
    },
    "items": [
      {
        "id": "258ab704-cb29-4858-a35d-724d150bc85b",
        "product_id": "289e49ce-dfbc-4347-9b4c-52f001f009c3",
        "variant_id": "2a85a3c1-a901-49bf-a2a9-71e9d719a994",
        "variant_name": "Large",
        "name": "Fynbos gift hamper",
        "unit_price_cents": 19950,
        "quantity": 2
      }
    ],
    "site_id": "2503846b-7ed1-42bb-9f5d-f26d5cfdf4a9",
    "paid_at": "2026-08-03T09:14:00.000Z",
    "refunded_at": null,
    "failure_reason": null,
    "link_message_id": null,
    "emails": {},
    "created_at": "2026-08-03T09:12:41.000Z"
  }
}
  • The nested `order` object is the exact shape GET /api/v1/commerce/orders/{id} returns: store the webhook or re-fetch later, the two never disagree on shape.
  • The nested `order` is a snapshot at the moment of the event, `emails` included: an order email sent after it (the confirmation goes out within a minute or so of the payment) shows on a re-fetch, not on the webhook you already have.
  • `commerce.order.fulfilled` fires on manual hand-over AND on a Courier Guy booking; the booking variant carries `tracking_reference` and `order.fulfilment.waybill_id`.
  • `commerce.order.paid` fires once per charge, after the signed Paystack webhook confirms the money, never on redirect.
  • `commerce.order.abandoned` fires when a checkout was started but never paid (Paystack reports it abandoned, or the reference never reached the gateway and aged out). This is the hook for basket-recovery flows.

table_row.created, table_row.status_changed

{
  "event_id": "5b0c7a2e-9d41-4f1e-8a3b-0c2d4e6f8a10",
  "row_id": "8e2f4a6c-1b3d-4e5f-9a7b-2c4d6e8f0a12",
  "table_id": "3c5e7a9b-2d4f-4a6c-8e0b-1d3f5a7c9e24",
  "table_name": "Prescriptions",
  "business_id": "22222222-2222-4222-9222-222222222222",
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "conversation_id": "fa3efd47-67b2-428c-b75f-17d1dd2e9790",
  "status": "ready",
  "previous_status": "received",
  "data": {
    "medicine": "Amoxicillin 500mg",
    "quantity": 21,
    "collect_on": "2026-10-02"
  },
  "source": "flow",
  "occurred_at": "2026-10-01T10:00:00.000Z"
}
  • One shape for both events: every key is always present. `previous_status` is null on `table_row.created`. `status` is the status the event moved to, so a row moved twice in quick succession sends each move as it happened.
  • Delivered at least once: use `event_id` to drop a repeat.
  • `data` holds the row's column values, keyed by column key, in stored form (numbers as numbers, money in cents, dates as YYYY-MM-DD, phones in E.164). Answers a form sent that no column takes are never sent.
  • `source` says how the row arrived: flow, inbox, import, api, manual, or the Leads sources (whatsapp, sms, email, site_form, site_booking, agent_chat). Rows from an import fire `table_row.created` but never the "row added" flows; their later status changes fire both.

Event catalogue

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.