Webhooks · HMAC-SHA256

Catch effective-date changes with webhooks

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

Get a free keyAPI docs

What actually fires — and what does not

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.

  • Possible 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.
  • A metadata-only update is real but rarely interesting. If you only want substantive movement, drop deliveries whose changed_fields is exactly ["metadata"].

Step 1 — subscribe

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.

Terminal — create a subscription
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"
  }'
Example response (201) — signing_secret is shown ONCE
{
  "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."
}
Terminal — manage subscriptions
# 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"
The signing secret is returned once, in the creation response, and never again. Lose it and your only option is to delete the subscription and create a new one.

Step 2 — the delivery

Each delivery is a POST carrying one change, with five headers:

  • X-ALT-Signaturesha256=<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-Eventlaw.created or law.updated.
  • X-ALT-Delivery — the delivery uuid. This is your idempotency key.
  • X-ALT-Webhook-Id — the subscription uuid.
Example body (shape)
{
  "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"
}

Step 3 — verify the signature (do not skip this)

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.

Node.js — Express, mirroring the sender exactly
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);
}
Python — FastAPI, same algorithm
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}
Reject anything that fails verification. An unsigned or badly signed POST to a compliance-alerting endpoint is exactly the thing an attacker would use to make your team believe a deadline moved.

Step 4 — replay, retries and idempotency

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.

  • De-duplicate on 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.
  • Retries: up to 6 POST attempts per delivery, with exponential backoff of 2^n minutes capped at 60 — roughly 2m, 4m, 8m, 16m, 32m, 60m. After the budget is spent the delivery is marked dead.
  • 10 consecutive dead deliveries auto-disable the subscription; any success resets the counter. GET /v1/webhooks/{id} shows failure_count, disabled_at and disabled_reason, so a health check can catch it before you notice the silence.
  • Each delivery POST times out after 10 seconds. Verify, persist, return 200, and do the real work off the request — a slow handler manufactures its own retries.
  • The replay tolerance the sender assumes is 300 seconds. Use the same window in your verifier.

If you are not on Pro yet

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.

Endpoints used in this guide

POST
/v1/webhooksCreate a subscription. Pro+. Returns signing_secret once.
GET
/v1/webhooksList this key's subscriptions.
GET
/v1/webhooks/{id}One subscription incl. failure_count / disabled_reason.
DELETE
/v1/webhooks/{id}Unsubscribe.
GET
/v1/changesPoll the same change stream with ?since=<cursor>. Developer+.
GET
/v1/feedLean stream of significant changes only. Developer+.

Full reference in the developer docs. Plans and limits on pricing.

FAQ

What exactly is signed?

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>.

Is there an event for “the effective date changed”?

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.

How do I stop duplicate alerts?

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.

My webhook went quiet. What happened?

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.

Keep going

🔌 Track AI laws inside Claude Code and Cursor with MCP🗺️ Fetch a state’s AI laws with the API🗓️ Build an AI compliance deadline calendar Ground an AI agent in real AI-law data📦 The dataset →🗓️ AI law deadlines →💳 Plans & limits →

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.