Webhooks

Verify a signature

Every delivery is signed with HMAC-SHA256 over the exact bytes we send. Here is working verification code in Python and Node, and the one mistake that breaks it.

Your endpoint is on the public internet, so anyone can POST to it. The signature is what separates a delivery from us from a delivery from someone who found your URL. Check it before your receiver does anything.

What we sign

Every delivery carries:

X-Engram-Signature: sha256=<hex digest>

The digest is HMAC-SHA256 of the raw body bytes, keyed with your endpoint's secret (the whsec_ value you received when you subscribed). Recompute it over the bytes exactly as they arrived and compare in constant time.

Sign the raw bytes, never a re-serialized object. JSON key order is not stable across languages, so a receiver that parses the body and re-encodes it before hashing gets a different digest for the same payload and concludes we are an attacker. Read the body as bytes first, verify, then parse.

Verify in Python

The standard library has everything needed: hmac, hashlib, and hmac.compare_digest for the constant-time comparison.

import hashlib
import hmac


def verify(secret: str, body: bytes, header: str | None) -> bool:
    """True when `header` is the signature we would produce for these exact bytes."""
    if not header:
        return False
    digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"sha256={digest}", header)

In FastAPI, read the body before anything parses it:

import json
import os

from fastapi import FastAPI, Header, HTTPException, Request

app = FastAPI()
SECRET = os.environ["ENGRAM_WEBHOOK_SECRET"]


@app.post("/engram")
async def engram_webhook(request: Request, x_engram_signature: str | None = Header(default=None)):
    body = await request.body()                      # raw bytes, exactly as sent
    if not verify(SECRET, body, x_engram_signature):
        raise HTTPException(401, "bad signature")

    payload = json.loads(body)
    queue_for_processing(payload["event"], payload["data"])
    return {"ok": True}                              # answer fast; do the work off the request

In Flask, request.get_data() is the raw body:

from flask import Flask, abort, request

app = Flask(__name__)


@app.post("/engram")
def engram_webhook():
    body = request.get_data()
    if not verify(SECRET, body, request.headers.get("X-Engram-Signature")):
        abort(401)
    payload = request.get_json()
    queue_for_processing(payload["event"], payload["data"])
    return "", 204

Verify in Node

crypto.timingSafeEqual is the constant-time comparison, and it throws on different-length buffers, so check the length first.

import crypto from "node:crypto";

export function verify(secret, body, header) {
  if (!header) return false;
  const digest = crypto.createHmac("sha256", secret).update(body).digest("hex");
  const expected = Buffer.from(`sha256=${digest}`, "utf8");
  const received = Buffer.from(header, "utf8");
  if (expected.length !== received.length) return false;
  return crypto.timingSafeEqual(expected, received);
}

With Express, mount the raw body parser on this route only, so the rest of your app keeps parsing JSON normally:

import express from "express";

const app = express();
const SECRET = process.env.ENGRAM_WEBHOOK_SECRET;

app.post("/engram", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(SECRET, req.body, req.get("X-Engram-Signature"))) {
    return res.status(401).send("bad signature");
  }
  const payload = JSON.parse(req.body.toString("utf8"));
  queueForProcessing(payload.event, payload.data);
  res.sendStatus(204);
});

Look up or replace a secret

A workspace admin can read the secret back from GET /v1/webhooks, so a receiver that lost it can be repaired without touching the endpoint. For every other caller the field is null.

curl https://api.engramdynamics.org/v1/webhooks \
  -H "Authorization: Bearer <your admin key>" 

To replace a secret, rotate it: POST /v1/webhooks/{id}/rotate returns a new whsec_ value and keeps the endpoint's id, its URL and its event list. The old secret stops verifying on that call, so deploy the new one to your receiver first. Full details on Replace a signing secret.

Three mistakes worth avoiding

Next

Now make the receiver survive a bad afternoon: Delivery and retries.