Skip to main content
Percher is still being built, but you can try it out with a free account right now!

Event webhooks

Signed deploy.succeeded / deploy.failed / app.crashed / domain.expiring

Ask your agent
Ping my other service whenever I publish.Read the guide at percher.app/docs/webhooks
Send my deploy and crash alerts to Discord.Read the guide at percher.app/docs/webhooks
For agents and developers

Percher POSTs signed JSON payloads to your webhook URL when events happen. Useful for Discord/Slack bots, on-call pagers, or custom dashboards.

Events

  • 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. For kind: "live" the event fires only after the post-swap canary closes cleanly, so a receiver will never be contradicted by a deploy.failed for the same deployId. The url field is always the auto-assigned <app>.percher.run domain; custom-domain entry points must be looked up separately. For kind: "preview" 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/foo vs feature-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 window
  • domain.expiring — custom-domain SSL cert is within 7 days of expiry
  • security.finding — a security scan first finds a fixable/exploitable vulnerability in a live app (data: { tier: "yellow" | "red", findings: [{ cve, component, severity, fixedIn, tier, deadlineAt }] }). Fires once per finding when it crosses the threshold, not on every re-scan.

app.unhealthyis 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.unhealthyfires 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.

Setup

Settings → Notifications → paste your receiver URL. 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.

Verifying signatures

Every delivery carries X-Percher-Signature: sha256=<hex> — HMAC-SHA256 of ${timestamp}.${body}. Reject anything older than a few minutes for replay protection.

// Node / Bun receiver
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, req, body) {
  const sig = req.headers.get("X-Percher-Signature") ?? "";
  const ts = req.headers.get("X-Percher-Timestamp") ?? "";
  const expected = createHmac("sha256", secret)
    .update(`${ts}.${body}`)
    .digest("hex");
  const provided = sig.replace(/^sha256=/, "");
  if (expected.length !== provided.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}

Deliveries are best-effort with a 5-second timeout. 5xx responses are logged but not retried — queue events in your receiver if you need retries or ordering guarantees.