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:
spool webhook set billing --event-type payment.settledOn 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:
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.
Creating a webhook
Section titled “Creating a webhook”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:
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.
What a delivery looks like
Section titled “What a delivery looks like”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 fromquerybefore 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.
Forge deliveries
Section titled “Forge deliveries”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:
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.
Repos and graphs
Section titled “Repos and graphs”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.
When a delivery didn’t start anything
Section titled “When a delivery didn’t start anything”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:
spool webhook deliveries github# dlv-01a198d08596000008-3REtsDbz 10s ago accepted github.push dev:default:evt-614c90f7f3c7c038 dump startedMost “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.