Relay SMS Platform
Core Concepts

Webhook Security

Webhook Security

Every webhook delivery from Relay is signed. Your endpoint can verify the signature to confirm the payload came from Relay and has not been tampered with in transit.

The signature header

Each delivery includes the header:

Code
Relay-Signature: t=1713260400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

The value is a comma-separated list of key-value pairs:

  • t — Unix timestamp (seconds) at the moment we computed the signature.
  • v1 — hex-encoded HMAC-SHA256 of ${t}.${raw_request_body} using your webhook's signing secret.

Additional headers carried on every delivery:

HeaderMeaning
Relay-SignatureThe versioned signature (see above).
X-Relay-EventThe event name (for example message.status.updated).
X-Relay-Delivery-IDA stable idempotency key for this delivery attempt. Safe to use for dedup on your side.
X-Relay-TimestampThe original event timestamp (when the SMS status changed). Note: this may differ from t in Relay-Signature, which is the delivery-time timestamp used in the HMAC. For replay protection, always verify against the t value from the signature header, not this header.

Obtaining the signing secret

The signing secret is returned exactly once, in the response body of the create-webhook call:

TerminalCode
curl -X POST https://api.relay.works/v1/webhooks \ -H "x-api-key: rly_your_api_key" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/relay", "events": ["message.status.updated"] }'
JSONCode
{ "id": "wh_abc123", "workspace_id": "ws_def456", "url": "https://your-app.com/webhooks/relay", "events": ["message.status.updated"], "created_at": "2026-04-16T12:34:56Z", "signing_secret": "whsec_3a7b1f9c4d2e8b6a5c0d9e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b", "signing_secret_warning": "Save this value now — the plaintext signing secret will not be returned by any subsequent API call." }

Store signing_secret in your own secrets manager immediately. Subsequent GET /v1/webhooks responses will not include it.

Important: Use the full signing_secret value including the whsec_ prefix as your HMAC key. Unlike some other APIs, the prefix is part of the key material. Stripping it will produce a different HMAC and verification will fail silently.

Verifying a signature

Node.js

JavascriptCode
const crypto = require('crypto'); function verifyRelaySignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) { const parts = Object.fromEntries( signatureHeader.split(',').map(kv => kv.split('=')) ); const { t, v1 } = parts; if (!t || !v1) return false; // Reject signatures outside the tolerance window (accounts for clock skew // in both directions — server ahead or behind). const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(t)); if (Number.isNaN(ageSeconds) || ageSeconds > toleranceSeconds) return false; const expected = crypto .createHmac('sha256', secret) .update(`${t}.${rawBody}`) .digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)); } // Express example — note: use the raw body, not the parsed JSON. app.post('/webhooks/relay', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8'); if (!verifyRelaySignature(rawBody, req.header('Relay-Signature'), process.env.RELAY_WEBHOOK_SECRET)) { return res.status(401).send('Invalid signature'); } const event = JSON.parse(rawBody); // ... handle event res.status(200).send('OK'); } );

Python

Code
import hmac import hashlib import time def verify_relay_signature(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool: parts = dict(kv.split("=", 1) for kv in signature_header.split(",")) t = parts.get("t") v1 = parts.get("v1") if not t or not v1: return False try: age_seconds = abs(int(time.time()) - int(t)) except ValueError: return False if age_seconds > tolerance_seconds: return False expected = hmac.new( secret.encode("utf-8"), f"{t}.{raw_body.decode('utf-8')}".encode("utf-8"), hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, v1)

Go

GoCode
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "strings" "time" ) func VerifyRelaySignature(rawBody []byte, signatureHeader, secret string, tolerance time.Duration) bool { parts := map[string]string{} for _, kv := range strings.Split(signatureHeader, ",") { bits := strings.SplitN(kv, "=", 2) if len(bits) == 2 { parts[bits[0]] = bits[1] } } t, ok := parts["t"] v1, ok2 := parts["v1"] if !ok || !ok2 { return false } tsInt, err := strconv.ParseInt(t, 10, 64) if err != nil { return false } // Reject timestamps outside the tolerance window in either direction // (accounts for clock skew — server ahead or behind). age := time.Now().Unix() - tsInt if age < 0 { age = -age } if time.Duration(age)*time.Second > tolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + "." + string(rawBody))) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(v1)) }

Critical pitfalls

  1. Verify against the raw body, not parsed JSON. Re-serializing the parsed object will produce a different string (different key order, different whitespace) and your HMAC will not match.
  2. Use a constant-time comparison. === in JavaScript or == in Python leaks timing information. Use crypto.timingSafeEqual / hmac.compare_digest.
  3. Enforce the timestamp tolerance. Without it, an attacker who captures one valid signed delivery can replay it forever. 5 minutes is a reasonable default.
  4. Reject unsigned deliveries. If Relay-Signature is missing, assume the request did not originate from Relay.

Rotating the signing secret

If you suspect your signing secret has been exposed, rotate it:

TerminalCode
curl -X POST https://api.relay.works/v1/webhooks/wh_abc123/rotate-secret \ -H "x-api-key: rly_your_api_key"

The response contains a new signing_secret. The previous secret is immediately invalidated — any in-flight deliveries signed with the old secret will fail verification on your side once you update. Plan rotations during low-traffic windows or implement dual-secret verification during the transition.

Deprecated: X-Relay-Signature

Relay currently emits a second, legacy header X-Relay-Signature whose value is the raw HMAC of the body (no timestamp). This exists for backward compatibility with consumers written before the versioned header was introduced and will be removed in v2. New integrations should always verify Relay-Signature.

Last modified on