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.

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:// (or http://, 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 means http://localhost:4000/webhooks won't work as a webhook URL — use a tunneling service like ngrok or Cloudflare Tunnel in development.
  • Return a 2xx status 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
end

Elixir

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}")
end

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