Design Decisions
Why things are built the way they are.
Why Not Raw Card Numbers?
Handling raw card numbers requires SAQ-D PCI compliance: 300+ security requirements, annual audits, and significant operational overhead.
By using Stripe.js for client-side tokenization, servers stay at SAQ-A (22 requirements). Card data never touches your infrastructure — clients send a Stripe PaymentMethod ID, not card numbers.
Why HMAC for Client Identity?
Client IDs are derived via HMAC-SHA256(card_fingerprint, server_secret). This provides:
Deterministic: Same card always maps to the same client ID on a given server. No registration needed.
Private: The card fingerprint cannot be recovered from the client ID (HMAC is a one-way function).
Isolated: Different servers use different secrets, so the same card produces different client IDs. No cross-service tracking.
No registration: Identity is derived from the payment itself — no email, password, or account creation.
Alternatives considered:
Hashing the fingerprint directly (SHA-256 without key): Attacker with a fingerprint database could precompute all client IDs. HMAC's server secret prevents this.
Random client IDs: Would require the client to store and present the ID. HMAC makes it deterministic — the server can always re-derive it from the card.
Why Not Stripe's Built-in Customer Balance?
Stripe's customer.balance is tightly coupled to their invoicing system — it automatically applies to the next invoice rather than being a general-purpose wallet.
For a flexible credits system with per-request deductions at microsecond latency, a custom ledger with atomic operations is more appropriate. Redis can deduct a balance in <1ms; a Stripe API call takes 200-500ms.
Why Atomic Balance Operations?
Concurrent API requests from the same client must not overdraw the balance. Consider:
Client has 500 units
Request A reads balance: 500
Request B reads balance: 500
Request A deducts 200: writes 300
Request B deducts 200: writes 300
Both succeed, but only 200 units were deducted instead of 400. The client got a free request.
Both stores use database-level atomicity:
Redis: Lua script checks and deducts in a single atomic operation (Redis is single-threaded, so Lua scripts run without interruption)
PostgreSQL:
UPDATE ... WHERE balance >= amount RETURNING balance— the WHERE clause prevents negative balances, and row-level locking prevents concurrent reads from both succeeding
Why Base64 Headers?
Following x402's convention: payment data is JSON-encoded then base64-encoded in HTTP headers. Benefits:
Keeps the body free: The response body can contain the actual resource (on 200) or a human-readable paywall page (on 402). Payment metadata rides alongside in headers without interfering.
Machine-readable: Automated clients and AI agents can parse headers without parsing the body.
HTTP-safe: Base64 avoids issues with special characters in header values.
Why Credits Instead of Per-Request Billing?
Stripe imposes a $0.50 minimum charge and a ~$0.30 fixed fee per transaction. If an API call costs $0.01:
Per-request: $0.30+ fees on a $0.01 charge (3,000% overhead, and below the $0.50 minimum anyway)
Credits: $0.30 fee on a $5.00 top-up (6% overhead, amortized over 500 requests)
The credits model makes micropayments economically viable on traditional payment rails.
Why CommonJS (Not ESM)?
The TypeScript is compiled to CommonJS ("module": "commonjs" in tsconfig). While ESM is the future, CommonJS provides:
Maximum compatibility with the Node.js ecosystem
Works in all Node.js versions without
--experimental-modulesCompatible with both
require()and dynamicimport()Most Stripe SDK and Express middleware examples use CommonJS
A future version may add dual ESM/CJS publishing.
Last updated