Webhooks
Who this is for: Operators who want signed HTTPS notifications of the differences between two CRA rule sets, sent when they ask for them.
When you finish: You can register an endpoint, verify takehome-signature, and inspect or replay deliveries from the API.
Takehome sends a rule_set.changed delivery only when you call POST /v1/webhooks/dispatch (with from and to rule-set versions) or replay a delivery; never automatically when the CRA publishes new T4127 (Payroll Deductions Formulas) rules. To hear about a new edition, watch GET /v1/changes (or its RSS feed, /v1/changes.rss) and dispatch yourself. Billing webhooks are off while paid plans are deferred: POST /v1/billing/webhook answers 404.
Register an endpoint
curl -sS -X POST https://takehome.gautamkhosla.com/v1/webhooks \
-H 'content-type: application/json' \
-H 'authorization: Bearer np_live_YOUR_KEY_HERE' \
-d '{"url":"https://your-app.example.com/takehome/webhook"}'The response includes id, url, created_at, and secret (whsec_…). The secret is shown once. Store it like an API key. The whole url is also shown only in this answer: listing (GET /v1/webhooks) and reading (GET /v1/webhooks/{id}) an endpoint show its url as the scheme and host only, https://<host>/, to every key (the test key included), because receivers often keep a credential in the path or query. Keep your own copy of the full address.
The URL must be https, carry no user name or password, not be on gautamkhosla.com or one of its subdomains (where this service runs), and be at most 2,048 bytes (HTTP 400 otherwise); one account's URLs together may be at most 16,384 bytes (HTTP 409). A refused registration stores nothing and does not count toward the 20 registrations a day, unless it raced another registration of the same account that was stored first (both counted before the endpoint limit was checked). An account has at most 5 endpoints.
List endpoints: GET /v1/webhooks. Delete: DELETE /v1/webhooks/{id}.
Payload
Outbound POST body (JSON):
{
"event": "rule_set.changed",
"from": "2026-01-01",
"to": "2026-07-01",
"changed": ["cpp", "ei"],
"occurred_at": "2026-06-15T12:00:00Z"
}Headers include content-type: application/json and takehome-signature.
HMAC signature (exact bytes)
Signature header format: takehome-signature: t=<unix>,v1=<hex>.
The MAC is HMAC-SHA256 over the UTF-8 string timestamp + "." + raw_body where:
-
timestampis the same decimal Unix seconds ast=(string of digits). -
raw_bodyis the exact HTTP request body bytes (before JSON parsing).
The secret is the whsec_ value from registration. Do not sign the body alone without the timestamp prefix.
Receivers must reject events when |now - t| > 300 seconds (five-minute replay window).
Each delivery is tried up to 3 times, each attempt with a 5-second timeout and redirect: "manual" (a redirect ends the delivery). A dispatch tries its endpoints in rounds: the first attempt to every endpoint at once; then, at least 1 second after the slowest attempt of that round ended, a second attempt to those that failed; then, at least 2 seconds after the second round ended, a third. So a fast-failing endpoint can wait up to 6 seconds, then 7, when another endpoint of the same dispatch times out. Right before each round we check which endpoints still exist (see Delivery log and replay). Your server's response body is read up to 4 KiB and discarded (not stored in the log).
Verify in Node
import { createHmac, timingSafeEqual } from 'node:crypto';
import http from 'node:http';
const SECRET = process.env.TAKEHOME_WHSEC;
const TOLERANCE_SEC = 300;
function verifySignature(secret, rawBody, header, nowSec) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('=')),
);
const t = Number.parseInt(parts.t, 10);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(nowSec - t) > TOLERANCE_SEC) return false;
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(v1, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const rawBody = Buffer.concat(chunks).toString('utf8');
const header = req.headers['takehome-signature'] ?? '';
const ok = verifySignature(SECRET, rawBody, header, Math.floor(Date.now() / 1000));
res.statusCode = ok ? 200 : 401;
res.end(ok ? 'ok' : 'invalid');
});
});
server.listen(3456);Run with TAKEHOME_WHSEC=whsec_your_secret node verify-webhook.mjs.
Verify in Python
import hashlib
import hmac
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["TAKEHOME_WHSEC"].encode("utf-8")
TOLERANCE_SEC = 300
def verify_signature(secret: bytes, raw_body: bytes, header: str, now_sec: int) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t_str = parts.get("t")
v1 = parts.get("v1")
if not t_str or not v1:
return False
t = int(t_str)
if abs(now_sec - t) > TOLERANCE_SEC:
return False
msg = f"{t}.".encode("utf-8") + raw_body
expected = hmac.new(secret, msg, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected)
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("Content-Length", "0"))
raw_body = self.rfile.read(length)
header = self.headers.get("takehome-signature", "")
ok = verify_signature(SECRET, raw_body, header, int(time.time()))
self.send_response(200 if ok else 401)
self.end_headers()
self.wfile.write(b"ok" if ok else b"invalid")
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 3457), Handler).serve_forever()Run with TAKEHOME_WHSEC=whsec_your_secret python3 verify_webhook.py.
Delivery log and replay
GET /v1/webhooks/deliveries returns one page of records, newest first: deliveries[] with status, attempts (the attempts actually made), last_error, replay_of, and timestamps, at most 20 (?limit=1 to 20), and next_cursor. Pass it back as ?cursor= for the next page; it is null on the last page. Response bodies from your server are never persisted.
Replay a failed delivery: POST /v1/webhooks/deliveries/{id}/replay. The new attempt links back through replay_of.
Dispatch to every endpoint of your account (at most 5): POST /v1/webhooks/dispatch with { "from": "2026-01-01", "to": "2026-07-01" }. An account may have at most 100 deliveries in a UTC clock hour (for example 14:00 to 15:00 UTC) and 300 in a UTC day, one per endpoint for each dispatch and one per replay; a dispatch that would pass either is refused whole with HTTP 429 and sends nothing. Deleting an endpoint while a dispatch or replay to it runs: if the deletion is stored before the delivery records are written, the request answers 409 and sends nothing. After that, right before each round of attempts, we read which endpoints still exist and send only to those, so a deleted endpoint gets no further attempt: its delivery is listed as failed with last_error "The endpoint was deleted; no further attempt was sent.", and its record is deleted with the endpoint (it is not in the delivery log and cannot be replayed). A replay whose endpoint is deleted before its first attempt answers 409 with nothing sent. The one gap: a deletion stored in the moment between that read and the attempts it cleared cannot stop them, so at most one attempt per endpoint can arrive right after DELETE /v1/webhooks/{id} answered. None of this is ever an outage.
One dispatch or replay per account runs at a time (security round 25): each takes the account's send lease before it spends or sends anything and gives it back when it answers, and while one holds it, another answers HTTP 409 (invalid_request, "Another webhook dispatch or replay of this account is still running") with nothing sent or counted; retry when the first has answered. That is what keeps the gap above at one attempt per endpoint: before the lease, several dispatches running together could each send one. The lease ends by itself 60 seconds after it was taken (a server stopped mid-dispatch cannot hold it longer), and a round of attempts goes out only if the lease still has 20 seconds left when its check is read, so a dispatch that runs late stops sending (its deliveries are failed with "Not sent: this dispatch ran past its time limit; no further attempt was sent.") before the lease can pass to another one. The only way left to get two late attempts to one endpoint is a database answer that takes more than 20 seconds to reach our server.
Last reviewed: 2026-10-30
Engine: 0.1.0