Skip to content

Webhooks

A webhook is a named HTTP endpoint that turns a delivery from outside into an event on the project’s event bus, where handlers and waiting runs pick it up. Use one for anything that reports by POSTing: a payment provider, a form, a monitoring alert, or a forge sending pushes and pull requests.

Create one with the type its deliveries should become:

Terminal window
spool webhook set billing --event-type payment.settled

On a terminal, set prints the ingest URL, a secret (shown once) and a curl that uses both. In a pipe it prints the secret alone. The sender posts to the URL with the secret in a header:

Terminal window
curl -X POST "$INGEST_URL?source=shop" \
-H "X-Spool-Hook-Secret: $SECRET" \
-H 'content-type: application/json' \
-d '{"sku": "TENT-2P", "qty": 12}'
# {"event_id":"dev:default:evt-…","event_type":"payment.settled"}

The answer is 202 with the event’s id, and a payment.settled event is now on the bus for any handler or wait on that type.

A webhook verifies its deliveries in one of three ways, and that also decides the event type:

Mode Set with The sender proves itself with Event type
Token --event-type <type> the shared secret always <type>
Manifest --manifest github the forge’s signature per delivery, see Forge deliveries
Manual HMAC --event-type <type> --hmac-header <h> [--hmac-prefix <p>] an HMAC-SHA256 of the body always <type>

A token webhook mints its secret and stores only a digest of it. The sender presents the secret in the X-Spool-Hook-Secret header, or as ?secret= for a sender that can’t set headers. The query string ends up in logs and history, so prefer the header. A delivery without the secret is answered 401, naming the header it wanted. spool webhook rotate <name> mints a new secret, and the old one stops working at once.

A manifest webhook checks the signature a known sender puts on each delivery, and shapes the payload. The github manifest serves GitHub, Gitea and Forgejo. Without --supply-key a signing key is minted and shown once, and that is what you paste into the forge’s webhook settings; with --supply-key your own key is read from stdin or a prompt. Setting the webhook again keeps its key, and spool webhook rotate mints a new one.

A manual HMAC webhook is for any other sender that signs its body with a key you both know. --hmac-header names the header that carries the signature and --hmac-prefix what comes before it (sha256= by default). The key is read from stdin or a prompt:

Terminal window
printf %s "$SHOP_KEY" | spool webhook set shop --event-type shop.order \
--hmac-header X-Signature --hmac-prefix sha256=

The sender chose that key, so there is nothing to rotate; set the webhook again with the new one. A signed delivery whose signature is missing or wrong is answered 401, naming the signature header.

spool webhook list, get <name>, delete <name> and deliveries <name> manage them. In the console they live under Settings > Connections > Webhooks, where you can also create and rotate them.

The delivery arrives as the event’s payload, in the form {"body": …, "query": …}:

{ "body": { "sku": "TENT-2P", "qty": 12 }, "query": { "source": "shop" } }

Read a posted field as event.payload.body.sku in a filter (input.payload.body.sku in a handler’s input), and a query-string one as event.payload.query.source.

  • Body values keep their JSON types; query values are always strings.
  • A POST with no body arrives as an empty object, and a body that isn’t JSON arrives as a string.
  • A ?secret= is removed from query before the event is recorded.

A delivery can carry up to 25 MiB; a larger one is refused with 413 and shows in the delivery log. A large body also has to keep moving: past its first 2 MiB it must average 1 MiB/s and finish within 10 seconds, and the whole body must arrive within 30 seconds, or the delivery is refused with 408. A forge sends well inside these limits.

A signed delivery is deduplicated on its body, so a forge redelivering the same delivery resolves to the same event and starts nothing new. A token webhook deduplicates when the sender sets an X-Spool-Delivery-Id header; without one, every POST is a new event.

A manifest webhook names its events <webhook>.<forge event>, plus the delivery’s action as a third segment when it has one. A webhook named github emits:

Delivery Event type
a push github.push
a pull request opened github.pull_request.opened
a pull request updated with new commits github.pull_request.synchronize
an issue comment github.issue_comment.created
a release published github.release.published
the forge’s test delivery github.ping

So --on github.pull_request matches nothing, because a pull request delivery always carries an action. Use github.pull_request.opened, a list of actions, or github.pull_request.* (matching has the pattern rules). There is no bare push type either.

