Inbound SMS · Webhook delivery

SMS to Webhook — receive text messages as HTTP POST

Every inbound SMS to a DIDHub number can fire a signed HTTPS webhook to your endpoint within milliseconds. JSON payload, HMAC signature, configurable retries, and per-DID delivery routing. The foundation for two-way SMS, AI auto-responders, CRM/help-desk integration, and any custom messaging workflow. Free with the DID rental.

Why route SMS to a webhook

Webhooks are the high-leverage delivery mode — everything you can imagine doing with an inbound SMS becomes possible once it lands as a POST request on your server. Two-way conversations, AI agents, CRM auto-tickets, internal alerting, multi-step workflows, custom analytics — all just code, not phone-system configuration.

Common use cases

  • Two-way SMS — webhook fires → your handler decides what to reply → calls the DIDHub send-SMS API. Real conversations from a single SaaS UI.
  • AI auto-responder — route the message body into an LLM (OpenAI, Anthropic, your own), generate a reply, send it back — all under 2 seconds.
  • CRM ticket creator — webhook handler creates a ticket in Zendesk/Freshdesk/Salesforce/HubSpot with the SMS body, sender, and DID context.
  • Slack / Teams / Discord — pretty-print the SMS into a channel message with a "Reply" button that calls back to your handler.
  • Survey / poll responses — SMS reply ("YES", "NO", "STOP") routes to a tally store via webhook.
  • Two-factor / OTP capture — receive verification codes, parse with regex, trigger downstream auth flow.
  • Voice agent escalation — AI voice agent emits "I'll text you the details" — webhook delivers the customer's reply back into the agent's conversation context.
  • Compliance archival — every inbound SMS auto-logged to S3/Postgres for SOC 2 / HIPAA / FINRA retention.

Webhook payload schema

DIDHub POSTs a JSON body to your endpoint. The shape is stable — new fields are additive. The full payload:

{
  "event": "sms.received",
  "id": "msg_a4f9c2b1d8e3f5",
  "timestamp": "2026-05-07T14:32:18.412Z",
  "to": "+14155550123",
  "from": "+14155554567",
  "country": "US",
  "body": "Hi, I'm looking to port 12 numbers.",
  "encoding": "GSM-7",
  "segments": 1,
  "mms": [
    {
      "url": "https://media.didhub.io/m/abc123.jpg?sig=...",
      "mime": "image/jpeg",
      "size": 348291,
      "expires_at": "2026-05-14T14:32:18Z"
    }
  ],
  "meta": {
    "did_id": "did_3f8c1a2",
    "trunk_id": "trunk_a4f9c2",
    "upstream_carrier": "voxbone",
    "sender_country": "US",
    "smpp_message_id": "4831923859"
  }
}

Field reference

FieldTypeDescription
eventstringAlways sms.received for inbound. Other events: sms.delivered, sms.failed for outbound DLRs.
idstringDIDHub-assigned unique message ID. Stable, idempotent — safe to use as a primary key.
timestampRFC 3339When DIDHub received the SMS from the upstream carrier (UTC).
toE.164Your DIDHub number that received the message.
fromE.164 or alphanumericSender. Numeric for normal mobile-originated SMS; alphanumeric for sender-ID messages (some markets).
countryISO 3166-1 alpha-2Country of the receiving DID.
bodystring (UTF-8)Decoded message text. Always UTF-8; the source encoding is in encoding.
encodingstringGSM-7, UCS-2, or Latin-1. Useful for billing math and encoding-aware features.
segmentsintegerHow many SMS segments the message used. Long messages span multiple segments — concatenated transparently.
mmsarrayMMS attachments (zero or more). Each has url (signed, expires after 7 days), mime, size, expires_at.
metaobjectExtended fields (DID/trunk IDs, upstream carrier, raw SMPP/SS7 IDs). Use for debugging and advanced routing.

Code samples

Node.js (Express)

import express from 'express';
import crypto from 'node:crypto';

