Reference
REST API
Everything the PatchLabs site and dashboard do goes through this API, so everything they do, you can script. JSON in, JSON out, plain-English errors.
Overview
- Base URL
https://api.patchlabs.dev- Format
- JSON request and response bodies. Send
Content-Type: application/jsonwith a body. Times are ISO 8601 in UTC. IDs are UUIDs. - Browsers
- Cross-origin browser requests are accepted from patchlabs.dev only. Call the API from your server, scripts or CI.
- Stability
- Endpoints on this page are live. We add fields over time, so ignore ones you don’t recognise. We announce breaking changes on the changelog before making them.
Authentication
There are three levels, and each endpoint below says which it needs.
| Label | Send | Use it for |
|---|---|---|
| No auth | nothing | Free checks, reports and receipts by link, badges, stats, the free RLS check |
| API key | X-API-Key: pl_live_… | Scripts and CI: the /v1 endpoints and the MCP app tools |
| Session | Authorization: Bearer <session> | Everything in the dashboard: workspaces, apps, decisions |
API keys
Create one in Settings → API keys (or with POST /workspaces/{wid}/api-keys). It looks like pl_live_ followed by 32 characters and is shown once: we only store a hash and the first 12 characters, so you can tell keys apart. A key belongs to one workspace and can only reach that workspace’s apps. Revoke it at any time; it stops working immediately. Keep it in your CI’s secret store, never in code.
export PATCHLABS_API_KEY=pl_live_…
curl -s -X POST https://api.patchlabs.dev/v1/apps/$APP_ID/checks -H "X-API-Key: $PATCHLABS_API_KEY"Sessions
A session comes from the magic-link sign-in (POST /auth/start, then POST /auth/finish) and lasts 30 days. The website keeps it in your browser’s local storage. For your own scripts against workspace and app endpoints, sign in and copy the pl_session value from the browser’s developer tools into $PL_SESSION. Prefer API keys for anything long-lived.
Errors
Errors come back with an HTTP status and a body you can show to a person:
{"detail": "A check is already queued or running for this app."}The one exception is a request body that doesn’t match the expected shape (a missing field, a bad email). Those return 422 with a list that points at the field:
{"detail": [{"type": "missing", "loc": ["body", "email"], "msg": "Field required", "input": {…}}]}| Status | Meaning |
|---|---|
| 400 | A sign-in link that has expired or was already used |
| 401 | Missing or expired session, or a missing, invalid or revoked API key |
| 403 | Signed in, but not allowed: not an owner, wrong workspace, or a plan limit (apps, seats, schedules) |
| 404 | Not found, or not yours to see |
| 409 | Not possible in the current state: not verified yet, a check already running, not finished yet |
| 422 | Input we can’t use: not a GitHub repo, repo not found, unsafe webhook URL, wrong body shape |
| 429 | A rate limit or monthly run limit (see below) |
| 503 | GitHub couldn’t be reached; try again in a minute |
Rate limits
| What | Limit | When exceeded |
|---|---|---|
POST /checks | 3 open checks per email; 10 requests per network per day | 429 |
POST /tools/rls-check | 20 requests per network per hour, failed lookups included | 429 |
POST /auth/start | 5 links per email per hour, plus a per-network cap | Still 202, no email sent |
Run now, /v1 runs, MCP run_check | The plan’s manual runs per month (4, 30, 200), shared | 429 |
| Open checks per app | One queued or running at a time | 409 |
Check statuses
| Status | Meaning | Finished |
|---|---|---|
awaiting_verification | Free check waiting for patchlabs-verify.txt | No |
queued | Verified, waiting for the lab | No |
running | In the lab now | No |
done | Leaks found and a fix passed the lab proof | Yes, receipt issued |
needs_review | Leaks found; a person is reviewing the fix | Yes, receipt issued |
no_leaks | No leaks found in the tables tested | Yes, receipt issued |
failed | The check couldn’t finish; we’re alerted | Yes, no receipt |
All endpoints
Internal endpoints used by our lab worker and admin page aren’t listed; they need credentials only PatchLabs holds.
Free checks
A free check is a one-off full check of a public repo. It needs no account, only an email and the one-file verification.
POST /checks
No authRequest a full check. We email the requester a link to the check page with the verification step.
Body
- repo string
- Public GitHub repo:
owner/nameor a github.com URL. - email string
- Where the report link goes.
curl -s -X POST https://api.patchlabs.dev/checks -H 'Content-Type: application/json' \
-d '{"repo": "you/your-app", "email": "you@company.com"}'{"id": "7c1e2f0a-…", "status": "awaiting_verification", "repo": "you/your-app",
"link": "https://patchlabs.dev/check.html?id=7c1e2f0a-…"}422 not a GitHub repo, repo not found or not public, or a bad email429 3 checks already open for this email, or 10 requests from this network today
GET /checks/{id}
No auth · by linkThe check and, once it has finished, its report. Fields appear as the check moves along.
Response fields
- verify object
- While
awaiting_verification:{file, code, add_url}. - summary object
- When finished:
tables_leaking,readable_by_anyone,readable_by_signed_in,lab_proof_passed,break_risk,questions_for_owner,models, andfindings: each finding carriescwe(list),owaspandowasp_name(OWASP Top 10 2021); on an app’s check alsofirst_seen,days_open,fixed_at(null while open),fixed_in_check_idandregressed. - report_md, fix_sql string
- When finished: the report as Markdown and the fix migration (
nullwhen there are no leaks). - error string
- When
failed: a plain-English explanation. - events array
- Timeline:
[{at, kind}]. - decisions, answers array
- Always present; see decisions.
curl -s https://api.patchlabs.dev/checks/$CHECK_ID{"id": "7c1e…", "repo": "you/your-app", "status": "done",
"created_at": "…", "verified_at": "…", "finished_at": "…",
"summary": {"tables_leaking": ["profiles"], "readable_by_anyone": ["profiles"],
"readable_by_signed_in": [], "lab_proof_passed": true, "break_risk": "low",
"questions_for_owner": [], "models": [{"model": "haiku", "passed": true, "seconds": 36}]},
"report_md": "# Leak check: you/your-app …", "fix_sql": "DROP POLICY …",
"events": [{"at": "…", "kind": "requested"}, {"at": "…", "kind": "verified"}, …],
"decisions": [], "answers": []}404 no check with that ID
POST /checks/{id}/verify
No auth · by linkLook for patchlabs-verify.txt with the check’s code on the default branch. On success the check joins the queue. Calling it again after that just returns the current status.
curl -s -X POST https://api.patchlabs.dev/checks/$CHECK_ID/verify{"status": "queued"}409 file or code not found on the default branch yet503 GitHub unreachable; try again
Decisions and receipts
Decisions and answers need an owner, admin or approver session in the app’s workspace, and a finished check. Receipts are public by link. Guides: decisions, receipts.
POST /checks/{id}/decision
Session · approver+Approve a fix, or reject tables as intentionally readable (remembered for the app as suppressions).
Body
- action approve | reject
- Required.
- tables string[], optional
- Defaults to every table the check flagged; must be a subset of them.
- reason string, optional
- Up to 2,000 characters.
curl -s -X POST https://api.patchlabs.dev/checks/$CHECK_ID/decision -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' \
-d '{"action": "reject", "tables": ["shops"], "reason": "Public by design"}'{"decisions": [{"action": "reject", "tables": ["shops"], "reason": "Public by design", "by": "Priya", "at": "…"}]}403 not an app check, not a member, or a member/viewer/client role409 check not finished422 a named table wasn’t flagged
POST /checks/{id}/answers
Session · approver+Answer the report’s business questions. Answering a question again replaces the earlier answer.
Body
- answers array
- 1 to 50 items of
{question, answer}; eachquestionmust match one insummary.questions_for_owner.
curl -s -X POST https://api.patchlabs.dev/checks/$CHECK_ID/answers -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' \
-d '{"answers": [{"question": "Should sellers’ emails be public?", "answer": "No"}]}'{"answers": [{"question": "Should sellers’ emails be public?", "answer": "No", "by": "Priya", "at": "…"}]}403 not an app check, not a member, or a member/viewer/client role409 not finished, or the check has no questions422 unknown question
GET /receipts/{id}
No auth · by linkThe Ed25519-signed receipt for a finished check.
curl -s https://api.patchlabs.dev/receipts/$CHECK_ID{"receipt": {"check_id": "…", "repo": "…", "status": "done", "tables_leaking": ["profiles"],
"lab_proof_passed": true, "fix_sha256": "…", "finished_at": "…",
"decisions": [], "answers": []},
"signature": "base64…", "key_id": "ed25519:…", "algorithm": "Ed25519"}404 no check with that ID409 check not finished
GET /.well-known/patchlabs-receipt-key
No authThe public key that signs receipts.
curl -s https://api.patchlabs.dev/.well-known/patchlabs-receipt-key{"key_id": "ed25519:…", "algorithm": "Ed25519", "public_key": "base64 raw 32 bytes",
"pem": "-----BEGIN PUBLIC KEY-----\n…", "signed_bytes": "JSON of `receipt` with sorted keys and separators (',', ':')"}Try it
Send this request to the live API from your browser.
Needs JavaScript; use the curl example instead.
Sign-in
Sign-in is by magic link. The site handles these calls for you; they are listed for completeness.
POST /auth/start
No authEmail a sign-in link to {site}/app/login?token=…. Always answers 202, whether or not the address has an account. The link works once and expires in 20 minutes.
Body
- email string
- Required.
- next string, optional
- A path on patchlabs.dev to return to after sign-in, such as
/app/.
curl -s -X POST https://api.patchlabs.dev/auth/start -H 'Content-Type: application/json' -d '{"email": "you@company.com"}'{"ok": true, "detail": "If that address can sign in, a link is on its way. It expires in 20 minutes."}POST /auth/finish
No authExchange the link’s token for a session (valid 30 days). Creates the user and a personal workspace on first sign-in, and links earlier free checks with the same email.
Body
- token string
- From the sign-in link.
- invite string, optional
- An invite token to accept at the same time.
curl -s -X POST https://api.patchlabs.dev/auth/finish -H 'Content-Type: application/json' -d '{"token": "…"}'{"session": "…", "user": {"id": "…", "email": "you@company.com", "name": null, "created_at": "…"},
"next": "/app/", "invite": null}400 link expired or already used
POST /auth/logout
SessionRevoke the session in the Authorization header.
curl -s -X POST https://api.patchlabs.dev/auth/logout -H "Authorization: Bearer $PL_SESSION"{"ok": true}POST /invites/accept
SessionAccept a workspace invite while signed in. The invite must have been sent to your email address.
Body
- invite string
- The token from the invite link.
curl -s -X POST https://api.patchlabs.dev/invites/accept -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' -d '{"invite": "…"}'{"accepted": true, "workspace_id": "…", "workspace_name": "Acme"}409 expired, used, sent to another address, or no free seats
Your account
GET /me
SessionYou and the workspaces you belong to. Use a workspace id from here in the workspace and app endpoints.
curl -s https://api.patchlabs.dev/me -H "Authorization: Bearer $PL_SESSION"{"user": {"id": "…", "email": "you@company.com", "name": "Priya", "created_at": "…"},
"workspaces": [{"id": "…", "name": "you's workspace", "role": "owner", "plan": "free"}]}PATCH /me
SessionSet your display name (shown on decisions and invites).
Body
- name string
- Up to 100 characters; empty clears it.
curl -s -X PATCH https://api.patchlabs.dev/me -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' -d '{"name": "Priya"}'{"user": {…}, "workspaces": […]}GET /me/checks
SessionFree checks linked to you (by account or email), newest first.
curl -s https://api.patchlabs.dev/me/checks -H "Authorization: Bearer $PL_SESSION"{"checks": [{"id": "…", "repo": "you/your-app", "status": "done", "created_at": "…"}]}GET /me/export
SessionEverything we hold about you, as a JSON file download (patchlabs-export.json).
curl -s -OJ https://api.patchlabs.dev/me/export -H "Authorization: Bearer $PL_SESSION"DELETE /me
SessionDelete your account. Workspaces you own alone are deleted with their apps, checks and keys; shared ones pass to their longest-standing member.
curl -s -X DELETE https://api.patchlabs.dev/me -H "Authorization: Bearer $PL_SESSION"{"ok": true, "deleted_workspaces": 1}Workspaces
Every workspace endpoint needs you to be a member, and your role must allow the call. Roles, most to least: owner, admin (everything but making or removing owners), approver (also approves or rejects fixes and answers the owner’s questions), member (views and runs checks), viewer (read-only) and client (read-only, only the apps shared with them, plus support tickets about those apps; clients never use a team seat). “approver+” means owner, admin or approver. Guide: teams.
GET /workspaces/{wid}
Session · any rolePlan, limits, usage, webhook, members and pending invites. What you get depends on your role: the webhook, its secret and invites only for owners and admins; a client gets only the name, plan, their own membership and permissions.
curl -s https://api.patchlabs.dev/workspaces/$WORKSPACE_ID -H "Authorization: Bearer $PL_SESSION"{"id": "…", "name": "Acme", "plan": "maker", "role": "owner", "created_at": "…",
"limits": {"apps": 3, "runs_per_month": 30, "seats": 2, "schedules": ["off", "weekly", "monthly"]},
"usage": {"apps": 2, "runs_this_month": 5, "members": 2},
"webhook_url": "https://hooks.slack.com/services/…", "webhook_format": "auto", "webhook_kind": "slack",
"permissions": ["decide", "manage", "owner", "run", "view", "view_team"],
"members": [{"user_id": "…", "email": "…", "name": "…", "role": "owner", "joined_at": "…", "app_ids": null}],
"invites": [{"id": "…", "email": "…", "role": "client", "app_ids": ["…"], "created_at": "…", "expires_at": "…"}]}PATCH /workspaces/{wid}
Session · owner/adminRename the workspace or set its webhook. Slack, Discord and Microsoft Teams webhook URLs get native chat messages; any other URL gets the signed JSON event.
Body
- name string, optional
- 1 to 100 characters.
- webhook_url string | null, optional
- https only, on a public host;
nullor empty removes it. - webhook_format auto | json | slack | discord | teams, optional
auto(default) detectshooks.slack.com,discord.com/api/webhooks,*.webhook.office.comand Teams Workflows on*.powerplatform.com. Setteamsfor a Workflows URL on*.logic.azure.com.
curl -s -X PATCH https://api.patchlabs.dev/workspaces/$WORKSPACE_ID -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' \
-d '{"webhook_url": "https://hooks.slack.com/services/T000/B000/XXXX"}'{…the workspace…}403 not an owner or admin422 webhook URL not https, unresolvable, or private, or an unknown format
POST /workspaces/{wid}/webhook/test
Session · owner/adminSend a test to the webhook in its own format (a chat message for Slack, Discord and Teams, a test event otherwise) and report what it answered.
curl -s -X POST https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/webhook/test -H "Authorization: Bearer $PL_SESSION"{"ok": true, "status_code": 200, "format": "slack"}
# or {"ok": false, "status_code": null, "format": "json", "detail": "…why it failed…"}409 no webhook URL set
POST /workspaces/{wid}/invites
Session · owner/adminEmail an invite link (valid 14 days). Re-inviting an address replaces its pending invite. Only owners can invite owners.
Body
- email string
- Required.
- role owner | admin | approver | member | viewer | client
- Defaults to
member. - app_ids string[], optional
- Clients only: the apps they will see. Every id must be an app in this workspace.
curl -s -X POST https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/invites -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' \
-d '{"email": "dev@client.com", "role": "member"}'{"id": "…", "email": "dev@client.com", "role": "member", "created_at": "…", "expires_at": "…"}403 not an owner or admin, an admin inviting an owner, or no free seats (pending invites count)409 already a member422 app_ids for a non-client role, or an app from elsewhere
DELETE /workspaces/{wid}/invites/{iid}
Session · owner/adminCancel a pending invite.
curl -s -X DELETE https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/invites/$INVITE_ID -H "Authorization: Bearer $PL_SESSION"{"ok": true}404 no such invite
PATCH /workspaces/{wid}/members/{uid}
Session · owner/adminChange a member’s role. Admins can’t change owners or make anyone an owner. Someone who can no longer run checks (viewer, client) loses their API keys.
Body
- role owner | admin | approver | member | viewer | client
- Required.
- app_ids string[], optional
- With
role: client: the apps they see.
curl -s -X PATCH https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/members/$USER_ID -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' -d '{"role": "approver"}'{…the workspace…}403 not an owner or admin, or an admin touching an owner409 would leave the workspace with no owner
PUT /workspaces/{wid}/members/{uid}/apps
Session · owner/adminSet the apps a client sees (replaces the list).
Response fields
- app_ids string[]
- Apps in this workspace; empty means none.
curl -s -X PUT https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/members/$USER_ID/apps -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' -d '{"app_ids": ["…"]}'{…the workspace…}409 the member isn’t a client422 an app from elsewhere
DELETE /workspaces/{wid}/members/{uid}
SessionRemove a member. Owners can remove anyone, admins anyone but owners; everyone can remove themselves.
curl -s -X DELETE https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/members/$USER_ID -H "Authorization: Bearer $PL_SESSION"{"ok": true}403 removing someone else without the right role409 the last owner
GET /workspaces/{wid}/api-keys
Session · viewer+List API keys (never the keys themselves).
curl -s https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/api-keys -H "Authorization: Bearer $PL_SESSION"{"api_keys": [{"id": "…", "name": "GitHub Actions", "prefix": "pl_live_Xy3k", "created_at": "…", "last_used_at": "…"}]}POST /workspaces/{wid}/api-keys
Session · member+Create an API key. The full key is in this response only; we store a hash.
Body
- name string
- 1 to 100 characters, so you know where it’s used.
curl -s -X POST https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/api-keys -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' -d '{"name": "GitHub Actions"}'{"id": "…", "name": "GitHub Actions", "prefix": "pl_live_Xy3k", "created_at": "…",
"last_used_at": null, "key": "pl_live_Xy3k…32 characters…"}DELETE /workspaces/{wid}/api-keys/{kid}
Session · member+Revoke an API key. It stops working immediately. Owners and admins revoke any key, others their own.
curl -s -X DELETE https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/api-keys/$KEY_ID -H "Authorization: Bearer $PL_SESSION"{"ok": true}403 someone else’s key404 no such key
POST /workspaces/{wid}/upgrade-request
Session · owner/adminAsk to move to a paid plan. We reply by email within a day and set the plan (card payments aren’t live yet).
Body
- plan maker | agency | enterprise
- Required.
- note string, optional
- Anything we should know, up to 2,000 characters.
curl -s -X POST https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/upgrade-request -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' -d '{"plan": "agency"}'{"id": "…", "plan": "agency", "note": null, "created_at": "…", "detail": "Thanks. We'll reply within a day."}Audit log
An append-only record of who did what in the workspace: sign-ins and sign-outs, invites, role changes, removals, apps, checks started, decisions, suppressions, API keys, webhooks, exports, tickets and plan changes. Owners and admins only. Secrets, tokens and webhook URLs are never recorded (only the webhook’s host). Emails show in full only for current members and pending invites; anyone else is masked (b•••@example.com), and “a former member” after deleting their account.
GET /workspaces/{wid}/audit
Session · owner/adminEvents, newest first. Page with before = the last page’s next_before (null on the last page).
Response fields
- action query, optional
- An action such as
member.role_changed, or a family such asmember. - actor query, optional
- A user id, or
api_key,admin(PatchLabs admin) orsystem(scheduler). - since, until query, optional
- Dates (
2026-11-10, both inclusive) or ISO times. - limit, before query, optional
- Page size 1 to 200 (default 50); cursor.
curl -s 'https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/audit?action=member&limit=50' -H "Authorization: Bearer $PL_SESSION"{"events": [{"id": 812, "at": "…", "action": "member.role_changed",
"actor": {"kind": "user", "id": "…", "label": "Priya", "email": "priya@agency.com"},
"target": {"type": "member", "id": "…", "label": null, "email": "dev@client.com"},
"detail": {"from": "member", "to": "approver"}}],
"next_before": 811, "actions": ["auth.sign_in", "auth.sign_out", "member.invited", …]}403 not an owner or admin422 a date that can’t be read
GET /workspaces/{wid}/audit/export
Session · owner/adminThe same filters as a CSV download (up to 10,000 rows). The export itself is recorded as audit.exported.
curl -s -OJ 'https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/audit/export?since=2026-11-01' -H "Authorization: Bearer $PL_SESSION"id,at,action,actor_kind,actor,actor_email,target_type,target_id,target,target_email,detailApps
An app is a monitored repo inside a workspace. Owners and admins add, change and delete apps; members and above run checks; everyone can view them, except that a client sees only the apps shared with them. Guide: monitoring.
GET /workspaces/{wid}/apps
Session · any roleEvery app in the workspace (a client: the apps shared with them) with its latest finished check, any open one, and whether the result is stale.
curl -s https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/apps -H "Authorization: Bearer $PL_SESSION"{"apps": [{"id": "…", "workspace_id": "…", "repo": "you/your-app", "branch": "main",
"client_label": "Acme", "schedule": "weekly", "next_check_at": "…",
"badge_public": false, "created_at": "…", "verified_at": "…", "verified": true,
"last_check": {"id": "…", "status": "no_leaks", "finished_at": "…", "tables_leaking": []},
"open_check_id": null, "suppressed_tables": [],
"freshness": {"stale": false, "days_since_check": 3, "stale_after_days": 14}}]}POST /workspaces/{wid}/apps
Session · owner/adminAdd an app. It starts unverified and returns its verification details.
Body
- repo string
- Public GitHub repo.
- client_label string, optional
- Up to 100 characters.
- schedule off | weekly | monthly, optional
- Defaults to
monthly; must be allowed by the plan.
curl -s -X POST https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/apps -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' \
-d '{"repo": "you/your-app", "client_label": "Acme"}'{"id": "…", "repo": "you/your-app", "verified": false, …,
"verify": {"file": "patchlabs-verify.txt", "code": "patchlabs-…", "add_url": "https://github.com/…"}}403 not an owner or admin, plan’s app limit, or schedule not on the plan409 repo already in this workspace422 repo not found
POST /apps/{aid}/verify
Session · owner/adminCheck the verification file. On success the app is verified for good and its first check is queued.
curl -s -X POST https://api.patchlabs.dev/apps/$APP_ID/verify -H "Authorization: Bearer $PL_SESSION"{"verified": true, "check_id": "…"}409 file or code not found yet503 GitHub unreachable
GET /apps/{aid}
Session · any roleThe app, its latest 50 checks, its live posture (see Live posture), the diff between its last two finished checks, and its findings: open (in the latest finished check, worst first) and recently fixed, each with CWE/OWASP tags, first_seen, days_open and, once fixed, fixed_at. A finding is matched across checks by its id and object. Model costs are never included.
curl -s https://api.patchlabs.dev/apps/$APP_ID -H "Authorization: Bearer $PL_SESSION"{"id": "…", "repo": "you/your-app", …,
"checks": [{"id": "…", "status": "done", "trigger": "scheduled", "created_at": "…",
"finished_at": "…", "tables_leaking": ["orders"], "lab_proof_passed": true}],
"diff": {"new_leaks": ["orders"], "fixed": [], "still_leaking": [], "suppressed": [],
"to_check_id": "…", "from_check_id": "…"},
"findings": {"open": [{"id": "PL-001", "object": "public.orders", "severity": "critical", "title": "…",
"cwe": ["CWE-359", "CWE-863"], "owasp": "A01:2021", "owasp_name": "Broken Access Control",
"first_seen": "…", "first_check_id": "…", "days_open": 12, "fixed_at": null, "regressed": false}],
"fixed": [{"id": "PL-023", "object": "package.json", "days_open": 7, "fixed_at": "…", "fixed_in_check_id": "…", …}]}}PATCH /apps/{aid}
Session · owner/adminChange the label, schedule or badge. Changing the schedule restarts its clock.
Body
- client_label string | null, optional
- schedule off | weekly | monthly, optional
- badge_public boolean, optional
curl -s -X PATCH https://api.patchlabs.dev/apps/$APP_ID -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' -d '{"schedule": "weekly", "badge_public": true}'{…the app…}403 schedule not on the plan
DELETE /apps/{aid}
Session · owner/adminDelete the app with all its checks, reports and receipts.
curl -s -X DELETE https://api.patchlabs.dev/apps/$APP_ID -H "Authorization: Bearer $PL_SESSION"{"ok": true}POST /apps/{aid}/checks
Session · member+Run a check now (trigger manual). Verified apps skip verification.
curl -s -X POST https://api.patchlabs.dev/apps/$APP_ID/checks -H "Authorization: Bearer $PL_SESSION"{"check_id": "…", "status": "queued", "url": "https://patchlabs.dev/check?id=…"}409 not verified, or a check is already queued or running429 monthly run limit reached
Live posture
Passive checks of the app’s live domain, once the team proves it controls it: one plain http:// request (to see the redirect), one https:// load of the home page (at most 5 redirects) and that load’s TLS handshake. Nothing else: no other paths, no forms, no API calls, never the database. GET /apps/{aid} carries live (below); GET /workspaces/{wid}/apps carries the same with last cut to {grade, score, checked_at, counts}. Product page: Live posture; finding IDs PL-027 to PL-036.
PUT /apps/{aid}/live
Session · owner/adminSet or change the app’s domain. A new domain gets a new live code (patchlabs-live- plus 16 hex characters, separate from the repo code), starts unverified and drops the old domain’s history. Setting the same domain again changes nothing.
Response fields
- url string
- An
https://address with a host name (not an IP address or a private name). Any path is dropped.
curl -s -X PUT https://api.patchlabs.dev/apps/$APP_ID/live -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' -d '{"url": "https://app.example.com"}'{"url": "https://app.example.com", "verified": false, "verified_at": null, "next_check_at": null,
"verify": {"file": "patchlabs-verify.txt", "file_url": "https://app.example.com/patchlabs-verify.txt",
"code": "patchlabs-live-3f9c2a17d04be6a1",
"meta": "<meta name="patchlabs-verify" content="patchlabs-live-3f9c2a17d04be6a1">"},
"last": null}
# url is null when no domain is set (then verify and last are null); verify is present only while unverified403 not an owner or admin422 a bad address, with a sentence saying why
DELETE /apps/{aid}/live
Session · owner/adminRemove the domain and its whole posture history. The site itself isn’t touched.
curl -s -X DELETE https://api.patchlabs.dev/apps/$APP_ID/live -H "Authorization: Bearer $PL_SESSION"{"ok": true}POST /apps/{aid}/live/verify
Session · owner/adminLook for the code: a file at https://<domain>/patchlabs-verify.txt whose whole content is the code, or <meta name="patchlabs-verify" content="…"> in the home page’s HTML. Redirects must stay on the same host (or its www twin). On success the first check runs at once; if that page load fails, the domain is still verified and posture is null with a detail sentence.
curl -s -X POST https://api.patchlabs.dev/apps/$APP_ID/live/verify -H "Authorization: Bearer $PL_SESSION"{"verified": true, "posture": {…posture result, trigger "verify"…}}409 no domain set, or the file and meta tag weren’t found (with a sentence)
POST /apps/{aid}/live/checks
Session · member+Re-check now (trigger manual). Scheduled re-checks run daily while the app’s schedule isn’t off. An email and the workspace webhook (event: "posture.changed") go out when the grade drops or a finding appears.
curl -s -X POST https://api.patchlabs.dev/apps/$APP_ID/live/checks -H "Authorization: Bearer $PL_SESSION"{"id": "…", "url": "https://app.example.com", "final_url": "https://app.example.com/",
"grade": "C", "score": 65, "checked_at": "…", "trigger": "manual",
"counts": {"critical": 0, "high": 0, "medium": 2, "low": 1, "info": 0},
"findings": [{"id": "PL-030", "line": "live", "severity": "medium", "title": "No Content-Security-Policy",
"object": "https://app.example.com", "evidence": "The page sends no Content-Security-Policy header or meta tag.",
"fix": "Send a Content-Security-Policy that lists where scripts may load from (start with default-src 'self').",
"cwe": ["CWE-693"], "owasp": "A05:2021", "owasp_name": "Security Misconfiguration"}],
"passed": [{"id": "PL-027", "title": "HTTPS is forced"}, …],
"facts": {"status": 200, "redirects": ["https://app.example.com/"], "https_redirect": true, "https_preloaded_tld": false,
"tls": {"version": "TLSv1.3", "issuer": "Let's Encrypt", "expires": "2027-01-05", "days_left": 56},
"headers": {"strict-transport-security": "max-age=63072000", "content-security-policy": null, …},
"cookies": [{"name": "sb-session", "secure": true, "httponly": true, "samesite": "Lax"}],
"server": "cloudflare"}}409 domain not verified429 checked in the last 10 minutes502 the page couldn’t be loaded
Grade: score = 100 − 30 per failed high check − 15 per medium − 5 per low (info is free; a check that fails on several cookies costs its points once), floor 0. A ≥ 90, B ≥ 75, C ≥ 60, D ≥ 40, else F. An invalid or expired certificate is always F. On HSTS-preloaded top-level domains (such as .app and .dev) PL-027 and PL-029 pass by default.
GET /apps/{aid}/live/checks
Session · any roleThe domain’s posture history, newest first, at most 30.
curl -s https://api.patchlabs.dev/apps/$APP_ID/live/checks -H "Authorization: Bearer $PL_SESSION"{"checks": [{"id": "…", "grade": "C", "score": 65, "counts": {"critical": 0, "high": 0, "medium": 2, "low": 1, "info": 0},
"trigger": "scheduled", "checked_at": "…"}]}
# trigger: verify | scheduled | manualSupport tickets
Tickets belong to a workspace: its team sees them and our replies, nobody else does; viewers read but can’t write. A client sees and writes only tickets about apps shared with them, must give app_id (one of their apps) when raising one, and sees teammates’ emails masked. The app’s support page uses these calls. We reply within a day.
GET /workspaces/{wid}/support/tickets
Session · any roleThe workspace’s tickets, most recently updated first, with a count per status.
Response fields
- status query, optional
active(open or waiting on you),open,waiting_on_customer,resolvedorclosed.
curl -s https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/support/tickets -H "Authorization: Bearer $PL_SESSION"{"tickets": [{"id": "…", "number": 12, "subject": "Report says profiles leak", "category": "finding",
"priority": "normal", "status": "waiting_on_customer", "app": {"id": "…", "repo": "you/your-app"},
"created_by": "Olive", "message_count": 2, "last_from": "support", "updated_at": "…"}],
"counts": {"open": 0, "waiting_on_customer": 1, "resolved": 3, "closed": 1}}POST /workspaces/{wid}/support/tickets
Session · member+ or clientRaise a ticket. We email you a confirmation; the founder is emailed too.
Body
- subject string
- Up to 200 characters.
- category billing | finding | fix | account | bug | other
- Required.
- message string
- Plain text, up to 10,000 characters. Line breaks are kept; it is never rendered as HTML.
- priority normal | urgent
- Optional, default
normal. Urgent: personal data is exposed right now, or PatchLabs is unusable. - app_id string, optional
- An app in the same workspace.
curl -s -X POST https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/support/tickets -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' \
-d '{"subject": "Report says profiles leak", "category": "finding", "message": "Is this real?"}'{"id": "…", "number": 12, "status": "open", "priority": "normal", "subject": "Report says profiles leak", …,
"messages": [{"author": "customer", "kind": "message", "by": "Olive", "body": "Is this real?", "created_at": "…"}]}422 missing or too-long subject or message, unknown category or priority, or an app from another workspace429 20 tickets from you today
GET /support/tickets/{tid}
Session · any roleOne ticket and its thread: your team’s messages, our replies (author: support, by: PatchLabs support) and status changes (kind: status, body is the new status).
curl -s https://api.patchlabs.dev/support/tickets/$TICKET_ID -H "Authorization: Bearer $PL_SESSION"404 no such ticket, or not in a workspace you belong to
POST /support/tickets/{tid}/messages
Session · member+ or clientReply. The ticket goes back to open, even if it was resolved or closed, and we’re emailed.
Body
- body string
- Plain text, up to 10,000 characters.
curl -s -X POST https://api.patchlabs.dev/support/tickets/$TICKET_ID/messages -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' -d '{"body": "Applied the fix, thanks."}'{"id": "…", "status": "open", "messages": […]}429 200 replies from you today
POST /support/tickets/{tid}/close
Session · member+ or clientClose the ticket. Replying later opens it again.
curl -s -X POST https://api.patchlabs.dev/support/tickets/$TICKET_ID/close -H "Authorization: Bearer $PL_SESSION"{"id": "…", "status": "closed", "closed_at": "…", "messages": […]}CI endpoints (API key)
For scripts and CI. Send a workspace API key as X-API-Key; it works only for that workspace’s apps. The GitHub Action uses these two.
POST /v1/apps/{aid}/checks
API keyRun a check now (trigger api). Shares the workspace’s monthly run allowance.
curl -s -X POST https://api.patchlabs.dev/v1/apps/$APP_ID/checks -H "X-API-Key: $PATCHLABS_API_KEY"{"check_id": "…", "status": "queued", "url": "https://patchlabs.dev/check?id=…"}401 missing, invalid or revoked key403 key from another workspace404 no such app409 not verified, or a check is open429 monthly run limit reached
GET /v1/checks/{cid}
API keyStatus and summary of a check on one of the workspace’s apps. Poll until finished is true.
Response fields
- finished boolean
- True once the status is
done,no_leaks,needs_revieworfailed. - leaks_found boolean
- True when
tables_leakingisn’t empty. - lab_proof_passed boolean | null
- Whether the fix passed the lab proof.
curl -s https://api.patchlabs.dev/v1/checks/$CHECK_ID -H "X-API-Key: $PATCHLABS_API_KEY"{"id": "…", "app_id": "…", "repo": "you/your-app", "status": "done", "trigger": "api",
"created_at": "…", "finished_at": "…", "finished": true, "leaks_found": true,
"tables_leaking": ["profiles"], "lab_proof_passed": true,
"summary": {…}, "url": "https://patchlabs.dev/check?id=…"}401 bad key403 key from another workspace404 no such check
Public
No authentication, safe to call from anywhere.
GET /badge/{aid}.svg
No authThe app’s badge as SVG. Shows the status only when the app’s badge is public, otherwise “private”. When the last finished check is older than twice the app’s schedule (14 days weekly, 60 monthly, 45 without a schedule) it shows a grey “last checked N days ago” instead of a status. Cached for an hour. Guide: badge.
curl -s https://api.patchlabs.dev/badge/$APP_ID.svg -o badge.svgGET /badge/{aid}/live.svg
No authThe app’s Live posture badge: “live posture · B · 10 Nov 2026”, the grade and the day of the last check. It reads “private” unless the app’s badge is public and its domain is verified. Cached for an hour.
curl -s https://api.patchlabs.dev/badge/$APP_ID/live.svg -o live.svgGET /public/apps/{aid}
No authRepo, status, last-checked date and freshness, only when the badge is public. A stale result is reported as stale, never as its old status. live is the latest posture grade when the domain is verified, otherwise null.
curl -s https://api.patchlabs.dev/public/apps/$APP_ID{"repo": "you/your-app", "status": "no_known_leaks", "last_checked": "…",
"stale": false, "days_since_check": 3, "stale_after_days": 60,
"live": {"grade": "B", "checked_at": "…"}}
# status: no_known_leaks | fix_in_progress | not_checked_yet | stale404 badge not public
GET /taxonomy/findings
No authThe CWE and OWASP Top 10 (2021) tags for every finding ID, with their names.
curl -s https://api.patchlabs.dev/taxonomy/findings{"findings": {"PL-001": {"cwe": ["CWE-359", "CWE-863"], "owasp": "A01:2021", "owasp_name": "Broken Access Control"}, …},
"cwe": {"CWE-359": "Exposure of Private Personal Information to an Unauthorized Actor", …},
"owasp": {"A01:2021": "Broken Access Control", …}}Try it
Send this request to the live API from your browser.
Needs JavaScript; use the curl example instead.
GET /stats
No authLive totals across PatchLabs.
curl -s https://api.patchlabs.dev/stats{"checks_completed": <int>, "apps_monitored": <int>, "leaks_found": <int>, "fixes_proven": <int>}GET /health
No authWhether the API and database are up, when the lab worker last checked in, and how many checks are queued.
curl -s https://api.patchlabs.dev/health{"ok": true, "db": "ok", "worker_last_seen": "…", "queued": <int>}Try it
Send this request to the live API from your browser.
Needs JavaScript; use the curl example instead.
Free tools
No sign-up. The RLS check reads a repo’s migrations (web version: /tools/rls-check); the headers check loads one public page (web version: /tools/headers-check).
POST /tools/rls-check
No authReads supabase/migrations/*.sql on the default branch, pinned to its head commit, in filename order (at most 60 files and 2 MB; truncated says when it stopped early). Only tables in the public schema are analysed. Results are cached for 10 minutes per repo and commit.
Body
- repo string
- Public GitHub repo.
curl -s -X POST https://api.patchlabs.dev/tools/rls-check -H 'Content-Type: application/json' -d '{"repo": "manra399/patchlabs-demo-app"}'{"repo": "manra399/patchlabs-demo-app", "branch": "main", "commit": "…",
"files_scanned": 1, "truncated": false, "cached": false,
"tables": [{"name": "profiles", "rls_enabled": true, "policies": […],
"personal_columns": ["email", "phone"], "file": "supabase/migrations/…", "line": 3}],
"findings": [{"id": "PL-001", "title": "Anyone with the public key can read personal data",
"table": "profiles", "file": "…", "line": 12, "severity": "critical", "snippet": "…"}],
"limits": "static analysis only; the full check proves it in the lab"}422 not a GitHub repo429 20 requests an hour from this network (failed lookups count)503 GitHub unreachable
Try it
Send this request to the live API from your browser.
Needs JavaScript; use the curl example instead.
Finding IDs, most severe first
| ID | Title | What it matches |
|---|---|---|
PL-001 | Anyone with the public key can read personal data | A SELECT or ALL policy for anon/public, not scoped to the user, on a table with email, phone, address, date-of-birth or similar columns |
PL-002 | Any signed-in user can read all rows | A policy that only checks the user is signed in |
PL-003 | Row level security is on but the table has no policies | Nobody but the service role can read it: often an unfinished table |
PL-004 | UPDATE policy without WITH CHECK | Users may be able to change a row into one they shouldn’t own |
PL-005 | Policy is always true | USING (true) or WITH CHECK (true) |
PL-006 | SECURITY DEFINER function exposure | No search_path set, or granted to anon/public |
PL-007 | Public storage bucket | A bucket inserted or updated with public = true |
PL-008 | Row level security is not enabled | Every role with table grants can read and write it |
POST /tools/headers-check
No authThe Live posture check for any public address: one plain http:// request, one https:// load of the home page (at most 5 redirects) and its TLS handshake. Returns the posture result (as above, without id or trigger). Nothing is stored except the host name and a hashed network address for the hourly limit.
Body
- url string
- An
https://orhttp://address with a public host name. Any path is dropped: the home page is what’s checked.
curl -s -X POST https://api.patchlabs.dev/tools/headers-check -H 'Content-Type: application/json' -d '{"url": "https://patchlabs.dev"}'{"url": "https://patchlabs.dev", "final_url": "https://patchlabs.dev/", "grade": "B", "score": 75, "checked_at": "…",
"counts": {"critical": 0, "high": 0, "medium": 1, "low": 2, "info": 1},
"findings": [{"id": "PL-030", "severity": "medium", "title": "No Content-Security-Policy", …}, …],
"passed": [{"id": "PL-027", "title": "HTTPS is forced"}, …], "facts": {…}}422 a bad or private address, with a sentence429 10 requests an hour from this network502 the page couldn’t be loaded, with a sentence
Try it
Send this request to the live API from your browser.
Needs JavaScript; use the curl example instead.
MCP
POST /mcp
No auth · API key for app toolsA remote MCP server: JSON-RPC 2.0 over Streamable HTTP, stateless, plain JSON responses. Tools rls_check, start_check, get_check, and with X-API-Key, list_apps and run_check. GET /mcp answers 405. Full guide: MCP server.
curl -s -X POST https://api.patchlabs.dev/mcp -H 'Content-Type: application/json' -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'