Signature Verification
Surge signs every outbound webhook request following the Standard Webhooks specification. Verifying the signature confirms that the request came from Surge and wasn't tampered with in transit.
The three headers
Surge sets these headers on every webhook POST:
| Header | Description |
|---|---|
webhook-id | Unique ID for this webhook delivery attempt |
webhook-timestamp | Unix timestamp (seconds) when the event was sent |
webhook-signature | HMAC-SHA256 signatures, comma-separated, each prefixed with v1, |
The webhook-signature header may contain multiple v1,<signature> values separated by spaces if your platform has multiple signing secrets (during key rotation). Accept the request if any signature verifies successfully.
Signing algorithm
Surge computes the signature as:
HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{raw-body}")The secret is base64-encoded and prefixed with whsec_ in the dashboard. Strip the prefix and base64-decode it before using it as the HMAC key.
The signed message is the string webhook-id, a literal ., webhook-timestamp, a literal ., and then the raw request body, all concatenated without any separating whitespace beyond the . characters.
Replay protection
The webhook-timestamp lets you reject replays. Surge recommends rejecting requests where webhook-timestamp is more than five minutes old. Most Standard Webhooks library implementations enforce this tolerance window automatically.
Verification examples
from standardwebhooks import Webhook
import os
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)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;
}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# The `standardwebhooks` library isn't available for Elixir yet.
# Verify the HMAC manually:
defp verify_surge_signature(conn, secret) do
webhook_id = List.first(get_req_header(conn, "webhook-id"))
timestamp = List.first(get_req_header(conn, "webhook-timestamp"))
signature = List.first(get_req_header(conn, "webhook-signature"))
body = conn.assigns[:raw_body]
# Strip the "whsec_" prefix and base64-decode the secret
"whsec_" <> encoded = secret
decoded_secret = Base.decode64!(encoded, padding: false)
# Build the signed payload: "{webhook-id}.{webhook-timestamp}.{body}"
payload = "#{webhook_id}.#{timestamp}.#{body}"
expected = :crypto.mac(:hmac, :sha256, decoded_secret, payload) |> Base.encode64()
# Plug.Crypto.secure_compare/2 is a constant-time comparison.
Plug.Crypto.secure_compare(signature, "v1,#{expected}")
endFinding your webhook secret
Your signing secret is shown in the dashboard at Settings → Webhooks, next to each endpoint URL. It looks like whsec_<base64-encoded-bytes>.
Deprecated header
Earlier Surge versions used surge-signature instead of webhook-signature. If you are still reading surge-signature, migrate to webhook-signature. See Deprecation Notices.