Webhooks guide

Receive message events at your own URL with HMAC-SHA256 verification.

View as Markdown

As a message moves through its lifecycle - and when a customer replies on WhatsApp - Teekrr POSTs a JSON event to every active webhook endpoint you’ve registered for that event type. Webhooks are managed in-app at /api-management → Webhooks.

The five event types

EventWhenChannels
sentThe provider accepted the message and handed it offWhatsApp
deliveredThe provider confirms delivery to the recipient’s deviceSMS · WhatsApp
readThe recipient opened the message (if read receipts are on)WhatsApp
failedThe provider returned a permanent failure; reserved credit is releasedSMS · WhatsApp
inboundA customer sent you a messageWhatsApp

The email channel emits no webhook events. Email broadcasts are dispatched synchronously - the sent / failed / total counts in the POST /email response are the complete result. Use the dashboard’s Message Logs for per-recipient detail.

You can register an Email-channel subscription through the API, but it will never fire.

Headers Teekrr sends

HeaderValue
Content-Typeapplication/json
X-Teekrr-Signaturet=<unix seconds>,v1=<HMAC-SHA256 hex> - see Verifying the signature
X-Teekrr-EventThe event name: sent | delivered | read | failed | inbound
X-Teekrr-Channelsms | whatsapp | email

Payload envelope

Every body shares this five-key envelope:

1{
2 "event": "delivered",
3 "channel": "whatsapp",
4 "api_key": { "uuid": "", "name": "Production integration" },
5 "timestamp": "2026-05-06T10:15:00.123Z",
6 "data": { "…event- and channel-specific fields…" }
7}
  • channel tells you how to parse data - the SMS and WhatsApp payloads have different field names. Always read it before touching data.
  • api_key names the integration whose broadcast produced the event, so a consumer whose several keys point at one endpoint can tell them apart. It is null for a dashboard-composed send and for inbound.

All data field names are snake_case. See the Webhooks group in the API Reference for the full per-event schemas; in short:

Channel · eventsdata fields
SMS · delivered failedmessage_id, provider_status, msisdn
WhatsApp · sent delivered read failedprovider_message_id, msisdn, status, broadcast_uuid
WhatsApp · inboundprovider_message_id, msisdn, conversation_uuid, message_type, body, media_url, contact_name, received_at

Which events you receive

Four filters decide whether a given event reaches a given endpoint. All four must pass.

  1. event_types - required on every subscription, minimum one. You only receive the events you list.
  2. channels - optional. Omit it (or leave it null) to receive every channel; supply a subset to narrow. An empty array is rejected at registration.
  3. api_key_uuid - optional. Bind a subscription to one API key so it only fires for that integration’s broadcasts. null means any key.
  4. Broadcast origin - status events (sent, delivered, read, failed) fan out only for broadcasts sent through the API. A broadcast composed in the dashboard has no integration waiting on a callback and emits nothing.

Point 4 is the usual answer to “I registered a webhook and sent a test broadcast from the dashboard, but nothing arrived.” Send via POST /sms or POST /whatsapp to see status events.

inbound is exempt - a customer’s reply has no origin to attribute, so you receive it regardless of how the original message was sent. Inbound messages on the shared platform-default WhatsApp number are not attributable to one client and never fire.

Verifying the signature

The X-Teekrr-Signature header is not a bare hex digest. It carries a timestamp and a versioned signature:

X-Teekrr-Signature: t=1746526500,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

To verify:

  1. Parse t (unix seconds) and v1 (hex) out of the header.
  2. Build the signed string as `${t}.${rawBody}` - the timestamp, a literal ., then the raw, untouched request body.
  3. Compute HMAC-SHA256 of that string using your webhook’s plaintext signing secret (the whsec_… value shown once at creation) as the key.
  4. Compare against v1 with a constant-time comparison.
  5. Reject the request if t is more than 5 minutes old.

The HMAC key is the plaintext secret exactly as issued - do not hash it first.

The timestamp is signed alongside the body, not merely sent with it. A signature over the body alone would stay valid forever, so a captured delivery could be replayed at any time and still verify. Enforce the age window in your handler - step 5 above.

1import express from "express";
2import { createHmac, timingSafeEqual } from "crypto";
3
4const app = express();
5const SECRET = process.env.TEEKRR_WEBHOOK_SECRET; // "whsec_…"
6const TOLERANCE_SECONDS = 300;
7
8function parseSignature(header) {
9 const parts = Object.fromEntries(
10 (header ?? "").split(",").map((kv) => kv.split("=", 2))
11 );
12 return { t: Number(parts.t), v1: parts.v1 };
13}
14
15// IMPORTANT: capture the raw body. JSON parsing changes whitespace and breaks the signature.
16app.post(
17 "/webhooks/teekrr",
18 express.raw({ type: "application/json" }),
19 (req, res) => {
20 const { t, v1 } = parseSignature(req.header("X-Teekrr-Signature"));
21 if (!t || !v1) return res.status(401).send("malformed signature");
22
23 // Reject replays.
24 if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) {
25 return res.status(401).send("signature too old");
26 }
27
28 const signed = Buffer.concat([Buffer.from(`${t}.`), req.body]);
29 const expected = createHmac("sha256", SECRET).update(signed).digest("hex");
30
31 const a = Buffer.from(expected, "hex");
32 const b = Buffer.from(v1, "hex");
33 if (a.length !== b.length || !timingSafeEqual(a, b)) {
34 return res.status(401).send("invalid signature");
35 }
36
37 const payload = JSON.parse(req.body.toString("utf8"));
38 // payload.event - "delivered" | "failed" | "sent" | "read" | "inbound"
39 // payload.channel - "sms" | "whatsapp" | "email"
40 // payload.data - shape depends on payload.channel
41
42 // ... your business logic ...
43
44 res.status(200).send("ok");
45 }
46);

Always use the raw, untouched request body. JSON parsing changes whitespace and breaks the signature. Express needs express.raw({ type: "application/json" }). FastAPI needs await request.body() before request.json().

The signing secret

The plaintext secret (whsec_ followed by 40 hex characters) is returned once, in the 201 response when you create the webhook. Store it server-side immediately - afterwards only its first 12 characters are retrievable, and there is no way to recover the rest. If you lose it, delete the webhook and create a new one.

Retries & timeouts

  • Teekrr applies a 10-second timeout per delivery.
  • Success is any 2xx. Everything else is recorded as a failure.
  • Redirects are not followed. A 3xx response is a hard failure - register the final URL directly.
  • Teekrr does not retry failed deliveries. Design your endpoint to be idempotent anyway, and use the dashboard’s Message Logs as a backstop.
  • Your endpoint must be a public HTTPS address. Endpoints that stop resolving to one are blocked, and the attempt is recorded in your delivery logs.
  • Every delivery attempt is recorded at /api-management → Webhooks → Delivery Logs, including the first 512 characters of your response body.

Idempotency

There is no delivery-level event ID. De-duplicate on the provider message identifier inside data, paired with the event name:

  • SMS - (event, data.message_id)
  • WhatsApp - (event, data.provider_message_id)

A single message legitimately produces several events (sent, then delivered, then read), so the event name has to be part of the key.