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.