Everything on the dashboard is a call with your key.
Make a key in Settings and send it as a bearer token. Responses are JSON; errors are {"error": "…"} with a 4xx status.
curl -X POST https://programmable.watch/api/watchlists \ -H "Authorization: Bearer wsk_…" \ -F name="Q4 subcontractors" -F [email protected] curl https://programmable.watch/api/watchlists/{id}/export?format=json -H "Authorization: Bearer wsk_…"
| Method | Path | What it does |
|---|---|---|
| GET | /api/watchlists | Your watchlists with counts. |
| POST | /api/watchlists | Make a list from a CSV (multipart `file`), pasted text (`text`) or JSON `{name, rows: [...]}`. Matching runs before it answers. |
| GET | /api/watchlists/{id} | The list, its rows with candidates and scores, and open findings. |
| POST | /api/watchlists/{id}/match | Search the registry again for every undecided row. |
| POST | /api/watchlists/{id}/check | Check every confirmed supplier on the list now. |
| GET | /api/watchlists/{id}/export?format=csv|json | The audit export. |
| GET | /api/rows/{id} | One supplier: row, findings, checks, latest evidence, documents, audit events. |
| POST | /api/rows/{id}/decision | `{action: "confirm", uei, note?}`, `{action: "reject"}`, `{action: "retire"}` or `{action: "reopen"}`. |
| POST | /api/rows/{id}/check | Check this supplier now. |
| GET/POST | /api/rows/{id}/documents | List, or add a document (multipart: kind, label, issued_on, expires_on, value, file). |
| GET/DELETE | /api/documents/{id} | Download or remove a document. |
| GET | /api/findings?watchlist=&row=&open=true | Findings across lists. |
| POST | /api/findings/{id}/ack | Record that a person saw a finding. |
| GET/PUT/DELETE | /api/account/webhook | Where findings are sent. The secret is returned once, on create or rotate. |
| GET/POST | /api/watchlists/{id}/bulk | Preview, then run, a bulk confirm: `{minScore, requireState, requireCity}`. Recorded as a bulk decision by the caller. |
| POST | /api/watchlists/{id}/reconcile | Upload a new version of the list (multipart `file`). Kept rows keep everything; dropped ones are retired or removed. |
| GET/POST | /api/watchlists/{id}/snapshots | Frozen audit exports with a SHA-256. Download one at `/api/snapshots/{id}`. |
| PATCH | /api/rows/{id} | `{contact_email?, tags?}`. |
| POST | /api/rows/{id}/attestations | `{kind: 'cmmc', level, expires_on?}` or `{kind: 'sprs', score, reported_on}`. |
| GET/POST | /api/rows/{id}/requests | Ask the supplier for a document: `{kind, to_email?, message?}`. They upload through a one-time link. |
| POST | /api/rows/{id}/share | `{enabled}`: the supplier's public status page on or off. |
| POST | /api/findings/{id}/decision | `{decision: 'confirmed' | 'dismissed', note?}` on a possible exclusion, screening or state-registry hit. |
| GET | /api/views · /api/views/{tag} | Suppliers by tag across lists, with status, CMMC and SPRS. |
| GET | /api/zapier/findings?since= | Newest findings first with stable ids: a polling trigger for Zapier, Make or n8n. |
| GET/POST/PATCH/DELETE | /api/team/members · /api/team/members/{userId} · /api/team/invites/{id} | The team: invite by email as reviewer or viewer, change roles, remove. Owners only. |
| GET/PUT | /api/account/prefs | The caller's digest: `{digest: daily|weekly|off, digest_hour}`. |
| GET/POST/DELETE | /api/channels · /api/channels/{id} | Slack or Teams incoming webhooks for findings. |
Python
import requests
BASE = "https://programmable.watch"
H = {"Authorization": "Bearer wsk_…"}
# 1. Upload a list; matching runs before the call returns.
with open("suppliers.csv", "rb") as f:
wl = requests.post(f"{BASE}/api/watchlists", headers=H, data={"name": "Q4 subs"}, files={"file": f}).json()
# 2. Confirm the clear-cut matches under a rule, leave the rest for a person.
requests.post(f"{BASE}/api/watchlists/{wl['id']}/bulk", headers=H, json={"minScore": 0.95, "requireState": True, "requireCity": False})
# 3. Pull the audit export.
audit = requests.get(f"{BASE}/api/watchlists/{wl['id']}/export", headers=H, params={"format": "json"}).json()
for s in audit["suppliers"]:
print(s["supplier"], s["match"]["status"], [f["title"] for f in s["open_findings"]])Zapier, Make and n8n
Two ways in. Push: point the signed webhook at a catch hook. Pull: a polling trigger on /api/zapier/findings, which returns the newest findings first, each with a stable id for deduplication and a url back to the supplier. Add ?since=<ISO time> to fetch only what opened after a moment. The key can go in the query as key= for tools that can't set headers.
Webhook payloads
One POST per supplier whose findings changed, with the ones that opened and resolved. Signed with your secret; X-Programmable-Id is stable across retries.
POST <your url>
X-Programmable-Signature: t=1760000000,v1=<hex HMAC-SHA256 of "<t>.<body>">
X-Programmable-Id: 7c1b…
{ "id": "7c1b…", "event": "findings.changed", "created_at": "2026-10-06T09:00:12Z",
"data": { "watchlist_id": "…", "row_id": "…", "supplier": "Delta Coatings Co", "uei": "ZA3BC4DE5FG6",
"source_version": "2026-10-05",
"opened": [ { "id": "41", "kind": "exclusion_on_record", "severity": "critical",
"title": "Active exclusion on this registration (DLA)", "detail": "…", "data": { … } } ],
"resolved": [] } }Checking the signature
const [t, v1] = header.split(",").map((p) => p.split("=")[1]);
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v1, "hex")) && Math.abs(Date.now() / 1000 - Number(t)) < 300;Where the facts come from
Watch holds no public-record data of its own. Every fact is fetched from Programmable.info's registry and stored as evidence with the registry's data date (source_version), so the export can cite what was true at the time of each check.