Surge

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:// (or http://, but HTTPS is strongly recommended)
  • Resolve to a public IP address
  • Return a 2xx status code within the request timeout
  • Handle duplicate deliveries (Surge retries failed attempts, so your handler should be idempotent)
Important

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.

Note

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).

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)

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)
Tip

Return 200 quickly. If your processing takes time, enqueue the work and return immediately — Surge will retry if your handler times out.

Replay protection

Note

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

Important

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 steps