Inbound SMS Webhook Setup: Receive and Reply to Texts
Inbound SMS webhook setup means giving your messaging provider an HTTPS URL that it POSTs to every time someone texts your number. Sending is a request you make; receiving is a request made to you — which is why webhooks trip up developers who have only ever called APIs, never hosted an endpoint for one. This guide covers the full loop on senderZ: register the webhook, verify the HMAC-SHA256 signature so forged requests never reach your business logic, and reply through the send API. Every example is runnable — curl for the one-off calls, TypeScript for the handler.
How Inbound SMS Webhooks Work
A webhook is a reverse API call. Instead of your code polling GET /v1/messages every few seconds hoping something new arrived, senderZ pushes each inbound message to your server the moment it lands:
Someone texts your senderZ number
│
▼
senderZ ingests the message (iMessage or SMS — same pipeline)
│
│ compliance pass runs first: STOP/START/HELP keywords
│ are processed and the opt-out list is updated before
│ anything reaches your code
▼
POST https://yourapp.com/webhooks/senderz
│
│ X-Senderz-Signature: sha256=<hex HMAC of the body>
▼
Your endpoint: verify signature → return 200 → process → reply
Two details in that flow are worth pausing on.
First, compliance runs before your webhook fires. When a contact texts STOP, senderZ records the opt-out and blocks future sends to that number on its own — your handler receives the event but carries zero legal burden for processing the keyword. The SMS compliance guide for developers covers what the TCPA expects beyond keyword handling.
Second, one webhook covers both channels. An inbound iMessage and an inbound SMS produce the same payload shape; the channel field tells you which rail the message arrived on. You write one handler, not two.
Register Your Inbound SMS Webhook
Registration is a single API call. You provide three things: the HTTPS URL senderZ should POST to, the list of events you want, and a signing secret that senderZ will use to compute the signature on every delivery.
curl -X POST https://api.senderz.com/v1/webhooks \
-H "Authorization: Bearer tf_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/senderz",
"events": ["message.received"],
"secret": "whsec_pick_a_long_random_string"
}'
A successful registration returns 201 Created:
{
"id": "01JXCF9K2QW8N5T3V7R4M6YBZD",
"url": "https://yourapp.com/webhooks/senderz",
"events": ["message.received"],
"active": true,
"created_at": "2026-06-10T17:40:00.000Z"
}
Save the id — you need it later to read delivery logs or rotate the secret. Three validation rules apply, and each failure comes back as 400 with { "error": "...", "code": "VALIDATION_ERROR" }:
- The URL must be HTTPS. Plain
http://registrations are rejected outright. Signatures authenticate the sender; TLS protects the message contents in transit — you need both. - The secret must be at least 16 characters. Generate it randomly (
openssl rand -hex 24works) rather than typing something memorable. senderZ encrypts it at rest with AES-256-GCM. - The events array must be non-empty and drawn from the supported list below.
| Event | Fires when |
|-------|-----------|
| message.queued | Your outbound message was accepted and queued for routing |
| message.sent | The message left senderZ's delivery infrastructure |
| message.delivered | Delivery confirmation came back from the channel |
| message.failed | Delivery failed after routing and retries |
| message.received | Someone texted one of your numbers — the subject of this guide |
This post subscribes to message.received only. Adding the delivery-status events to the same registration later is a PUT /v1/webhooks/:id call, not a new endpoint. The webhook API reference documents every field.
Build the Webhook Endpoint and Verify the HMAC Signature
Here is what senderZ POSTs to your URL when a text arrives:
{
"event": "message.received",
"message_id": "01JXCFA8Q2V7N4T9W6B3ZK5MRD",
"from": "+15550142368",
"to": "+15559873321",
"body": "Does Thursday at 2pm still work?",
"channel": "imessage",
"timestamp": "2026-06-10T17:42:09.000Z"
}
from is the person who texted you, to is your senderZ number, and message_id is a ULID you can use for deduplication. Inbound events identify themselves through the event field in the payload; delivery-status events additionally carry an X-Senderz-Event header. Branch on the payload field and your handler covers both.
Every delivery includes an X-Senderz-Signature header in the form sha256=<hex>, where the hex value is an HMAC-SHA256 of the exact request body, keyed with the secret you registered. Verify it before doing anything else — an unverified webhook endpoint is an open door that accepts fake "customer replies" from anyone who finds the URL.
One rule prevents nearly every signature failure you will ever see: verify the raw body, not a re-parsed version of it. JSON.stringify(req.body) in a framework that already parsed the request can reorder keys or normalize whitespace, and a single changed byte produces a completely different HMAC. Read the body as text, verify those exact bytes, and only then parse.
The handler below runs unmodified on Cloudflare Workers, Deno, and Bun, and on Node.js 18+ with minor glue — everything uses the standard Web Crypto SubtleCrypto API:
// worker.ts — inbound webhook receiver for senderZ
interface Env {
SENDERZ_WEBHOOK_SECRET: string // same value you registered as "secret"
SENDERZ_API_KEY: string // used in the reply step below
}
interface InboundMessage {
event: 'message.received'
message_id: string
from: string // the customer who texted you
to: string // your senderZ number
body: string
channel: 'imessage' | 'sms'
timestamp: string // ISO 8601
}
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
}
return bytes
}
async function verifySignature(
rawBody: string,
signatureHeader: string | null,
secret: string
): Promise<boolean> {
if (!signatureHeader || !signatureHeader.startsWith('sha256=')) {
return false
}
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
)
// crypto.subtle.verify performs the comparison inside the crypto
// implementation — no hand-rolled string equality on secret material,
// which is where timing-attack surface usually creeps in.
return crypto.subtle.verify(
'HMAC',
key,
hexToBytes(signatureHeader.slice('sha256='.length)),
encoder.encode(rawBody)
)
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 })
}
// 1. Read the RAW body before any JSON parsing
const rawBody = await request.text()
// 2. Verify the signature over those exact bytes
const valid = await verifySignature(
rawBody,
request.headers.get('X-Senderz-Signature'),
env.SENDERZ_WEBHOOK_SECRET
)
if (!valid) {
return new Response('Invalid signature', { status: 401 })
}
// 3. Parse and branch on the event field
const payload = JSON.parse(rawBody) as InboundMessage
if (payload.event === 'message.received') {
// Real work happens off the response path (next section)
ctx.waitUntil(handleInbound(payload, env))
}
// 4. Acknowledge fast — a 2xx tells senderZ the delivery succeeded
return new Response('ok', { status: 200 })
},
}
If you are on Express instead of a fetch-based runtime, the same rule applies: mount express.raw({ type: 'application/json' }) on the webhook route so req.body is the untouched buffer, verify it, then parse. The default express.json() middleware destroys the bytes you need.
The Reply Pattern: Answer an Inbound SMS Through the API
Notice what the handler above does not do: it does not look up the contact, call your database, or send a reply before returning. Return the 200 first, then do the work. senderZ logs a failed attempt when a delivery errors or times out — delivery-status dispatches are cut off after 10 seconds — and a slow handler also delays your own reply loop. ctx.waitUntil() (or a job queue in other runtimes) keeps processing alive after the response goes out.
The reply itself is a standard send. Webhooks are the inbound half of the conversation; POST /v1/messages is the outbound half:
async function handleInbound(msg: InboundMessage, env: Env): Promise<void> {
// Your business logic: look up the contact, update your CRM,
// decide what to say. Then reply through the send API.
const reply = `Got it — we received: "${msg.body.slice(0, 80)}"`
const res = await fetch('https://api.senderz.com/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${env.SENDERZ_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: msg.from, // reply to the person who texted you
channel: 'auto', // iMessage when the recipient supports it, SMS fallback
body: reply,
}),
})
const result = await res.json()
console.log('reply queued:', result)
}
The send API responds 202 Accepted as soon as the message is queued for routing:
{
"message_id": "01JXCGA3T7M2R9W5K8N4V6YQPB",
"status": "queued",
"channel": "auto",
"estimated_delivery_ms": 2000
}
With channel: "auto", senderZ checks whether the recipient's number supports iMessage and routes there first, falling back to SMS when it does not. Since the inbound payload already told you which channel the customer used, you can also pin the reply with channel: "imessage" or channel: "sms" to keep the thread on one rail. This receive-then-reply loop is the foundation of two-way messaging — the two-way SMS chatbot walkthrough builds a full conversational flow directly on this pattern.
Test and Monitor Your Inbound Webhook
The fastest end-to-end test: text your senderZ number from your own phone, then read the delivery log. Every webhook dispatch — success or failure — is recorded with the HTTP status your endpoint returned:
curl https://api.senderz.com/v1/webhooks/01JXCF9K2QW8N5T3V7R4M6YBZD/logs \
-H "Authorization: Bearer tf_your_api_key"
{
"data": [
{
"id": "01JXCG2M9RW5K8P3V6N1T4YQZB",
"event": "message.received",
"status_code": 200,
"attempt": 1,
"sent_at": "2026-06-10T17:42:10.000Z",
"success": true,
"message": {
"to": "+15559873321",
"direction": "inbound",
"channel": "imessage"
}
}
]
}
Reading the log:
status_code: 200withsuccess: true— your endpoint received and acknowledged the event.status_code: 401— your own signature check rejected the delivery. Almost always the raw-body problem from the section above, or a secret mismatch between senderZ and your environment variable.status_code: 0— your endpoint never responded: DNS failure, TLS error, timeout, or the server was down.
Two production habits close out the setup. Deduplicate on message_id. Any webhook system can deliver an event more than once under network ambiguity — a 200 that gets lost on the return path looks identical to a failed delivery from the sender's side. Keying processed events by message_id in your database makes redelivery harmless. Rotate the signing secret on a schedule. Update your endpoint to accept the new secret first, then rotate the registration:
curl -X PUT https://api.senderz.com/v1/webhooks/01JXCF9K2QW8N5T3V7R4M6YBZD \
-H "Authorization: Bearer tf_your_api_key" \
-H "Content-Type: application/json" \
-d '{"secret": "whsec_new_random_string_here"}'
For local development, remember the HTTPS requirement: senderZ will not register a localhost URL. Expose your dev server through a tunneling tool that hands out a temporary HTTPS hostname, register that, and swap in the production URL when you deploy.
Inbound SMS Webhook FAQ
FAQ
Frequently asked questions
Do I need a public HTTPS URL to receive inbound SMS webhooks?
Yes. senderZ rejects non-HTTPS webhook URLs at registration with a VALIDATION_ERROR, and localhost is not reachable from the outside. For local development, expose your dev server through a tunneling tool that provides a temporary HTTPS hostname, then update the webhook URL when you deploy.
What happens if my endpoint is down when a text arrives?
The delivery attempt is recorded in the webhook log with the status code your server returned, or 0 if it never responded. Check GET /v1/webhooks/:id/logs to see exactly what happened, fix the endpoint, and new inbound messages resume flowing. Keep handlers idempotent by deduplicating on message_id so a redelivered event never double-processes.
Why does my signature verification keep failing?
The usual cause is verifying a re-serialized version of the payload instead of the raw request body. JSON.stringify on an already-parsed body can reorder keys or change whitespace, and every changed byte produces a different HMAC. Read the body as raw text, verify those exact bytes against the sha256= value in the X-Senderz-Signature header, and only then parse the JSON.
Does senderZ handle STOP replies automatically?
Yes. Opt-out keywords — STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT — are processed and recorded before webhook fan-out, and future sends to that number are blocked automatically. Your webhook still receives the message.received event, so you can mirror the opt-out into your own CRM or support system.
Can one webhook receive both inbound messages and delivery events?
Yes. Subscribe a single URL to several events and branch on the event field in each payload. Registering separate webhooks per event type also works when different services own different concerns — each registration keeps its own URL, secret, and delivery log.
An inbound webhook plus the send API is the entire two-way messaging loop: register once, verify every delivery, reply through POST /v1/messages. Get an API key and send your first message in the senderZ quickstart — the 14-day free trial requires no credit card, and every plan includes unlimited messages to existing contacts with webhooks on all tiers.