text change
All checks were successful
Deploy / build-deploy (push) Successful in 3m48s

This commit is contained in:
Jorijn van der Graaf 2026-08-21 04:52:25 +02:00
commit 413740af2e
4 changed files with 134 additions and 13 deletions

View file

@ -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

View file

@ -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