Webhooks deliver repository events to an HTTP endpoint you control. Anvil sends each event as a JSON POST signed with HMAC-SHA256 so you can verify it came from Anvil. This page covers creating a webhook, verifying signatures, the retry and circuit-breaker behavior, and the JSON payload for every event type.
Create a webhook
- Open your repository’s Settings → Webhooks on anvil.fangorn.io.
- Enter a Payload URL. The scheme must be
httporhttps. URLs that resolve to loopback, link-local, or private ranges (localhost,localhost.localdomain,*.local,127.0.0.0/8,0.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16, and the IPv6 loopback, unspecified, unique-local, and link-local equivalents) are rejected to prevent SSRF. Anvil also resolves the hostname and rejects it if the resolved address falls in one of these ranges. - Enter a Secret. It must be non-empty. Use a random string of at least 32 characters; you will use it to verify signatures.
- Select which events to subscribe to (see Event types). At least one event is required.
- Leave Active checked. An inactive webhook is kept but receives no deliveries.
- Click Add Webhook.
Each saved webhook appears in the list with its URL, subscribed events, and active/inactive state, and offers Ping, Edit, and Delete actions.
After saving, click Ping to confirm the endpoint is reachable. The ping is a POST carrying the same headers as a real delivery (see Delivery format); its body differs from a normal event (see Test ping). The result of the ping — delivered with its HTTP status, or the failure reason — is shown next to the webhook.
HTTP and HTTPS
Both http:// and https:// URLs are accepted. Anvil does not require TLS, but because the payload and signature travel in the request body and headers, use https:// for any endpoint that leaves your network.
Delivery format
Every delivery is an HTTP POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Hub-Signature-256 |
sha256=<hmac-hex> — HMAC-SHA256 of the raw body, keyed with your secret, lowercase hex |
X-Anvil-Event |
The event type, for example push |
X-Anvil-Delivery |
A unique delivery UUID (a test ping uses test-<webhook-id> instead) |
The body of an event delivery has the same envelope for every event type:
{
"event": "push",
"action": "push",
"timestamp": "2026-05-29 14:03:11Z",
"data": { }
}
| Field | Description |
|---|---|
event |
The event type (push, pull_request, issues, ci_run). |
action |
The specific action within the event. See Event types. |
timestamp |
When the underlying event was recorded, formatted as YYYY-MM-DD HH:MM:SSZ (UTC). |
data |
Event-specific fields. See Event payloads. |
Event types
When you subscribe to an event type you receive every action within it. The action field tells you which one occurred.
| Event | Actions | Triggered when |
|---|---|---|
push |
push |
Commits are pushed to any ref. |
pull_request |
opened, closed, merged |
A pull request is opened, closed, or merged. |
issues |
opened, closed |
An issue is opened or closed. |
ci_run |
started, completed |
A CI run starts, or finishes (passed or failed). |
These four are the only subscribable event types. Selecting any other value when configuring a webhook is rejected.
Verify the signature
Compute the HMAC-SHA256 of the raw request body using your webhook secret, encode it as lowercase hex, and compare it against the X-Hub-Signature-256 header after stripping the sha256= prefix. Compare with a constant-time function to avoid timing attacks, and hash the raw bytes you received before any JSON parsing or re-encoding.
import hashlib
import hmac
def verify(secret: str, body: bytes, signature_header: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
received = signature_header.removeprefix("sha256=")
return hmac.compare_digest(expected, received)
The same check in Elixir:
def verify?(secret, body, signature_header) do
expected =
:crypto.mac(:hmac, :sha256, secret, body)
|> Base.encode16(case: :lower)
received = String.replace_prefix(signature_header, "sha256=", "")
Plug.Crypto.secure_compare(expected, received)
end
Reject any request whose signature does not match. The signature is computed over the exact bytes Anvil sends, so verify against the raw request body, not a re-serialized copy.
Retries and circuit breaker
A delivery succeeds when your endpoint returns a 2xx status. Anything else — a 4xx/5xx response, a connection error, or a timeout — counts as a failure.
| Behavior | Value |
|---|---|
| Maximum attempts per delivery | 3 |
| Back-off before a retry | 4^attempt seconds: 4s after the first failure, 16s after the second |
| Circuit-breaker threshold | 5 consecutive failures to one webhook |
| Circuit-breaker suppression | 5 minutes |
A delivery is attempted up to three times. After the first failure it is held for 4 seconds, then retried; after the second failure it is held for 16 seconds, then retried. If the third attempt also fails, the delivery is marked failed and not retried again.
The circuit breaker tracks consecutive failures per webhook. After 5 in a row, the circuit opens: new deliveries to that webhook are recorded as failed without an HTTP request for 5 minutes. A single successful delivery resets the failure count and closes the circuit. While the circuit is open, the retry back-off above still applies, but each attempt is short-circuited rather than sent.
Because Anvil only retries the same payload three times and may suppress deliveries while the circuit is open, your endpoint should treat webhooks as best-effort notifications. Make handling idempotent (use X-Anvil-Delivery to deduplicate) and reconcile against the repository state if an endpoint was down.
Test ping
A test ping does not use the standard event envelope. Its body is:
{
"event": "ping",
"hook": {
"id": "1f0a2b3c-4d5e-4f60-8a1b-2c3d4e5f6a7b",
"url": "https://example.com/webhook",
"events": ["push", "pull_request"]
},
"message": "This is a test ping from Anvil."
}
The ping is signed and sent with the same headers as a real delivery, except X-Anvil-Event is ping and X-Anvil-Delivery is test-<webhook-id>. A ping is sent synchronously and is not retried; the settings page reports its outcome directly.
Event payloads
All IDs are UUIDs. The data object mirrors the internal event fields with string keys. Fields that were not set on the originating event are null. The examples below show the full body, including the envelope.
push
Emitted once per pushed ref. ref is the full ref name. before_sha and after_sha are the ref’s old and new tips; a push that creates a ref has an all-zero before_sha, and a deletion has an all-zero after_sha. commits is reserved for the list of pushed commits and is null when not populated.
{
"event": "push",
"action": "push",
"timestamp": "2026-05-29 14:03:11Z",
"data": {
"repo_id": "a3f1c2e4-9b7d-4a21-8c5e-1d2f3a4b5c6d",
"org_id": "f0e1d2c3-b4a5-4968-9788-0a1b2c3d4e5f",
"actor_id": "7c9b1a2d-3e4f-4051-8263-748596a0b1c2",
"ref": "refs/heads/main",
"before_sha": "9fceb02d0ae598e95dc970b74767f19372d61af8",
"after_sha": "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
"commits": null
}
}
pull_request
The field set varies by action. opened includes the title and branches; merged includes the merge strategy and resulting SHA; closed carries the identifiers and number only.
{
"event": "pull_request",
"action": "opened",
"timestamp": "2026-05-29 14:07:42Z",
"data": {
"repo_id": "a3f1c2e4-9b7d-4a21-8c5e-1d2f3a4b5c6d",
"org_id": "f0e1d2c3-b4a5-4968-9788-0a1b2c3d4e5f",
"actor_id": "7c9b1a2d-3e4f-4051-8263-748596a0b1c2",
"pr_id": "2b6d8f0a-1c3e-4d5f-9a8b-7c6d5e4f3a2b",
"number": 42,
"title": "Add retry back-off to webhook deliveries",
"base_branch": "main",
"head_branch": "fix/webhook-backoff"
}
}
A closed delivery carries pr_id and number without the title or branches:
{
"event": "pull_request",
"action": "closed",
"timestamp": "2026-05-29 15:02:18Z",
"data": {
"repo_id": "a3f1c2e4-9b7d-4a21-8c5e-1d2f3a4b5c6d",
"org_id": "f0e1d2c3-b4a5-4968-9788-0a1b2c3d4e5f",
"actor_id": "7c9b1a2d-3e4f-4051-8263-748596a0b1c2",
"pr_id": "2b6d8f0a-1c3e-4d5f-9a8b-7c6d5e4f3a2b",
"number": 42
}
}
A merged delivery instead carries strategy and merge_sha. strategy is one of merge_commit, squash, or rebase:
{
"event": "pull_request",
"action": "merged",
"timestamp": "2026-05-29 15:20:09Z",
"data": {
"repo_id": "a3f1c2e4-9b7d-4a21-8c5e-1d2f3a4b5c6d",
"org_id": "f0e1d2c3-b4a5-4968-9788-0a1b2c3d4e5f",
"actor_id": "7c9b1a2d-3e4f-4051-8263-748596a0b1c2",
"pr_id": "2b6d8f0a-1c3e-4d5f-9a8b-7c6d5e4f3a2b",
"number": 42,
"strategy": "squash",
"merge_sha": "c1d2e3f4a5b60718293a4b5c6d7e8f9012345678"
}
}
issues
opened and closed carry the same fields: the issue id, number, and title.
{
"event": "issues",
"action": "opened",
"timestamp": "2026-05-29 16:11:55Z",
"data": {
"repo_id": "a3f1c2e4-9b7d-4a21-8c5e-1d2f3a4b5c6d",
"org_id": "f0e1d2c3-b4a5-4968-9788-0a1b2c3d4e5f",
"actor_id": "7c9b1a2d-3e4f-4051-8263-748596a0b1c2",
"issue_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
"number": 108,
"title": "Webhook retries should respect the circuit breaker"
}
}
ci_run
started carries the pipeline and run identifiers; completed adds the final status, which is passed or failed.
{
"event": "ci_run",
"action": "completed",
"timestamp": "2026-05-29 14:18:30Z",
"data": {
"repo_id": "a3f1c2e4-9b7d-4a21-8c5e-1d2f3a4b5c6d",
"org_id": "f0e1d2c3-b4a5-4968-9788-0a1b2c3d4e5f",
"actor_id": "7c9b1a2d-3e4f-4051-8263-748596a0b1c2",
"pipeline_id": "8a7b6c5d-4e3f-4201-9182-736455463728",
"run_id": "9b8a7c6d-5e4f-4312-8201-918273645546",
"status": "passed"
}
}
A started delivery omits status:
{
"event": "ci_run",
"action": "started",
"timestamp": "2026-05-29 14:12:01Z",
"data": {
"repo_id": "a3f1c2e4-9b7d-4a21-8c5e-1d2f3a4b5c6d",
"org_id": "f0e1d2c3-b4a5-4968-9788-0a1b2c3d4e5f",
"actor_id": "7c9b1a2d-3e4f-4051-8263-748596a0b1c2",
"pipeline_id": "8a7b6c5d-4e3f-4201-9182-736455463728",
"run_id": "9b8a7c6d-5e4f-4312-8201-918273645546"
}
}
Related pages
- .anvil.yml — define the pipelines that emit
ci_runevents. - Runners — the workers that execute CI runs.
- CLI reference — manage repositories and CI from the command line.
- Get started — set up a repository on Anvil.