Guide to Surge Webhooks: Features and Best Practices
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
| 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.
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_ | Notify the customer that the import finished |
phone_ | 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_ 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_ 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
- Open the Webhooks page in the Surge dashboard and add your endpoint URL, for example
https://webhooks.example.com/webhooks/surge. - Choose the event subscriptions.
- Copy the signing secret shown next to the URL into
SURGE_WEBHOOK_SECRET, keeping thewhsec_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
typeandaccount_idin 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
2xxright 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
idwhen present, andtypeplusdata.idotherwise.
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.sentas the baseline andmessage.deliveredas 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_ 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.