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 (recommended — includes per-process 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 within ±5 minutes of server time. |
X-Webhook-Nonce | Yes | Unique string per delivery (e.g. random hex). Prevents replay within the 5-minute window (per server process; multi-node deployments need shared storage). |
Legacy mode (backward compatible — no replay protection):
| Header | Required | Description |
|---|---|---|
Content-Type | Yes | Must be application/json |
X-Webhook-Signature | Yes | HMAC-SHA256 hex digest of the raw request body only |
Legacy mode is supported for callers that don’t send X-Webhook-Timestamp or X-Webhook-Nonce. Enhanced mode is strongly recommended for new integrations.
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 (session spawned and trigger delivered):
{ "ok": true, "triggerId": "wh_a1b2c3d4e5f6g7h8", "sessionId": "uuid" }Filtered (event type didn’t match the filter — no session spawned):
{ "ok": true, "filtered": true }Errors:
| Status | Meaning |
|---|---|
401 | Missing/invalid signature headers, or signature doesn’t match, or timestamp too old/future |
409 | Nonce already used within the 5-minute replay window (enhanced mode only) |
400 | Invalid JSON body |
404 | Webhook not found or disabled |
500 | Webhook has no runner assigned |
502 | Runner rejected the spawn request or is unreachable |
503 | Runner is offline or not connected to the server |
504 | Session was spawned but didn’t connect within the timeout (~15s) |
Computing the HMAC signature
Section titled “Computing the HMAC signature”PizzaPi supports two signing modes. Enhanced mode (recommended) adds a timestamp and nonce to the HMAC input, providing replay protection. Legacy mode signs the raw body only and is supported for backward compatibility with existing integrations.
Enhanced mode (recommended)
Section titled “Enhanced mode (recommended)”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 has no replay protection but is still accepted for callers that pre-date the enhanced format.
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 on a webhook row. You’ll see a “Sure?” confirmation button (auto-dismisses after 3 seconds). 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 must have a runner assigned (runnerId) to fire successfully. If no runner is assigned, fire attempts return 500. When creating webhooks from the runner detail view in the UI, the runner is automatically assigned.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
401 Invalid 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 Missing X-Webhook-Timestamp | Using enhanced mode but forgot the timestamp header. Either add it (recommended) or omit both X-Webhook-Timestamp and X-Webhook-Nonce entirely to use legacy mode. |
401 Webhook timestamp is too old | The timestamp in X-Webhook-Timestamp is more than 5 minutes from server time. 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 Webhook has no runner assigned | The webhook’s runnerId is null. Update it via the API or recreate from the runner detail view. |
503 Runner is offline | The assigned runner has disconnected. Start the runner daemon or check connectivity. |
504 Session did not connect in time | The session was spawned on the runner but didn’t register with the server within ~15 seconds. Check runner logs for errors. |
| 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. |