Webhooks
Being told, instead of asking — and the three lines of verification everybody gets wrong.
Polling is most of what a rate limit is spent on, and almost all of it is spent learning that nothing has happened. Register an endpoint on your application's page at settings/applications, pick what it should hear about, and copy the signing secret — it is shown once.
Who hears what #
An event about a person reaches your endpoint only if that person authorised your application and it holds the scope that would let it read the same thing. The check happens when the event is sent rather than when you subscribed, because an authorization can be taken back and a subscription cannot know.
So: a webhook is not a second way in. If GET /v1/lists would refuse you for
somebody, so does the bell.
| event | needs | what it is |
|---|---|---|
episode.aired | catalog:read | a dub gained an episode. The one event with nobody in it, so every subscriber hears it and no grant is checked. |
list.saved | lists:read | a row was written — added, or stepped on |
list.removed | lists:read | a title left their list |
list.rated | lists:read | what they thought of it changed, or was taken back |
post.created | social:read | they wrote something |
follow.added | social:read | they started reading somebody |
follow.removed | social:read | they stopped |
What arrives #
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: acyka-webhooks/1
X-Acyka-Event: episode.aired
X-Acyka-Delivery: 84213
X-Acyka-Signature: t=1700000000,v1=5bdcc146bf60754e6a04…
{"event":"episode.aired","data":{"shikimori_id":52991,"episode":18}} {
"event": "list.saved",
"data": {
"user_id": "1204",
"shikimori_id": 52991,
"title": "Sousou no Frieren",
"status": "watching",
"episode": 4,
"score": null
}
} user_id is a string, and it is the id rather than the nickname — a name can move,
and an application that stored one has a key that stops resolving. It is the same string /v1/me answers with.
Verifying it #
The signature is t=<unix seconds>,v1=<hex hmac-sha256> over <t>.<raw body>, with your endpoint's secret as the key. Every library
here ships the check, and it is worth saying what the three hard parts are — because they are
the three everybody who writes it themselves gets wrong once.
- The raw bytes, not a parsed body. The signature covers what was sent. Parsing and re-serialising gives a different string the moment key order or number formatting differs, and the check then fails for good reasons that look like bad ones.
- A constant-time compare.
===on strings returns as soon as two characters differ, and how long that took is a measurement of how much of the signature was right — a hundred requests per byte, and a forgeable signature at the end of it. - Check how old it is. The timestamp is inside the signed string precisely so you can: signing the body alone would give a signature that stays valid for ever, and a captured delivery could be replayed next month.
import { verify, BadSignature } from '@acyka/api';
export async function POST({ request }) {
const raw = await request.text(); // the raw text, before any parsing
try {
const event = await verify({
body: raw,
signature: request.headers.get('x-acyka-signature'),
secret: process.env.ACYKA_WEBHOOK_SECRET!
});
// Answer first, work afterwards: an endpoint that does five seconds of
// work before answering is an endpoint that gets retried while it works.
queue.push(event);
return new Response(null, { status: 204 });
} catch (err) {
if (err instanceof BadSignature) return new Response(null, { status: 400 });
throw err;
}
} Retries, and when we stop #
Answer any 2xx and it is done. Anything else — or nothing at all — is retried: eight attempts over about a day, ten seconds apart at first and twelve hours apart at the end. Deliveries are rows rather than tasks in memory, so an application that was down for ten minutes finds out what it missed, including across a deploy on our side.
Twenty dead deliveries in a week with nothing landing switches the endpoint off, and the page says so. Going on knocking at a URL that has moved is a thing we would be doing to somebody else's server rather than for them.
The address has to be public https #
A hostname on the public internet, over TLS. localhost, a private range and a
link-local address are all refused — without that rule, a webhook URL is a way to make this
server call itself or its host's metadata service and hand you the answer.
For local work, a tunnel: the endpoint has to be reachable from outside for the same reason your own will be.