# Guide to Surge Webhooks: Features and Best Practices

[Surge](https://surge.app) is an SMS, MMS and voice API for businesses that message their customers, organised into projects that can hold many accounts. Much of what matters happens after you call the API: carriers deliver or reject messages, contacts reply or text STOP, campaigns clear carrier review, calls end and voicemails arrive. Surge's webhooks report all of that to your systems.

## What are Surge webhooks?

Surge webhooks are set per project. You register **one endpoint URL** for the project, and it receives every subscribed event type from every account under the project, so your handler routes on `type` and `account_id`. Each request is a JSON `POST` signed with Standard Webhooks headers: `webhook-id`, `webhook-timestamp` and `webhook-signature`.

## Surge webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard **Webhooks** page, one endpoint per project |
| Scope | All accounts under the project |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `webhook-signature` (older `surge-signature` is deprecated) |
| Secret | Per endpoint, `whsec_<base64>`; several can be live during rotation |
| Body | JSON |
| Event routing | Body field `type` |
| Retries | Exponential back-off. Count, intervals and timeout not documented |
| Redirects | Not followed. A `3xx` is a failure |
| Source IPs | Not published |

Handlers for the fourteen documented events are in the skill's [examples folder](https://github.com/hookdeck/webhook-skills/tree/main/skills/surge-webhooks/examples).

## Events and payloads

Surge's docs pages list 14 event types:

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `message.sent` | Surge accepts an outbound message and sends it to the carrier (before delivery is confirmed) | Mark a send as confirmed |
| `message.delivered` | The carrier confirms delivery. Not every carrier reports it, and it doesn't always mean the handset got it | Update delivery status |
| `message.failed` | An outbound message could not be delivered; `failure_reason` explains why | Stop sending on `opted_out` or `invalid_mobile_number`, retry `carrier_error` |
| `message.received` | A contact sends a message to one of your numbers | Auto-replies, support tickets, hand-off to an agent |
| `contact.opted_in` | A contact who had opted out sends START, YES or UNSTOP | Re-enable messaging for the contact |
| `contact.opted_out` | A contact sends STOP, CANCEL, UNSUBSCRIBE, END or QUIT | Suppress the contact in your own systems |
| `conversation.created` | A new thread between a number and a contact is created (alongside the first `message.received`) | Create a ticket or CRM lead |
| `campaign.approved` | A campaign becomes `active` after carrier approval | Progress onboarding, attach numbers |
| `phone_number.imported` | A number is imported by porting or hosting; check `data.campaign.attachment_status` | Notify the customer that the import finished |
| `phone_number.attached_to_campaign` | A number is attached to a campaign and ready for production | Flip a "ready" flag |
| `link.followed` | A contact first clicks a shortened link (later clicks don't fire) | Click-through tracking |
| `call.ended` | A call ends, whatever the direction or how it ended | Call logs, follow-up messages |
| `recording.completed` | A non-voicemail call recording finished processing | `GET /recordings/{recording_id}/file` to download audio |
| `voicemail.received` | A voicemail recording finished processing | Missed-call alerts, transcription |

Surge's OpenAPI specification also defines `campaign.rejected`, `campaign.submitted` and `campaign.warning`, with schemas and examples, though no docs page mentions them. If you register campaigns through Surge, subscribe to them and treat their shape as less settled than the rest.

Every event shares this envelope:

| Field | Meaning |
| --- | --- |
| `type` | The event name |
| `id` | Event id, when present. Use it as the deduplication key |
| `timestamp` | When the event was created |
| `account_id` | The account under your project that the event belongs to |
| `data` | Event payload. Messages carry `id`, `body`, `conversation`; `message.failed` adds `failure_reason`; `link.followed` carries `message.id` (the top-level `message_id` is deprecated) |

The envelope `id` is inconsistent: Surge's overview shows one (`evt_...`), but the OpenAPI schemas don't require it and their examples leave it out. Fall back to `type` plus `data.id` for deduplication when it's missing.

For `message.failed`, `failure_reason` is a code such as `opted_out`, `invalid_mobile_number`, `message_filtered`, `daily_message_cap_reached` or `carrier_error`; Surge's [error reference](https://docs.surge.app/api-reference/errors) has the full list.

This is the `call.ended` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/surge/latest/call.ended.json). It has no envelope `id`:

```json
{
  "account_id": "acct_01japd271aeatb7txrzr2xj8sg",
  "data": {
    "contact": {
      "email": "dom@toretto.family",
      "first_name": "Dominic",
      "id": "ctc_01ja88cboqffhswjx8zbak3ykk",
      "last_name": "Toretto",
      "metadata": {
        "car": "1970 Dodge Charger R/T"
      },
      "phone_number": "+18015551234"
    },
    "duration": 184,
    "from": "+18015551234",
    "id": "call_01jjnn7s0zfx5tdcsxjfy93et2",
    "initiated_at": "2025-03-31T21:01:37Z",
    "phone_number_id": "pn_01jsjwe4d9fx3tpymgtg958d9w",
    "status": "completed",
    "to": "+18015556789"
  },
  "timestamp": "2024-10-21T23:29:41Z",
  "type": "call.ended"
}

```

## Setting up Surge webhooks

1. Open the **Webhooks** page in the Surge dashboard and add your endpoint URL, for example `https://webhooks.example.com/webhooks/surge`.
2. Choose the event subscriptions.
3. Copy the signing secret shown next to the URL into `SURGE_WEBHOOK_SECRET`, keeping the `whsec_` prefix.

Surge refuses URLs that resolve to private ranges (`10.x`, `172.16.x`, `192.168.x`, `127.x`) or use reserved TLDs (`.local`, `.internal`, `.localhost`, `.test`), so `http://localhost` won't work. For local development, create a Hookdeck source with the **Surge** type, paste the secret, register the source URL and forward it with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 surge --path /webhooks/surge

```

Then text one of your Surge numbers to trigger `message.received`.

## Securing Surge webhooks

Surge's signature page gives `HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{raw-body}")`, with the secret's `whsec_` prefix stripped and the rest base64-decoded. During a rotation, `webhook-signature` can hold several `v1,` entries. Surge's header table calls them comma-separated while its prose says spaces; spaces are the Standard Webhooks form. The function below accepts any matching `v1,` entry:

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Surge recommends rejecting timestamps older than five minutes. Behind Hookdeck that would reject real deliveries, because Hookdeck applies no tolerance and keeps the original `webhook-timestamp` when it retries or replays an event. The verifier above checks the signature only.

The receiver stores each event under a key that copes with a missing envelope `id`:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// messagingEvents.put(key, event) stores the event once and resolves when durable.
function createSurgeReceiver(messagingEvents) {
  const app = express();
  app.post('/webhooks/surge', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.SURGE_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const event = parseJsonObject(req.body);
    if (!event) return res.sendStatus(400); // signed, but not a JSON object
    const key = event.id || `${event.type}:${event.data && event.data.id}`; // id isn't always sent
    try {
      await messagingEvents.put(key, event);
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Hookdeck's Surge source answers Surge with `200` in every case. Requests that fail the signature check are recorded as failed verification and not delivered onward.

> **Make Surge webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies every Surge event and can split one project endpoint across services by event type or account.

## Surge webhook limitations and pain points

### One endpoint per project

**The Problem:** Opt-outs, call logs and campaign updates all land on the same URL, and separate teams own them.

**Why It Happens:** Surge allows one endpoint per project, and it receives every subscribed type for every account.

**Workarounds:**

* Route on `type` and `account_id` in a thin receiver, then hand off to internal queues.

**How Hookdeck Can Help:** Point the project endpoint at a Hookdeck source and use [filters](/docs/filters) on `type` or `account_id` to send events to different [destinations](/docs/destinations).

### Undocumented retry policy

**The Problem:** You can't tell how long Surge keeps retrying a failed delivery.

**Why It Happens:** Surge says it retries with exponential back-off but doesn't give a count, intervals or the response timeout.

**Workarounds:**

* Return `2xx` right after storing the event, and reconcile messages through the API after an outage.

**How Hookdeck Can Help:** Hookdeck accepts each event immediately and [retries](/docs/retries) your service on a published schedule you control.

### Deduplication keys are inconsistent

**The Problem:** Duplicate deliveries slip through because the event has no `id`.

**Why It Happens:** The envelope `id` isn't always present, and Surge describes `webhook-id` as unique per delivery attempt, so it changes on retries.

**Workarounds:**

* Use `id` when present, and `type` plus `data.id` otherwise.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can match on body fields such as `type` and `data.id`.

### Delivery receipts aren't guaranteed

**The Problem:** Some messages never get a `message.delivered`.

**Why It Happens:** Not every carrier reports delivery, and a delivery receipt doesn't always mean the handset received the message.

**Workarounds:**

* Treat `message.sent` as the baseline and `message.delivered` as extra confirmation when it comes.

**How Hookdeck Can Help:** Hookdeck's event history lets you check which messages got a receipt and which didn't.

## Best practices

### Honour opt-outs everywhere

`contact.opted_out` fires on STOP, CANCEL, UNSUBSCRIBE, END or QUIT. Suppress the contact in every system that can message them, not just in Surge.

### Act on `failure_reason`

Stop sending on `opted_out` and `invalid_mobile_number`; `carrier_error` may be worth a retry later.

### Download recordings promptly

`recording.completed` and `voicemail.received` mean the audio is ready. Fetch it with `GET /recordings/{recording_id}/file` from a worker.

### Make handlers idempotent

Surge can deliver an event more than once. Store the deduplication key with each change, as the [webhook idempotency guide](/webhooks/guides/implement-webhook-idempotency) recommends.

## Conclusion

Surge sends messaging, compliance, campaign and call activity for a whole project to one Standard Webhooks-signed endpoint. Splitting that feed by `type` and `account_id` is the main design decision.

[Hookdeck Event Gateway](https://hookdeck.com) splits Surge's project feed by type and account, deduplicates it and retries your services. [Sign up for a free account](https://dashboard.hookdeck.com/signup).