Registering a webhook and verifying its signature properly

The exact bytes the HMAC covers, why parsed JSON can't be re-serialised for verification, and a handler that is safe to expose publicly.

A webhook endpoint you haven’t verified is an unauthenticated write path into your system, and the mistake that breaks verification is almost never the crypto. Infrai signs each delivery with X-Infrai-Signature: sha256=<hex>, an HMAC-SHA256 over the exact request body using the secret you supplied at registration — so verifying means hashing the raw bytes, not the object your framework parsed out of them.

Register with a secret, hash the raw body, compare in constant time. That’s the whole protocol.

Register

curl -sS -X POST "https://api.infrai.cc/v1/account/webhooks/register" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://ops.example.com/hooks/infrai",
    "events": ["email.delivered", "email.bounced", "wallet.low_balance"],
    "description": "delivery and wallet events",
    "secret": "a-long-random-string-you-generate",
    "retry_policy": "default"
  }'
{
  "ok": true,
  "data": {
    "webhook_id": "whk_7Uu2kQxWvR4mBn8d",
    "url": "https://ops.example.com/hooks/infrai",
    "events": ["email.delivered", "email.bounced", "wallet.low_balance"],
    "description": "delivery and wallet events",
    "secret_hash": "sha256:9f2c…",
    "active": true,
    "status": "active",
    "retry_policy": "default",
    "failure_count_24h": 0,
    "created_at": "2026-09-21T02:45:00Z"
  }
}

The secret comes back as a hash, not the value — the platform keeps what it needs to sign and you keep what you need to verify. retry_policy accepts default or aggressive.

events is a fixed catalogue: delivery events like email.delivered and sms.failed, job events like video.job.completed and image.job.failed, wallet events like wallet.low_balance, plus cron.executed, queue.dlq and error.captured. Subscribe to ["*"] if you’d rather filter on your side, but then be ready for every event type the platform grows.

The delivery

Each POST carries Content-Type: application/json, X-Infrai-Event naming the event, and the signature header. The body looks like this:

{
  "id": "evt_4f8a19c2b7d3461e9c0a5e2f7b1d8a63",
  "event": "email.bounced",
  "account_id": "acct_...275b",
  "created_at": "2026-09-21T02:45:12Z",
  "data": {"message_id": "msg_91bTcQ", "to": "ada@example.com", "reason": "mailbox_full"}
}

The body is serialised compactly with sorted keys, which matters for one reason only: it’s why you must never re-serialise before verifying. Your JSON library will produce different bytes — different spacing, different key order, a float rendered differently — and the hash won’t match.

Hash what arrived on the wire.

A handler that verifies correctly

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.INFRAI_WEBHOOK_SECRET;
if (!SECRET) throw new Error("INFRAI_WEBHOOK_SECRET is not set");

const app = express();

// express.raw, not express.json: the signature covers the bytes that arrived, so
// the handler needs them before any parser has touched them.
app.post("/hooks/infrai", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("x-infrai-signature") ?? "";
  const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");

  const a = Buffer.from(header);
  const b = Buffer.from(expected);
  // Length check first: timingSafeEqual throws on a length mismatch.
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).end();
  }

  const event = JSON.parse(req.body.toString("utf8"));
  // Acknowledge fast, then do the work out of band. A handler that finishes its
  // processing before replying is a handler that times out under load.
  res.status(204).end();
  queueForProcessing(event).catch((e) => console.error("processing failed", e));
});

async function queueForProcessing(event) {
  console.log(`accepted ${event.event} id=${event.id}`);
}

app.listen(8080, () => console.log("listening on :8080"));

Three details are load-bearing. Raw body, not parsed. Constant-time comparison, because a === on a hex string leaks timing. And a fast 204 — the delivery has an eight-second budget, so anything slower than that is a failed delivery even though your code worked.

Idempotency is on you

The same id can arrive twice. Retries exist, and a delivery that succeeded but whose acknowledgement was lost will be sent again.

Store the id and drop duplicates:

import hashlib
import hmac
import os

SECRET = os.environ["INFRAI_WEBHOOK_SECRET"].encode()
_seen: set[str] = set()


def verify(raw_body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(header or "", expected)


def handle(raw_body: bytes, header: str) -> tuple[int, str]:
    if not verify(raw_body, header):
        return 401, "bad signature"
    import json
    event = json.loads(raw_body)
    if event["id"] in _seen:
        return 204, "duplicate"
    _seen.add(event["id"])
    return 204, f"accepted {event['event']}"

In production that set is a table or a cache with a TTL, not a process-local variable — a restart that forgets which events it processed is a restart that reprocesses them.

Test it without waiting for an event

curl -sS -X POST "https://api.infrai.cc/v1/account/webhooks/test/whk_7Uu2kQxWvR4mBn8d" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The response reports delivered, http_status, latency_ms and any error — so you find out whether your endpoint is reachable and verifying correctly before a real event depends on it. GET /v1/account/webhooks/get/{id} then shows last_delivery_status, failure_count_24h and auto_disabled_at, which is how you notice a subscription that has been quietly failing.

Limitations worth planning around

There’s no replay-by-time-range: you can inspect past deliveries with GET /v1/account/webhooks/deliveries/{id} and re-test, but “resend everything from Tuesday” isn’t a call you can make. Design your consumer so a gap is recoverable by polling the underlying resource instead — for delivery events that’s GET /v1/email/get/{id} for one message, or GET /v1/email/event/list for the stream.

There’s also no timestamp in the signature scheme, so the signature alone doesn’t bound replay age. If that matters for your threat model, record event ids with a TTL and reject anything older than your window.

The upside is that the whole loop lives on one credential: the events come from the same account as the email that generated them, the queue you hand them to is POST /v1/queue/publish, and a verification failure you want to investigate goes to POST /v1/errors/capture — no second vendor, no second key, one line in GET /v1/account/usage.

Webhook management routes report billing_class: free in discovery: register, list, get, update, test and delete cost nothing per call. The capabilities emitting the events are the billable part — read the live rate from GET /v1/discovery and your spend from GET /v1/account/usage (verified 2026-09-21), and expect those rates to keep drifting downward.

References

Browse more account developer guides