const app = express();
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));

app.post('/sms/inbound', (req, res) => {
  // 1. Verify HMAC signature
  const sig = req.headers['x-didhub-signature'];
  const expected = crypto.createHmac('sha256', process.env.DIDHUB_WEBHOOK_SECRET)
    .update(req.rawBody).digest('hex');
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return res.status(401).end();
  }

  // 2. Process the SMS
  const { id, from, to, body, mms } = req.body;
  console.log(`SMS from ${from} to ${to}: ${body}`);

  // 3. Acknowledge fast (handle work async)
  res.status(200).json({ ok: true });
});

app.listen(3000);

Python (FastAPI)

from fastapi import FastAPI, Request, HTTPException
import hmac, hashlib, os

app = FastAPI()
SECRET = os.environ["DIDHUB_WEBHOOK_SECRET"].encode()

@app.post("/sms/inbound")
async def inbound_sms(request: Request):
    raw = await request.body()
    sig = request.headers.get("x-didhub-signature", "")
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        raise HTTPException(401)

    payload = await request.json()
    # payload["from"], payload["to"], payload["body"], payload["mms"]
    return {"ok": True}

Go (net/http)

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
    "os"
)

type SMS struct {
    ID, From, To, Body string
}

func handler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    mac := hmac.New(sha256.New, []byte(os.Getenv("DIDHUB_WEBHOOK_SECRET")))
    mac.Write(body)
    if hex.EncodeToString(mac.Sum(nil)) != r.Header.Get("X-DIDHub-Signature") {
        http.Error(w, "bad sig", 401); return
    }
    var sms SMS
    json.Unmarshal(body, &sms)
    // process sms.From, sms.Body, etc.
    w.WriteHeader(200)
}

The DIDHub send-SMS API uses the same JSON shape in reverse — once you've got the inbound handler working, sending a reply is a single POST /v1/sms with {from, to, body}.

Webhook security

HMAC-SHA256 signatures

Every webhook DIDHub sends is signed with HMAC-SHA256 using a per-account secret. The signature is in the X-DIDHub-Signature header, computed over the raw request body. Verify it before trusting any payload — this prevents anyone who knows your URL from impersonating DIDHub.

IP allowlist

If your firewall supports it, restrict inbound to DIDHub's webhook origin IP ranges (published at https://didhub.io/.well-known/webhook-origins). Combined with HMAC verification this is defense-in-depth — spoofed source IPs still fail signature check, but allowlisting eliminates the need to even process spoofed traffic.

HTTPS only

