Webhooks
Tell us a URL and we will post events to it as they happen, rather than you polling for something that might take four seconds or four hours.
The payload
POST /your-endpoint HTTP/1.1
Content-Type: application/json
Textmast-Signature: t=1758106800,v1=5257a869e7ecebeda32affa62cdca3fa…
{
"id": "evt_01j8xk2p7n4qc3m9v0wz5bt6ay",
"object": "event",
"type": "message.delivered",
"api_version": "2026-09-01",
"created_at": "2026-09-17T09:14:26+00:00",
"data": {
"id": "01j8xk2p7n4qc3m9v0wz5bt6ay",
"object": "message",
"status": "delivered",
"to": "+447400123456",
"segments": 1,
"price": { "amount": "0.0350", "currency": "GBP" },
"metadata": { "order_id": "SO-4192" }
}
}Event types
Every status transition produces message.<status>, so the list is the same as the one on the delivery reports page:
message.scheduled,message.queued,message.routed,message.submitted,message.sentmessage.reconciling— we lost the carrier's reply and are asking. Not a failuremessage.delivered— the one most people wantmessage.failed,message.expired,message.rejected,message.cancelled
Subscribe with message.* rather than enumerating them, so a status we add later does not silently stop reaching you. * subscribes to everything.
An event fires only on a real transition. A resent or out-of-order carrier callback does not fire your handler a second time, and never fires it backwards.
Verify the signature
The header is Textmast-Signature: t=<unix seconds>,v1=<hex>. The signature is HMAC-SHA256 over "<t>.<raw body>" using your endpoint's signing secret.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verify(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=').map((s) => s.trim())),
);
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
// Replay protection. Without this, anyone who ever captured a "delivered"
// webhook can post it again whenever they like.
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}Sign over the raw body, not a re-serialised object. JSON.parsethen JSON.stringify will reorder keys and change whitespace, and the signature will never match. Most frameworks need to be told to keep the raw body.
The timestamp is inside the signed material, which is the part people skip. Signing the body alone means a captured request stays valid forever and the attacker can set the timestamp to whatever they like.
Respond fast, then do the work
Return 2xx as soon as you have stored the event. Anything else is a failure and will be retried. Do not do your database writes, email sending and PDF generation before replying — a handler that takes thirty seconds will time out, get retried, and do all of it twice.
Retries
Eight attempts after the first, spread over roughly eleven hours:
10s · 30s · 2m · 10m · 30m · 1h · 3h · 6hAfter that the delivery stops being retried, but the event is kept — so an outage on your side is recoverable rather than a permanent hole in your data.
Deduplicate on id
At-least-once, not exactly-once. A response we never saw is a delivery we will retry, and your handler will see the same evt_… twice.
The event id is the same on every endpoint you have registered. That is deliberate: making it unique per endpoint would mean one event arriving twice with two identities, which is exactly what deduplication exists to prevent.
Versioning
Every event carries api_version, pinned to your account when you signed up. We do not change the shape of events under a version — your integration will not break because we shipped something.
https only
The payload carries phone numbers and message bodies. That is personal data, and we will not put it on the wire in clear. http:// endpoints are rejected at creation.