Your own webhook
The exact request I send to your own endpoint, how to prove it came from me, and a prompt that writes the receiver for you.
Which way the traffic goes
I make the requests. You give me an https address, and from then on I send a POST to it whenever something happens that you would want to know about. You never call me.
There is one exception, and it happens once: before I write anything real, I post a confirmation link to your endpoint and somebody has to open it. Until that happens the destination sits in your settings marked as not confirmed.
What arrives
A POST with a JSON body and three headers that matter:
Content-Type- Always
application/json. X-Timestamp- Unix seconds, as a string. When I built and signed the request.
X-Signature- Lower-case hex HMAC-SHA256 over
<X-Timestamp>.<body>, keyed with your signing secret.
An incident looks like this. check is absent when the incident belongs to the whole project rather than to one path through it, and detail is the same sentence the email would have opened with.
{
"check": { "id": "0199…", "name": "Checkout" },
"detail": "Checkout stopped working. The pay button is gone.",
"event": "incident.opened",
"project": {
"id": "0199…",
"name": "fitmealplan",
"url": "https://fitmealplan.app"
}
}The closing message is identical apart from event, which reads incident.closed. A visitor error arrives as visitor_error.detected and carries an error object and an ignoreUrl instead of detail. And the confirmation, the only thing an unconfirmed endpoint ever receives, carries the link and nothing else — no project, no address, no identifiers, because at that moment nobody has said they want them:
{
"confirmUrl": "https://stillworks.watch/r/xY7…",
"event": "recipient.verification"
}Proving it came from me
Your signing secret is shown once, when you add the endpoint, and never again — not in the interface and not through the API. Copy it then. If you lose it, remove the endpoint and add it again, which issues a new one.
Take the value of X-Timestamp, a full stop, then the request body exactly as it arrived. That string — not the body on its own — is what I signed. A signature over the body alone would never go out of date, so anyone who captured a single request could replay it whenever they liked and the timestamp header would be decoration.
Read the raw body before anything parses it. This is the one mistake that costs an afternoon: express.json() and its equivalents consume the bytes, and a body you re-serialise from the parsed object will not hash to the same value — a stray space or a reordered key is enough.
A receiver, written for you
Paste this into Claude Code, Codex, Cursor or whichever assistant you build with. It describes the whole contract, including the parts that are easy to get subtly wrong.
Add an endpoint to this project that receives signed webhooks from stillworks.watch and refuses everything else. Rules, in this order: 1. Read the RAW request body as text before anything parses it. The signature covers the exact bytes that were sent, so a framework that parses JSON first (express.json, body-parser, a validated body helper) silently breaks every signature. Use the raw-body form of whatever framework this project already uses. 2. Read the X-Timestamp and X-Signature headers. Answer 400 if the timestamp is missing, unparseable, or more than 300 seconds away from the current time. That is what stops a captured request being replayed. 3. Build the string `<X-Timestamp>.<raw body>` and compute HMAC-SHA256 over it with the secret in STILLWORKS_WEBHOOK_SECRET. Hex-encode it, lower case. 4. Compare that to X-Signature in constant time — crypto.timingSafeEqual over two buffers of equal length, never === on the strings. Answer 401 when it differs. 5. Only after all of that, parse the JSON. Its "event" field is one of incident.opened, incident.closed, visitor_error.detected or recipient.verification. 6. When the event is recipient.verification, log the "confirmUrl" field somewhere I will actually see it. A human has to open that link once, otherwise stillworks.watch never writes to this endpoint again. 7. Answer 2xx within a second and do any slow work afterwards. The sender waits 10 seconds, does not follow redirects, and treats anything that is not 2xx as a failed delivery. Then add STILLWORKS_WEBHOOK_SECRET to the environment example file with a comment, and tell me the full public https URL of the new endpoint so I can paste it into stillworks.watch.
Or write it yourself
A route handler in Next.js:
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request): Promise<Response> {
// The raw text, before any parsing. The signature covers these exact bytes.
const body = await request.text();
const timestamp = request.headers.get("x-timestamp") ?? "";
const signature = request.headers.get("x-signature") ?? "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) {
return new Response(null, { status: 400 });
}
const expected = createHmac("sha256", process.env.STILLWORKS_WEBHOOK_SECRET ?? "")
.update(`${timestamp}.${body}`)
.digest("hex");
const mine = Buffer.from(expected, "hex");
const theirs = Buffer.from(signature, "hex");
if (mine.length !== theirs.length || !timingSafeEqual(mine, theirs)) {
return new Response(null, { status: 401 });
}
const message = JSON.parse(body);
if (message.event === "recipient.verification") {
console.log("Open this once to switch the endpoint on:", message.confirmUrl);
}
return new Response(null, { status: 204 });
}The same thing in Express:
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
// express.raw, never express.json: the JSON parser consumes the exact bytes
// the signature was computed over, and nothing you rebuild from the parsed
// object will hash the same way.
app.post("/stillworks", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Timestamp") ?? "";
const signature = req.get("X-Signature") ?? "";
const body = req.body.toString("utf8");
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 300) return res.sendStatus(400);
const expected = createHmac("sha256", process.env.STILLWORKS_WEBHOOK_SECRET)
.update(`${timestamp}.${body}`)
.digest("hex");
const mine = Buffer.from(expected, "hex");
const theirs = Buffer.from(signature, "hex");
if (mine.length !== theirs.length || !timingSafeEqual(mine, theirs)) {
return res.sendStatus(401);
}
const message = JSON.parse(body);
if (message.event === "recipient.verification") {
console.log("Open this once to switch the endpoint on:", message.confirmUrl);
}
res.sendStatus(204);
});What your endpoint has to be
https, always. A webhook address is itself a credential — it usually carries a hard-to-guess path — and plain http would put it on the wire in the clear on every message.
Reachable from the public internet. I resolve the host before I dial it and refuse anything that answers with a private or loopback address, so a tunnel with a public https address works and localhost does not. The URL may not carry a username or password.
Quick, and answering 2xx. I wait ten seconds and I do not follow redirects. Anything that is not a 2xx counts as a failed delivery, and a failed delivery is attempted three times, about five minutes apart, before I stop. Answer first, work afterwards.
Switching it on
The confirmation request arrives immediately after you add the endpoint — before you have written a single line of the handler, if you paste the address first. If your handler logged it, open the confirmUrl from that body and answer Yes, write here. If it did not, press Send again next to the destination in your settings. That sends a fresh link and stops the old one working, and it leaves your signing secret alone — removing the endpoint and adding it back would issue a new one and mean redeploying your receiver.
A confirmation link is good for 24 hours and works once. Where I write covers the rest of that flow, and when I write covers what has to happen before there is anything to send.