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/json with 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.

LabelSendUse it for
No authnothingFree checks, reports and receipts by link, badges, stats, the free RLS check
API keyX-API-Key: pl_live_…Scripts and CI: the /v1 endpoints and the MCP app tools
SessionAuthorization: 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.

Terminal
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:

409 Conflict
{"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:

422 Unprocessable Content
{"detail": [{"type": "missing", "loc": ["body", "email"], "msg": "Field required", "input": {…}}]}
StatusMeaning
400A sign-in link that has expired or was already used
401Missing or expired session, or a missing, invalid or revoked API key
403Signed in, but not allowed: not an owner, wrong workspace, or a plan limit (apps, seats, schedules)
404Not found, or not yours to see
409Not possible in the current state: not verified yet, a check already running, not finished yet
422Input we can’t use: not a GitHub repo, repo not found, unsafe webhook URL, wrong body shape
429A rate limit or monthly run limit (see below)
503GitHub couldn’t be reached; try again in a minute

Rate limits

WhatLimitWhen exceeded
POST /checks3 open checks per email; 10 requests per network per day429
POST /tools/rls-check20 requests per network per hour, failed lookups included429
POST /auth/start5 links per email per hour, plus a per-network capStill 202, no email sent
Run now, /v1 runs, MCP run_checkThe plan’s manual runs per month (4, 30, 200), shared429
Open checks per appOne queued or running at a time409

Check statuses

StatusMeaningFinished
awaiting_verificationFree check waiting for patchlabs-verify.txtNo
queuedVerified, waiting for the labNo
runningIn the lab nowNo
doneLeaks found and a fix passed the lab proofYes, receipt issued
needs_reviewLeaks found; a person is reviewing the fixYes, receipt issued
no_leaksNo leaks found in the tables testedYes, receipt issued
failedThe check couldn’t finish; we’re alertedYes, no receipt

All endpoints

MethodPathAuth
Free checks
POST/checksNo auth
GET/checks/{id}No auth · by link
POST/checks/{id}/verifyNo auth · by link
Decisions and receipts
POST/checks/{id}/decisionSession · approver+
POST/checks/{id}/answersSession · approver+
GET/receipts/{id}No auth · by link
GET/.well-known/patchlabs-receipt-keyNo auth
Sign-in
POST/auth/startNo auth
POST/auth/finishNo auth
POST/auth/logoutSession
POST/invites/acceptSession
Your account
GET/meSession
PATCH/meSession
GET/me/checksSession
GET/me/exportSession
DELETE/meSession
Workspaces
GET/workspaces/{wid}Session · any role
PATCH/workspaces/{wid}Session · owner/admin
POST/workspaces/{wid}/webhook/testSession · owner/admin
POST/workspaces/{wid}/invitesSession · owner/admin
DELETE/workspaces/{wid}/invites/{iid}Session · owner/admin
PATCH/workspaces/{wid}/members/{uid}Session · owner/admin
PUT/workspaces/{wid}/members/{uid}/appsSession · owner/admin
DELETE/workspaces/{wid}/members/{uid}Session
GET/workspaces/{wid}/api-keysSession · viewer+
POST/workspaces/{wid}/api-keysSession · member+
DELETE/workspaces/{wid}/api-keys/{kid}Session · member+
POST/workspaces/{wid}/upgrade-requestSession · owner/admin
Audit log
GET/workspaces/{wid}/auditSession · owner/admin
GET/workspaces/{wid}/audit/exportSession · owner/admin
Apps
GET/workspaces/{wid}/appsSession · any role
POST/workspaces/{wid}/appsSession · owner/admin
POST/apps/{aid}/verifySession · owner/admin
GET/apps/{aid}Session · any role
PATCH/apps/{aid}Session · owner/admin
DELETE/apps/{aid}Session · owner/admin
POST/apps/{aid}/checksSession · member+
Live posture
PUT/apps/{aid}/liveSession · owner/admin
DELETE/apps/{aid}/liveSession · owner/admin
POST/apps/{aid}/live/verifySession · owner/admin
POST/apps/{aid}/live/checksSession · member+
GET/apps/{aid}/live/checksSession · any role
Support tickets
GET/workspaces/{wid}/support/ticketsSession · any role
POST/workspaces/{wid}/support/ticketsSession · member+ or client
GET/support/tickets/{tid}Session · any role
POST/support/tickets/{tid}/messagesSession · member+ or client
POST/support/tickets/{tid}/closeSession · member+ or client
CI endpoints (API key)
POST/v1/apps/{aid}/checksAPI key
GET/v1/checks/{cid}API key
Public
GET/badge/{aid}.svgNo auth
GET/badge/{aid}/live.svgNo auth
GET/public/apps/{aid}No auth
GET/taxonomy/findingsNo auth
GET/statsNo auth
GET/healthNo auth
Free tools
POST/tools/rls-checkNo auth
POST/tools/headers-checkNo auth
MCP
POST/mcpNo auth · API key for app tools

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 auth

Request a full check. We email the requester a link to the check page with the verification step.

Body

repo string
Public GitHub repo: owner/name or a github.com URL.
email string
Where the report link goes.
Request
curl -s -X POST https://api.patchlabs.dev/checks -H 'Content-Type: application/json' \
  -d '{"repo": "you/your-app", "email": "you@company.com"}'
Response · 201 Created
{"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 link

The 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, and findings: each finding carries cwe (list), owasp and owasp_name (OWASP Top 10 2021); on an app’s check also first_seen, days_open, fixed_at (null while open), fixed_in_check_id and regressed.
report_md, fix_sql string
When finished: the report as Markdown and the fix migration (null when there are no leaks).
error string
When failed: a plain-English explanation.
events array
Timeline: [{at, kind}].
decisions, answers array
Always present; see decisions.
Request
curl -s https://api.patchlabs.dev/checks/$CHECK_ID
Response · 200 OK
{"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 link

Look 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.

Request
curl -s -X POST https://api.patchlabs.dev/checks/$CHECK_ID/verify
Response · 200 OK
{"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.
Request
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"}'
Response · 201 Created
{"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}; each question must match one in summary.questions_for_owner.
Request
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"}]}'
Response · 200 OK
{"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 link

The Ed25519-signed receipt for a finished check.

Request
curl -s https://api.patchlabs.dev/receipts/$CHECK_ID
Response · 200 OK
{"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 auth

The public key that signs receipts.

Request
curl -s https://api.patchlabs.dev/.well-known/patchlabs-receipt-key
Response · 200 OK
{"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 auth

Email 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/.
Request
curl -s -X POST https://api.patchlabs.dev/auth/start -H 'Content-Type: application/json' -d '{"email": "you@company.com"}'
Response · 202 Accepted
{"ok": true, "detail": "If that address can sign in, a link is on its way. It expires in 20 minutes."}

POST /auth/finish

No auth

Exchange 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.
Request
curl -s -X POST https://api.patchlabs.dev/auth/finish -H 'Content-Type: application/json' -d '{"token": "…"}'
Response · 200 OK
{"session": "…", "user": {"id": "…", "email": "you@company.com", "name": null, "created_at": "…"},
 "next": "/app/", "invite": null}

400 link expired or already used

POST /auth/logout

Session

Revoke the session in the Authorization header.

Request
curl -s -X POST https://api.patchlabs.dev/auth/logout -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"ok": true}

POST /invites/accept

Session

Accept 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.
Request
curl -s -X POST https://api.patchlabs.dev/invites/accept -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' -d '{"invite": "…"}'
Response · 200 OK
{"accepted": true, "workspace_id": "…", "workspace_name": "Acme"}

409 expired, used, sent to another address, or no free seats

Your account

GET /me

Session

You and the workspaces you belong to. Use a workspace id from here in the workspace and app endpoints.

Request
curl -s https://api.patchlabs.dev/me -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"user": {"id": "…", "email": "you@company.com", "name": "Priya", "created_at": "…"},
 "workspaces": [{"id": "…", "name": "you's workspace", "role": "owner", "plan": "free"}]}

PATCH /me

Session

Set your display name (shown on decisions and invites).

Body

name string
Up to 100 characters; empty clears it.
Request
curl -s -X PATCH https://api.patchlabs.dev/me -H "Authorization: Bearer $PL_SESSION" -H 'Content-Type: application/json' -d '{"name": "Priya"}'
Response · 200 OK
{"user": {…}, "workspaces": […]}

GET /me/checks

Session

Free checks linked to you (by account or email), newest first.

Request
curl -s https://api.patchlabs.dev/me/checks -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"checks": [{"id": "…", "repo": "you/your-app", "status": "done", "created_at": "…"}]}

GET /me/export

Session

Everything we hold about you, as a JSON file download (patchlabs-export.json).

Request
curl -s -OJ https://api.patchlabs.dev/me/export -H "Authorization: Bearer $PL_SESSION"

DELETE /me

Session

Delete your account. Workspaces you own alone are deleted with their apps, checks and keys; shared ones pass to their longest-standing member.

Request
curl -s -X DELETE https://api.patchlabs.dev/me -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"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 role

Plan, 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.

Request
curl -s https://api.patchlabs.dev/workspaces/$WORKSPACE_ID -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"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/admin

Rename 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; null or empty removes it.
webhook_format auto | json | slack | discord | teams, optional
auto (default) detects hooks.slack.com, discord.com/api/webhooks, *.webhook.office.com and Teams Workflows on *.powerplatform.com. Set teams for a Workflows URL on *.logic.azure.com.
Request
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"}'
Response · 200 OK
{…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/admin

Send 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.

Request
curl -s -X POST https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/webhook/test -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"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/admin

Email 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.
Request
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"}'
Response · 201 Created
{"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/admin

Cancel a pending invite.

Request
curl -s -X DELETE https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/invites/$INVITE_ID -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"ok": true}

404 no such invite

PATCH /workspaces/{wid}/members/{uid}

Session · owner/admin

Change 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.
Request
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"}'
Response · 200 OK
{…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/admin

Set the apps a client sees (replaces the list).

Response fields

app_ids string[]
Apps in this workspace; empty means none.
Request
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": ["…"]}'
Response · 200 OK
{…the workspace…}

409 the member isn’t a client422 an app from elsewhere

DELETE /workspaces/{wid}/members/{uid}

Session

Remove a member. Owners can remove anyone, admins anyone but owners; everyone can remove themselves.

Request
curl -s -X DELETE https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/members/$USER_ID -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"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).

Request
curl -s https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/api-keys -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"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.
Request
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"}'
Response · 201 Created
{"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.

Request
curl -s -X DELETE https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/api-keys/$KEY_ID -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"ok": true}

403 someone else’s key404 no such key

POST /workspaces/{wid}/upgrade-request

Session · owner/admin

Ask 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.
Request
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"}'
Response · 201 Created
{"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/admin

Events, 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 as member.
actor query, optional
A user id, or api_key, admin (PatchLabs admin) or system (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.
Request
curl -s 'https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/audit?action=member&limit=50' -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"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/admin

The same filters as a CSV download (up to 10,000 rows). The export itself is recorded as audit.exported.

Request
curl -s -OJ 'https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/audit/export?since=2026-11-01' -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK (text/csv)
id,at,action,actor_kind,actor,actor_email,target_type,target_id,target,target_email,detail

Apps

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 role

Every 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.

Request
curl -s https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/apps -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"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/admin

Add 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.
Request
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"}'
Response · 201 Created
{"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/admin

Check the verification file. On success the app is verified for good and its first check is queued.

Request
curl -s -X POST https://api.patchlabs.dev/apps/$APP_ID/verify -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"verified": true, "check_id": "…"}

409 file or code not found yet503 GitHub unreachable

GET /apps/{aid}

Session · any role

The 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.

Request
curl -s https://api.patchlabs.dev/apps/$APP_ID -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"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/admin

Change 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
 
Request
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}'
Response · 200 OK
{…the app…}

403 schedule not on the plan

DELETE /apps/{aid}

Session · owner/admin

Delete the app with all its checks, reports and receipts.

Request
curl -s -X DELETE https://api.patchlabs.dev/apps/$APP_ID -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"ok": true}

POST /apps/{aid}/checks

Session · member+

Run a check now (trigger manual). Verified apps skip verification.

Request
curl -s -X POST https://api.patchlabs.dev/apps/$APP_ID/checks -H "Authorization: Bearer $PL_SESSION"
Response · 201 Created
{"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/admin

Set 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.
Request
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"}'
Response · 200 OK
{"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 unverified

403 not an owner or admin422 a bad address, with a sentence saying why

DELETE /apps/{aid}/live

Session · owner/admin

Remove the domain and its whole posture history. The site itself isn’t touched.

Request
curl -s -X DELETE https://api.patchlabs.dev/apps/$APP_ID/live -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"ok": true}

POST /apps/{aid}/live/verify

Session · owner/admin

Look 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.

Request
curl -s -X POST https://api.patchlabs.dev/apps/$APP_ID/live/verify -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"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.

Request
curl -s -X POST https://api.patchlabs.dev/apps/$APP_ID/live/checks -H "Authorization: Bearer $PL_SESSION"
Response · 201 Created
{"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 role

The domain’s posture history, newest first, at most 30.

Request
curl -s https://api.patchlabs.dev/apps/$APP_ID/live/checks -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"checks": [{"id": "…", "grade": "C", "score": 65, "counts": {"critical": 0, "high": 0, "medium": 2, "low": 1, "info": 0},
             "trigger": "scheduled", "checked_at": "…"}]}
# trigger: verify | scheduled | manual

Support 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 role

The 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, resolved or closed.
Request
curl -s https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/support/tickets -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"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 client

Raise 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.
Request
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?"}'
Response · 201 Created
{"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 role

One 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).

Request
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 client

Reply. 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.
Request
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."}'
Response · 201 Created
{"id": "…", "status": "open", "messages": […]}

429 200 replies from you today

POST /support/tickets/{tid}/close

Session · member+ or client

Close the ticket. Replying later opens it again.

Request
curl -s -X POST https://api.patchlabs.dev/support/tickets/$TICKET_ID/close -H "Authorization: Bearer $PL_SESSION"
Response · 200 OK
{"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 key

Run a check now (trigger api). Shares the workspace’s monthly run allowance.

Request
curl -s -X POST https://api.patchlabs.dev/v1/apps/$APP_ID/checks -H "X-API-Key: $PATCHLABS_API_KEY"
Response · 201 Created
{"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 key

Status 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_review or failed.
leaks_found boolean
True when tables_leaking isn’t empty.
lab_proof_passed boolean | null
Whether the fix passed the lab proof.
Request
curl -s https://api.patchlabs.dev/v1/checks/$CHECK_ID -H "X-API-Key: $PATCHLABS_API_KEY"
Response · 200 OK
{"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 auth

The 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.

Request
curl -s https://api.patchlabs.dev/badge/$APP_ID.svg -o badge.svg

GET /badge/{aid}/live.svg

No auth

The 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.

Request
curl -s https://api.patchlabs.dev/badge/$APP_ID/live.svg -o live.svg

GET /public/apps/{aid}

No auth

Repo, 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.

Request
curl -s https://api.patchlabs.dev/public/apps/$APP_ID
Response · 200 OK
{"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 | stale

404 badge not public

GET /taxonomy/findings

No auth

The CWE and OWASP Top 10 (2021) tags for every finding ID, with their names.

Request
curl -s https://api.patchlabs.dev/taxonomy/findings
Response · 200 OK
{"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 auth

Live totals across PatchLabs.

Request
curl -s https://api.patchlabs.dev/stats
Response · 200 OK
{"checks_completed": <int>, "apps_monitored": <int>, "leaks_found": <int>, "fixes_proven": <int>}

GET /health

No auth

Whether the API and database are up, when the lab worker last checked in, and how many checks are queued.

Request
curl -s https://api.patchlabs.dev/health
Response · 200 OK
{"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 auth

Reads 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.
Request
curl -s -X POST https://api.patchlabs.dev/tools/rls-check -H 'Content-Type: application/json' -d '{"repo": "manra399/patchlabs-demo-app"}'
Response · 200 OK
{"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

IDTitleWhat it matches
PL-001Anyone with the public key can read personal dataA 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-002Any signed-in user can read all rowsA policy that only checks the user is signed in
PL-003Row level security is on but the table has no policiesNobody but the service role can read it: often an unfinished table
PL-004UPDATE policy without WITH CHECKUsers may be able to change a row into one they shouldn’t own
PL-005Policy is always trueUSING (true) or WITH CHECK (true)
PL-006SECURITY DEFINER function exposureNo search_path set, or granted to anon/public
PL-007Public storage bucketA bucket inserted or updated with public = true
PL-008Row level security is not enabledEvery role with table grants can read and write it

POST /tools/headers-check

No auth

The 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:// or http:// address with a public host name. Any path is dropped: the home page is what’s checked.
Request
curl -s -X POST https://api.patchlabs.dev/tools/headers-check -H 'Content-Type: application/json' -d '{"url": "https://patchlabs.dev"}'
Response · 200 OK
{"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 tools

A 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.

Request
curl -s -X POST https://api.patchlabs.dev/mcp -H 'Content-Type: application/json' -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
Updated 10 Nov 2026Something unclear or wrong? Tell the founder