Why this matters
- StreamHub processes creator payouts, subscription billing, and tip transactions — payment bugs mean lost revenue, double charges, or regulatory fines.
- Idempotency is non-negotiable: network retries must never create duplicate charges.
- The ledger is append-only; balances are computed, never directly mutated.
Payment system components
- Payment gateway — Stripe, Adyen, or Razorpay handles card tokenisation and PCI compliance.
- Ledger service — double-entry accounting; every debit has a matching credit.
- Idempotency store — Redis or DB keyed by idempotency key; returns cached response on retry.
- Webhook processor — async confirmation from payment gateway (charge succeeded, payout completed).
- Reconciliation engine — daily batch compares internal ledger with gateway settlement reports.
- Payout service — batch creator earnings to bank accounts on a schedule.
Double-entry ledger
Payment platform architecture
Every transaction creates balanced journal entries — total debits equal total credits.
CREATE TABLE ledger_entries (
id UUID PRIMARY KEY,
journal_id UUID NOT NULL,
account_id UUID NOT NULL,
amount_cents BIGINT NOT NULL, -- positive = credit, negative = debit
currency CHAR(3) DEFAULT 'USD',
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE accounts (
id UUID PRIMARY KEY,
owner_id UUID,
account_type VARCHAR(32), -- 'user_wallet', 'platform_revenue', 'escrow'
currency CHAR(3) DEFAULT 'USD'
);
def process_tip(from_user: str, to_creator: str, amount_cents: int, idempotency_key: str):
journal_id = uuid4()
entries = [
{"account_id": user_wallet(from_user), "amount_cents": -amount_cents},
{"account_id": creator_wallet(to_creator), "amount_cents": amount_cents - platform_fee(amount_cents)},
{"account_id": platform_revenue(), "amount_cents": platform_fee(amount_cents)},
]
assert sum(e["amount_cents"] for e in entries) == 0
ledger.append_journal(journal_id, entries)
Idempotent payment API
POST /v1/payments/charge
Idempotency-Key: tip_usr42_crt99_20260401
Content-Type: application/json
{
"amount_cents": 500,
"currency": "USD",
"source": "pm_card_visa_4242",
"description": "Tip for creator crt_99"
}
def charge(request, idempotency_key: str):
cached = idempotency_store.get(idempotency_key)
if cached:
return cached # return same response on retry
result = stripe.PaymentIntent.create(
amount=request.amount_cents,
currency=request.currency,
payment_method=request.source,
confirm=True,
idempotency_key=idempotency_key
)
ledger.record_charge(result)
response = format_response(result)
idempotency_store.set(idempotency_key, response, ttl=86400)
return response
PCI compliance boundary
Card numbers never touch StreamHub servers. The client tokenises via Stripe.js.
const { paymentMethod } = await stripe.createPaymentMethod({
type: 'card',
card: cardElement,
});
// Send paymentMethod.id (pm_xxx) to server — never the card number
| Aspect | PCI scope | Approach |
|---|---|---|
| Card data collection | Stripe.js / Elements on client | SAQ-A eligibility (simplest) |
| Server-side charges | Payment method tokens only | Never store PAN/CVV |
| Webhooks | Verify Stripe signature | Prevent forged payment events |
| Payout bank details | Stripe Connect onboarding | Platform never sees bank account numbers |
| Audit | Immutable ledger + webhook log | Regulatory compliance evidence |
Card data collection
PCI scopeStripe.js / Elements on clientApproachSAQ-A eligibility (simplest)Server-side charges
PCI scopePayment method tokens onlyApproachNever store PAN/CVVWebhooks
PCI scopeVerify Stripe signatureApproachPrevent forged payment eventsPayout bank details
PCI scopeStripe Connect onboardingApproachPlatform never sees bank account numbersAudit
PCI scopeImmutable ledger + webhook logApproachRegulatory compliance evidence
Using Stripe Connect keeps StreamHub at SAQ-A — the lowest PCI compliance burden.
Webhook processing
Payment gateways confirm asynchronously via webhooks.
@app.post("/webhooks/stripe")
def stripe_webhook(request):
payload = request.body
sig = request.headers["Stripe-Signature"]
event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)
if event.type == "payment_intent.succeeded":
payment = event.data.object
ledger.confirm_charge(payment.id)
notify_creator(payment.metadata["creator_id"], payment.amount)
elif event.type == "payment_intent.payment_failed":
ledger.reverse_pending_charge(event.data.object.id)
notify_user_failure(event.data.object.metadata["user_id"])
return {"status": "ok"}
Process webhooks idempotently — Stripe may deliver the same event multiple times.
Reconciliation
Daily job compares internal ledger with Stripe settlement reports.
def reconcile(date: str):
internal = ledger.get_settled_transactions(date)
external = stripe.BalanceTransaction.list(created={"gte": date_start, "lt": date_end})
mismatches = []
for txn in internal:
stripe_txn = external.get(txn.stripe_id)
if not stripe_txn or stripe_txn.amount != txn.amount_cents:
mismatches.append(txn)
if mismatches:
alert_finance_team(mismatches)
create_adjustment_entries(mismatches)
Scale estimates
| Metric | Estimate |
|---|---|
| Daily transactions | 2M |
| Peak TPS | ~500 |
| Average transaction | $4.50 |
| Daily volume | $9M |
| Creator payouts per month | 50K |
| Reconciliation mismatches | < 0.01% |
| Idempotency key TTL | 24 hours |
Daily transactions
Estimate2MPeak TPS
Estimate~500Average transaction
Estimate$4.50Daily volume
Estimate$9MCreator payouts per month
Estimate50KReconciliation mismatches
Estimate< 0.01%Idempotency key TTL
Estimate24 hours
Ledger writes are low-volume relative to reads. Optimise for correctness and auditability, not raw throughput.
Quick recall
Everything you need if you only revisit this box.
- Double-entry ledger: every debit has a matching credit; balances are computed, never mutated.
- Idempotency keys prevent duplicate charges on network retries — cache response for 24 hours.
- PCI boundary: card data stays on Stripe.js client; server handles tokens only.
- Webhooks confirm payments asynchronously; process idempotently with signature verification.
- Daily reconciliation catches mismatches between internal ledger and gateway settlements.
- Store money as integer cents — never floating-point.
Test yourself
Answer these before moving on — recall is what makes it stick.