senderZ pushes events to a URL you register. Same endpoint receives:
- Delivery callbacks (
message.queued,message.sent,message.delivered,message.failed) - Inbound messages (
message.received) - Voice events (
voice.intent,voice.summary) — see voice docs
Register a webhook
curl -X POST https://api.senderz.com/v1/webhooks \
-H "Authorization: Bearer tf_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.example.com/senderz",
"events": ["message.received", "message.delivered", "message.failed"],
"secret": "your-random-32-byte-hex-secret"
}'
The secret is the HMAC key we’ll sign every payload with — pick something strong and store it where you’ll verify incoming requests.
Authentication
Every webhook POST carries:
| Header | Value |
|---|---|
X-Senderz-Signature | sha256=<hex> — HMAC-SHA256 of the raw body using your secret |
X-Senderz-Event | The event name, e.g. message.delivered |
X-Senderz-Sandbox | 1 when the event came from a sandbox-key send |
Verify the signature with timing-safe compare:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody: string, header: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
const provided = header.replace('sha256=', '')
return (
expected.length === provided.length &&
timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(provided, 'hex'))
)
}
Inbound message payload
{
"event": "message.received",
"message_id": "01HXY3M0EXAMPLEINBOUNDID",
"tenant_id": "01HXY3M0EXAMPLETENANT",
"from": "+15551234567",
"to": "+15555550100",
"body": "yes please",
"channel": "sms",
"received_at": "2026-05-01T18:23:11Z"
}
Delivery callback payloads
message.delivered:
{
"event": "message.delivered",
"message_id": "01HXY3M0KEXAMPLEMSGID",
"tenant_id": "01HXY3M0EXAMPLETENANT",
"to": "+15551234567",
"channel": "imessage",
"delivered_at": "2026-05-01T18:23:14Z"
}
message.failed:
{
"event": "message.failed",
"message_id": "01HXY3M0KEXAMPLEMSGID",
"tenant_id": "01HXY3M0EXAMPLETENANT",
"to": "+15551234567",
"channel": "sms",
"reason": "invalid_number",
"failed_at": "2026-05-01T18:23:15Z"
}
Failure reason taxonomy
reason | Permanent? | Recommended action |
|---|---|---|
invalid_number | yes | Add to your DNC list |
carrier_error | no | Retry with backoff |
opted_out | yes | senderZ already blocks these — do not retry |
quota_exceeded | no | Wait for quota reset |
bluebubbles_error | no | Transient infrastructure; retry |
STOP / HELP autoresponse
senderZ handles inbound STOP and HELP keywords automatically:
- STOP (
STOP/STOPALL/UNSUBSCRIBE/CANCEL/END/QUIT) — adds the sender to the tenant’s opt-out registry. Future sends fail withreason: opted_out. - HELP (
HELP/INFO/AIDE) — sends a CTIA-compliant reply assembled from your persona. Configure the override atPUT /v1/ai/personawithhelp_response.
You still receive the message.received webhook for both. Do not reply on top of our autoresponse — the carrier may flag the conversation as a loop.
Retry policy
We retry failed webhook deliveries up to 3 times with exponential backoff (2s, 4s, 8s). Subsequent deliveries are best-effort — log them in webhook_logs. Return any 2xx within 10 seconds to acknowledge.
Related
- Send a message — the request side
- Compliance — STOP/HELP details + opt-out lifecycle
- Testing — sandbox webhook dispatch flag