Agent skill
Surge Webhooks Skill
Receive and verify Surge webhooks (SMS/MMS and voice messaging API, signed with Standard Webhooks). Use when setting up Surge webhook handlers, debugging webhook-signature verification, or handling events like message.received, message.delivered, message.failed, contact.opted_out, campaign.approved, and call.ended.
Install this skill
npx skills add hookdeck/webhook-skills --skill surge-webhooks
Surge is an SMS/MMS and voice messaging API for businesses. You register one webhook endpoint per project in the dashboard, and Surge delivers events from every account under the project as JSON POST requests signed with Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature. The event name is the top-level type of the envelope {id, type, timestamp, account_, and data varies by event.
When to Use This Skill
- How do I receive Surge webhooks?
- How do I verify Surge webhook signatures (
webhook-signature,whsec_secret)? - How do I handle
message.received,message.delivered,message.failedorcontact.opted_outevents? - How do I react to
campaign.approvedorphone_?number.attached_ to_ campaign - Why is my Surge webhook signature verification failing, or rejecting Hookdeck retries?
Verification (core)
Signed content is {webhook-id}.{webhook-timestamp}.{raw body}. Surge signs it with HMAC-SHA256 using the base64-decoded secret (the part after whsec_), base64-encodes the result, and sends one or more space-separated v1,<signature> entries (several during secret rotation). Use a Standard Webhooks library (standardwebhooks on npm and PyPI), which compares in constant time. Read the webhook-* headers, and when webhook-id is absent fall back to the svix-* headers (svix-id, svix-timestamp, svix-signature), as Hookdeck does.
const { Webhook } = require('standardwebhooks');
const prefix = req.get('webhook-id') ? 'webhook' : 'svix';
const headers = {
'webhook-id': req.get(`${prefix}-id`),
'webhook-timestamp': req.get(`${prefix}-timestamp`),
'webhook-signature': req.get(`${prefix}-signature`),
};
// rawBody: the raw Buffer/string, never re-serialized JSON
const event = new Webhook(process.env.SURGE_WEBHOOK_SECRET).verify(rawBody, headers);
// throws on a bad signature or a timestamp outside the window; returns the parsed body
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
wh = Webhook(os.environ["SURGE_WEBHOOK_SECRET"]) # "whsec_..."
event = wh.verify(raw_body, {"webhook-id": id_, "webhook-timestamp": ts, "webhook-signature": sig})
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
The secret is shown per endpoint on the dashboard's Webhooks page. It looks like whsec_; keep the full value in SURGE_WEBHOOK_SECRET. Handlers fail closed: with the variable unset they return 500 instead of accepting anything. A missing header or a bad signature returns 400. The raw body is required, so in Express use express.raw({ type: '*/*' }) and parse the body only after the check. Earlier Surge versions sent surge-signature; that header is deprecated, so read webhook-signature.
Hookdeck retries and replays hit the 5-minute window
The Standard Webhooks libraries always enforce a fixed 5-minute timestamp window, which matches Surge's recommendation to reject requests more than five minutes old, but it can't be configured. Hookdeck retries and replays resend the original timestamp, so the library rejects any retry or replay that happens more than 5 minutes after the original delivery. If you verify behind Hookdeck (or replay old events), use the manual verifier in references/verification.md, where the window can be dropped or widened. Deduplicate on the envelope id instead of relying on the timestamp.
Common Event Types
Fourteen events are documented, and the list may not be exhaustive. The name is type in the JSON body. The examples handle all of these and acknowledge any other type with 200:
| Event | Triggered When |
|---|---|
message.sent | Surge accepts an outbound message and passes it to the carrier |
message.delivered | The carrier confirms delivery (not every carrier reports it) |
message.failed | An outbound message could not be delivered; failure_reason says why |
message.received | An inbound message arrives at one of your phone numbers |
contact.opted_in | A contact opts back in (START, YES, UNSTOP) |
contact.opted_out | A contact opts out (STOP, CANCEL, UNSUBSCRIBE, END, QUIT) |
conversation.created | A new conversation thread is created (fires alongside the first message.received) |
campaign.approved | A campaign becomes active after carrier approval |
phone_number.imported | A number is imported from another provider (porting or hosting) |
phone_ | A number is attached to a campaign and ready for production sends |
link.followed | A contact first clicks a shortened link in a message |
call.ended | A call ends, whatever the direction or termination |
recording.completed | A non-voicemail call recording is ready to download |
voicemail.received | A voicemail recording is ready to download |
See references/overview.md for the envelope, payload examples and delivery behavior.
Environment Variables
# Signing secret of the webhook endpoint (Standard Webhooks format, starts with whsec_)
SURGE_WEBHOOK_SECRET=whsec_xxxxx
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 surge --path /webhooks/surge
Surge blocks webhook URLs that resolve to private IPs or .local/.test-style TLDs, so use the tunnel's public URL as the endpoint, not localhost.
Reference Materials
- references/overview.md - Surge webhook concepts, events, payload shape, delivery and retries
- references/setup.md - Add the endpoint in the dashboard and get the secret
- references/verification.md - Standard Webhooks verification details, header fallback, manual verifier, timestamp window