Polling for a status change that happens twice a year is a waste of a cron slot. Subscribe once, and every observed insert or update on a tracked record arrives as a signed POST — with the changed field names attached, so you can route “the effective date moved” straight to the humans who care.
Published · AI Law Tracker Editorial Team
There are exactly two events: law.created (a record appeared) and law.updated (a record changed). There is deliberately no dedicated “effective date changed” event, because effective dates live inside the record’s free-text status and summary fields exactly as the source words them — inventing a synthetic event would mean parsing prose and asserting a date we were never told.
Instead, every law.updated delivery carries changed_fields: the public field names that moved. Filter on that. For effective-date work the fields you care about are status, in_force, record_date and summary.
changed_fields values for a law record: title, identifier, record_type, status, in_force, record_date, summary, source, official_url, and metadata (an opaque marker for internal churn).changed_fields is null on an insert — there is no previous state to diff against.changed_fields is exactly ["metadata"].Webhooks require an issued API key (a subscription needs a stable owner) and are a Pro-and-above feature: the per-key allowance is 3 on Pro, 25 on Business, unlimited on Enterprise, and 0 on Free/Developer — which returns 403 tier_upgrade_required naming required_tier: "pro".
target_url must be a public HTTPS endpoint. events, scope and jurisdiction are optional filters; omit events to receive both.
curl -X POST "https://ai-law-tracker.com/api/v1/webhooks" \
-H "X-API-Key: alt_pro_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://your-app.com/hooks/alt",
"events": ["law.created", "law.updated"],
"scope": "state",
"jurisdiction": "california"
}'{
"api_version": "v1",
"data": {
"id": "b3c4d5e6-0000-4444-8888-abcdef012345",
"target_url": "https://your-app.com/hooks/alt",
"events": ["law.created", "law.updated"],
"scope": "state",
"jurisdiction": "california",
"active": true,
"failure_count": 0,
"created_at": "2026-08-06T12:00:00Z",
"signing_secret": "whsec_… (shown once)"
},
"note": "Store signing_secret now — it is shown only once."
}# list this key's webhooks
curl -s "https://ai-law-tracker.com/api/v1/webhooks" -H "X-API-Key: alt_pro_your_key_here"
# one subscription
curl -s "https://ai-law-tracker.com/api/v1/webhooks/b3c4d5e6-0000-4444-8888-abcdef012345" \
-H "X-API-Key: alt_pro_your_key_here"
# unsubscribe
curl -X DELETE "https://ai-law-tracker.com/api/v1/webhooks/b3c4d5e6-0000-4444-8888-abcdef012345" \
-H "X-API-Key: alt_pro_your_key_here"Each delivery is a POST carrying one change, with five headers:
X-ALT-Signature — sha256=<hex>, the HMAC of the signed content (below).X-ALT-Timestamp — unix seconds, folded into the signed content so a captured request cannot be replayed under a new timestamp.X-ALT-Event — law.created or law.updated.X-ALT-Delivery — the delivery uuid. This is your idempotency key.X-ALT-Webhook-Id — the subscription uuid.{
"event": "law.updated",
"event_id": "a1b2c3d4-0000-4444-8888-abcdef012345",
"delivery_id": "d4e5f6a7-0000-4444-8888-abcdef012345",
"webhook_id": "b3c4d5e6-0000-4444-8888-abcdef012345",
"change_kind": "update",
"changed_fields": ["status"],
"observed_at": "2026-08-06T09:15:44.512Z",
"record": {
"id": "5f9c2b0a-1e34-4d21-9a77-0c1b2d3e4f56",
"scope": "state",
"jurisdiction": { "slug": "california", "name": "California", "abbr": "CA" },
"identifier": "SB 942",
"title": "California AI Transparency Act",
"status": "Signed — effective 2026-01-01",
"official_url": "https://leginfo.legislature.ca.gov/…",
"updated_at": "2026-08-06T09:15:44.512Z",
"attribution": "Data by AI Law Tracker (CC BY 4.0) — https://ai-law-tracker.com"
},
"attribution": "Data by AI Law Tracker (CC BY 4.0) — https://ai-law-tracker.com"
}The scheme is Stripe-style. The signed content is the X-ALT-Timestamp value, a literal dot, then the request body exactly as received — the raw bytes, not a re-serialised object.
Compute HMAC-SHA256(signing_secret, signed_content), hex-encode it, and compare it in constant time against the hex in X-ALT-Signature after stripping the sha256= prefix.
Two failure modes bite everyone. First: if your framework re-serialises JSON before you hash it, the bytes change and every signature fails — capture the raw body. Second: a length mismatch is simply “not valid”, never an exception; compare lengths before a constant-time compare so a malformed header cannot throw.
const crypto = require('node:crypto');
const express = require('express');
const app = express();
const SECRET = process.env.ALT_WEBHOOK_SECRET;
const TOLERANCE_SEC = 300; // the sender's replay tolerance
// CRITICAL: keep the RAW bytes. Hashing a re-serialised object will not match.
app.post(
'/hooks/alt',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body.toString('utf8');
const ts = req.get('X-ALT-Timestamp');
const received = String(req.get('X-ALT-Signature') || '')
.replace(/^sha256=/, '')
.trim();
if (!received) return res.status(400).send('missing signature');
// Replay guard: the timestamp is inside the signed content, so a captured
// body cannot be re-signed under a fresh timestamp.
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(ts));
if (!Number.isFinite(skew) || skew > TOLERANCE_SEC) {
return res.status(400).send('timestamp outside tolerance');
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${ts}.${raw}`) // signed_content
.digest('hex');
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).send('signature mismatch');
}
// Verified. Ack FAST, then work asynchronously.
const payload = JSON.parse(raw);
res.status(200).end();
void handleChange(req.get('X-ALT-Delivery'), payload);
}
);
async function handleChange(deliveryId, payload) {
// See step 4 — de-duplicate on deliveryId / payload.event_id first.
if (await alreadyProcessed(deliveryId)) return;
const fields = payload.changed_fields || [];
const touchesDates = ['status', 'in_force', 'record_date', 'summary'].some((f) =>
fields.includes(f)
);
if (payload.event === 'law.created' || touchesDates) {
await notifyComplianceTeam(payload.record, fields);
}
await markProcessed(deliveryId);
}import hashlib
import hmac
import os
import time
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
SECRET = os.environ["ALT_WEBHOOK_SECRET"].encode()
TOLERANCE_SEC = 300
@app.post("/hooks/alt")
async def alt_webhook(
request: Request,
x_alt_signature: str = Header(default=""),
x_alt_timestamp: str = Header(default=""),
x_alt_delivery: str = Header(default=""),
):
raw = await request.body() # RAW bytes — never a re-serialised dict
received = x_alt_signature.removeprefix("sha256=").strip()
if not received:
raise HTTPException(400, "missing signature")
try:
skew = abs(int(time.time()) - int(x_alt_timestamp))
except ValueError:
raise HTTPException(400, "missing/invalid timestamp")
if skew > TOLERANCE_SEC:
raise HTTPException(400, "timestamp outside tolerance")
signed_content = f"{x_alt_timestamp}.".encode() + raw
expected = hmac.new(SECRET, signed_content, hashlib.sha256).hexdigest()
if not hmac.compare_digest(received, expected):
raise HTTPException(400, "signature mismatch")
payload = await request.json()
if await already_processed(x_alt_delivery):
return {"ok": True, "duplicate": True}
fields = payload.get("changed_fields") or []
if payload["event"] == "law.created" or {"status", "in_force", "record_date", "summary"} & set(fields):
await notify_compliance_team(payload["record"], fields)
await mark_processed(x_alt_delivery)
return {"ok": True}Delivery is at-least-once. Assume you will see the same change twice — a slow ack, a network blip, or a retry after a 500 all produce a duplicate.
X-ALT-Delivery (unique per attempt) or on event_id (unique per change). Use event_id when you want “process this change once regardless of how many times it was delivered”; use the delivery id when you want an audit trail of attempts.GET /v1/webhooks/{id} shows failure_count, disabled_at and disabled_reason, so a health check can catch it before you notice the silence.Two honest fallbacks, both real endpoints:
GET /v1/changes?since=<cursor> — the same underlying change stream as a poll. Developer and above; poll with the previous response’s meta.cursor. The lookback window is 30 days on Developer, 90 on Pro, full on Business.GET /v1/deadlines — free at every tier, and the right tool if what you actually want is “which dates are coming up” rather than “what changed”. See the calendar guide./v1/webhooksCreate a subscription. Pro+. Returns signing_secret once./v1/webhooksList this key's subscriptions./v1/webhooks/{id}One subscription incl. failure_count / disabled_reason./v1/webhooks/{id}Unsubscribe./v1/changesPoll the same change stream with ?since=<cursor>. Developer+./v1/feedLean stream of significant changes only. Developer+.Full reference in the developer docs. Plans and limits on pricing.
The string `${X-ALT-Timestamp}.${raw request body}` — the timestamp, a literal dot, then the exact bytes of the JSON body. HMAC-SHA256 under your signing_secret, hex-encoded, sent as X-ALT-Signature: sha256=<hex>.
No, and that is deliberate. Effective dates are free text taken verbatim from the source, so a synthetic event would require asserting a parsed date we were never given. You get law.updated with changed_fields, and you filter on status / in_force / record_date / summary.
Store the X-ALT-Delivery uuid (or the payload event_id) and drop anything you have already seen. Delivery is at-least-once by design — retries after a timeout or a 500 will re-send the same change.
Check GET /v1/webhooks/{id}. Ten consecutive dead deliveries auto-disable a subscription and set disabled_at and disabled_reason; failure_count shows how close you are. Any successful delivery resets the counter to zero.
Data by AI Law Tracker, licensed CC BY 4.0 — attribution required: “Data by AI Law Tracker (CC BY 4.0) — https://ai-law-tracker.com”. AI Law Tracker provides informational data, not legal advice. Verify every record against its official source before relying on it.