Webhooks
Webhooks let external services — CI/CD pipelines, chatops bots, cron jobs, monitoring alerts — automatically spawn a fresh agent session and deliver a JSON payload into it. Each webhook has an HMAC-SHA256 secret so only authorized callers can fire it.
When a webhook fires, PizzaPi:
- Validates the HMAC signature against the webhook’s secret
- Spawns a new session on the webhook’s assigned runner
- Delivers the request body as a trigger into that session
The agent receives the payload and can act on it immediately — run tests, triage an alert, process a deployment notification, or anything else you configure via the webhook’s prompt.
When to use webhooks
Section titled “When to use webhooks”- CI/CD integration — GitHub Actions, GitLab CI, or Jenkins fires a webhook after a failed build; the agent investigates and opens a fix PR.
- ChatOps — A Slack bot forwards a message to PizzaPi; the agent processes it and responds.
- Scheduled tasks — A cron job fires a webhook periodically to run maintenance scripts.
- Monitoring — An alerting system (PagerDuty, Datadog) triggers an agent to diagnose an incident.
Creating a webhook
Section titled “Creating a webhook”Webhooks are managed from the Webhooks tab in the runner detail view of the web UI.
-
Open your runner in the PizzaPi web interface and switch to the Webhooks tab.
-
Click New Webhook to expand the creation form.
-
Fill in the fields:
Field Required Description Name Yes A human-readable label (e.g. “GitHub Deploy Hook”). Source Yes An identifier for the origin system (e.g. github,slack,cron,custom). Defaults tocustom.Project No Working directory ( cwd) for spawned sessions. You can pick from recent projects or type a path.Prompt No Initial instructions sent to the spawned session. This is where you tell the agent what to do with the payload. Model No Override the model for spawned sessions ( provider/id). If empty, the runner’s default model is used. If available models are detected from the runner, a dropdown is shown.Event Filter No Comma-separated list of event types (e.g. deploy, build). When set, the webhook only fires for payloads whosetypefield matches one of these values. Non-matching events return200 OKwith"filtered": trueand do not spawn a session. -
Click Create. PizzaPi generates a unique webhook ID and a random HMAC secret (64-character hex string, 32 bytes of entropy).
-
Copy the secret immediately. It’s shown in a banner after creation. You’ll need it to sign requests.
Webhook fields reference
Section titled “Webhook fields reference”Each webhook object contains:
{ "id": "uuid", "userId": "owner-user-id", "name": "My Webhook", "secret": "64-char-hex-hmac-secret", "source": "github", "runnerId": "runner-uuid", "cwd": "/path/to/project", "prompt": "Process the incoming webhook event.", "model": { "provider": "anthropic", "id": "claude-sonnet-4-20250514" }, "eventFilter": ["push", "pull_request"], "enabled": true, "createdAt": "2025-01-15T10:30:00.000Z", "updatedAt": "2025-01-15T10:30:00.000Z"}enabled— Toggle a webhook on/off from the UI without deleting it. Disabled webhooks return404on fire attempts.eventFilter— When non-empty, thetypefield of the incoming JSON body is checked against this list. Unmatched events are silently accepted (HTTP 200) but don’t spawn a session.model— An object withprovider(e.g."anthropic") andid(e.g."claude-sonnet-4-20250514"). Set tonullto use the runner default.
Firing a webhook
Section titled “Firing a webhook”The fire endpoint does not require session cookie authentication — it’s validated entirely via HMAC-SHA256.
Endpoint
Section titled “Endpoint”POST /api/webhooks/:id/fireHeaders
Section titled “Headers”Enhanced mode (required by default — includes replay protection):
| Header | Required | Description |
|---|---|---|
Content-Type |
Yes | Must be application/json |
X-Webhook-Signature |
Yes | HMAC-SHA256 hex digest of ${timestamp}.${nonce}.${rawBody} |
X-Webhook-Timestamp |
Yes | ISO 8601 / RFC 3339 timestamp (e.g. 2025-01-15T10:30:00Z). Must be no more than 30 seconds in the future or 5 minutes in the past of server time. |
X-Webhook-Nonce |
Yes | Unique string per delivery (e.g. random hex). Redis-backed replay protection (in-memory fallback if Redis is unavailable); multi-node deployments sharing Redis are covered automatically. |
Legacy mode (deprecated — disabled by default):
| Header | Required | Description |
|---|---|---|
Content-Type |
Yes | Must be application/json |
X-Webhook-Signature |
Yes | HMAC-SHA256 hex digest of the raw request body only |
A body-only signature has no freshness, so anyone who captures one request can
replay it indefinitely. The relay therefore rejects requests without both
X-Webhook-Timestamp and X-Webhook-Nonce (401). A request that sends only
one of the two headers is also rejected; it is never downgraded to legacy mode.
To keep an old integration working while you migrate it, set
PIZZAPI_WEBHOOK_ALLOW_LEGACY_SIGNATURES=true on the relay. Legacy requests are
then accepted, logged as deprecated, and deduplicated per webhook for 24 hours:
a second request with an identical signed body in that window returns 409.
An attempt that fails in a retryable way (403 runner unavailable, 500, or
503) releases its deduplication record, so retrying the identical request
works once the runner or route recovers. A
caller that legitimately repeats the same body must add a unique field (such as
a delivery ID) to the body. The deduplication record expires after 24 hours, so
legacy mode still allows a captured request to be replayed later. Migrate to
enhanced mode and then remove the variable.
Request body
Section titled “Request body”Any valid JSON object. If you’re using event filters, include a type field:
{ "type": "deploy", "repo": "myorg/myapp", "branch": "main", "commit": "abc123", "status": "failure", "url": "https://github.com/myorg/myapp/actions/runs/12345"}Response
Section titled “Response”Success (event delivered): the response includes ok, eventId, sessionIds, and spawnedSessions. A webhook may deliver to one or more configured routes; this is not a single triggerId/sessionId contract.
{ "ok": true, "eventId": "evt_…", "sessionIds": ["…"], "spawnedSessions": []}Filtered (event type did not match the configured filter):
{ "ok": true, "filtered": true }Errors: invalid signatures, missing or partial timestamp/nonce headers, and disabled legacy signatures return 401; replayed enhanced-mode nonces and duplicate legacy bodies return 409; invalid JSON returns 400; missing/disabled webhooks return 404; an unassigned runner without a route returns 500; unavailable or failed delivery returns 503. Exact error messages are generated by the current server handler.
Computing the HMAC signature
Section titled “Computing the HMAC signature”PizzaPi supports two signing modes. Enhanced mode (required by default) adds a timestamp and nonce to the HMAC input, providing replay protection. Legacy mode signs the raw body only; it is deprecated and accepted only when the relay sets PIZZAPI_WEBHOOK_ALLOW_LEGACY_SIGNATURES=true.
Enhanced mode (required by default)
Section titled “Enhanced mode (required by default)”The signature is an HMAC-SHA256 hex digest of ${timestamp}.${nonce}.${rawBody}.
# Set your variablesWEBHOOK_URL="https://pizza.example.com/api/webhooks/YOUR_WEBHOOK_ID/fire"SECRET="your-64-char-hex-secret"BODY='{"type":"deploy","repo":"myorg/myapp","status":"failure"}'TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")NONCE=$(openssl rand -hex 16)
# Compute HMAC-SHA256 over timestamp.nonce.body# Works with both LibreSSL (macOS default) and OpenSSL 3.xSIG=$(echo -n "$TIMESTAMP.$NONCE.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
# Fire the webhookcurl -X POST "$WEBHOOK_URL" \ -H "Content-Type: application/json" \ -H "X-Webhook-Signature: $SIG" \ -H "X-Webhook-Timestamp: $TIMESTAMP" \ -H "X-Webhook-Nonce: $NONCE" \ -d "$BODY"import { createHmac, randomBytes } from "crypto";
const secret = "your-64-char-hex-secret";const body = JSON.stringify({ type: "deploy", repo: "myorg/myapp", status: "failure" });const timestamp = new Date().toISOString();const nonce = randomBytes(16).toString("hex");const signature = createHmac("sha256", secret) .update(`${timestamp}.${nonce}.${body}`) .digest("hex");
const res = await fetch("https://pizza.example.com/api/webhooks/YOUR_WEBHOOK_ID/fire", { method: "POST", headers: { "Content-Type": "application/json", "X-Webhook-Signature": signature, "X-Webhook-Timestamp": timestamp, "X-Webhook-Nonce": nonce, }, body,});console.log(await res.json());import hmac, hashlib, json, secrets, requestsfrom datetime import datetime, timezone
secret_key = "your-64-char-hex-secret"body = json.dumps({"type": "deploy", "repo": "myorg/myapp", "status": "failure"})timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")nonce = secrets.token_hex(16)signature = hmac.new( secret_key.encode(), f"{timestamp}.{nonce}.{body}".encode(), hashlib.sha256,).hexdigest()
res = requests.post( "https://pizza.example.com/api/webhooks/YOUR_WEBHOOK_ID/fire", headers={ "Content-Type": "application/json", "X-Webhook-Signature": signature, "X-Webhook-Timestamp": timestamp, "X-Webhook-Nonce": nonce, }, data=body,)print(res.json())Legacy mode (backward compatible)
Section titled “Legacy mode (backward compatible)”The signature is an HMAC-SHA256 hex digest of the raw request body only. No timestamp or nonce headers are sent. This mode is rejected unless the relay sets PIZZAPI_WEBHOOK_ALLOW_LEGACY_SIGNATURES=true; even then, it only deduplicates identical bodies for 24 hours and provides no freshness guarantee.
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -X POST "$WEBHOOK_URL" \ -H "Content-Type: application/json" \ -H "X-Webhook-Signature: $SIG" \ -d "$BODY"Example: GitHub Actions → PizzaPi
Section titled “Example: GitHub Actions → PizzaPi”This workflow fires a PizzaPi webhook when a CI build fails, so the agent can investigate and propose a fix.
name: Notify PizzaPi on failure
on: workflow_run: workflows: ["CI"] types: [completed]
jobs: notify: if: ${{ github.event.workflow_run.conclusion == 'failure' }} runs-on: ubuntu-latest steps: - name: Fire PizzaPi webhook env: WEBHOOK_URL: ${{ secrets.PIZZAPI_WEBHOOK_URL }} WEBHOOK_SECRET: ${{ secrets.PIZZAPI_WEBHOOK_SECRET }} run: | BODY=$(jq -n \ --arg type "ci_failure" \ --arg repo "${{ github.repository }}" \ --arg branch "${{ github.event.workflow_run.head_branch }}" \ --arg commit "${{ github.event.workflow_run.head_sha }}" \ --arg run_url "${{ github.event.workflow_run.html_url }}" \ '{type: $type, repo: $repo, branch: $branch, commit: $commit, run_url: $run_url}')
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") NONCE=$(openssl rand -hex 16) SIG=$(echo -n "$TIMESTAMP.$NONCE.$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.*= //')
curl -sf -X POST "$WEBHOOK_URL" \ -H "Content-Type: application/json" \ -H "X-Webhook-Signature: $SIG" \ -H "X-Webhook-Timestamp: $TIMESTAMP" \ -H "X-Webhook-Nonce: $NONCE" \ -d "$BODY"Setup steps:
-
Create a webhook in PizzaPi with:
- Name:
GitHub CI Failure - Source:
github - Project: path to the repo on your runner
- Prompt:
A CI build failed. The trigger payload has the repo, branch, commit, and run URL. Clone the repo if needed, check out the failing branch, read the CI logs at the run URL, diagnose the failure, and open a PR with a fix. - Event Filter:
ci_failure
- Name:
-
Copy the fire URL and secret from the expanded webhook in the UI.
-
In your GitHub repo, go to Settings → Secrets and variables → Actions and add:
PIZZAPI_WEBHOOK_URL— the fire URLPIZZAPI_WEBHOOK_SECRET— the HMAC secret
-
Add the workflow file above to your repo.
Managing webhooks
Section titled “Managing webhooks”Enable / disable
Section titled “Enable / disable”Each webhook has a toggle switch in the UI. Disabled webhooks return 404 on fire attempts — the caller sees no difference from a non-existent webhook, which avoids leaking information about your webhook IDs.
Editing
Section titled “Editing”Click the expand arrow (chevron) on any webhook row to see its details. Currently, webhooks can be toggled on/off via the UI. To change other fields (name, source, prompt, cwd, model, event filter), use the REST API:
curl -X PUT "https://pizza.example.com/api/webhooks/WEBHOOK_ID" \ -H "Content-Type: application/json" \ -H "Cookie: your-session-cookie" \ -d '{"prompt": "Updated instructions for the agent."}'Deleting
Section titled “Deleting”Click the trash icon to open a confirmation dialog, then click Delete webhook to confirm. Deletion is permanent — the webhook ID and secret are gone forever.
REST API reference
Section titled “REST API reference”All CRUD endpoints require session cookie authentication (i.e., you must be logged into the PizzaPi web UI). The fire endpoint uses HMAC authentication instead.
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/webhooks |
Create a new webhook |
GET |
/api/webhooks |
List all your webhooks |
GET |
/api/webhooks/:id |
Get a single webhook |
PUT |
/api/webhooks/:id |
Update webhook fields |
DELETE |
/api/webhooks/:id |
Delete a webhook |
POST |
/api/webhooks/:id/fire |
Fire webhook (HMAC auth) |
Create webhook
Section titled “Create webhook”curl -X POST "https://pizza.example.com/api/webhooks" \ -H "Content-Type: application/json" \ -H "Cookie: your-session-cookie" \ -d '{ "name": "Deploy Hook", "source": "github", "runnerId": "runner-uuid", "cwd": "/Users/me/projects/myapp", "prompt": "Process the deploy event.", "model": { "provider": "anthropic", "id": "claude-sonnet-4-20250514" }, "eventFilter": ["deploy"] }'Required fields: name, source. All other fields are optional.
The response includes the full webhook object with the generated id and secret.
Update webhook
Section titled “Update webhook”Send only the fields you want to change:
curl -X PUT "https://pizza.example.com/api/webhooks/WEBHOOK_ID" \ -H "Content-Type: application/json" \ -H "Cookie: your-session-cookie" \ -d '{"enabled": false, "prompt": "New instructions."}'Set a field to null to clear it (e.g., "model": null to revert to the runner default).
Security guidance
Section titled “Security guidance”Protect your secrets
Section titled “Protect your secrets”- Store webhook secrets in your CI/CD platform’s secret manager (GitHub Actions Secrets, GitLab CI Variables, etc.). Never hardcode them in workflow files or commit them to a repository.
- Use HTTPS for your PizzaPi server. HMAC authenticates the payload but doesn’t encrypt it — without TLS, the payload and signature are visible to network observers.
Secret rotation
Section titled “Secret rotation”PizzaPi doesn’t currently have a one-click secret rotation in the UI. To rotate a secret:
- Create a new webhook with the same configuration (name, source, prompt, cwd, model, event filter).
- Update your external service (GitHub Actions, etc.) to use the new webhook URL and secret.
- Verify the new webhook fires successfully.
- Disable the old webhook (toggle it off), then delete it once you’re confident the new one works.
Signature verification details
Section titled “Signature verification details”- The server uses timing-safe comparison (
crypto.timingSafeEqual) to prevent timing side-channel attacks. - The HMAC is computed over the raw request body bytes, not a parsed-and-re-serialized version. Your client must sign the exact bytes it sends.
- Disabled webhooks return
404(not403), so attackers cannot distinguish disabled webhooks from non-existent ones.
Runner assignment
Section titled “Runner assignment”A webhook with a runnerId uses its config-backed spawn route. If no runner is assigned, it can still publish through a matching user-owned route; if there is no route configured for the webhook event, the fire request returns 500.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
401 Missing/invalid X-Webhook-Signature |
Secret mismatch, or the body was modified between signing and sending (e.g., re-serialized by a proxy). Sign the exact bytes you send. In enhanced mode the signed string is ${timestamp}.${nonce}.${rawBody}. |
401 Timestamp too old or future |
The timestamp in X-Webhook-Timestamp is more than 30s in the future or more than 5 minutes in the past. Check system clock synchronization. |
409 Webhook nonce has already been used |
A replay was detected — the same nonce was used for a successful delivery within the last 5 minutes. Generate a fresh nonce per request. |
404 Webhook not found |
Webhook ID is wrong, webhook was deleted, or webhook is disabled. |
500 |
No runner is assigned and no matching route is configured for this webhook event. |
503 |
No route delivered the event (for example, the runner is offline or rejected the spawn); retry after checking runner connectivity. |
| Session spawns but agent does nothing | The webhook’s prompt field is empty, so the agent has no instructions. Set a prompt that tells the agent what to do with the payload. |