Automatic supporter-key issuance
POST /webhooks/bmc, opt-in via NETNL_BMC_WEBHOOK_SECRET, turns a
qualifying Buy Me a Coffee (BMC) donation into a netnl tenant credential,
mailed directly to the donor — see
openspec/changes/archive/2026-09-08-add-supporter-issuance/design.md for the pinned
decisions this runbook operates. It replaces the manual half of
Supporter keys: an operator no longer has to notice a
donation and run netnl-admin user add by hand, though that fallback still
works, unchanged, when the bridge is disabled.
What this is not
This is not a payment integration. BMC remains the payment processor; the facade only consumes its webhook notification after a donation has already been captured. No card data, refund handling, or subscription state exists here.
Prerequisites
Alongside the ordinary facade settings (NETNL_UPSTREAM_*, NETNL_DB,
...), the bridge additionally requires:
NETNL_PUBLIC_ENDPOINT: the facade's own public batch endpoint URL, interpolated into the credential mail.NETNL_SMTP_HOSTandNETNL_SMTP_FROM: the bridge sends mail, so an SMTP relay must exist.NETNL_SMTP_PORT/_USERNAME/_PASSWORD/_MODE/_TIMEOUTtune it further — seedeploy/.env.examplefor the full list and defaults.
Setting NETNL_BMC_WEBHOOK_SECRET while any of these is missing fails the
facade's startup outright (SettingsError, naming the missing variable) —
there is no state where the secret is live but mail delivery is
half-configured.
1. Generate the secret
openssl rand -hex 32
Treat this exactly like the upstream credential: never commit it, never
print it in a log line. In the co-located topology it goes in
deploy/.env (gitignored); in the K8s topology it goes in the
netnl-secrets equivalent alongside the upstream credential — see
deploy-facade.md for both. Rotating it later is a
plain redeploy with a new value; BMC's dashboard needs the same new value
set at the same time, or every delivery starts failing signature
verification (401) until both sides agree again.
2. Configure the BMC dashboard
On the BMC dashboard's webhook configuration for this account:
- Set the webhook URL to
https://<your-facade-host>/webhooks/bmc. - Set the shared secret to the value generated above.
- Note the exact name of the header BMC signs the payload into. This
was not confirmed against a live delivery at build time — the facade
defaults to
X-Signature-Sha256(NETNL_BMC_SIGNATURE_HEADER), but if the dashboard shows a different header name, setNETNL_BMC_SIGNATURE_HEADERto match before relying on the bridge.bmc.verify_signatureaccepts the digest either hex- or base64-encoded, with an optionalsha256=prefix, so the encoding should not need adjusting — only the header name, if it differs.
3. Roll out and verify the gate is on
Deploy with NETNL_BMC_WEBHOOK_SECRET (and the mail variables) set, then
confirm the bridge actually enforces its signature before trusting it with
a real donation:
curl -i -X POST https://<your-facade-host>/webhooks/bmc \
-H 'Content-Type: application/json' \
-d '{}'
Expect 401. If instead you get 501, the bridge is not enabled at
all (NETNL_BMC_WEBHOOK_SECRET is unset, or the deploy has not picked up
the new environment) — fix that before proceeding. A 401 here is the
signal that the route exists and rejects an unsigned request, which is the
entire security boundary this bridge has (see "Security notes" below).
4. Test with a real (test-mode) delivery
BMC's dashboard can usually send a test delivery. By default the facade
ignores anything not in live_mode (IGNORE_TEST_MODE) — deliberately,
so a stray test delivery from the dashboard never mints a real credential.
To exercise the bridge end to end with a test delivery:
NETNL_BMC_ACCEPT_TEST_MODE=1 # set alongside the other supporter variables
Trigger the test delivery from the BMC dashboard, then check the facade
logs (logging.getLogger("netnl.supporter")) for event=issued, and
confirm a mail arrived with a fresh credential. Afterwards:
- Unset
NETNL_BMC_ACCEPT_TEST_MODEand redeploy — leaving it set means every future test delivery (including one triggered by someone else with dashboard access) mints a real, working credential. - Revoke the test credential:
sh netnl-admin user revoke <the-generated-username>(the username is in the credential mail, ornetnl-admin user list).
5. Live
Once the above holds, real donations mint keys with no further operator
action. A donation qualifies by default once it is 2.00 or more in the
account's currency (NETNL_SUPPORTER_MIN_AMOUNT=2) — raise it, lower it, or
set NETNL_SUPPORTER_CURRENCY, if that default does not fit; see
deploy/.env.example for all three. The credential mail is sent as
plaintext with an HTML alternative (multipart/alternative) — the HTML
part loads no image, font, or stylesheet, and makes no network request of
any kind; a client that renders only plaintext sees exactly the same
credential.
Troubleshooting
| Symptom | Likely cause | What to check |
|---|---|---|
| Every delivery gets 401 | Header name or secret mismatch | Re-check the exact signature header name in the BMC dashboard against NETNL_BMC_SIGNATURE_HEADER; re-confirm both sides have the same secret |
| Facade refuses to start after setting the secret | A required mail/endpoint variable is missing | The SettingsError names the missing variable — see "Prerequisites" above |
| A real donation produced no mail and no error visible to the donor | Check facade logs for event=delivery-failed txn_id=... (logging.getLogger("netnl.supporter")); if that's inconclusive, inspect the supporter_issuance table directly with sqlite3 <db-path> "SELECT * FROM supporter_issuance WHERE txn_id = '<id>'" (there is no netnl-admin subcommand for this — it is a plain read-only query against the facade's own SQLite file) |
Confirm SMTP credentials/relay are reachable from the facade; a transaction stuck past NETNL_SUPPORTER_MAX_ATTEMPTS is parked — an operator-issued key via the manual fallback (supporter-key.md) unblocks that donor immediately |
| A donation with no email on file never issues | Working as intended (UNDELIVERABLE_NO_EMAIL) |
There is no credential to reissue automatically — use the manual fallback if the donor can be reached another way |
| A donation from a test/sandbox delivery unexpectedly minted a key | NETNL_BMC_ACCEPT_TEST_MODE=1 was left set |
Unset it and redeploy; revoke the resulting credential |
| A transaction is parked (503 forever) after several retries, with no obvious mail failure | A slow or intermittently-reachable SMTP relay burned through NETNL_SUPPORTER_MAX_ATTEMPTS — each of BMC's own retries counts as one more attempt, whether it failed outright or simply timed out |
Check facade logs for repeated event=delivery-failed for the same txn_id; fix the relay/network path, then issue the donor a credential by hand via the manual fallback (supporter-key.md) rather than waiting for a further BMC retry (the transaction stays parked until an operator intervenes) |
An audit row named supporter-deliver-orphaned appears for a transaction |
A concurrent-delivery edge case (see design.md's "B1(b), a conditional outcome write") — vanishingly rare in practice, but not impossible at a lease boundary | The donor may already have received a mail carrying a now-revoked (dead) credential from the losing side of the race; the winning takeover's own mail carries the one that actually works. No action needed unless the donor reports the first mail's credential not working — that is expected; point them at the most recent mail for that donation |
Security notes
- The shared secret is the only gate. There is no IP allowlist, no
mTLS — anything that can produce a valid HMAC-SHA256 signature under the
configured secret can trigger issuance. Treat it with the same care as
the upstream credential. The facade refuses to start with a secret
shorter than 32 characters (a floor against an obviously-too-short
accidental value, not a strength target);
openssl rand -hex 32(step 1) is 64. NETNL_SMTP_MODE=plaintextalso puts the SMTP relay password on the wire unencrypted, exactly like the credential mail body itself — it requires the explicitNETNL_SMTP_ALLOW_PLAINTEXT=1opt-in for that reason, same asNETNL_ALLOW_HTTPfor the upstream hop. Preferstarttlsorsslunless the relay is genuinely local/trusted.- Rotation is a plain redeploy with a new secret value, updated on both sides at the same time (see step 1). There is no overlap window where two secrets are simultaneously valid — a delivery signed under the old secret after rotation gets 401, which is why BMC's own retry schedule (not a grace period on the facade) is what recovers a delivery that lands mid- rotation.
- Replay is bounded, not prevented outright. A resent, validly-signed
delivery for a transaction id already
deliveredis a safe no-op (no second credential, no second mail) for as long as that row exists — see "Retention" below for how long that is. There is no separate replay window or nonce; idempotency is entirely a function of the transaction id BMC itself assigns. - No supporter PII is ever stored. The donor's email address and name exist only in memory for the duration of sending the mail. The database keeps only the transaction id, the generated username, delivery state, an attempt counter, and timestamps.
- Retention, and post-prune replay (accepted residual risk).
supporter_issuancerows are pruned on the sameNETNL_AUDIT_RETENTION_DAYScutoff the audit trail uses (no separate retention variable) — an operator lengthening or shorteningNETNL_AUDIT_RETENTION_DAYSmoves this window too. Once a row is pruned, a resent, validly-signed delivery for that transaction id is indistinguishable from a brand-new one: it mints a second credential, and the original stays active unless separately revoked. This is accepted, not closed by a tombstone table — seeopenspec/changes/archive/2026-09-08-add-supporter-issuance/design.md, "Post-prune replay", for why (in short: closing it that way reintroduces the unbounded-growth problem retention exists to bound, against a threat that already requires the webhook secret or a captured signed request — at which point a live forgery is no harder than a replay). If you spot a donor with two active credentials from one donation, revoke the extra one by hand. NETNL_SUPPORTER_NOTIFY(optional). Set this to an operator address to get a short mail ("supporter key issued:<username>, txn<id>") after every successful delivery — no password, no supporter PII beyond what BMC's own dashboard/notifications already show the operator. A failure to send this notification is logged and never turns a successful delivery into a failure.
See also
- Supporter keys — the issuance model this bridge automates, and the manual fallback.
openspec/changes/archive/2026-09-08-add-supporter-issuance/design.md— the pinned decisions (D1-D5), configuration table, and route ordering this runbook operates.- deploy-facade.md — where the facade's own secrets live in each supported topology.