Verify & handle webhooks
Every webhook request carries two HMAC signatures computed from the raw body + your endpoint's signing secret. Verify one of them before trusting the payload.
Headers
X-Sendoka-Event: message.delivered
X-Sendoka-Delivery-Id: whd_01HN...
X-Sendoka-Signature: <hex>[,<hex>] # legacy V1: HMAC-SHA256(raw_body, secret)
X-Sendoka-Timestamp: 1713820800 # unix seconds when the request was signed
X-Sendoka-Signature-V2: <hex>[,<hex>] # V2: HMAC-SHA256("{timestamp}.{raw_body}", secret)
Verify V2. It binds the timestamp into the signature, so a captured payload can't be replayed after your tolerance window. V1 (body-only) is kept for older integrations and verifies the same body — migrate when you can.
Signatures can be a comma-separated list. During a secret-rotation grace
window each header carries one signature per active secret. Always split on ,
and accept the delivery if any value matches — the snippets below do this.
The body also carries delivery_id, identical to the header — use whichever
is easier to reach in your framework.
Verify — Node (Express)
import crypto from "node:crypto";
import type { Request, Response } from "express";
const TOLERANCE_S = 300; // 5 minutes
function verify(raw: Buffer, timestamp: string, signature: string, secret: string) {
const t = Number(timestamp);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) {
throw new Error("stale or missing timestamp");
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${raw.toString()}`)
.digest("hex");
const a = Buffer.from(expected);
// During secret rotation the header is a comma-separated list — one signature
// per active secret. Accept if any matches.
const ok = signature.split(",").some((sig) => {
const b = Buffer.from(sig.trim());
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
if (!ok) {
throw new Error("bad signature");
}
return JSON.parse(raw.toString());
}
app.post("/webhooks/sendoka", async (req: Request, res: Response) => {
const raw = (req as { rawBody: Buffer }).rawBody; // express.raw({ type: "*/*" })
try {
const event = verify(
raw,
req.header("X-Sendoka-Timestamp")!,
req.header("X-Sendoka-Signature-V2")!,
process.env.SENDOKA_WEBHOOK_SECRET!
);
await handleEvent(event);
res.status(200).end();
} catch {
res.status(401).end();
}
});
Verify — Python (Flask)
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
TOLERANCE_S = 300
SECRET = os.environ["SENDOKA_WEBHOOK_SECRET"].encode()
def verify(raw: bytes, timestamp: str, signature: str) -> dict:
if abs(time.time() - int(timestamp)) > TOLERANCE_S:
raise ValueError("stale timestamp")
expected = hmac.new(SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()
# During secret rotation the header is a comma-separated list — one signature
# per active secret. Accept if any matches.
if not any(hmac.compare_digest(expected, sig.strip()) for sig in signature.split(",")):
raise ValueError("bad signature")
return json.loads(raw)
@app.post("/webhooks/sendoka")
def sendoka_webhook():
try:
event = verify(
request.get_data(), # raw bytes — never re-serialize parsed JSON
request.headers["X-Sendoka-Timestamp"],
request.headers["X-Sendoka-Signature-V2"],
)
except (ValueError, KeyError):
abort(401)
handle_event(event)
return "", 200
Verify — Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const toleranceS = 300
var (
errStale = errors.New("stale or missing timestamp")
errBadSignature = errors.New("bad signature")
)
func verify(raw []byte, timestamp, signature, secret string) (map[string]any, error) {
t, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-t)) > toleranceS {
return nil, errStale
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(raw)
expected := hex.EncodeToString(mac.Sum(nil))
// During secret rotation the header is a comma-separated list — one
// signature per active secret. Accept if any matches.
ok := false
for _, sig := range strings.Split(signature, ",") {
if hmac.Equal([]byte(expected), []byte(strings.TrimSpace(sig))) {
ok = true
break
}
}
if !ok {
return nil, errBadSignature
}
var event map[string]any
return event, json.Unmarshal(raw, &event)
}
func handler(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
event, err := verify(
raw,
r.Header.Get("X-Sendoka-Timestamp"),
r.Header.Get("X-Sendoka-Signature-V2"),
os.Getenv("SENDOKA_WEBHOOK_SECRET"),
)
if err != nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
go handleEvent(event) // slow work after the 200
w.WriteHeader(http.StatusOK)
}
Legacy V1
V1 is the body-only HMAC in X-Sendoka-Signature:
hex(HMAC-SHA256(raw_body, secret))
No timestamp binding — a captured request verifies forever. Fine while you migrate; don't build new receivers on it.
Respond fast
- Return 200 within 10 seconds. Longer → Sendoka marks the delivery failed and retries with exponential backoff (up to 5 attempts total).
- Do the slow work after the 200. Use a queue, not inline processing.
- Non-2xx responses → retry. After 5 failed attempts the delivery is marked
failed; recover with bulk replay.
Idempotency on your side
Webhooks can be delivered more than once. Deduplicate on delivery_id (body) or X-Sendoka-Delivery-Id (header):
async function handleEvent(event: SendokaEvent) {
const seen = await redis.set(`seen:${event.delivery_id}`, "1", "EX", 86400, "NX");
if (!seen) return; // already processed
// ... real work
}
Secret rotation
Dashboard → Webhooks → your endpoint → Rotate secret. During the grace window (default 24h) Sendoka dual-signs every delivery and retry with both the old and the new secret, sending one signature per secret in each X-Sendoka-Signature* header (comma-separated). As long as your verifier splits on , and accepts any match — as the snippets above do — you can rotate the live secret and roll out the new one any time before the window closes with zero dropped deliveries. After it closes, only the new secret signs.
Gotchas
- Use the raw body, not the parsed JSON. Re-serializing drops whitespace and breaks the signature.
- Clock drift matters. If your server time is off by >5 min, V2 verification fails. Run NTP.
- Don't log the secret. It's shown once on endpoint creation; store it in your secret manager.
- Replays get a new
delivery_id— your dedup key won't match a previous entry, by design.