Skip to navigation

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:

{
"event": "delivered",
"channel": "whatsapp",
"api_key": { "uuid": "…", "name": "Production integration" },
"timestamp": "2026-05-06T10:15:00.123Z",
"data": { "…event- and channel-specific fields…" }
}
  • channel tells you which channel produced the event. Status payloads carry the same data fields on every channel, so one handler parses both.
  • 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_uuid, msisdn, status, broadcast_uuid
WhatsApp · sent delivered read failedmessage_uuid, msisdn, status, broadcast_uuid
WhatsApp · inboundmessage_uuid, msisdn, conversation_uuid, message_type, body, media_url, contact_name, received_at

The status fields mean:

FieldMeaning
message_uuidThe Teekrr message UUID - the same uuid the send endpoint returned and that Message Logs is keyed on. Use it to correlate the event back to the recipient you submitted. Provider-side message identifiers are never exposed.
msisdnThe recipient, as it was submitted.
statusThe readable status, always identical to the top-level event.
broadcast_uuidThe broadcast this message belonged to. null only where the message cannot be attributed to one.

Two events, one shape:

{
"event": "delivered",
"channel": "sms",
"api_key": { "uuid": "d6ac9976-215a-4f0a-8d69-35965657d06f", "name": "Webhook test" },
"timestamp": "2026-08-26T06:40:13.740Z",
"data": {
"message_uuid": "2843d44b-aa50-4a75-8e86-0cfdff3d9c25",
"msisdn": "60172342869",
"status": "delivered",
"broadcast_uuid": "f8a78a4c-21ca-4f20-af4f-6b39f479381b"
}
}
{
"event": "delivered",
"channel": "whatsapp",
"api_key": { "uuid": "d6ac9976-215a-4f0a-8d69-35965657d06f", "name": "Webhook test" },
"timestamp": "2026-08-26T06:38:37.980Z",
"data": {
"message_uuid": "1f0c4e77-3b2a-4d18-9a6e-70b5c1d2e8f4",
"msisdn": "+60162167517",
"status": "delivered",
"broadcast_uuid": "f8a78a4c-21ca-4f20-af4f-6b39f479381b"
}
}

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.

import express from "express";
import { createHmac, timingSafeEqual } from "crypto";
const app = express();
const SECRET = process.env.TEEKRR_WEBHOOK_SECRET; // "whsec_…"
const TOLERANCE_SECONDS = 300;
function parseSignature(header) {
const parts = Object.fromEntries(
(header ?? "").split(",").map((kv) => kv.split("=", 2))
);
return { t: Number(parts.t), v1: parts.v1 };
}
// IMPORTANT: capture the raw body. JSON parsing changes whitespace and breaks the signature.
app.post(
"/webhooks/teekrr",
express.raw({ type: "application/json" }),
(req, res) => {
const { t, v1 } = parseSignature(req.header("X-Teekrr-Signature"));
if (!t || !v1) return res.status(401).send("malformed signature");
// Reject replays.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) {
return res.status(401).send("signature too old");
}
const signed = Buffer.concat([Buffer.from(`${t}.`), req.body]);
const expected = createHmac("sha256", SECRET).update(signed).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send("invalid signature");
}
const payload = JSON.parse(req.body.toString("utf8"));
// payload.event - "delivered" | "failed" | "sent" | "read" | "inbound"
// payload.channel - "sms" | "whatsapp" | "email"
// payload.data - shape depends on payload.channel
// ... your business logic ...
res.status(200).send("ok");
}
);

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 (event, data.message_uuid) - the same key on every channel.

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