Programmable Watch
Supplier compliance for a Programmable Watch account: SAM.gov status, findings, cases, reports.
Hosted MCP Server
npx add-mcp 'https://programmable.watch/api/mcp'Installs into Claude Code, Codex, Cursor and more
Documentation
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: [...]}`. Up to 200 rows sent as text or rows are matched before it answers; a bigger list is matched in the background. Answers `{id, rows, skipped, mapping, matching, matched}`: `matching` is the progress (`{running, done, total, unsearched}`, as GET …/match), and `matched` (`{matched, confirmed, needs_review, unmatched, errors}`) is kept for older callers: the real counts when matching finished inside the call, otherwise zeros with the rows still to search counted as `unmatched`. |
| 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, in the background. Answers `{matching, matched, watchlist}`: follow `matching` with GET …/match; `matched` is the older shape, kept for existing callers, with the rows still to search counted as `unmatched`. |
| POST | /api/watchlists/{id}/check | Check every confirmed supplier on the list now. |
| GET | /api/watchlists/{id}/export?format=csv|json|xlsx | The audit export, as CSV (default), JSON or an Excel workbook. |
| GET | /api/watchlists/{id}/rows?q=&status=&tag=&assigned=me|{userId}&flag=&limit=&offset= | The list's rows a page at a time, filtered. |
| 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", note?}`, `{action: "resume"}` (a retired supplier, watched again with the match it kept) `{action: "reopen"}` (search again from scratch) or `{action: "undo"}` (a confirm or reject taken back: the row returns to review with the same candidates). |
| 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. With the two-person rule on, nothing is confirmed: 202 with `pending`, a proposal a different reviewer approves. |
| POST | /api/watchlists/{id}/bulk/{proposal} | `{decision: 'approve' | 'send_back', note?}` on a bulk confirm waiting for a second reviewer. Approving is refused for the proposer and for API keys; it confirms the proposed rows still waiting. |
| POST | /api/watchlists/{id}/reconcile | Upload a new version of the list (multipart `file`). Kept rows keep everything; new ones are matched in the background; dropped unconfirmed ones are removed. Watched suppliers missing from the file are retired only with `retire_missing=true`. `dry_run=true` returns the plan (added, kept, renamed, restored, missing) and its `hash` without changing anything; send `plan_hash` with the apply and a list that changed since is a `409` with the new plan (of two applies sent together, one wins). The apply answers `{result, watchlist}`, where `result` is `{version, added, kept, restored, retired, removed, renamed, missing_kept, matching, matched}`: `missing_kept` counts watched suppliers missing from the file that stay watched, `matching` is true when new rows are being matched in the background, and `matched` (`{confirmed, needs_review, unmatched}`) is kept for older callers, with the rows waiting for their search counted as `unmatched`. |
| POST | /api/watchlists/{id}/rows | Add one supplier: `{name, state?, uei?, cage?, vendor_id?, city?, contact_email?, tier?}`. Searched at once; an exact UEI or CAGE confirms and checks it. 409 when its UEI or vendor ID is already on the list, with the `row_id` holding it (and `removed: true` when that row was taken off the list: it keeps its vendor ID so a later version brings it back). |
| GET | /api/watchlists/{id}/match | Where background matching stands: `{running, done, total, unsearched}`. A list made from more than 200 rows is matched after the create answers. POST searches every undecided row again. |
| 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?, vendor_id?}`. `vendor_id` is your ERP's key, unique on the list; `null` clears it. |
| PUT | /api/suppliers/by-vendor/{vendor_id} | Add or update one supplier by your ERP's vendor ID: `{name, watchlist_id?, uei?, cage?, state?, city?, zip?, website?, contact_email?, tags?, tier?}`. Idempotent; 201 when added, 200 when found. See ERP sync below. |
| POST | /api/suppliers | The same upsert with `vendor_id` and/or `uei` in the body: found by vendor ID, else by UEI. |
| GET | /api/suppliers?vendor_id=|uei=&watchlist_id= · /api/suppliers/by-vendor/{vendor_id} | The rows a vendor ID or UEI names, each with its status, `blocked`, reasons and worst open severity. |
| GET | /api/suppliers/blocked.csv · /api/suppliers/blocked | The vendors to block now, for an ERP import: `vendor_id, uei, name, reason, since` (CSV), or JSON with every reason and row. |
| GET | /api/zapier/supplier-status?after_id=&event= | Supplier status events newest first with stable ids: a polling trigger for an ERP sync in Zapier, Make or n8n. The id is the cursor: `?after_id=<last id seen>` returns only later events, oldest first, so nothing is missed between polls (`?since=<ISO>` still works). |
| POST | /api/rows/{id}/attestations | `{kind: 'cmmc', level, assessment? ('self', 'c3pao' or 'dibcac'), status? ('final' or 'conditional'), assessed_on? (the CMMC Status Date), expires_on?, poam_closeout_due?, affirmed_on?, affirming_official?, uid?, c3pao?}` 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. |
| GET/POST/DELETE | /api/account/keys · /api/account/keys/{id} | API keys: `{name?, scope: 'read' | 'full'}`; the key is shown once. A read-only key can list and export but changes nothing: every write answers 403. Owners only. |
| POST | /api/findings/{id}/snooze | `{days}` (0 to 365): keep a finding out of digests and the default list for a while; 0 wakes it. It still resolves on its own. |
| GET/POST | /api/rows/{id}/notes | Notes on a supplier: `{body}`. |
| GET | /api/rows/{id}/changes | The supplier's change log: every field change a check recorded, newest first. |
| GET/POST/DELETE | /api/watchlists/{id}/schedules · /api/schedules/{id} | Scheduled exports: `{cadence: 'weekly' | 'monthly', day, hour, timezone?, to_email?, to_webhook?, confirm_external?}`. `to_email` must be a team member or on a claimed domain; an owner gets a 409 for any other address and can send again with `confirm_external: true`. |
| POST | /api/watchlists/{id}/live-link | `{enabled}`: a live CSV of the audit export at `/x/<token>.csv`, shown once. Anyone with the link can read it, no key needed; turn it off to revoke. Owners only. |
| GET | /api/watchlists/{id}/pack · /api/views/{tag}/pack | The flow-down pack for a list or a tag, as a PDF. |
| GET | /api/snapshots/{id}/diff?against= | What changed between this snapshot and another; by default the previous one on the same list. |
| POST | /api/watchlists/{id}/assign | `{user_ids: [...]}`: split the review queue, every unassigned undecided row round-robin. |
| POST | /api/watchlists/{id}/bulk-decision | `{action: 'reject' | 'retire', row_ids: [...], note?}`. Confirming is never bulk by id; use `/bulk` with a rule, or `/api/rows/{id}/decision`. |
| GET | /api/account/export | Everything the account holds, as one JSON file. Owners only. |
| GET | /api/audit?actor=&action=&target_type=&q=&from=&to=&limit=&offset=&format=csv | The account-wide audit log; `action` takes a prefix like `finding.*`. |
| GET | /api/registry/search?name=&state=&city= | Free-text registry search for choosing a match by hand: candidates with scores and reasons. A name match never confirms a row; confirm with `/api/rows/{id}/decision`. |
| GET/POST | /api/account/webhook/deliveries · /api/account/webhook/deliveries/{id}/redeliver · /api/account/webhook/test | Recent deliveries with the failed count; resend one; send a test event to the endpoint. The writes are owners only. |
| POST/GET/DELETE | /api/uploads · /api/uploads/{id}?name=&state=&uei= | Two-step upload: park a file (multipart `file`) and get the detected column mapping with sample rows; re-read it with a corrected mapping (`-` ignores a field); then POST `/api/watchlists` with `{upload_id, name, mapping}`. |
| GET/POST | /api/requirements | Requirement sets with where they apply, plus the built-in templates and requirement types. Make one: `{template}` or `{name, items: [{type, params, severity}]}`. |
| GET/PATCH/DELETE | /api/requirements/{id} | One set: `{name?, description?, items?}`. Items that keep their `id` keep their findings and exceptions. |
| POST/DELETE | /api/requirements/{id}/links · /api/requirements/{id}/links/{linkId} | Attach a set: `{scope: 'watchlist' | 'tag' | 'row' | 'project', scope_id}`; or detach it. |
| GET | /api/requirements/{id}/matrix?format=csv | Every supplier the set reaches against every requirement in it, each with its status and reason. |
| GET | /api/rows/{id}/requirements | How one supplier stands against every set that reaches it. |
| GET/POST | /api/cases?status=active|open|pending_approval|closed&owner=me|none|{userId}&overdue=1&row_id= | The case queue; open one by hand: `{row_id, title?, finding_ids?, severity?, owner?, due_on?, note?}`. |
| GET/PATCH | /api/cases/{id} | A case with its findings and notes; change `{owner?, due_on?, title?, severity?}`. |
| POST | /api/cases/{id}/close · /review · /reopen · /notes | Close: `{disposition: resolved | accepted_risk | exception | suspended | terminated, reason, exception_until?}`. With the two-person rule, a second reviewer answers `/review` `{approve, note?}`. `/reopen` `{why}`; `/notes` `{body}`. |
| POST | /api/rows/{id}/unsuspend | `{why}`: lift a suspension a case set. |
| POST | /api/watchlists/{id}/tier | `{tier: 'critical' | 'standard' | 'low', row_ids?}`: how closely suppliers are watched; every row when `row_ids` is absent. |
| GET | /api/reviews?status=due|completed&overdue=1&row_id= · /api/reviews/{id} | Periodic reviews, and one with its summary of everything that changed in its period. |
| POST | /api/reviews/{id}/complete · /api/reviews/approve-clean | `{outcome: approved | changes_requested | ended, note?}`; or approve in one go every listed review `{ids}` whose period was clean. |
| GET | /api/rows/{id}/as-of?at=YYYY-MM-DD | The supplier as it stood at the end of that day: evidence with data dates and fetch times, open findings, decisions, cases, documents, requirements. |
| GET | /api/rows/{id}/evidence?at=&format=pdf|json | The evidence package, now or as of a date: a PDF with the complete data attached inside it (`X-Evidence-SHA256` is the data's hash). |
| GET/POST | /api/projects?archived=1&row_id= | Projects: one per contract or program, with counts of suppliers and open findings (`row_id` marks which a supplier is on). Make one: `{name, contract_number?, prime_contract?, agency?, period_start?, period_end?, requirement_set_id?, clause_set_id?, instructions?, suppliers?: {row_ids?, watchlist_id?, tag?}}`, or `{from_tag}` for a project named after a tag with every supplier carrying it. |
| GET/PATCH/DELETE | /api/projects/{id} | One project with its suppliers and requirement sets. PATCH any field, `requirement_set_id` or `clause_set_id` (null clears), or `{archived}`; DELETE archives it (suppliers and history are kept). |
| GET/POST/DELETE | /api/projects/{id}/suppliers | A project's suppliers. Add `{row_ids?, watchlist_id?, tag?}`; take out `{row_ids}` (they stay on their lists). |
| GET | /api/projects/{id}/as-of?at=YYYY-MM-DD&format=csv · /api/projects/{id}/pack | Whether every supplier on the project was clean at the end of a day (each with its answer and reasons), and the evidence pack for the whole contract as a PDF. |
| GET/POST | /api/awards?row_id=&project_id=&status=&from=&to=&q=&format=csv | Award records: a subcontract, PO or award with the pre-award check it was recorded on. Record one: `{row_id, number, award_date, kind?, prime_contract?, value?, project_id?, clause_set_id?, requirement_set_id?, notes?, override_reason?, send_flowdown?, dry_run?}`. Watch uses a successful check from the last N hours or runs one, freezes the evidence and applies the account's gate: 409 with `blockers` when the supplier is excluded, has open critical findings or isn't verified, unless `override_reason` is given. The CSV is a purchasing-system review sample. |
| GET/PATCH | /api/awards/{id} | One award with its frozen evidence. With the two-person rule, a different reviewer approves an override: `{decision: approve | reject, note?}`. |
| GET | /api/awards/{id}/evidence.pdf?format=json | The award's dated evidence file: a cover sheet proving the pre-award check, then the supplier's evidence package as of that check. |
| GET/POST | /api/clauses · /api/clause-sets · /api/clause-sets/{id} | The flow-down clause library (built in, plus `{number, title, flows_when?}` of your own) and clause sets: `{template}` or `{name, numbers, requirement_set_id?}`. A supplier acknowledges a set through the portal (`{type: 'flowdown', clause_set_id}` in a package). |
| GET | /api/audit/verify | Recompute the audit log's hash chain and check it against the daily anchors. |
| GET | /api/reports/{kind}?format=csv | Standard reports: `expiring` (`days`), `cases`, `exceptions`, `screening` (`from`, `to`), `cmmc`, `small_business` (`spend_column`), `trend`; `list_id` or `tag` narrow the supplier ones. |
| GET/POST | /api/rows/{id}/packages | Supplier portal requests: packages sent, signed questionnaires, and what unmet requirements suggest. Send one: `{items: [{type: 'document', kind} | {type: 'questionnaire', template}], due_on?, message?}`. |
| DELETE | /api/packages/{id} | Withdraw a portal request the supplier hasn't finished. |
| POST | /api/packages/{id} | `{action: 'resend'}` emails the request's link again to the address it went to (a new link, with 60 more days, when it expired), within the sending limits; `{action: 'link'}` returns the link to send yourself. A send that didn't go out answers `mailed: false` and `mail_error`. |
| GET/POST | /api/onboarding?status= · /api/onboarding/{id} | Onboarding requests and where one stands. Start one: `{watchlist_id, name, uei?, cage?, state?, contact_email?, tier?, requirement_set_id?, reason?, send_package?}`. |
| POST | /api/onboarding/{id}/decision · /withdraw | `{decision: approve | reject, note?, condition?, condition_until?}`, never by the requester; a request that isn't ready needs a condition and a date. |
Limits
Calls that do work are rate-limited per account, in fixed one-hour windows: supplier upserts (600, API and MCP together), supplier lookups (3,000), the vendors-to-block export (120), creating a list (30), matching, reconciling and rule-based bulk confirms (20 each), checking a whole list (12), checking one supplier (120), registry searches (600), the account export (5), parked uploads (60) and document uploads (120, one count per file across the API, the portal, the supplier pages and emailed attachments); document requests are 100 per day. Public links are limited per address: the live CSV (120 an hour), supplier uploads (20 to 30 an hour) and the supplier's own recheck (10 an hour). Over the limit you get 429 with {"error": "…"}, naming the wait where it is known; the count resets at the top of the window. Thirty wrong keys from one address in an hour lock that address out for the rest of it. A supplier can ask for a recheck from their status page once per 24 hours; a second request inside that window answers {"ran": false, "next_allowed_at": "…"} without running a check.
Python
import time, requests
BASE = "https://programmable.watch"
H = {"Authorization": "Bearer wsk_…"}
# 1. Upload a list; a big one is matched in the background, so wait for it.
with open("suppliers.csv", "rb") as f:
wl = requests.post(f"{BASE}/api/watchlists", headers=H, data={"name": "Q4 subs"}, files={"file": f}).json()
while (wl.get("matching") or {}).get("running"):
time.sleep(10)
wl["matching"] = requests.get(f"{BASE}/api/watchlists/{wl['id']}/match", headers=H).json()["matching"]
# 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"]])
AI agents (MCP)
Watch is an MCP server at /api/mcp (streamable HTTP). Connect with an API key: a read-only key gets the lookup, findings, cases, requirement, report, project (list_projects, project_status) and point-in-time tools; a full key on a reviewer can also open cases and add notes, recorded in the audit log under the key. For an ERP: supplier_by_vendor_id and blocked_suppliers read, and upsert_supplier (full key) adds or updates a supplier as the PUT does.
claude mcp add --transport http watch https://programmable.watch/api/mcp --header "Authorization: Bearer wsk_…"
Action links and sentences in links
Everything the Home chat can do is also a link: https://programmable.watch/do/<action>?<parameter>=<value>. The person who opens it signs in to their own account (the address survives sign-in), records are looked up there by id, UEI, CAGE or name, and the chat takes it from there: an answer shows at once, a missing or ambiguous parameter is asked for, and a change, an email or an award comes back as a card to confirm. A link never writes anything by itself, and one followed from another site waits for the person to press Run. Each answer and card in the chat has its own Copy link, and Developers > Action links lists every action with an example from your own records.
A sentence in a link only fills Home's box, never sends it: https://programmable.watch/dashboard?prompt=Watch%20Acme%20Corp%20for%20exclusions (sentence= works too). To watch from a link, /do/watches.create?sentence=… reads the sentence and shows the watch to confirm.
| Action | Parameters (needed in bold) |
|---|---|
supplier.find Find a supplier | supplier, uei, cage, list, query |
supplier.status A supplier's status | supplier, uei, cage, topic |
supplier.as_of Was a supplier clean on a day? | supplier, date, uei, cage |
findings.list Findings | severity, topic, list, tag, supplier, project, unseen, all, docs, report |
waiting.mine What's waiting on me | |
suppliers.blocked Blocked suppliers | project |
reports.run Run a report | report, days, list, tag |
requirements.matrix A requirement matrix | set |
help What I can do | |
project.status A project | project |
project.as_of Was everyone on a project clean on a day? | project, date |
project.pack A project's evidence pack | project |
project.add Add a supplier to a project (confirmed) | supplier, project |
match.confirm Confirm a match (confirmed) | supplier, uei, cage |
finding.ack Mark findings seen (confirmed) | supplier, topic, severity, all |
finding.snooze Snooze a finding (confirmed) | supplier, days, topic, severity, date |
case.open Open a case (confirmed) | supplier, text, member, date |
note.add Add a note (confirmed) | supplier, text, case |
supplier.tier Set a tier (confirmed) | supplier, tier |
supplier.tag Tag a supplier (confirmed) | supplier, new_tag, tag |
list.add Add a supplier to a list (confirmed) | supplier, list |
watch.pause Pause or resume a watch (confirmed) | watch, active |
portal.request Request documents (confirmed) | supplier, docs, email, date |
portal.remind Remind a supplier (confirmed) | supplier |
team.invite Invite a reviewer (confirmed) | email, role |
award.record Record an award (confirmed) | supplier, award_no, date, money, text |
watches.create Watch suppliers (confirmed) | sentence |
watches.edit Change a watch (confirmed) | sentence |
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": [] } }
Other events
case.opened,case.updated,case.closed: a case with its supplier, severity, status, disposition, reason and any exception date.supplier.approved,supplier.rejected: an onboarding decision, with the supplier, UEI, CAGE, tier, every approver and any conditions. Create or block the vendor in your ERP from these.supplier.status_changed,supplier.blocked,supplier.unblocked: a supplier's status or its do-not-pay flag changed, with its vendor ID. See ERP sync below.award.created: an award was recorded (or its override approved), with the number, supplier, UEI, vendor ID, the gate result and the evidence hash.audit.anchor: once a day, the head of the account's audit hash chain (seq,hash). Keep them; a log rewritten later won't match.export.ready: a scheduled export is ready.
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;
ERP sync: vendor IDs, upserts and the block list
Give each supplier your ERP's vendor ID (Costpoint, Unanet, Coupa, Ariba, SAP: whatever key your vendor master uses). An upload picks it up from a column called vendor ID, vendor number, vendor code or supplier ID; it can also be set on the supplier page or through the API. A vendor ID is unique on its list (the same vendor can sit on two lists, as the same UEI can). A re-upload finds each supplier by its vendor ID first, then its UEI, then its name and state, so a vendor renamed in the ERP keeps its row, its match and its history.
curl -X PUT https://programmable.watch/api/suppliers/by-vendor/V-00123 \
-H "Authorization: Bearer wsk_…" -H "Content-Type: application/json" \
-d '{"name": "Delta Coatings Co", "uei": "ZA3BC4DE5FG6", "state": "TX", "watchlist_id": "…", "tags": ["W912DQ-26-C-0012"]}'
201 { "created": true, "changed": true, "rematched": true,
"supplier": { "row_id": "…", "vendor_id": "V-00123", "uei": "ZA3BC4DE5FG6", "name": "Delta Coatings Co",
"watchlist_id": "…", "watchlist": "Q4 subs", "status": "watching", "match": "confirmed",
"blocked": false, "blocked_reasons": [], "blocked_since": null, "worst_open_severity": null, … } }
The supplier is found by its vendor ID, else by its UEI (only a row with no vendor ID or the same one), on watchlist_id or on any list; a supplier on two lists answers 409 until you name the list. Fields you send are written and fields you leave out are left alone; sending the same body again changes nothing. A new supplier goes through the same matching as an upload: an exact UEI or CAGE confirms it, a name alone never does (it waits for a person in the review queue). A confirmed supplier isn't given another UEI by an upsert (409); retire or reopen it first. A full-access key and a reviewer role are needed, as for every write.
Status and blocked
status:watching(confirmed and checked),excepted(confirmed, with an open finding under an exception that hasn't ended),needs_review(not yet confirmed to a registration),not_matched(a person said no candidate is this supplier),retired(no longer watched),removed(dropped from the list before it was confirmed).blocked(do not pay or award) is true exactly when the supplier is confirmed and either has an open critical finding that isn't under an exception (an active exclusion, a possible exclusion or screening hit a person confirmed, an inactive or expired registration, an expired CMMC certificate or document, a critical requirement not met), or a case put it on hold and the hold hasn't been lifted. A snooze doesn't unblock. A supplier that isn't confirmed is never blocked, because Watch doesn't yet know who it is: treatneeds_reviewas not verified.sinceis when the earliest reason still in force began.
{ "id": "…", "event": "supplier.blocked", "created_at": "2026-10-22T09:00:12Z",
"data": { "seq": 4182, "changed_at": "2026-10-22T09:00:12Z", "row_id": "…", "vendor_id": "V-00123",
"uei": "ZA3BC4DE5FG6", "cage": "1AB23", "name": "Delta Coatings Co", "watchlist_id": "…", "watchlist": "Q4 subs",
"status": "watching", "previous_status": "watching", "match": "confirmed", "tier": "standard",
"worst_open_severity": "critical", "open_findings": 2,
"blocked": true, "previous_blocked": false, "blocked_since": "2026-10-22T08:59:58Z",
"blocked_reasons": ["Active exclusion on this registration (DLA)"], "url": "…" } }
supplier.status_changed fires when the status changes (previous_status is null the first time a supplier is seen); supplier.blocked and supplier.unblocked when the flag flips. Each carries the whole current state, so apply the one with the highest seq and order doesn't matter. They are signed like every other event, follow the webhook's list filter, and are kept for the polling feed at /api/zapier/supplier-status whether or not a webhook is set. For a nightly import, /api/suppliers/blocked.csv is the whole hold list at that moment: one line per vendor ID and UEI, with every reason.
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.