Every webhook trigger declared in TRIGGER.md gets a unique, stable URL keyed by source:
https://api.usezombie.com/v1/webhooks/{zombie_id}/{source}
External systems POST to this URL. The platform verifies the signature and delivers the event to the agent. No tunneling, no ngrok, no custom servers on your side. zombiectl install --from prints the full set of URLs (one per declared webhook trigger) at install time.
Most users don’t wire webhooks by hand. The platform-ops install skill registers each declared webhook automatically via your existing gh CLI — no paste-into-GitHub step. Use Quickstart for the full guided flow. The reference below is for custom webhooks — your own provider, an internal service, etc.
Declaring a webhook trigger
An agent declares its webhooks in TRIGGER.md under the triggers: array (1–8 entries). One agent can wake on multiple webhook sources plus a cron:
---
name: my-agent
x-usezombie:
triggers:
- type: webhook
source: github
events: [workflow_run]
signature:
secret_ref: github_secret
header: x-hub-signature-256
prefix: "sha256="
---
source is a label that appears in the activity stream (github, slack, linear, etc.) and selects the per-trigger inbound URL — https://api.usezombie.com/v1/webhooks/{zombie_id}/{source}. events is an optional whitelist of upstream event names (1–16 entries, ≤64 chars each); omit it to accept every event the source delivers. The signature block tells the platform how to verify the request.
The singular trigger: shape is rejected at install (ERR_ZOMBIE_INVALID_CONFIG: use "triggers:" (array)); always declare the array form, even for a single webhook.
Authentication
Every inbound webhook is authenticated before the agent is woken. The platform supports HMAC signature verification — Hash-based Message Authentication Code, a standard scheme where the sender signs the payload with a shared secret and the receiver re-computes the signature to verify it. GitHub, Slack, Linear, Jira, Grafana, and most SaaS providers use HMAC.
Natively normalized providers
Three sources have built-in defaults — set triggers[].source to one of them and the platform fills in the signature header and prefix automatically. You only supply secret_ref:
triggers[].source | Sig header (auto) | Prefix (auto) | Timestamp header (auto) |
|---|
slack | x-slack-signature | v0= | x-slack-request-timestamp (replay-protected) |
github | x-hub-signature-256 | sha256= | — |
linear | linear-signature | (empty) | — |
# Minimal — registry fills the rest
triggers:
- type: webhook
source: github
signature:
secret_ref: github
Other providers (generic HMAC)
For any other source — Jira, AgentMail, an internal service of your own — declare the signature header and prefix yourself (or omit the signature block entirely if the upstream doesn’t sign payloads, as some lightweight providers don’t):
triggers:
- type: webhook
source: my_provider
signature:
secret_ref: my_provider # vault key holding the shared secret
header: x-provider-signature # the header your provider sets
prefix: "sha256=" # leave "" if no prefix
ts_header: x-provider-timestamp # optional — enables replay protection
The parser rejects an unknown source that supplies only secret_ref with no header — explicit configuration is required outside the registry.
secret_ref always names a credential in the workspace vault carrying the upstream’s signing secret. See Credentials.
Payload normalization is automatic for named sources. When source is github, slack, or linear, POST /v1/webhooks/{zombie_id}/{source} runs the provider-specific normalizer (event-type extraction, payload shape coercion) before the agent sees the event — no flag, no separate URL. Pick a different source label (e.g. source: github_raw) with an explicit signature block to bypass normalization and receive the raw payload. Clerk-shaped (Svix) webhooks are the one exception: they use a distinct route, POST /v1/webhooks/svix/{zombie_id}, to accommodate Svix’s multi-signature rotation scheme.
Sending an event
Once the agent is installed, your provider POSTs to its URL with a signed body:
curl -X POST https://api.usezombie.com/v1/webhooks/zmb_2041/github \
-H "x-hub-signature-256: sha256=<computed-hmac>" \
-H "Content-Type: application/json" \
-d '{
"event_id": "evt-001",
"type": "email.received",
"data": { "from": "[email protected]", "subject": "Demo request" }
}'
The platform verifies the signature, enqueues the event, and returns 202 Accepted:
{ "accepted": true, "request_id": "req_01JQ7K..." }
Delivery is at-least-once — your provider may retry on transient failure, so the same event can arrive twice. Include a unique event_id in the body so the agent can dedupe.
Failure responses
| Status | Cause |
|---|
401 | Missing, malformed, or mis-signed signature header. |
404 | Agent ID does not exist. |
410 | Agent was killed; re-install with zombiectl install --from <path>. |
429 | Rate limit hit; back off per Retry-After. |
503 | Budget breached, or platform applying backpressure; safe to retry after the period resets. |
Approval gate webhooks
Some agent actions — sending money, merging a PR, posting to a public channel — should not happen without a human in the loop. The approval gate lets an agent pause mid-run, emit an approval request, and wait for a human to click approve or deny in the dashboard.
For programmatic decisions (e.g. an internal tool calling back into usezombie), POST the decision to:
curl -X POST https://api.usezombie.com/v1/webhooks/zmb_2041/approval \
-H "Authorization: Bearer $APPROVAL_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "approval_id": "apr_01JQ...", "decision": "approve" }'
The approval token is auto-provisioned for the agent at install time.
Observing webhook traffic
Every accepted and rejected webhook appears in the activity stream:
Rejected requests carry a reason code (webhook_rejected: signature_mismatch, etc.) so you can tell a noisy upstream from a real auth bug.
Advanced: URL-embedded secret
Some upstreams (a few SaaS form-postbacks, a few legacy systems) can’t attach a signature header. For those, the platform accepts a path-embedded secret:
https://api.usezombie.com/v1/webhooks/{zombie_id}/{url_secret}
The url_secret is matched in constant time. Reserved segments (approval, grant-approval, svix) cannot be used as secret values. Prefer HMAC where the upstream supports it — the URL-embedded form is a fallback.
Path resolution order. The trailing segment is resolved against declared triggers[].source values first, then falls back to a url_secret lookup. So POST /v1/webhooks/zmb_2041/github routes to the github source when the agent declares one, and only searches url_secret values for a constant-time match on the literal string github when no such source is declared. Practical consequence: avoid choosing a url_secret value that collides with any triggers[].source on the same agent — the source wins and the secret will never match.