Webhooks
Webhooks
When a monitored app’s check finishes, PatchLabs can POST the result to a URL you choose. The payload has a ready-made text line, so a Slack incoming webhook works with no code.
Set up
- A workspace owner opens Settings → Webhook, pastes a URL and saves.
- Press Send test. You’ll see the status code your endpoint answered.
- If your own code receives the webhook, copy the signing secret shown there (
whsec_…) and verify every request.
Or with the API:
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"}'
curl -s -X POST https://api.patchlabs.dev/workspaces/$WORKSPACE_ID/webhook/test \
-H "Authorization: Bearer $PL_SESSION"
# {"ok": true, "status_code": 200}A workspace has one webhook URL. It must start with https:// and point at a public host: addresses that resolve to private, loopback or link-local networks are refused. To remove it, save an empty value (or send "webhook_url": null).
Saving the first URL creates the workspace’s signing secret. Owners see it in webhook_secret on GET /workspaces/{id} (members get null) and can replace it with POST /workspaces/{id}/webhook/rotate-secret; the old secret stops working at once.
Events
check.finished
Sent when any check on any of the workspace’s apps finishes: scheduled, run-now, CI or MCP. Free checks that aren’t linked to an app don’t send webhooks.
{
"event": "check.finished",
"text": "PatchLabs: you/shop-app (Acme): leaks found and a tested fix is ready. https://patchlabs.dev/check?id=7c1e…",
"check": {
"id": "7c1e2f0a-…",
"app_id": "3f2b9c1e-…",
"repo": "you/shop-app",
"status": "done",
"tables_leaking": ["orders", "shops"],
"url": "https://patchlabs.dev/check?id=7c1e2f0a-…",
"new_leaks": ["orders"],
"fixed": ["profiles"],
"suppressed": ["shops"]
}
}text- One line for people: app name and client label, the headline, and the report link.
check.statusdone,no_leaks,needs_revieworfailed. See statuses.check.tables_leaking- Every leaking table in this check, including ones marked as intended.
check.new_leaks,fixed,suppressed- The diff against the previous finished check. Alert on
new_leaks;suppressedlists tables your team marked as intended. All three are empty for a failed check.
The headline in text is one of: “no data leaks found”, “leaks found and a tested fix is ready”, “leaks found; a person is reviewing the fix”, or “the check could not finish; we have been alerted”.
test
Sent by the test button. Use it to check your endpoint and your channel.
{"event": "test", "text": "PatchLabs: webhooks for Acme are working.",
"workspace": {"id": "…", "name": "Acme"}}Slack
Create an incoming webhook for the channel you want (in Slack: a Slack app with Incoming Webhooks turned on), copy its https://hooks.slack.com/services/… URL, and save it as your webhook. Slack posts the text field as the message and ignores the rest. Slack changes its set-up screens from time to time, so check Slack’s docs for the current steps.
PatchLabs 09:41 you/shop-app (Acme): leaks found and a tested fix is ready. https://patchlabs.dev/check?id=7c1e…
Discord
Discord webhooks expect a different field name, but Discord also accepts Slack-style payloads at the same URL with /slack on the end. Create a webhook in the channel’s settings, add /slack, and save that. Check Discord’s docs if it doesn’t post.
https://discord.com/api/webhooks/123456789/abcdef…/slackYour own endpoint
Any HTTPS endpoint that accepts a JSON POST and answers 2xx within 5 seconds works. Do the slow work after you answer, and check the signature before acting on a payload. A minimal receiver:
import express from 'express';
const app = express();
app.post('/hooks/patchlabs', express.json(), (req, res) => {
res.sendStatus(204); // answer first: you have 5 seconds
const { event, check } = req.body;
if (event === 'check.finished' && check.new_leaks.length) {
openTicket(check.repo, check.new_leaks, check.url); // your code
}
});
app.listen(3000);Delivery rules
- Attempts
- One. There are no retries. If your endpoint is down, that delivery is lost (the check, its report and the emails are not).
- Timeout
- 5 seconds for the whole request.
- Redirects
- Not followed. Use the final URL.
- Headers
Content-Type: application/json,User-Agent: PatchLabs-Webhook/1,X-PatchLabs-TimestampandX-PatchLabs-Signature(see Security).- Address
- We resolve the host, refuse private addresses, and connect to the address we checked, so a DNS change mid-request can’t redirect it inside a network.
Because there are no retries, treat webhooks as a fast nudge, not a record. Members get the same news by email, and the app endpoint always has the full history.
Security: verify the signature
Every webhook request is signed with your workspace’s secret, so your endpoint can tell a real PatchLabs request from a look-alike sent by someone who learned the URL.
X-PatchLabs-Timestamp- When we sent it, in Unix seconds.
X-PatchLabs-Signaturev1=followed by the hex HMAC-SHA256 of<timestamp>.<raw body>, keyed with yourwhsec_…secret.
Compute the HMAC over the raw request bytes before parsing the JSON, compare in constant time, and reject timestamps more than 5 minutes old so a captured request can’t be replayed later:
import hashlib, hmac, time
def verify(secret: str, headers, raw_body: bytes, tolerance: int = 300) -> bool:
ts = headers.get("X-PatchLabs-Timestamp", "")
sig = headers.get("X-PatchLabs-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
return False # missing, or more than 5 minutes off: possibly a replay
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256)
return hmac.compare_digest(sig, "v1=" + mac.hexdigest())In Flask that is verify(SECRET, request.headers, request.get_data()); answer 401 when it returns False. Keep the secret out of your repo (an environment variable or secret store), and rotate it in Settings → Webhook if it leaks. Slack and Discord ignore the extra headers.