Programmable
API

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_…"
MethodPathWhat it does
GET/api/watchlistsYour watchlists with counts.
POST/api/watchlistsMake 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}/matchSearch the registry again for every undecided row.
POST/api/watchlists/{id}/checkCheck every confirmed supplier on the list now.
GET/api/watchlists/{id}/export?format=csv|jsonThe 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}/checkCheck this supplier now.
GET/POST/api/rows/{id}/documentsList, 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=trueFindings across lists.
POST/api/findings/{id}/ackRecord that a person saw a finding.
GET/PUT/DELETE/api/account/webhookWhere findings are sent. The secret is returned once, on create or rotate.
GET/POST/api/watchlists/{id}/bulkPreview, then run, a bulk confirm: `{minScore, requireState, requireCity}`. Recorded as a bulk decision by the caller.
POST/api/watchlists/{id}/reconcileUpload a new version of the list (multipart `file`). Kept rows keep everything; dropped ones are retired or removed.
GET/POST/api/watchlists/{id}/snapshotsFrozen 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}/requestsAsk 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/prefsThe 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.