Webhooks
Trigger pipeline execution via HTTP webhooks. Pipelines can read the incoming request and optionally send an HTTP response while continuing to execute in the background.
Setup
1. Create a pipeline with a webhook secret
pocketci set-pipeline my-pipeline.ts \
--server http://localhost:8080 \
--webhook-secret "my-secret-key"The webhook secret is optional. If omitted, the webhook endpoint accepts all requests without signature validation.
2. Configure the server
pocketci server \
--port 8080 \
--storage sqlite://pocketci.db \
--webhook-timeout 5s # How long to wait for http.respond() (default: 5s)Triggering Pipelines
Send any HTTP method to /api/webhooks/:pipeline_id:
curl -X POST http://localhost:8080/api/webhooks/<pipeline-id> \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: <hmac-sha256-hex>" \
-d '{"event": "push", "ref": "refs/heads/main"}'Signature Validation
When a pipeline has a webhook secret configured, requests must include an HMAC-SHA256 signature of the raw request body.
Via header (preferred):
X-Webhook-Signature: <hex-encoded HMAC-SHA256>Via query parameter (for providers that don't support custom headers):
/api/webhooks/<id>?signature=<hex-encoded HMAC-SHA256>Computing the signature:
# Bash
echo -n '{"event": "push"}' | openssl dgst -sha256 -hmac "my-secret-key" | cut -d' ' -f2# Python
import hmac, hashlib
hmac.new(b"my-secret-key", b'{"event": "push"}', hashlib.sha256).hexdigest()// Node.js
const crypto = require("crypto");
crypto
.createHmac("sha256", "my-secret-key")
.update('{"event": "push"}')
.digest("hex");Webhook Providers
The server automatically detects the incoming webhook provider and verifies its signature. Pipelines receive the detected provider and eventType on the http.request() object.
GitHub
Detected when the request includes an X-GitHub-Event header.
provider:"github"eventType: value ofX-GitHub-Event(e.g."push","pull_request")- Signature header:
X-Hub-Signature-256: sha256=<hex>(HMAC-SHA256) - If a webhook secret is configured and the header is missing or invalid, the request is rejected with
401 Unauthorized.
curl -X POST http://localhost:8080/api/webhooks/<pipeline-id> \
-H "X-GitHub-Event: push" \
-H "X-Hub-Signature-256: sha256=<hex>" \
-d '{"ref":"refs/heads/main"}'Slack
Detected when the request includes an X-Slack-Signature header.
provider:"slack"eventType: top-leveltypefield from the JSON body (e.g."event_callback","url_verification")- Signature:
X-Slack-Signature: v0=<hex>verified againstv0:<X-Slack-Request-Timestamp>:<body>using HMAC-SHA256 - Both
X-Slack-SignatureandX-Slack-Request-Timestampmust be present when a secret is configured.
Generic (fallback)
Used for all other requests that don't match a specific provider.
provider:"generic"eventType:""(empty)- Signature header:
X-Webhook-Signature: <hex-encoded HMAC-SHA256> - Signature query param:
?signature=<hex-encoded HMAC-SHA256>
This is the same behaviour as the original webhook implementation and is compatible with any tool that can send a plain HMAC-SHA256 signature.
JavaScript/TypeScript API
Pipelines access webhook data through the global http object.
http.request()
Returns the incoming HTTP request, or undefined if the pipeline was not triggered via webhook.
interface HttpRequest {
provider: string; // Detected provider: "github", "slack", or "generic"
eventType: string; // Provider-specific event type (e.g. "push", "event_callback")
method: string; // "GET", "POST", etc.
url: string; // Request URL path with query string
headers: Record<string, string>; // Request headers
body: string; // Raw request body
query: Record<string, string>; // Parsed query parameters
}http.respond(response)
Sends an HTTP response back to the webhook caller. The pipeline continues executing after the response is sent.
interface HttpResponse {
status: number; // HTTP status code (default: 200)
body?: string; // Response body
headers?: Record<string, string>; // Response headers
}Key behaviors:
- One-shot: only the first call takes effect; subsequent calls are ignored
- Non-blocking: the pipeline continues running after responding
- No-op when not triggered via webhook
- If
http.respond()is not called within the server's--webhook-timeout, the server returns202 Acceptedwith a JSON body containing therun_id
Examples
Minimal webhook
const pipeline = async () => {
const req = http.request();
if (req) {
http.respond({ status: 200, body: "ok" });
}
// Continue processing...
};
export { pipeline };Read body and respond with data
const pipeline = async () => {
const req = http.request();
if (req) {
const data = JSON.parse(req.body);
http.respond({
status: 200,
body: JSON.stringify({ received: true, keys: Object.keys(data) }),
headers: { "Content-Type": "application/json" },
});
}
// Run containers in the background after responding
await runtime.run({
name: "process",
image: "alpine",
command: { path: "echo", args: ["processing webhook"] },
});
};
export { pipeline };GitHub webhook
const pipeline = async () => {
const req = http.request();
if (!req) return;
// req.provider === "github", req.eventType === "push" | "pull_request" | ...
http.respond({
status: 200,
body: JSON.stringify({ accepted: true, event: req.eventType }),
headers: { "Content-Type": "application/json" },
});
const payload = JSON.parse(req.body);
if (req.eventType === "push") {
await runtime.run({
name: "build",
image: "golang:1.22",
command: { path: "go", args: ["build", "./..."] },
});
}
};
export { pipeline };Slack webhook
const pipeline = async () => {
const req = http.request();
if (!req) return;
// Handle Slack's url_verification challenge
if (req.eventType === "url_verification") {
const { challenge } = JSON.parse(req.body);
http.respond({
status: 200,
body: JSON.stringify({ challenge }),
headers: { "Content-Type": "application/json" },
});
return;
}
http.respond({ status: 200, body: "ok" });
const payload = JSON.parse(req.body);
console.log("Slack event:", payload.event?.type);
};
export { pipeline };Pipeline that works both ways
const pipeline = async () => {
const req = http.request();
if (req) {
// Triggered via webhook
http.respond({ status: 200, body: "acknowledged" });
console.log(`Webhook: ${req.method} from ${req.headers["User-Agent"]}`);
} else {
// Triggered manually via /api/pipelines/:id/trigger
console.log("Manual trigger");
}
// Same logic regardless of trigger method
await runtime.run({
name: "test",
image: "golang:1.22",
command: { path: "go", args: ["test", "./..."] },
});
};
export { pipeline };Conditional Execution
You can gate a pipeline or individual jobs so they only run when the incoming webhook matches specific criteria, using the expr-lang expression language.
Available variables
| Variable | Type | Description |
|---|---|---|
provider | string | Detected provider: "github", "slack", "generic" |
eventType | string | Provider-specific event type (e.g. "push") |
method | string | HTTP method ("GET", "POST", …) |
headers | map[string]string | Request headers (keys are lowercase) |
query | map[string]string | Parsed query parameters |
body | string | Raw request body |
payload | map[string]any / nil | JSON-decoded body; nil when body is not valid JSON |
webhookTrigger(expression) — JS/TS pipelines
Call webhookTrigger() anywhere in your pipeline. It returns true when:
- the pipeline was not triggered by a webhook (manual runs always pass), or
- the expression evaluates to
trueagainst the current webhook data.
Returns false (and logs an error) if the expression itself is invalid.
const pipeline = async () => {
const req = http.request();
if (req) {
http.respond({ status: 200, body: "acknowledged" });
}
// Only deploy on GitHub push events to main
if (
!webhookTrigger(
'provider == "github" && eventType == "push" && payload.ref == "refs/heads/main"',
)
) {
return;
}
await runtime.run({
name: "deploy",
image: "alpine",
command: { path: "sh", args: ["-c", "echo deploying"] },
});
};
export { pipeline };You can also use payload to drill into nested JSON fields:
// Only act on Slack messages mentioning the bot
if (!webhookTrigger('payload.event.type == "app_mention"')) return;webhook_trigger — YAML (Concourse-compatible) pipelines
Add a webhook_trigger field to any job. The field is an expr-lang boolean expression evaluated against the same variables listed above.
- When the expression returns
false, the job is skipped (status"skipped") and downstream jobs that depend on it still proceed. - When the pipeline is triggered manually (no webhook), the expression is not evaluated — the job always runs.
jobs:
# Runs only when a GitHub push event arrives
- name: build-on-push
webhook_trigger: 'provider == "github" && eventType == "push"'
plan:
- task: build
config:
platform: linux
image_resource:
type: registry-image
source: { repository: golang, tag: "1.22" }
run:
path: go
args: [build, ./...]
# Runs only on pull-request events
- name: test-on-pr
webhook_trigger: 'provider == "github" && eventType == "pull_request"'
plan:
- task: test
config:
platform: linux
image_resource:
type: registry-image
source: { repository: golang, tag: "1.22" }
run:
path: go
args: [test, ./...]
# Always runs (no webhook_trigger)
- name: notify
plan:
- task: send-notification
...Filter on nested JSON payload fields using payload:
jobs:
# Only trigger for pushes to main
- name: deploy
webhook_trigger: 'payload.ref == "refs/heads/main"'
plan:
- task: deploy
...
# Slack: only react to bot mentions
- name: respond-to-mention
webhook_trigger: 'provider == "slack" && payload.event.type == "app_mention"'
plan:
- task: reply
...Response Behavior
| Scenario | HTTP Response |
|---|---|
Pipeline calls http.respond() before timeout | Pipeline's response (status, body, headers) |
Pipeline doesn't call http.respond() in time | 202 Accepted with {"run_id": "..."} |
| Pipeline errors before responding | 202 Accepted (pipeline still ran) |
| No webhook secret, no signature sent | Request accepted (no validation) |
| Webhook secret set, no signature | 401 Unauthorized |
| Webhook secret set, invalid signature | 401 Unauthorized |
Pipeline Context
When triggered via webhook, pipelineContext.triggeredBy is set to "webhook". For manual triggers, it is "manual".
if (pipelineContext.triggeredBy === "webhook") {
// Handle webhook-specific logic
}