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
- Hashing a re-serialized body. The most common cause of "the signature never matches". Hash the bytes you received.
- Comparing with
==. A plain string comparison returns early on the first differing character, which leaks timing. Usecompare_digestortimingSafeEqual. - Trusting the event name first.
X-Engram-Eventis unauthenticated until the signature checks out, so route on it after verifying, not before.
Next
Now make the receiver survive a bad afternoon: Delivery and retries.