REST · curl, Python, TypeScript

Fetch a state’s AI laws with the API

One GET returns the tracked AI-regulation records for a jurisdiction, each with its status, its dates, and the official .gov URL behind it. Here is the whole loop: find the slug, page through the records, handle the errors, and know exactly which caps your tier is under.

Published · AI Law Tracker Editorial Team

Get a free keyAPI docs

Step 0 — authentication and the free tier

Every read endpoint works with no credentials at all. Anonymous callers are limited to 20 requests/minute and 100 requests/day per IP, capped at 5 records per page, and receive a trimmed field envelope. That is enough to evaluate the data and not much more.

A free key — self-serve, no card — raises you to 60 requests/minute and 300 requests/day (9,000 a month), returns the complete record (summary, source, dates, in_force) and allows 25 records per page. Send it in either header form:

Terminal — the two accepted auth headers
# Preferred
curl -s "https://ai-law-tracker.com/api/v1/health" -H "X-API-Key: alt_free_your_key_here"

# Equivalent
curl -s "https://ai-law-tracker.com/api/v1/health" -H "Authorization: Bearer alt_free_your_key_here"
Terminal — get a key
curl -X POST "https://ai-law-tracker.com/api/v1/keys" \
  -H "Content-Type: application/json" \
  -d '{"email":"you@company.com"}'
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Keyed callers additionally get X-Quota-Day-Remaining and X-Quota-Month-Remaining where the cap is finite. Read them; do not guess your budget.

Step 1 — find the jurisdiction slug

The jurisdiction filter takes an exact slug, not a display name. GET /v1/states lists the US state jurisdictions with their record counts; GET /v1/jurisdictions covers everything including us-federal, eu and country slugs.

Terminal
curl -s "https://ai-law-tracker.com/api/v1/states" -H "X-API-Key: alt_free_your_key_here"
Example response (shape)
{
  "api_version": "v1",
  "data": [
    { "slug": "california", "name": "California", "abbr": "CA", "scope": "state", "count": 55 },
    { "slug": "colorado",   "name": "Colorado",   "abbr": "CO", "scope": "state", "count": 12 }
  ],
  "meta": { "count": 50 }
}
These taxonomy endpoints return byte-identical data to every caller, so they are cached at the edge (public, s-maxage=300). Cache the slug list on your side too — it changes rarely.

Step 2 — pull the records with curl

GET /v1/laws accepts scope, jurisdiction, status (case-insensitive substring), in_force, updated_since, q, sort (updated_at | record_date | title), order, limit and offset. Filters and sorting are honoured at every tier — only page size and pagination depth are tier-capped.

Terminal
curl -s -G "https://ai-law-tracker.com/api/v1/laws" \
  -H "X-API-Key: alt_free_your_key_here" \
  --data-urlencode "jurisdiction=california" \
  --data-urlencode "sort=record_date" \
  --data-urlencode "order=desc" \
  --data-urlencode "limit=25"
Example response (values illustrative, shape live)
{
  "api_version": "v1",
  "data": [
    {
      "id": "5f9c2b0a-1e34-4d21-9a77-0c1b2d3e4f56",
      "scope": "state",
      "jurisdiction": { "slug": "california", "name": "California", "abbr": "CA" },
      "identifier": "SB 942",
      "title": "California AI Transparency Act",
      "record_type": "bill",
      "status": "Signed — effective 2026-01-01",
      "in_force": null,
      "record_date": "2024-09-19T00:00:00Z",
      "summary": "Requires covered providers to offer AI-detection tooling and to apply latent disclosures…",
      "source": "leginfo.legislature.ca.gov",
      "official_url": "https://leginfo.legislature.ca.gov/faces/billTextClient.xhtml?bill_id=202320240SB942",
      "updated_at": "2026-07-05T14:02:11Z",
      "checked_at": "2026-08-01T03:11:42Z",
      "attribution": "Data by AI Law Tracker (CC BY 4.0) — https://ai-law-tracker.com"
    }
  ],
  "meta": {
    "total": 55,
    "count": 25,
    "limit": 25,
    "offset": 0,
    "tier": "free",
    "ai_scope": "ai_specific",
    "gated": ["limit"],
    "upgrade": "Free shows the full record quality but at low volume…"
  }
}
updated_at moves only on a real content diff; checked_at moves every time the source was successfully re-checked, even when nothing changed. Use updated_at for “what changed”, checked_at for “how fresh is this”.

Step 3 — paginate properly (Python)

Page with limit and offset, and stop on meta.total — never on an assumption about page size. If you ask for more than your tier allows, the API does not error: it clamps the value, returns the smaller page, and tells you it did so in meta.gated. Read the echoed meta.limit rather than the one you sent.

Python 3 — requests
import os
import time
import requests

BASE = "https://ai-law-tracker.com/api/v1"
SESSION = requests.Session()
SESSION.headers.update({"X-API-Key": os.environ["ALT_API_KEY"]})


