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 500 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, 2,000 requests/day and 60,000 requests/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. Anonymous access needs no key at all (20 req/min, 500 req/day, 5-record pages). A free key is self-serve with no card and gives 60 req/min, 2,000 req/day, 60,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 and anonymous at 5. The response is still a 200 — check meta.limit for what was applied and meta.gated for the list of clamped parameters. Developer and above 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.