PSP webhooks → credit: HMAC, idempotent, escrowed, anchored · qalpay/README.md
qalpay — payment-platform → QALS bridge
Service: http://127.0.0.1:8839 · zero-dependency (Python stdlib) · data in ./data
Status: v1, tested (test_qalpay.sh — 48 checks, all green 2026-09-10)
qalpay is the bridge between the two (unnamed, therefore platform-agnostic)
payment platforms and the QALS credit ledger. It receives signed payment
webhooks, verifies them, and credits the buyer's loopd account from that
platform's own funded escrow agent. Every credit is receipted and anchored
on-chain (qalpipe → IOTA). The 50M¢ (500K-QALS) program cap is law in loopd
(AU_TOKEN_LAW_PLAYBOOK.md §7) — qalpay never issues credit; it only moves
escrow the operator has already funded through /topup (which is where the cap
bites, 402 past it). Since 2026-09-15 the issuance path is the issuer
wallet qals:issuer (endowed one-time via loopd POST /issuer/endow with the
remaining sellable supply — 30,108,499¢ + 19,891,501¢ already issued = exactly
50,000,000¢): a funded top-up transfers QALS from it to the buyer, receipts
are kind=issuance (issued_from=qals:issuer), and when the wallet is empty
top-ups answer 402 "sellable supply exhausted". Check the remainder any
time: GET :8823/issuer/status.
PSP (×2) ──signed webhook──► qalpay :8839
│ 1. X-Signature verify (HMAC-SHA256, raw body)
│ 2. parse/adapt → AUD check → idempotency → limits
│ 3. escrow funded?
▼
loopd :8823 (ledger of record)
├─ /agents/ensure buyer handle → agent (idempotent)
├─ /transfer platform escrow → buyer
└─ /topup FUNDING op (admin) ← 50M¢ (500K-QALS) CAP · issuer-wallet transfer
▼
qalpipe anchor — sha256(receipt) on-chain, verify=PASS
Funding flow (one line): your money → PSP settles to you → you POST /platforms/{id}/fund → loopd /topup (program cap enforced) → platform escrow balance. Sales can never exceed funded escrow + cap.
1. THE WEBHOOK CONTRACT (give this to each PSP verbatim)
Endpoint (per platform): POST {BASE_URL}/webhook/{platform_id}
(BASE_URL is whatever you expose qalpay at — see §7; until tunneled it is http://127.0.0.1:8839)
Headers (exactly two matter):
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Signature | lowercase-hex HMAC-SHA256 of the RAW request body, keyed with your platform's webhook secret |
# how qalpay verifies (and how you test):
import hmac, hashlib
sig = hmac.new(secret.encode(), raw_body_bytes, hashlib.sha256).hexdigest()
Body — normalized schema (preferred):
{
"event": "payment.succeeded",
"reference": "your-unique-charge-reference",
"amount_cents": 5000,
"currency": "AUD",
"customer_ref": "alice.qal OR your-platform-user-id",
"product": "credit_pack",
"idempotency_key": "your-unique-charge-reference"
}
Adapter A — Stripe-ish envelope also accepted. If your platform emits
{type, data:{object:{...}}}, qalpay maps it:
| normalized field | taken from |
|---|---|
| event | type == "charge.succeeded" → payment.succeeded (other types are ACK'd 200-ignored) |
| reference | data.object.id |
| amount_cents | data.object.amount (smallest unit — cents, Stripe convention) |
| currency | data.object.currency (lowercase ok, AUD enforced) |
| customer_ref | data.object.metadata.customer_ref (fallback: customer) |
| product | data.object.metadata.product |
| idempotency_key | data.object.metadata.idempotency_key (fallback: charge id) |
Processing order (fixed): signature (401) → parse (signed-garbage → 400 + dead-letter) → unhandled event (200 {ignored}) → currency AUD (422) → idempotency (409 + original receipt) → limits min 100c / max single 500000c, per-platform configurable (422) → escrow funded (else 503 "platform escrow needs funding" — safe to retry) → loopd transfer → receipt anchored → 200:
{"credited_cents": 5000, "receipt_id": "rp_ab12cd34ef",
"anchor_tx": "<iota tx digest>", "anchor_status": "PASS",
"remaining_platform_escrow_cents": 15000}
Retry semantics (configure your PSP retry policy to this):
| response | meaning | your side |
|---|---|---|
| 200 | credited or intentionally ignored | stop |
| 401 | bad/missing signature | stop — fix your signing |
| 409 | duplicate (idempotency_key seen) | stop — body carries the original receipt |
| 422 | policy refusal (non-AUD, over/under limits) | stop — do not retry |
| 503 | escrow needs funding / loopd briefly down | retry with backoff |
| 5xx | processing failure (network etc.) | retry with backoff — idempotency makes retries safe |
customer_ref handling: a Qal handle ([a-z0-9_.-], ≤64) passes through
unchanged; anything else is sanitized deterministically
(US-99/abc → us_99_abc) so the same platform user id always maps to the
same loopd agent. If your ids contain mixed case/odd chars, prefer sending
metadata.customer_ref already lowercased.
2. Key exchange (per platform — the two PSPs, and qalarc/tradez/goetica)
Each launch platform gets three things, delivered out of band (Signal to Alexei, or a shared vault — never email, never in a repo):
platform_id— the path segment:qalarc,tradez,goetica(test instances are suffixed, e.g.qalarc-t-<runid>)webhook_secret— 64-hex, generated at registration, shown once (re-registering an existing name is idempotent and does NOT re-show it)- webhook URL —
POST {BASE_URL}/webhook/{platform_id}
Admin ceremony (all X-Admin-Key-gated; key = env QALPAY_ADMIN_KEY, else
generated + printed once at first startup, stored in data/admin.json):
# register (idempotent "ensure" — creates loopd escrow agent automatically)
curl -X POST :8839/platforms -H 'X-Admin-Key: …' -d '{"name":"qalarc"}'
# → {"platform_id":"qalarc","webhook_secret":"<SHOWN ONCE>","escrow_agent_id":"ag_…",…}
# fund the escrow (real money in → loopd /topup → program cap enforced here)
curl -X POST :8839/platforms/qalarc/fund -H 'X-Admin-Key: …' -d '{"cents":100000}'
# list platforms (secrets redacted to last4)
curl :8839/platforms -H 'X-Admin-Key: …'
3. Reconciliation runbook (daily)
curl -s :8839/report -H "X-Admin-Key: $QALPAY_ADMIN_KEY" | python3 -m json.tool
Per platform: webhooks_received · credits_issued_cents · refunds_cents ·
escrow_balance_cents (live from loopd) — plus totals.program_remaining_cents
straight from loopd. Invariant to check: per-platform
credits_issued_cents == sum of that platform's credit deltas in
data/ledger.jsonl; escrow balance == funded − credited. First request after
midnight auto-closes yesterday into data/recon-YYYYMMDD.json.
| symptom | cause | action |
|---|---|---|
| escrow_balance_cents low / 503s in webhooks log | platform spent its funding | POST /platforms/{id}/fund; if loopd 402s, the program cap is near — governance decision, not a knob |
webhooks stuck bad_signature |
PSP rotated/typo'd secret | re-issue secret out of band (register new platform id, retire old) |
ledger rows kind:"anchor_retry_needed" |
chain was down at credit time | re-anchor: python3 ../qalpipe/qalpipe.py anchor data/receipts/<id>.json (money already moved; anchor is evidence, not settlement) |
| PSP reports retry storms | check GET /webhooks?platform=<id> — statuses tell you which gate rejected |
see §1 table |
loopd restart runbook (qalpay is HTTP-only to loopd; restart is safe):
pgrep -af loopd.py # is it running?
cd loopd && nohup python3 loopd.py & # state persists in loopd/data/*.json
curl -s :8823/health # {"ok": true, ...}
Webhooks during loopd downtime return 503 → PSPs retry → nothing is lost.
4. Refunds — v1 policy (honest limits)
curl -X POST :8839/refunds -H 'X-Admin-Key: …' \
-d '{"platform":"qalarc","receipt_id":"rp_ab12cd34ef","cents":1000,"reason":"goodwill"}'
- Records a NEGATIVE entry in qalpay's ledger + writes and anchors a
refund receipt. Response says
"loopd_debited": false— truthfully. - The customer's loopd balance is NOT debited in v1. loopd transfers are HMAC-signed by the sender, and qalpay does not hold customer secrets. Until v2 (customer-authorized clawback via the wallet claim flow, or a signed spend-back), the practical refund path is: record it here, then have the customer spend down, or process the money refund entirely on the PSP side and treat the QALS as goodwill credit.
centsdefaults to the full original credit and is capped at it. Refunded payments stay 409-replay-protected — a refund never re-opens a webhook.
5. Security notes
- Signature: HMAC-SHA256 over the exact raw bytes (timing-safe compare). A body re-serialized by a proxy will fail — PSPs must sign the bytes they send.
- Idempotency/replay: registry keyed
platform:idempotency_key(namespaced — platforms are independent). Replays get 409 + the original receipt: a webhook can credit at most once, even across retries and after refunds. - Least money: credits draw only from the named platform's escrow; a compromised webhook secret for one platform cannot touch another's float.
- Exposure: binds
127.0.0.1only. When going live, expose via Tailscale or a cloud tunnel and rate-limit webhook source IPs at the reverse proxy (e.g. nginxlimit_req_zonekeyed on$binary_remote_addr, ~10 r/s burst 20, plus an allowlist of the PSPs' published webhook IP ranges). TLS terminates at the tunnel/proxy. - Secrets: platform webhook secrets + escrow agent secrets + admin key
live in
data/(chmod the dir 700). Backupdata/with the same care as loopd's. Admin key gates:/platforms,/*/fund,/refunds,/report,/webhooks. - Anchoring: every credit + refund receipt is sha256-anchored on Qalnet
(
qal_data::DataAnchor); receipt content digestssha256(platform|reference|amount_cents|customer_handle|receipt_id). Chain-down degrades toanchor_status:"FAILED"+ ananchor_retry_neededledger row — never blocks a payment.
6. Files
qalpay/
├── qalpay.py the service (zero-dep; qalclaim-style conventions)
├── test_qalpay.sh full proof suite — 48 checks, exit 1 on any failure
├── test_caps.sh per-buyer caps proof suite (isolated :8859)
├── test_btc_rail.sh BTC paper rail proof suite (isolated :8862, stub rates)
├── README.md this document (the PSP configuration doc)
└── data/ (runtime, gitignore-grade)
├── platforms.json platform registry incl. escrow agent secrets
├── idempotency.json seen webhook keys → original receipts
├── ledger.jsonl append-only credits (+) / refunds (−) / btc_credits / anchor retries
├── webhooks.jsonl every inbound webhook: status + http code
├── dlq.jsonl signed-but-unparseable bodies (manual replay source)
├── receipts/ anchored receipt files (rp_* credits, rf_* refunds, btc_* BTC rail)
├── btc.json BTC paper rail: invoices + per-buyer btc_day buckets
└── recon-YYYYMMDD.json daily digests (auto-closed after midnight)
Run: python3 qalpay.py (env: QALPAY_PORT 8839, QALPAY_DATA, QALPAY_ADMIN_KEY, LOOPD_URL).
Test: bash test_qalpay.sh (isolated: temp data dir, port 8843, needs loopd :8823 + chain :9000).
7. Go-live checklist (per platform)
- ☐ Terms of Use (prepaid credit framing, AUD $1.00 flat, fees disclosed) lawyer-reviewed — gate for real money
- ☐ Platform registered (
POST /platforms) · webhook_secret delivered out of band · platform_id + URL confirmed with the PSP - ☐ Escrow funded (
POST /platforms/{id}/fund) and amount sanity-checked against expected volume; program cap headroom checked via/report - ☐ PSP sandbox charge → 200,
anchor_status:"PASS", buyer balance delta exact in loopd - ☐ PSP retry/replay drill → 409 with original receipt; bad-sig drill → 401 (their side re-signs)
- ☐ Reverse proxy up: TLS + source-IP allowlist + rate limit; qalpay still bound to 127.0.0.1
- ☐ Monitoring: daily
/reportreconciled; alert on 503s (escrow_underfunded) andanchor_retry_neededrows - ☐ Refund path agreed with the PSP (§4 limits understood in writing)
- ☐ Small live cohort first (≤100 users, ≤AU$100 each — mirrors release plan week 4), reconcile daily, then scale
8. BTC paper rail — build-order step 1 (PAPER, zero custody)
Status: PAPER SIMULATION (2026-09-13). This is build-order step 1 of
06_bank_exchange/EXTERNAL_CHAINS_MASTER_PLAN.md (§4). The policy it implements
is founder-approved: content/btc.md + BTC_WALLET_INTEGRATION_REQUIREMENTS.md.
The honesty part, up front:
- No BTC is accepted, held, or moved. Zero custody. There is no wallet, no key material, no chain connection in this rail.
- Addresses are placeholders: every invoice's address is literally
paper:<invoice_id>. Nobody can pay it and nothing on Bitcoin exists behind it. - Payment is simulated:
POST /btc/invoice/<id>/simulatemodels the confirmation ladder. It is a test seam standing in for a BTCPay Server webhook. - The rates are real (live public tickers, when not stubbed), and the credit path is the real one: escrow → loopd transfer → receipt → qalpipe anchor, with the per-buyer QALS caps enforced. A BTC purchase is still an issuance — paper BTC buys very real QALS credit in test escrows.
- BTC is a payment method, not a product — barter-style payment for credit at a published rate; never an exchange venue, never a transfer vessel.
Rate oracle — GET /btc/rate (public):
{"rate_aud": 100200.0, "sources": ["coingecko","kraken"],
"spread_pct": 0.3992, "fetched_at": 1789260075431, "stub": true}
Median of ≥2 live public sources — Kraken XBTAUD (last trade) + CoinGecko
simple/price (BTC-AUD) — fetched in parallel, 10s timeout each, 60s in-memory
cache. Guardrails (all → 503 with a reason):
| reason | trigger |
|---|---|
insufficient_sources |
fewer than 2 sources succeeded |
stale |
quote older than 2 min |
divergence |
cross-source spread (max−min)/median > 5% |
no_sources |
every source unreachable (stale-cache fallback ≤2 min first) |
QALPAY_BTC_RATE_STUB env injects deterministic rates for tests/offline: an
inline JSON {"sources":{"kraken":100000.0,"coingecko":100400.0},"age_ms":0}
or a path to such a file (re-read every call, cache bypassed). Stubbed
responses are flagged "stub": true — never mistaken for live data.
Invoices — POST /btc/invoice {buyer, aud_cents}:
- sats =
ceil( aud_cents×1e6 / (rate_snapshot × (1 − 0.015)) )— snapshot rate at creation, 1.5% spread disclosed, 15-min expiry,address: "paper:<invoice_id>",status: "pending". - Caps (422 past them): AU$2,000/invoice, AU$20,000/day per buyer
counted across created invoices (separate
btc_daybucket — the conservative reading: invoice spam can't sidestep the day cap). GET /btc/invoice/<id>— status (pending/seen (0-conf, display only)/unconfirmed-credit-pending/credited/expired), effective expiry at read.
Simulated payment — POST /btc/invoice/<id>/simulate {confs} — the
confirmation ladder from the requirements brief §3:
| confs | effect |
|---|---|
| 0 | seen (0-conf, display only) — never credited (RBF/BIP125 reversible) |
| 1 | unconfirmed-credit-pending |
| 2 (<AU$1k) · 3 (AU$1k–10k) · 6 (≥AU$10k) | credit via the real path |
| again, any confs | idempotent — returns the original credit, no double-pay |
| after expiry | 409 |
Credit path = process_webhook steps 6–9 verbatim: lazy btc-rail platform
(fund once with POST /platforms/btc-rail/fund), per-buyer QALS lifetime cap
enforced (a BTC purchase is still an issuance — over cap → 422
cap_blocked), escrow-funded check (503 + fix hint), loopd transfer with memo
btc:invoice:<id>, receipt anchored on qalnet like every other receipt.
Accounting — GET /btc/report (admin): invoices by status, credited AUD vs
sats totals, spread_earned_cents = Σ sats×rate_snapshot/1e6 − aud_cents over
credited invoices, per-buyer btc_day + QALS lifetime usage.
Rung-0 graduation path (what changes — nothing else): BTCPay Server on the
hub issues real invoices with real per-invoice xpub addresses; its webhook
replaces /simulate as the payment signal (HMAC-authed, replay-guarded);
confirmation counts come from the chain instead of the test body; paper:<id>
addresses become real P2WPKH addresses (BIP84 watch-only, per-invoice, never
reused); reorg walk-back + reversing entries come with the node rung. The
oracle, caps, spread, credit path, receipts, anchors, accounting — all
unchanged. Suite: bash test_btc_rail.sh (isolated :8862, stub rates).
Built 2026-09-10 · BTC paper rail 2026-09-13 · loopd :8823 (ledger of record, 50M¢/500K-QALS cap law, issuer-wallet transfers since 2026-09-15) · qalpipe (evidence layer) · style-consistent with qalclaim.