def fetch_state_laws(slug, page_size=25):
    """Yield every tracked AI-law record for one jurisdiction slug."""
    offset = 0
    while True:
        r = SESSION.get(
            f"{BASE}/laws",
            params={
                "jurisdiction": slug,
                "sort": "record_date",
                "order": "desc",
                "limit": page_size,
                "offset": offset,
            },
            timeout=30,
        )

        if r.status_code == 429:
            # Honour the server's own backoff rather than inventing one.
            time.sleep(int(r.headers.get("Retry-After", "5")))
            continue
        r.raise_for_status()

        payload = r.json()
        rows = payload["data"]
        meta = payload["meta"]

        for row in rows:
            yield row

        # meta.limit is what the server ACTUALLY applied after tier clamping.
        offset += meta["limit"]
        if not rows or offset >= meta["total"]:
            return


if __name__ == "__main__":
    for law in fetch_state_laws("california"):
        print(law["identifier"], "|", law["status"], "|", law["official_url"])

Step 4 — the same thing in TypeScript

TypeScript — fetch (Node 18+, Deno, Bun, edge runtimes)
type LawRecord = {
  id: string;
  scope: 'state' | 'federal' | 'eu' | 'global';
  jurisdiction: { slug: string; name: string | null; abbr: string | null };
  identifier: string | null;
  title: string | null;
  record_type: string | null;
  status: string | null;
  in_force: boolean | null;
  record_date: string | null;
  summary: string | null;
  source: string | null;
  official_url: string | null;
  updated_at: string | null;
  checked_at: string | null;
  attribution: string;
};

type ListResponse = {
  api_version: string;
  data: LawRecord[];
  meta: { total: number; count: number; limit: number; offset: number; tier?: string; gated?: string[] };
};

const BASE = 'https://ai-law-tracker.com/api/v1';

export async function fetchStateLaws(slug: string, apiKey: string): Promise<LawRecord[]> {
  const out: LawRecord[] = [];
  let offset = 0;

  for (;;) {
    const url = new URL(`${BASE}/laws`);
    url.searchParams.set('jurisdiction', slug);
    url.searchParams.set('sort', 'record_date');
    url.searchParams.set('order', 'desc');
    url.searchParams.set('limit', '25');
    url.searchParams.set('offset', String(offset));

    const res = await fetch(url, { headers: { 'X-API-Key': apiKey } });

    if (res.status === 429) {
      const wait = Number(res.headers.get('Retry-After') ?? '5');
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    if (!res.ok) {
      const body = await res.json().catch(() => null);
      throw new Error(`ALT ${res.status}: ${body?.error?.code ?? 'unknown'} — ${body?.error?.message ?? res.statusText}`);
    }

    const page = (await res.json()) as ListResponse;
    out.push(...page.data);

    offset += page.meta.limit;
    if (page.data.length === 0 || offset >= page.meta.total) return out;
  }
}

Step 5 — the error bodies you have to handle

Every failure uses one envelope, so a single handler covers all of them:

  • 400 `invalid_request` — a bad scope, an unparseable updated_since, or a missing q on /v1/search. The message names the offending parameter.
  • 429 `rate_limited` — you passed a per-minute or durable quota. Retry-After and error.retry_after both tell you how long to wait. Back off; do not retry tightly.
  • 503 `backend_unavailable` — the data backend is temporarily down. Retry with jitter; this is transient, not a contract change.
  • 403 `tier_upgrade_required` — you touched a paid surface (/v1/changes, /v1/feed, /v1/laws/{id}/history). The body names required_tier, so you can surface a precise message instead of a generic denial.
  • A tier-capped *list* request is never an error. It returns 200 with meta.gated naming what was clamped and meta.upgrade explaining why.
Error envelope
{
  "api_version": "v1",
  "error": {
    "code": "rate_limited",
    "message": "…",
    "docs": "https://ai-law-tracker.com/developers",
    "retry_after": 12
  }
}

Endpoints used in this guide

GET
/v1/statesUS state jurisdictions with record counts (the slug source).
GET
/v1/jurisdictionsEvery covered jurisdiction, filterable by scope.
GET
/v1/lawsList records: scope, jurisdiction, status, in_force, updated_since, q, sort, order, limit, offset.
GET
/v1/laws/{id}One record by uuid.
GET
/v1/laws/{id}/sourcesThe tiered source URLs behind a record.
GET
/v1/searchText search over title + summary. `q` required.
POST
/v1/keysSelf-serve free key by email.

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

FAQ

Is the API really free?

Yes for this workload — free as in no card, not free as in no signup. A key is required on every data endpoint, and the free one is self-serve: 60 req/min, 300 req/day, 9,000 req/month, the full record envelope and 25-record pages. Paid plans exist for production volume, the change feed, history and webhooks.

Why did my limit=100 come back as 25 rows?

Your tier clamped it. Free is capped at 25 records per page. The response is still a 200 — check meta.limit for what was applied and meta.gated for the list of clamped parameters. Paid tiers allow the full 100.

What does meta.total count?

By default it counts AI-specific records, which is echoed back as meta.ai_scope so the number is never ambiguous. It is the total matching your filters, not the size of the page — page with limit and offset until offset reaches total.

Can I redistribute the data?

Yes, under CC BY 4.0 with attribution. Every record carries the attribution string in its own payload so you can propagate it automatically.

Keep going

🔌 Track AI laws inside Claude Code and Cursor with MCP📡 Catch effective-date changes with webhooks🗓️ 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.