Skip to content

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:

  1. Validates the HMAC signature against the webhook’s secret
  2. Spawns a new session on the webhook’s assigned runner
  3. 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.


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

Webhooks are managed from the Webhooks tab in the runner detail view of the web UI.

  1. Open your runner in the PizzaPi web interface and switch to the Webhooks tab.

  2. Click New Webhook to expand the creation form.

  3. 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 to custom.
    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 whose type field matches one of these values. Non-matching events return 200 OK with "filtered": true and do not spawn a session.
  4. Click Create. PizzaPi generates a unique webhook ID and a random HMAC secret (64-character hex string, 32 bytes of entropy).

  5. Copy the secret immediately. It’s shown in a banner after creation. You’ll need it to sign requests.


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 return 404 on fire attempts.
  • eventFilter — When non-empty, the type field 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 with provider (e.g. "anthropic") and id (e.g. "claude-sonnet-4-20250514"). Set to null to use the runner default.

The fire endpoint does not require session cookie authentication — it’s validated entirely via HMAC-SHA256.

POST /api/webhooks/:id/fire

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.

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"
}

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.


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.

The signature is an HMAC-SHA256 hex digest of ${timestamp}.${nonce}.${rawBody}.

Terminal window
# Set your variables
WEBHOOK_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.x
SIG=$(echo -n "$TIMESTAMP.$NONCE.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
# Fire the webhook
curl -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"

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.

Terminal window
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"

This workflow fires a PizzaPi webhook when a CI build fails, so the agent can investigate and propose a fix.

.github/workflows/notify-pizzapi.yml
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:

  1. 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
  2. Copy the fire URL and secret from the expanded webhook in the UI.

  3. In your GitHub repo, go to Settings → Secrets and variables → Actions and add:

    • PIZZAPI_WEBHOOK_URL — the fire URL
    • PIZZAPI_WEBHOOK_SECRET — the HMAC secret
  4. Add the workflow file above to your repo.


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.

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:

Terminal window
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."}'

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.


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

Send only the fields you want to change:

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


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

PizzaPi doesn’t currently have a one-click secret rotation in the UI. To rotate a secret:

  1. Create a new webhook with the same configuration (name, source, prompt, cwd, model, event filter).
  2. Update your external service (GitHub Actions, etc.) to use the new webhook URL and secret.
  3. Verify the new webhook fires successfully.
  4. Disable the old webhook (toggle it off), then delete it once you’re confident the new one works.
  • 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 (not 403), so attackers cannot distinguish disabled webhooks from non-existent ones.

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.


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.