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.
Step 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 — 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 meanshttp://localhost:4000/webhookswon't work as a webhook URL — use a tunneling service like ngrok or Cloudflare Tunnel in development. - Return a
2xxstatus code within the request timeout - Handle duplicate deliveries (Surge retries failed attempts, so your handler should be idempotent)
Step 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.
Python
from standardwebhooks import Webhook
wh = Webhook(os.environ["SURGE_WEBHOOK_SECRET"])
try:
payload = wh.verify(request.body, request.headers)
# payload is the parsed event dict
except Exception:
return Response(status=400)TypeScript
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.SURGE_WEBHOOK_SECRET!);
try {
const payload = wh.verify(rawBody, req.headers);
// payload is the parsed event object
} catch {
res.status(400).end();
return;
}Ruby
require "standardwebhooks"
wh = StandardWebhooks::Webhook.new(ENV["SURGE_WEBHOOK_SECRET"])
begin
payload = wh.verify(request.raw_post, request.headers)
rescue StandardWebhooks::WebhookVerificationError
head :bad_request and return
endElixir
Verify the HMAC-SHA256 signature manually following the Standard Webhooks spec. Surge sets three headers — webhook-id, webhook-timestamp, and webhook-signature — on every request:
defp verify_surge_signature(conn, secret) do
webhook_id = get_req_header(conn, "webhook-id") |> List.first()
timestamp = get_req_header(conn, "webhook-timestamp") |> List.first()
signature = get_req_header(conn, "webhook-signature") |> List.first()
body = conn.assigns[:raw_body]
# Secret has a "whsec_" prefix; strip it and decode the base64 payload
"whsec_" <> encoded = secret
decoded_secret = Base.decode64!(encoded, padding: false)
# Standard Webhooks payload format: "{webhook-id}.{webhook-timestamp}.{body}"
payload = "#{webhook_id}.#{timestamp}.#{body}"
expected = :crypto.mac(:hmac, :sha256, decoded_secret, payload) |> Base.encode64()
Plug.Crypto.secure_compare(signature, "v1,#{expected}")
endWhere to find your webhook secret
Your signing secret is in Settings → Webhooks in the dashboard, next to your webhook endpoint URL.
Step 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.
Next: the full event catalogue
See Webhook Events for every event type, its payload shape, and when it fires.