QALS Wiki · the qalarc network⌂ qalarc.com/projects/qals
generated 2026-09-16 · qalcode autonomous research

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/abcus_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):

  1. platform_id — the path segment: qalarc, tradez, goetica (test instances are suffixed, e.g. qalarc-t-<runid>)
  2. webhook_secret — 64-hex, generated at registration, shown once (re-registering an existing name is idempotent and does NOT re-show it)
  3. webhook URLPOST {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"}'

5. Security notes

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)

  1. ☐ Terms of Use (prepaid credit framing, AUD $1.00 flat, fees disclosed) lawyer-reviewed — gate for real money
  2. ☐ Platform registered (POST /platforms) · webhook_secret delivered out of band · platform_id + URL confirmed with the PSP
  3. ☐ Escrow funded (POST /platforms/{id}/fund) and amount sanity-checked against expected volume; program cap headroom checked via /report
  4. ☐ PSP sandbox charge → 200, anchor_status:"PASS", buyer balance delta exact in loopd
  5. ☐ PSP retry/replay drill → 409 with original receipt; bad-sig drill → 401 (their side re-signs)
  6. ☐ Reverse proxy up: TLS + source-IP allowlist + rate limit; qalpay still bound to 127.0.0.1
  7. ☐ Monitoring: daily /report reconciled; alert on 503s (escrow_underfunded) and anchor_retry_needed rows
  8. ☐ Refund path agreed with the PSP (§4 limits understood in writing)
  9. ☐ 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:

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}:

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.