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> # legacy V1: HMAC-SHA256(raw_body, secret)
X-Sendoka-Timestamp: 1713820800 # unix seconds when the request was signed
X-Sendoka-Signature-V2: <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.
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);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
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()
if not hmac.compare_digest(expected, signature):
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"
"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))
if !hmac.Equal([]byte(expected), []byte(signature)) {
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. Both old and new secrets verify for the grace window (default 24h); update your env and you're done. Verify against both during rollout if you manage secrets manually.
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.