All docs

Webhooks

Register your endpoint, verify HMAC signatures, and consume delivery callbacks + inbound messages.

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:

HeaderValue
X-Senderz-Signaturesha256=<hex> — HMAC-SHA256 of the raw body using your secret
X-Senderz-EventThe event name, e.g. message.delivered
X-Senderz-Sandbox1 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

reasonPermanent?Recommended action
invalid_numberyesAdd to your DNC list
carrier_errornoRetry with backoff
opted_outyessenderZ already blocks these — do not retry
quota_exceedednoWait for quota reset
bluebubbles_errornoTransient 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 with reason: opted_out.
  • HELP (HELP / INFO / AIDE) — sends a CTIA-compliant reply assembled from your persona. Configure the override at PUT /v1/ai/persona with help_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.