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

  1. A workspace owner opens Settings → Webhook, pastes a URL and saves.
  2. Press Send test. You’ll see the status code your endpoint answered.
  3. If your own code receives the webhook, copy the signing secret shown there (whsec_…) and verify every request.

Or with the API:

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

POST your-url · application/json
{
  "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.status
done, no_leaks, needs_review or failed. 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; suppressed lists 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.

POST your-url · application/json
{"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.

#alerts
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.

Webhook URL
https://discord.com/api/webhooks/123456789/abcdef…/slack

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

server.js (Node 18+, Express)
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-Timestamp and X-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-Signature
v1= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with your whsec_… 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:

verify.py (Python 3, standard library)
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.

Updated 10 Nov 2026Something unclear or wrong? Tell the founder