Gareth Wilson Gareth Wilson

Guide to Surge Webhooks: Features and Best Practices

Published


Surge 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

FeatureDetails
ConfigurationDashboard Webhooks page, one endpoint per project
ScopeAll accounts under the project
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature (older surge-signature is deprecated)
SecretPer endpoint, whsec_<base64>; several can be live during rotation
BodyJSON
Event routingBody field type
RetriesExponential back-off. Count, intervals and timeout not documented
RedirectsNot followed. A 3xx is a failure
Source IPsNot published

Handlers for the fourteen documented events are in the skill's examples folder.

Events and payloads

Surge's docs pages list 14 event types:

EventTriggered WhenCommon Use Cases
message.sentSurge accepts an outbound message and sends it to the carrier (before delivery is confirmed)Mark a send as confirmed
message.deliveredThe carrier confirms delivery. Not every carrier reports it, and it doesn't always mean the handset got itUpdate delivery status
message.failedAn outbound message could not be delivered; failure_reason explains whyStop sending on opted_out or invalid_mobile_number, retry carrier_error
message.receivedA contact sends a message to one of your numbersAuto-replies, support tickets, hand-off to an agent
contact.opted_inA contact who had opted out sends START, YES or UNSTOPRe-enable messaging for the contact
contact.opted_outA contact sends STOP, CANCEL, UNSUBSCRIBE, END or QUITSuppress the contact in your own systems
conversation.createdA new thread between a number and a contact is created (alongside the first message.received)Create a ticket or CRM lead
campaign.approvedA campaign becomes active after carrier approvalProgress onboarding, attach numbers
phone_number.importedA number is imported by porting or hosting; check data.campaign.attachment_statusNotify the customer that the import finished
phone_number.attached_to_campaignA number is attached to a campaign and ready for productionFlip a "ready" flag
link.followedA contact first clicks a shortened link (later clicks don't fire)Click-through tracking
call.endedA call ends, whatever the direction or how it endedCall logs, follow-up messages
recording.completedA non-voicemail call recording finished processingGET /recordings/{recording_id}/file to download audio
voicemail.receivedA voicemail recording finished processingMissed-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:

FieldMeaning
typeThe event name
idEvent id, when present. Use it as the deduplication key
timestampWhen the event was created
account_idThe account under your project that the event belongs to
dataEvent 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 has the full list.

This is the call.ended sample from the webhook samples library. It has no envelope id:

{
  "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:

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:

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:

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 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 on type or account_id to send events to different 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 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 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 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 splits Surge's project feed by type and account, deduplicates it and retries your services. Sign up for a free account.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.