Webhooks
Receive events from external services (GitHub, GitLab, Stripe, etc.) and react with AI agents, notifications, or tool executions.
How It Works
Section titled “How It Works”External Service ──POST──▶ /api/webhooks/:path ──▶ Hook Manager ──▶ Action (HMAC verified) (match by path)- An external service sends an HTTP POST to
https://<your-domain>/api/webhooks/<path> - The webhook endpoint verifies the HMAC-SHA256 signature, over the raw request bytes, against each matching hook’s
webhookSecret - The endpoint answers
202 Acceptedstraight away; every hook whose signature verified is then triggered in the background - Each hook’s action runs — spawn an agent, send a notification, call another webhook, etc.
- If
notifyOwner: trueis set on aspawn_agentaction, the agent’s output is automatically sent to the owner’s linked channels (Telegram, etc.)
Responses and Delivery Semantics
Section titled “Responses and Delivery Semantics”Both webhook endpoints, POST /api/webhooks/:path and POST /api/hooks/incoming/:hookId, authenticate the request, answer at once, and run the hook actions in the background. A spawn_agent action can take far longer than a sender waits: GitHub gives up after about 10 seconds and redelivers, which used to start a second agent run.
| Status | Body | Meaning |
|---|---|---|
202 | /api/webhooks/:path: { "received": true, "accepted": true, "hooks": <n> }/api/hooks/incoming/:id: { "status": "accepted", "accepted": true, "hookId", "hookName", "hooks": 1 } | Authenticated and queued. hooks is how many hooks will run. |
200 | { "received": true, "duplicate": true } (incoming: { "status": "duplicate", "duplicate": true, ... }) | This delivery id was already seen for this hook; nothing fired. |
401 | { "error": ... } | Missing or invalid signature / secret. |
404 | { "error": ... } | No hook for this path (or id). |
503 | { "error": ... } + Retry-After: 30 | The run queue is over its bound (overload only, see below); the delivery was not accepted. Also returned while the server is shutting down. |
Removed response fields. /api/hooks/incoming/:hookId used to wait for the action and return status: "processed", executed, succeeded, failed and a per-hook results array. The action hasn’t run when the response goes out, so those fields are gone, and an action failure no longer turns into a 500.
Where to see results. Every run, including failures, is recorded in the hook’s execution log:
curl http://localhost:3005/api/hooks/<hook-id>/executions \ -H "Authorization: Bearer $OCTIPUS_API_TOKEN" | jq '.executions[0]'A failure before the action started (for example, the database was unavailable) is logged there too, with status: "error".
Idempotency (duplicate deliveries)
Section titled “Idempotency (duplicate deliveries)”If the sender includes a delivery id, it is remembered per hook, and a repeat is answered 200 {duplicate: true} without firing. The headers are read in this order: X-GitHub-Delivery, X-Gitea-Delivery, X-Gogs-Delivery, X-Gitlab-Event-UUID, X-Gitlab-Webhook-UUID, svix-id, webhook-id, Idempotency-Key and X-Idempotency-Key. Generic tracing ids such as X-Request-Id are not used.
- The id is claimed only after the signature verifies, so an unauthenticated caller can’t use up ids.
- While the delivery is queued or running, the claim is marked
in_progresswith a 15-minute window. If the process dies, a redelivery can run again after that. - Once an action has run, successfully or not, the claim lasts 24 hours. A redelivery, including GitHub’s manual Redeliver, is dropped then, because the action’s side effects may already have happened. To re-run it, send the delivery without its id, or use Hooks → Test.
- If no action ran (conditions didn’t match, cooldown,
maxExecutionsreached, or an error before the action), the claim is released, so a redelivery can try again. - Claims live in the shared key-value store, so dedupe works across API processes. If the store is unreachable, dedupe falls back to a per-process cache.
Run queue and limits
Section titled “Run queue and limits”Accepted deliveries are never turned away for being busy. GitHub, GitLab and Gitea don’t retry a failed delivery on their own, so a rejection would lose the event. Instead, each accepted delivery claims its id and joins a queue. It runs when a slot frees up, within these limits:
| Limit | Value | Behaviour |
|---|---|---|
| Running per hook | 2 | Further deliveries for that hook wait, first in, first out. |
| Running per user (hook owner) | 4 | Across all of that user’s hooks. |
| Running in total | 20 | A free slot goes to the user with the fewest runs in progress, round-robin on ties, so one user’s backlog can’t hold every slot. |
| Queued per hook | 50 | Past this bound the endpoint answers 503 + Retry-After. |
| Queued per user | 200 | Past this bound the endpoint answers 503 + Retry-After. |
A 503 means the delivery was not accepted: the endpoint claims no delivery id before sending it, and releases any id it did claim, so a retry of that delivery is treated as new.
When events can be lost. Providers that don’t retry automatically (GitHub, GitLab and Gitea among them) lose an event in two cases:
- Overload: a queue is over its bound, and the delivery is answered
503. - Shutdown or restart: deliveries still queued are dropped (see below), and deliveries arriving during shutdown get
503. A crash also loses queued deliveries.
In both cases the delivery id is not left claimed, so redelivering it from the provider’s delivery log (GitHub: Recent Deliveries → Redeliver) runs it.
A queued delivery keeps its claim in_progress, so a redelivery while it waits is a duplicate. Every 5 minutes the server renews the claims of all queued deliveries, and it renews once more when a run starts, so a long queue can’t let a claim lapse. A renewal only extends this delivery’s own claim. If the claim was taken over in the meantime (another process ran the delivery and marked it done, or a redelivery claimed it after a crash), the queued run is dropped without running, and the other claim is left untouched.
Cooldown and maxExecutions are enforced when a run starts: the run is reserved in the database first, so concurrent deliveries can’t slip past them. The limits are per API process.
Shutdown. Queued runs that haven’t started are dropped and their delivery ids released, so they run only if they are redelivered, manually or by a sender that retries. Running actions get up to 1 second to finish. New deliveries during shutdown get 503, including one that was mid-way through being accepted when shutdown began.
Prerequisites
Section titled “Prerequisites”Before setting up webhooks, you need:
1. Public URL
Section titled “1. Public URL”The Octipus backend must be reachable from the internet. Options:
-
Cloudflare Tunnel (recommended) — add an ingress rule in your tunnel config:
cloudflare/config.yml ingress:- hostname: app.your-domain.comservice: http://localhost:3005Then add a CNAME DNS record:
app→<tunnel-id>.cfargotunnel.com(proxied). -
ngrok — quick dev setup:
ngrok http 3005 -
Reverse proxy — nginx/caddy with TLS termination pointing to port 3005
2. Channel Bindings (for notifications)
Section titled “2. Channel Bindings (for notifications)”If you want Octipus to notify you about webhook events:
- Link your Telegram account in Settings → Channels (or via the
/linkcommand in Telegram) - Verify the binding — the channel must show as “verified” in your user profile
- Set
notifyOwner: truein the hook’s action config
3. Webhook Secret
Section titled “3. Webhook Secret”Generate a random secret for HMAC signature verification:
openssl rand -hex 20Both sides (Octipus hook and the external service) must share this secret. Never skip this — hooks without a webhookSecret are rejected with 401.
Setup Guide: GitHub
Section titled “Setup Guide: GitHub”Step 1: Create the Hook
Section titled “Step 1: Create the Hook”Via MCP (Claude Code / Antigravity):
Use octipus_create_recurring_task: name: "GitHub Notifications" trigger: "webhook" trigger_config: '{"webhookPath": "github", "webhookSecret": "<your-secret>"}' action: "spawn_agent" action_config: '{"agentPrompt": "A GitHub webhook event was received. Analyze the payload and produce a concise summary. For PRs: title, author, action, URL. For issues: title, author, labels. For pushes: branch, commits, author. Format with emoji prefixes.", "agentTopic": "communication", "orchestrated": false, "notifyOwner": true}'Via API:
curl -X POST http://localhost:3005/api/hooks \ -H "Authorization: Bearer $OCTIPUS_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "GitHub Notifications", "trigger": "webhook", "triggerConfig": { "webhookPath": "github", "webhookSecret": "<your-secret>" }, "action": "spawn_agent", "actionConfig": { "agentPrompt": "A GitHub webhook event was received. Analyze the payload and produce a concise summary.", "agentTopic": "communication", "orchestrated": false, "notifyOwner": true } }'Via UI: Go to Hooks → Create Hook and fill in the webhook trigger fields.
Step 2: Configure GitHub
Section titled “Step 2: Configure GitHub”Option A: GitHub CLI
gh api repos/OWNER/REPO/hooks -X POST --input - <<'EOF'{ "name": "web", "active": true, "events": ["pull_request", "issues", "push"], "config": { "url": "https://app.your-domain.com/api/webhooks/github", "content_type": "json", "secret": "<your-secret>", "insecure_ssl": "0" }}EOFOption B: GitHub UI
- Go to repo Settings → Webhooks → Add webhook
- Payload URL:
https://app.your-domain.com/api/webhooks/github - Content type:
application/json - Secret: the same secret from Step 1
- Events: select “Pull requests”, “Issues”, “Pushes” (or “Send me everything”)
Step 3: Verify
Section titled “Step 3: Verify”GitHub sends a ping event immediately after creating the webhook. Check:
# GitHub delivery historygh api repos/OWNER/REPO/hooks/<hook-id>/deliveries \ --jq '.[0] | "\(.event) → \(.status_code)"'
# Octipus execution logcurl http://localhost:3005/api/hooks/<hook-id>/executions \ -H "Authorization: Bearer $OCTIPUS_API_TOKEN" | jq '.executions[0]'Setup Guide: GitLab
Section titled “Setup Guide: GitLab”Step 1: Create the Hook
Section titled “Step 1: Create the Hook”Same as GitHub, but use a different webhookPath:
{ "triggerConfig": { "webhookPath": "gitlab", "webhookSecret": "<your-secret>" }}Step 2: Configure GitLab
Section titled “Step 2: Configure GitLab”- Go to project Settings → Webhooks
- URL:
https://app.your-domain.com/api/webhooks/gitlab - Secret token: your webhook secret
- Triggers: Push events, Merge request events, etc.
Note: GitLab uses X-Gitlab-Token header (not HMAC). The current webhook endpoint verifies via X-Hub-Signature-256 (GitHub-style HMAC). For GitLab, you may need to add a signature verification adapter or use the webhook without HMAC and rely on the secret token header.
Setup Guide: Generic Webhook
Section titled “Setup Guide: Generic Webhook”Any service that sends JSON POST requests with HMAC-SHA256 signatures works out of the box.
Signature Format
Section titled “Signature Format”The sender must include an X-Hub-Signature-256 header:
X-Hub-Signature-256: sha256=<hex-encoded HMAC-SHA256 of raw request body>Example: Manual Test
Section titled “Example: Manual Test”PAYLOAD='{"event": "deploy", "status": "success", "environment": "production"}'SECRET="your-webhook-secret"SIG="sha256=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -X POST https://app.your-domain.com/api/webhooks/deploy \ -H "Content-Type: application/json" \ -H "X-Hub-Signature-256: $SIG" \ -d "$PAYLOAD"Action Types for Webhooks
Section titled “Action Types for Webhooks”spawn_agent (AI analysis + notification)
Section titled “spawn_agent (AI analysis + notification)”Best for: events that need interpretation before notification.
{ "action": "spawn_agent", "actionConfig": { "agentPrompt": "Analyze this deployment event and summarize the status.", "agentTopic": "devops", "orchestrated": false, "notifyOwner": true }}orchestrated: false— spawns a single agent directly (faster)orchestrated: true— routes through the root agent for role classification and tool accessnotifyOwner: true— automatically sends the agent’s output to all verified channels when the agent completes
notify (direct notification)
Section titled “notify (direct notification)”Best for: simple alerts that don’t need AI processing.
{ "action": "notify", "actionConfig": { "notifyOwner": true, "notifyMessage": "Webhook received: {{webhook.body.event}} — {{webhook.body.status}}" }}webhook (forward/chain)
Section titled “webhook (forward/chain)”Best for: forwarding events to other services.
{ "action": "webhook", "actionConfig": { "webhookUrl": "https://other-service.com/api/events", "webhookMethod": "POST" }}execute_tool (run a tool directly)
Section titled “execute_tool (run a tool directly)”Best for: automated responses like running a git pull or executing a shell command.
{ "action": "execute_tool", "actionConfig": { "toolId": "shell", "toolAction": "execute", "toolParams": { "command": "cd /app && git pull origin main" } }}Webhook Context in Agent Prompts
Section titled “Webhook Context in Agent Prompts”When a webhook triggers a spawn_agent action, the webhook payload is automatically appended to the agent prompt:
<your prompt text>
--- Webhook Payload ---{ "action": "opened", "pull_request": { ... } }Event type: pull_requestThe Event type line is extracted from the X-GitHub-Event or X-GitLab-Event header when present.
You can also use template variables in prompts:
| Variable | Description |
|---|---|
{{webhook.path}} | The webhook path (e.g., “github”) |
{{webhook.method}} | HTTP method (always POST) |
{{webhook.body.*}} | Any field from the JSON payload |
Security
Section titled “Security”- HMAC-SHA256 required — every webhook hook must have a
webhookSecret. Hooks without one reject all requests with 401. - Signature verification — uses
X-Hub-Signature-256header with timing-safe comparison, computed over the raw request bytes (not re-serialised JSON). - Public endpoint —
/api/webhooks/*is excluded from bearer token auth (it uses HMAC instead). - Per-hook secrets — each hook can have a different secret, so different services get different credentials.
Troubleshooting
Section titled “Troubleshooting”| Problem | Check |
|---|---|
| 401 on webhook | Verify the secret matches on both sides. Check X-Hub-Signature-256 header is present. |
| 404 on webhook | Ensure the URL path matches webhookPath in trigger config (e.g., /api/webhooks/github needs webhookPath: "github"). |
| Hook not firing | Check hook is enabled. Verify webhookPath matches. Look at execution log for skipped entries. |
| 202 but nothing happened | The action runs after the response; check the hook’s execution log for an error entry. |
200 duplicate: true | The delivery id was already processed for this hook (see Idempotency). Send without the id to force a re-run. |
| 503 on webhook | The run queue is over its bound (50 queued per hook, 200 per user), or the server is shutting down. The delivery was not accepted; redeliver it after Retry-After. |
| 202 but the action runs late | The delivery is queued behind other runs of the same hook (2 at a time) or the same user (4 at a time). |
| Agent runs but no notification | Ensure notifyOwner: true is in action config. Verify channel bindings are verified in Settings → Channels. |
| Agent doesn’t see payload | The payload is auto-appended to the prompt. Check execution log → trigger context for the raw data. |
| 502 from tunnel | Verify the backend is running on port 3005. Check cloudflared/tunnel config has the correct ingress rule. |
Multiple Webhooks
Section titled “Multiple Webhooks”You can create multiple hooks with different paths for different services:
/api/webhooks/github → GitHub events/api/webhooks/gitlab → GitLab events/api/webhooks/stripe → Payment events/api/webhooks/deploy → CI/CD deploy notifications/api/webhooks/monitoring → Alerting systemsEach path can have its own secret and action configuration.