DIDHub refuses to deliver to plain-HTTP endpoints. Use a real TLS cert (Let's Encrypt is fine). Self-signed certs are accepted in test mode only.

Replay protection

The id field is unique per message. Maintain a recent-message-IDs cache (Redis, in-memory LRU) and ignore duplicate IDs; DIDHub may resend a message if your server didn't ACK with 2xx in time, and you don't want to double-process.

Per-DID secrets (optional)

By default a single account-level secret signs all webhooks. For multi-tenant systems, set per-DID secrets so each customer's webhook is signed differently — a leaked secret then only affects one customer's traffic.

Retries, idempotency, and timeouts

DIDHub waits up to 5 seconds for your webhook to respond with HTTP 2xx. If it times out, returns 5xx, or fails to connect, DIDHub retries with exponential backoff:

AttemptDelay
1 (initial)0s
2+5s
3+30s
4+2 min
5+10 min
6+1 hour
7-12+6 hours each (up to 36h total)

After all retries fail the message is marked delivery_failed in the dashboard and you can manually re-trigger from the message log. 4xx responses (your handler explicitly rejecting the message) do not retry — treat them as final.

Best practice: respond 200 immediately and queue the SMS for async processing. A handler that takes 5 seconds to do real work blocks the next inbound from the same trunk — a fast-ACK + worker queue pattern keeps throughput high under load.

Outbound delivery receipts

If you also use DIDHub for outbound SMS (POST /v1/sms), you get matching delivery-receipt webhooks for free:

{
  "event": "sms.delivered",
  "id": "msg_b8e2d1f4",
  "reference": "your-internal-id-here",
  "to": "+14155554567",
  "status": "delivered",
  "delivered_at": "2026-05-07T14:32:23.812Z",
  "latency_ms": 1247
}

Status values: queued, sent, delivered, failed, rejected. The same webhook URL receives both inbound and DLR events — differentiate via the event field.

Country availability

Webhook delivery works on every DID where DIDHub supports inbound SMS — same coverage as SMS-to-email. 60+ countries, all major markets.

Latency varies by upstream carrier and origination network: typical end-to-end is 200-800ms in North America and Europe, 500-2000ms in MENA / India / APAC, depending on the SS7 / SMPP route.

Pricing

Free. Webhook delivery, retries, signed payloads, MMS URL hosting (signed CDN with 7-day TTL), DLR callbacks, and the per-DID configuration UI are all included in the monthly DID rental. There are no per-message charges for receiving SMS, no charges for fired webhooks, and no charges for the storage of the message log.

Outbound SMS sent in response is billed per message at carrier wholesale plus DIDHub markup. See the calling rates page for per-country SMS rates.

FAQ

How fast does a webhook fire after the SMS arrives?

Typical latency from sender's tap to your endpoint receiving the POST is 200-800ms in North America/Europe, 500-2000ms in MENA/India/APAC. The bottleneck is the upstream SS7/SMPP carrier — DIDHub's webhook dispatch adds <50ms.

What if my server is down when an SMS arrives?

DIDHub retries with exponential backoff for up to 36 hours. After 12 attempts the message is marked failed and you can re-trigger from the dashboard. No SMS is lost — just delivery is delayed until your server's back.

How do I avoid double-processing a retried webhook?

The id field is stable across retries. Cache the last N message IDs (Redis, LRU, or a unique constraint on a DB column) and skip duplicates. Most teams use a 24-hour window since DIDHub stops retrying at 36h.

What's the max payload size?

SMS body is at most 1600 characters (10 segments × 160 chars). MMS attachments are up to 10 per message, max 5 MB each — the URL is in the payload, you fetch on demand. Total JSON payload size is bounded at ~2 KB for SMS-only, <5 KB with MMS metadata.

Can I have different webhook URLs per DID?

Yes. Each DID has its own routing config — useful for multi-tenant systems where each customer's number routes to their own dedicated handler. You can also do account-wide default + per-DID overrides for simpler setups.

How do I test the webhook before going live?

The DIDHub dashboard has a Send test webhook button on the per-DID routing tab — fires a sample sms.received payload to your URL with a real signature so you can validate the handler end-to-end. Replays of historical messages are also supported from the message log.

Does the webhook work with serverless functions?

Yes — AWS Lambda, Cloudflare Workers, Vercel Functions, Google Cloud Run all work fine. Cold-start latency is the only consideration: aim for <5 second total response time including cold start, otherwise DIDHub retries.

Can I use SMS-to-webhook AND SMS-to-email on the same number?

Yes — both can be configured simultaneously. Inbound SMS fans out to both your webhook AND the email recipients in parallel. Common pattern for keeping a human-readable inbox while feeding a CRM.

Is there an SDK?

Not currently — the webhook is plain JSON over HTTPS, so any HTTP framework in any language works. We may publish thin wrappers (Node, Python, Go) once API surface stabilises; the underlying contract won't change.

What about WebSocket / streaming delivery?

Not supported today — webhooks are the only push mode. If you need true real-time bidirectional connectivity (sub-100ms turn-around for AI voice agents that also handle SMS), pair SMS-to-webhook with the DIDHub voice trunk and run your bridge layer (LiveKit, Pipecat) as the stateful component.

Adjacent reading

Ready to get a number?

Pick a DID in 130+ countries from $1.99/month. Activates instantly on most numbers.