Event webhooks
Signed deploy.succeeded / deploy.failed / app.crashed / domain.expiring
How it works, with exact details
Percher POSTs signed JSON payloads to your webhook URL when something happens to your apps — a deploy succeeds or fails, an app crashes or goes unhealthy, a custom-domain cert is about to expire, a security scan finds something. Useful for Discord/Slack bots, on-call pagers, or custom dashboards. One URL per account; every event goes there.
Setup: Account → Overview → Notifications → paste your receiver URL into the Webhook URL field. A signing secret is generated and shown once — copy it into your receiver's PERCHER_WEBHOOK_SECRET env var. Changing the URL rotates the secret; clearing the URL clears the secret.
Every event, explained
deploy.succeeded— a deploy reached live (data:{ deployId, url, durationMs, kind: "live" | "preview", plan? }) — closes the async CI/CD loop so agents can smoke-test without polling. Forkind: "live"the event fires only after the post-swap canary closes cleanly, so a receiver will never be contradicted by adeploy.failedfor the samedeployId. Theurlfield is always the auto-assigned<app>.percher.rundomain; custom-domain entry points must be looked up separately. Stable app previews use<app>-preview.percher.run. For otherkind: "preview"publishes the URL pattern is<app>--p-<slug>.percher.run; the slug is the substring between--p-and the first., and the upstream branch name is not currently included in the payload. Slugs are[a-z0-9-]+truncated to 19 chars, so branches that differ only in punctuation (feature/foovsfeature-foo) share a preview URL.deploy.failed— any deploy fails (build, health, canary)app.crashed— the watchdog detects a crash (process exit / OOM)app.unhealthy— external uptime probe failed 3 times in a row (Starter+; billing)app.recovered— app responds again after an unhealthy windowdomain.expiring— custom-domain SSL cert is within 7 days of expirysecurity.finding— a security scan first finds a red-tier fixable/exploitable vulnerability in a live app (data:{ tier: "red", findings: [{ cve, component, severity, fixedIn, tier, deadlineAt }] }). Yellow findings remain visible in the dashboard but do not trigger the automatic webhook. Each red finding is sent once, not on every re-scan.
app.unhealthy is gated by a 15-minute cooldown so a flapping app doesn't pager-spam your receiver. app.recovered only fires after an unhealthy event that was actually delivered — if the matching unhealthy was suppressed by the cooldown, the recovery is suppressed too, so every recovered event you see pairs with an unhealthy event you saw.
app.crashed fires on container-level failures (the process exited); app.unhealthy fires when the app is reachable from inside the cluster but external probes can't hit it (DNS / Caddy / TLS / 5xx). They're independent — both can fire for the same incident.
What a delivery looks like
Every event uses the same envelope — data carries the per-event fields plus appId/appName when the event concerns an app.
Verify the webhook signature
Every delivery carries X-Percher-Signature: sha256=<hex> — HMAC-SHA256 of ${timestamp}.${body}. Reject anything older than a few minutes for replay protection.
Deliveries are best-effort with a 5-second timeout. Redirects are not followed and failed responses are not retried — use the final HTTPS receiver URL, return a 2xx directly, and queue events in your receiver if you need retries or ordering guarantees.