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.