Receiving Messages & Webhooks
Surge sends HTTP POST requests to a URL you control whenever something happens: a message arrives, a campaign is approved, a contact opts out. You configure the URL in the dashboard and Surge takes care of delivery, retries, and ordering.
1. Configure your webhook endpoint
Go to Settings → Webhooks in the dashboard at hq.surge.app. Add the URL where you want to receive events. Surge sends all event types to a single endpoint (you filter by type in your handler).
Your endpoint must:
- Be publicly accessible over
https://(orhttp://, but HTTPS is strongly recommended) - Resolve to a public IP address
- Return a
2xxstatus code within the request timeout - Handle duplicate deliveries (Surge retries failed attempts, so your handler should be idempotent)
Webhook URL restrictions. Surge blocks webhook URLs that resolve to private IP ranges (10.x.x.x, 172.16.x.x, 192.168.x.x, 127.x.x.x) or reserved TLDs (.local, .internal, .localhost, .test) to prevent server-side request forgery. This means http://localhost:4000/webhooks won't work — use a tunneling service like ngrok or Cloudflare Tunnel in development.
2. Verify the signature
Every webhook request includes a webhook-signature header. Verifying it confirms the request came from Surge and wasn't tampered with in transit.
Surge signs requests using HMAC-SHA256 following the Standard Webhooks specification. Use the standardwebhooks library in Python, TypeScript, or Ruby, or compute the signature directly in any language.
Surge sets three headers on every request: webhook-id, webhook-timestamp, and webhook-signature. The library handles them for you in Python / TypeScript / Ruby; you read them directly when computing the signature manually (Elixir example below).
Where to find your webhook secret
Your signing secret is in Settings → Webhooks in the dashboard, next to your webhook endpoint URL.
3. Handle events
A webhook event has this shape:
{
"type": "message.received",
"data": {
"id": "msg_01jav96823f9x9054d6gyzpp16",
"blast_id": null,
"body": "Hello there",
"metadata": {},
"attachments": [],
"received_at": "2024-10-21T23:29:43Z",
"conversation": {
"id": "cnv_01jav8xy7fe4nsay3c9deqxge9",
"phone_number": {
"id": "pn_01jsjwe4d9fx3tpymgtg958d9w",
"number": "+18015556789",
"type": "local"
},
"contact": {
"id": "ctc_01ja88cboqffhswjx8zbak3ykk",
"phone_number": "+18015551234"
}
}
}
}A minimal handler checks type and dispatches:
@app.post("/webhooks/surge")
async def surge_webhook(request: Request):
payload = wh.verify(await request.body(), request.headers)
match payload["type"]:
case "message.received":
await handle_inbound_message(payload["data"])
case "contact.opted_out":
await handle_opt_out(payload["data"])
case _:
pass # ignore events you don't need
return Response(status_code=200)Return 200 quickly. If your processing takes time, enqueue the work and return immediately — Surge will retry if your handler times out.
Replay protection
The Standard Webhooks spec includes a timestamp in the signature payload. Surge rejects replays outside a tolerance window. If you're testing with recorded payloads, make sure your test framework handles timestamp validation or disables it appropriately.
Older signature header
Earlier versions of Surge used surge-signature as the header name. This header is deprecated and being phased out. If your handler still reads surge-signature, migrate to webhook-signature. See Deprecation Notices.