Configure an endpoint URL in your Texto dashboard and we'll POST a JSON event every time a delivery receipt is recorded for one of your messages. Signed with HMAC-SHA256, retried on failure, and idempotent by design.
In the Texto dashboard, go to Developer → Webhooks, paste your endpoint URL, and enable the webhook.
Copy your signing secret. Store it as TEXTO_WEBHOOK_SECRET in your server environment — never commit it to source control.
Deploy an HTTPS endpoint that verifies the X-Texto-Signature header (snippet below) and returns a 2xx response within 15 seconds.
Fired once per delivery receipt for any message on your account, including team-member sends.
<your endpoint URL>| Header | Value | Description |
|---|---|---|
| Content-Type | application/json | Body is always UTF-8 JSON. |
| X-Texto-Event | message.dlr | Event type. Currently always message.dlr. |
| X-Texto-Delivery | UUID | Unique per delivery. Same UUID is sent on retries — dedupe on this. |
| X-Texto-Signature | sha256=<hex> | HMAC-SHA256 of the raw body using your signing secret. Only sent when signing is enabled. |
Byte-identical to GET /message/:id on the REST API — same fields, same names, same types. Anything new added there is automatically sent here.
{
"message": {
"id": "8c1f9b2e-1a4c-4f87-9bd2-2d2f6f6f6f6f",
"recipient": "+61412345678",
"body": "Hi Sam, your appointment is confirmed for Tue 9am.",
"status": "delivered",
"sent_at": "2026-05-06T03:14:22.000Z"
},
"delivery_receipt": {
"status": "delivered",
"received_at": "2026-05-06T03:14:25.421Z"
}
}Always verify the signature before trusting the payload. Use the raw request bytes (not a re-serialised JSON string) and a constant-time comparison.
import crypto from "node:crypto";
app.post("/webhooks/texto", express.raw({ type: "application/json" }), (req, res) => {
const signatureHeader = req.header("x-texto-signature") || "";
const expected = crypto
.createHmac("sha256", process.env.TEXTO_WEBHOOK_SECRET)
.update(req.body) // raw bytes — not JSON.stringify(parsed)
.digest("hex");
const provided = signatureHeader.replace(/^sha256=/, "");
const ok =
provided.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(provided, "hex"), Buffer.from(expected, "hex"));
if (!ok) return res.status(401).send("invalid signature");
const event = JSON.parse(req.body.toString("utf8"));
// event.message, event.delivery_receipt …
res.sendStatus(200);
});Reply with any 2xx status code (typically 200 OK or 204 No Content) to acknowledge receipt. The response body is ignored — you don't need to return JSON. Respond within 15 seconds; longer than that and we treat it as a failure and retry.
| Your response | What we do |
|---|---|
| 2xx | Marked delivered. No retry. |
| 4xx | Treated as a failure and retried on the schedule below — fix your endpoint and the next attempt will succeed. |
| 5xx | Retried on the schedule below. |
| No response / timeout | Retried on the schedule below. |
Up to 3 attempts in total. After the third failure the delivery is dropped and shown as failed in your "Recent deliveries" log.
| Attempt | When |
|---|---|
| 1 | Immediately when the delivery receipt is recorded |
| 2 | ~1 minute after attempt 1 fails |
| 3 | ~5 minutes after attempt 2 fails |
| — | After a further ~30 minutes the delivery is given up on |
The same X-Texto-Delivery UUID is sent on every retry, so retried events always look like duplicates of attempt 1 — dedupe on it.
Network hiccups can cause your endpoint to receive the same delivery twice. The X-Texto-Delivery header is stable across retries — store and dedupe on it before processing.
Configure your webhook endpoint in the Texto dashboard and you're live in minutes.