The forge’s JSON is kept whole at event.payload.body, and a few fields are lifted to the top of the envelope:

Field From
event.sha the pushed commit, or a pull request’s head commit
event.ref a push’s ref
event.head_ref, event.base_ref a pull request’s branches
event.number a pull request’s number
event.repo owner/name
event.sender the login that caused the delivery
event.forge github, gitea or forgejo, whichever sent it
event.paths, event.paths_known the files a push changed (see below)

A forge set to its form content type works too: its payload field is read as the same JSON.

A field is left out when the delivery has nothing for it. The subject (event.key) is the repo. Anything else comes from the body: event.payload.body.action, event.payload.body.pull_request.draft, event.payload.body.comment.body. A script handler also gets the lifted fields as $CI_TRIGGER_SHA, $CI_TRIGGER_REF, $CI_TRIGGER_REPO and so on.

event.paths is the sorted set of files a push added, modified or removed. It is always present. event.paths_known is false when the forge sent no usable list: any delivery other than a push, a push with no commits (a branch create or delete), or a push at the forge’s 20-commit cap. Write path filters so they run in that case, and an unknown set never drops a build:

Terminal window
spool handler set docs-skip ./build.sh --on github.push \
--when '!event.paths_known || event.paths.exists(p, !p.startsWith("docs/"))'

A manifest webhook marks each delivery trusted or untrusted. Its trust rule decides from what the delivery says about who caused it and whose code it is about:

Delivery Trusted when
push, ping, release, create, delete, workflow_dispatch, label, milestone, page_build, merge_group Always
Pull request from a fork Never; --trust-actor doesn’t waive this
Pull request (GitHub) The author is an owner, member or collaborator of the repo, or is named with --trust-actor
Pull request (Gitea, Forgejo) The author is named with --trust-actor; these forges send no author association
Comment or review (GitHub) The author is an owner, member or collaborator of the repo
Comment or review (Gitea, Forgejo) Never
workflow_run It ran on the repo’s own code (a fork’s pull request can run the repo’s workflows on the fork’s code)
deployment, deployment_status It was made through the API, or by a workflow run on the repo’s own code
workflow_job, check_run, check_suite, status Never, since they don’t say whose code ran
Any other type (issues, for example) Never

Token and manual HMAC webhooks have no trust rule: their deliveries are trusted, since only a holder of the secret or key can send one.

--trust-actor <login> (repeatable, with --manifest) vouches for one author’s pull requests, for example a bot that opens them and so has no association with the repo. It applies to the pull request’s author, never to whoever commented on or labelled it.

An untrusted delivery is still delivered. Its handler runs carry the untrusted-event tag and get no project secrets (Credentials covers handlers that ask for one), and it doesn’t end a waiting run unless the wait opts in (Untrusted events).

Untrusted spreads. What an untrusted run spawns is untrusted, and so is every handler run its events reach, run.* lifecycle events included. A status or notification handler that needs a credential is therefore refused for a fork’s runs. Secrets has what an untrusted run can and can’t do.

To build a repo on every push, use spool repo connect instead of wiring a webhook by hand. It creates one manifest webhook per forge, named after the host (github for github.com) and shared by every repo you connect there, plus a handler per repo that builds the pushed commit’s pipeline or graph. --status-credential adds a handler that reports the result to the commit. CI from a repo walks through it.

A graph can also own a webhook through a webhook trigger, minted when the graph is published and removed with it. Use that when one graph is the whole reaction to one sender. Create the webhook yourself when several handlers or graphs should read it.

Every webhook keeps a log of its deliveries from the last seven days. Each entry says whether the delivery was verified and accepted, the event type it became, and what every matching handler did with it: started a run, skipped by --when, --when failed (with the error), or refused (with the reason).

Read it in the console under More > Events, in the Webhooks section, or from the CLI:

Terminal window
spool webhook deliveries github
# dlv-01a198d08596000008-3REtsDbz 10s ago accepted github.push dev:default:evt-614c90f7f3c7c038 dump started

Most “the forge says it delivered and nothing ran” cases show up there: a type that no handler’s --on matches, a signature that doesn’t verify, or a predicate that errors.