diff --git a/deploy/README.md b/deploy/README.md
index 2808ba6..a5f1ccd 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -351,6 +351,46 @@ BUNQ_API_KEY=… TRANSFER_IBAN=NL.. \
# -> pulled 2 new credit(s) into /var/lib/catcrafts/orders.jsonl.transfer-credits.jsonl
```
+#### How settlement actually reaches the shop
+
+Two paths, and the split is deliberate.
+
+**Primary: the bunq webhook.** bunq pushes a `MUTATION` notification when money
+lands; the backend decodes the payment and appends it to the credits file, and
+the reconciler settles from there. Normally seconds end to end. Registered with
+`tools/register-bunq-webhook.sh` from a machine bunq's key permits (`--show`
+lists what is registered, with the secret redacted). **That call REPLACES the
+whole filter set**, so look before overwriting.
+
+**bunq does not sign these notifications** — verified against doc.bunq.com: no
+HMAC, no server signature, and certificate pinning authenticates us to bunq
+rather than bunq to us. So the endpoint's authentication is three transport-level
+controls, all of which have to stay in place:
+
+1. **Source-IP allowlist** for `185.40.108.0/22` in the Caddyfile. This is the
+ strong one: forging requires completing a TLS handshake from inside that
+ range. bunq warns the range may change; if it does, callbacks 403 and
+ settlement stops — it fails closed.
+2. **A secret path.** Caddy matches only the non-secret prefix `/hooks/bunq/*`,
+ so the secret lives solely in `BUNQ_WEBHOOK_PATH` in `payments.env` and not
+ in the web server config. The backend compares it in constant time, and a
+ non-POST to it falls through to an ordinary 404 rather than a distinctive
+ 405 — a different answer there would confirm a guessed path.
+3. **`log_skip` on that prefix.** Without it the secret would be written to the
+ access log and then ingested into the GoAccess database and shown in its
+ top-URLs panel, i.e. leaked to anyone who opens `/analytics/`.
+
+The handler deliberately does the least it can: decode one payment, append it,
+answer 200. It makes no settlement decision, so a forged callback can at worst
+manufacture a credit line — it cannot bypass the reference match or the
+covering-amount rule, and nothing ships without a human either way.
+
+**Backstop: the hourly pull.** Not optional. bunq retries a callback about six
+times and then drops it permanently, so a payment arriving while the backend
+restarts — every deploy is a restart — is lost to the webhook with no error
+anywhere. `deploy/catcrafts-credits.timer` runs `pull-and-ship-credits.sh`
+hourly to turn that from a missing donation into an hour of delay.
+
**The key is IP-restricted, and that decides the shape.** Verified the hard way
on 2026-08-20: the bunq API key permits one address (the owner's home
connection), and **bunq enforces that on every request, not only at device
diff --git a/deploy/catcrafts-credits.timer b/deploy/catcrafts-credits.timer
index aed6f12..77920d3 100644
--- a/deploy/catcrafts-credits.timer
+++ b/deploy/catcrafts-credits.timer
@@ -1,23 +1,26 @@
-# Every five minutes. Two things set the floor and the ceiling:
+# Hourly, because this is now the BACKSTOP rather than the primary path.
#
-# * bunq rate-limits reads to roughly 3 GET per 3 seconds per method, so one
-# call per five minutes is nowhere near it. The cost of going faster is
-# nothing technical; it is just noise.
-# * a donor watching the order page wants confirmation while they still care.
-# Five minutes here plus the reconciler's own 60 s cadence means a fresh
-# order confirms within about six minutes of the money landing.
+# Since the bunq webhook landed, a payment normally settles within seconds: bunq
+# pushes it, the handler writes the credit, the reconciler picks it up. This
+# timer exists for the case the webhook cannot cover — bunq retries a callback
+# roughly six times over a few minutes and then DROPS IT FOREVER, so a payment
+# that arrives while the backend is restarting (every deploy is a restart) is
+# lost to the webhook with no error anywhere. An hourly pull turns that from a
+# silently missing donation into an hour of delay.
+#
+# It was five minutes while the pull WAS the settlement path. Hourly is the
+# right cadence for a safety net, and it keeps the noise down.
#
# Persistent=true so a laptop that was asleep pulls once on waking instead of
-# silently skipping every window it missed — which is exactly when a donation
-# would otherwise sit unacknowledged overnight.
+# silently skipping every window it missed.
[Unit]
-Description=Pull bunq credits for catcrafts.net every 5 minutes
+Description=Backstop pull of bunq credits for catcrafts.net (hourly)
[Timer]
OnBootSec=2min
-OnUnitActiveSec=5min
+OnUnitActiveSec=1h
Persistent=true
-AccuracySec=30s
+AccuracySec=5min
[Install]
WantedBy=timers.target
diff --git a/shared/interfaces/Catcrafts.Shared-Views.cppm b/shared/interfaces/Catcrafts.Shared-Views.cppm
index ebeb8c6..f33041c 100644
--- a/shared/interfaces/Catcrafts.Shared-Views.cppm
+++ b/shared/interfaces/Catcrafts.Shared-Views.cppm
@@ -768,7 +768,7 @@ SafeHtml RenderPayFieldset(const Form::Checkout& prev, SafeHtml payError,
? Format(
R"()",
+ R"(Bank transfer Regular IBAN bank transfer.)",
Attr("value", std::string(Form::kPayBank)),
wantsCrypto ? SafeHtml{} : Raw(" checked"))
: SafeHtml{};
diff --git a/tools/register-bunq-webhook.sh b/tools/register-bunq-webhook.sh
new file mode 100755
index 0000000..5e506b7
--- /dev/null
+++ b/tools/register-bunq-webhook.sh
@@ -0,0 +1,78 @@
+#!/bin/sh
+# Tell bunq where to push payment notifications.
+#
+# Run from a machine whose address the bunq API key permits — the web host is
+# not one, which is the whole reason webhooks exist in this setup. The session
+# it uses comes from the context file that tools/pull-and-ship-credits.sh
+# created, so run that at least once first.
+#
+# tools/register-bunq-webhook.sh 'https://catcrafts.net/hooks/bunq/'
+# tools/register-bunq-webhook.sh --show # list what is registered
+#
+# THIS REPLACES THE WHOLE FILTER SET. bunq treats the POST as "here is the
+# complete list", so a call that omits an existing filter deletes it. Every
+# filter wanted has to be in the one request, which is why --show exists: look
+# before overwriting.
+#
+# The target URL is never echoed: it is a shared secret, and this script's
+# output tends to end up pasted into issues and chat logs.
+set -eu
+
+cd "$(dirname "$0")/.."
+
+CTX="${CREDITS_LOCAL:-$HOME/.cache/catcrafts}/orders.jsonl.transfer-credits.jsonl.bunq-context.json"
+[ -f "$CTX" ] || { echo "$0: no bunq context at $CTX — run tools/pull-and-ship-credits.sh first" >&2; exit 1; }
+
+SESSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["session_token"])' "$CTX")
+USERID=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["user_id"])' "$CTX")
+[ -n "$SESSION" ] && [ "$USERID" != "0" ] || { echo "$0: context has no session — run the pull once" >&2; exit 1; }
+
+API="https://api.bunq.com/v1/user/$USERID/notification-filter-url"
+HDRS="-H Content-Type:application/json -H X-Bunq-Client-Authentication:$SESSION -H User-Agent:catcrafts.net-register/1.0"
+
+if [ "${1:-}" = "--show" ]; then
+ # Redact the secret out of the response before it reaches a terminal.
+ # shellcheck disable=SC2086
+ curl -s $HDRS "$API" | python3 -c '
+import json,sys,re
+try: d=json.load(sys.stdin)
+except Exception: print("could not parse bunq response"); sys.exit(1)
+for item in d.get("Response",[]):
+ f=item.get("NotificationFilterUrl") or {}
+ t=f.get("notification_target","")
+ print(" category=%-22s target=%s" % (f.get("category","?"), re.sub(r"(/hooks/bunq/).*", r"\1", t)))
+'
+ exit 0
+fi
+
+TARGET="${1:-}"
+case "$TARGET" in
+ https://*) ;;
+ *) echo "$0: pass the full https:// callback URL (bunq refuses plain http)" >&2; exit 2 ;;
+esac
+
+# MUTATION alone: it fires on every balance change and carries the Payment
+# object, which is exactly what the handler decodes. Adding PAYMENT as well
+# would deliver most events twice, and while the handler deduplicates on the
+# payment id, doubling the traffic to buy nothing is not a trade.
+BODY=$(python3 -c '
+import json,sys
+# No notification_delivery_method: bunq rejects it as "Superfluous" on this
+# endpoint, because /notification-filter-url already means URL delivery.
+print(json.dumps({"notification_filters":[
+ {"notification_target":sys.argv[1],
+ "category":"MUTATION"}]}))
+' "$TARGET")
+
+# shellcheck disable=SC2086
+OUT=$(printf '%s' "$BODY" | curl -s -w '\n%{http_code}' $HDRS -X POST --data-binary @- "$API")
+CODE=$(printf '%s' "$OUT" | tail -1)
+if [ "$CODE" = 200 ]; then
+ echo "$0: registered MUTATION callbacks (target not shown)"
+ echo "$0: verify with $0 --show"
+else
+ # The body can echo the target back, so redact before printing an error.
+ printf '%s\n' "$OUT" | sed 's#\(/hooks/bunq/\)[^"]*#\1#g' >&2
+ echo "$0: bunq refused the registration (HTTP $CODE)" >&2
+ exit 1
+fi