stripe.PaymentIntent.create(amount=1000, currency="usd", idempotency_key=unique_key)payment_intent.succeeded webhook as the canonical confirmation. [src7]| Component | Role | Technology Options | Scaling Strategy |
|---|---|---|---|
| API Gateway | Rate limiting, auth, routing, idempotency-key extraction | Kong, AWS API Gateway, Cloudflare, Nginx | Horizontal; stateless |
| Payment Service | Orchestrates payment lifecycle (authorize, capture, refund) | Custom service (Node.js/Go/Java) | Horizontal with sticky sessions per idempotency key |
| Idempotency Store | Deduplicates requests using idempotency keys | Redis (TTL 24-72h), DynamoDB | Redis Cluster; TTL-based eviction |
| PSP Adapter | Abstracts payment gateway APIs (Stripe, Adyen, etc.) | Adapter pattern per PSP | One adapter per PSP; circuit breakers per provider |
| Ledger Service | Double-entry bookkeeping, immutable transaction log | PostgreSQL, CockroachDB, custom append-only | Write-ahead log; partitioned by time |
| Fraud Detection | Rules engine + ML scoring (velocity, device fingerprint) | Stripe Radar, Sift, custom rules engine | Async scoring; circuit breaker on ML service |
| Webhook Processor | Receives and processes PSP webhooks | Queue-backed worker (SQS, Kafka, RabbitMQ) | Horizontal workers; partition by merchant |
| Notification Service | Sends payment confirmations (email, push, in-app) | Event-driven consumer (Kafka/SQS) | Independent consumer scaling |
| Reconciliation Engine | Matches ledger entries against PSP settlement files | Batch job (daily/hourly) | Time-partitioned; parallel per PSP |
| Currency Service | Exchange rates, multi-currency conversion | Open Exchange Rates API, ECB feeds, Redis cache | Cached rates; refresh every 5-60 min |
| Retry Manager | Handles failed payment retries with backoff | Exponential backoff + jitter, dead letter queue | Queue-based; configurable per failure type |
| Circuit Breaker Mesh | Isolates PSP failures (Visa down != Mastercard down) | Resilience4j, Polly, custom per-PSP breakers | Per-PSP thresholds; auto-recovery |
| Audit Trail | Immutable log of all payment events for compliance | Append-only store (S3, event store, audit table) | Append-only; archive to cold storage |
START
|-- What is your PCI compliance posture?
| |-- Cannot handle any card data -> Use hosted checkout (Stripe Checkout, Adyen Drop-in)
| |-- Can use client-side tokenization -> Use Elements/Hosted Fields + server-side PaymentIntents
| |-- Full PCI Level 1 (SAQ D) -> Direct API integration with raw card data (rare, expensive)
|
|-- Expected transaction volume?
| |-- <1K txn/day -> Single payment service + PostgreSQL ledger + single PSP
| |-- 1K-100K txn/day -> Payment service + Redis idempotency + async webhooks + single PSP
| |-- 100K-1M txn/day -> Microservices + event-driven + multi-PSP with failover + dedicated ledger
| |-- >1M txn/day -> Full payment orchestration platform + sharded ledger + active-active regions
|
|-- Multi-PSP needed?
| |-- Single region, one payment method -> Single PSP is fine
| |-- Multi-region or cost optimization -> Payment orchestration layer (adapter pattern)
| |-- Regulatory requirements per region -> Local acquiring with PSP routing rules
|
|-- Subscription/recurring payments?
| |-- YES -> Add billing service with Stripe Billing / custom dunning logic
| |-- NO -> One-time PaymentIntent flow is sufficient
|
|-- DEFAULT -> Start with single PSP (Stripe), idempotent payment service, PostgreSQL ledger, webhook processor
Minimize PCI scope by using client-side tokenization. Stripe Elements, Adyen Drop-in, or Braintree Hosted Fields collect card data in an iframe -- your servers never see raw card numbers. This reduces your PCI compliance from SAQ D (400+ controls) to SAQ A (22 controls). [src3]
PCI Scope Levels:
- SAQ A: Fully hosted payment page (Checkout links) -> ~22 controls
- SAQ A-EP: Client-side tokenization (Elements/Drop-in) -> ~139 controls
- SAQ D: Direct card data handling (raw API) -> ~400+ controls
Decision: Use SAQ A or SAQ A-EP unless you have a dedicated PCI compliance team.
Verify: Check your Stripe Dashboard > Settings > Compliance to confirm your SAQ level.
Create a payment service that accepts idempotency keys on every mutation. Store the key + response in Redis (TTL 24-72h). On duplicate requests, return the cached response. [src2]
Request Flow:
1. Client generates UUID idempotency key
2. API Gateway extracts key, passes to Payment Service
3. Payment Service checks Redis for existing key
- EXISTS -> return cached response (200 OK)
- NOT EXISTS -> set key as "processing" in Redis
4. Call PSP API with idempotency key
5. Store PSP response in Redis with key
6. Write to ledger
7. Return response to client
Verify: Send the same payment request twice with the same idempotency key -- you should get identical responses and only one charge.
Every payment creates balanced ledger entries. A $100 charge from customer to merchant creates: debit customer account $100, credit merchant account $100. The sum across all entries is always zero. [src4]
CREATE TABLE ledger_entries (
id BIGSERIAL PRIMARY KEY,
transaction_id UUID NOT NULL,
account_id UUID NOT NULL,
entry_type VARCHAR(6) NOT NULL CHECK (entry_type IN ('debit', 'credit')),
amount_cents BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB
);
-- Immutability: no UPDATE or DELETE allowed
-- Invariant: SUM(debit) - SUM(credit) = 0
Verify: SELECT SUM(CASE WHEN entry_type='debit' THEN amount_cents ELSE -amount_cents END) FROM ledger_entries; must return 0.
Never trust a webhook without verifying its signature. Process webhooks asynchronously: acknowledge with 200 immediately, then enqueue for processing. Deduplicate by event ID. [src7]
Webhook Processing Pipeline:
1. Receive POST from PSP
2. Verify signature (HMAC-SHA256 or asymmetric)
3. Respond 200 OK immediately (< 5 seconds)
4. Enqueue event to message queue (SQS/Kafka/RabbitMQ)
5. Worker dequeues event
6. Check event ID in processed_events table (idempotent)
7. Update payment status, ledger, emit notifications
Verify: Replay a webhook event -- your system should process it exactly once.
PSPs send daily settlement files listing all completed transactions and fees. Your reconciliation engine compares these against your ledger to detect discrepancies. [src1]
Reconciliation Process:
1. Download PSP settlement file (CSV/JSON, usually T+1 or T+2)
2. Parse into normalized transaction records
3. For each record: find matching ledger entry by PSP transaction ID
4. Compare amounts (gross, fee, net)
5. Flag mismatches: MATCHED / AMOUNT_MISMATCH / MISSING_IN_LEDGER / MISSING_IN_PSP
6. Auto-resolve known patterns; alert on unresolved discrepancies
Verify: Run reconciliation on a test day -- all transactions should be MATCHED or have documented exceptions.
When using multiple PSPs, isolate failures so one provider's outage does not bring down the entire payment system. Each PSP gets its own circuit breaker. [src5]
Circuit Breaker States:
- CLOSED (normal): Requests flow through. Track failure rate.
- OPEN (tripped): All requests fail fast. Redirect to fallback PSP.
- HALF-OPEN (test): Allow limited requests to test recovery.
Per-PSP Configuration:
- Stripe: failure_threshold=5%, window=60s, cooldown=30s
- Adyen: failure_threshold=5%, window=60s, cooldown=30s
- Fallback: Always-on backup PSP for critical payments
Verify: Simulate PSP timeout -- traffic should route to fallback within the cooldown period.
# Input: order_id, amount in cents, currency
# Output: PaymentIntent object or cached result
import stripe
import uuid
stripe.api_key = "sk_live_..." # Use env var in production
def create_payment(order_id: str, amount_cents: int, currency: str = "usd"):
idempotency_key = f"pay_{order_id}" # Deterministic: same order = same key
try:
intent = stripe.PaymentIntent.create(
amount=amount_cents, # Integer cents, never float
currency=currency,
metadata={"order_id": order_id},
idempotency_key=idempotency_key,
)
return {"status": "ok", "client_secret": intent.client_secret, "id": intent.id}
except stripe.error.CardError as e:
return {"status": "card_error", "message": e.user_message}
except stripe.error.IdempotencyError:
return {"status": "idempotency_conflict", "message": "Retry with new key"}
// Input: Stripe webhook POST request
// Output: 200 OK (idempotent -- safe to receive duplicates)
const stripe = require("stripe")("sk_live_...");
const express = require("express");
const app = express();
const processedEvents = new Set(); // Use Redis in production
app.post("/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, "whsec_...");
} catch (err) {
return res.status(400).send(`Signature verification failed`);
}
res.status(200).json({ received: true }); // ACK immediately
if (processedEvents.has(event.id)) return; // Idempotent
processedEvents.add(event.id);
if (event.type === "payment_intent.succeeded") {
await fulfillOrder(event.data.object.metadata.order_id);
}
}
);
# Input: payment request with idempotency key
# Output: payment result (same result for same key)
import hashlib, json, uuid
from datetime import datetime, timedelta
def handle_payment(db, redis, request):
idem_key = request.headers.get("Idempotency-Key")
if not idem_key:
return {"error": "Idempotency-Key header required"}, 400
# Check Redis for cached response (fast path)
cached = redis.get(f"idem:{idem_key}")
if cached:
return json.loads(cached), 200
# Acquire lock to prevent concurrent processing of same key
lock = redis.set(f"idem_lock:{idem_key}", "processing", nx=True, ex=30)
if not lock:
return {"error": "Request in progress"}, 409
try:
result = process_payment(db, request.json)
redis.setex(f"idem:{idem_key}", 86400, json.dumps(result)) # Cache 24h
return result, 200
finally:
redis.delete(f"idem_lock:{idem_key}")
# BAD -- floating-point causes rounding errors in financial calculations
price = 19.99
tax = price * 0.0825 # 1.649175
total = price + tax # 21.639175
charged = round(total, 2) # 21.64 -- but cumulative rounding diverges at scale
# GOOD -- integer arithmetic is exact for money
price_cents = 1999
tax_cents = 165 # Pre-calculated or use integer math: 1999 * 825 // 10000
total_cents = price_cents + tax_cents # 2164 -- exact, no rounding errors
# Convert to display: f"${total_cents / 100:.2f}" -> "$21.64"
// BAD -- network retry creates duplicate charge
app.post("/charge", async (req, res) => {
const charge = await stripe.charges.create({
amount: req.body.amount,
currency: "usd",
source: req.body.token,
// No idempotency key -- if client retries, customer is charged twice
});
res.json(charge);
});
// GOOD -- idempotency key prevents double charges on retry
app.post("/charge", async (req, res) => {
const charge = await stripe.paymentIntents.create(
{ amount: req.body.amount, currency: "usd",
automatic_payment_methods: { enabled: true } },
{ idempotencyKey: req.body.order_id } // Same order = same key
);
res.json(charge);
});
# BAD -- relying solely on API response for payment status
intent = stripe.PaymentIntent.create(amount=1000, currency="usd")
if intent.status == "succeeded":
fulfill_order(order_id) # Race condition: status may change
# GOOD -- webhook is canonical payment confirmation
@app.route("/webhook", methods=["POST"])
def stripe_webhook():
payload = request.data
sig = request.headers.get("Stripe-Signature")
try:
event = stripe.Webhook.construct_event(payload, sig, WEBHOOK_SECRET)
except (ValueError, stripe.error.SignatureVerificationError):
return "Invalid signature", 400
if event["type"] == "payment_intent.succeeded":
fulfill_order(event["data"]["object"]["metadata"]["order_id"])
return "", 200
-- BAD -- updating ledger entries destroys audit trail
UPDATE ledger_entries SET amount_cents = 500 WHERE id = 12345;
-- No trace of what the original amount was or why it changed
-- GOOD -- corrections are new entries that reverse the original
INSERT INTO ledger_entries (transaction_id, account_id, entry_type, amount_cents, currency)
VALUES ('txn_abc_rev', 'customer_1', 'credit', 1000, 'USD'), -- reversal
('txn_abc_rev', 'merchant_1', 'debit', 1000, 'USD'), -- reversal
('txn_abc_v2', 'customer_1', 'debit', 500, 'USD'), -- corrected
('txn_abc_v2', 'merchant_1', 'credit', 500, 'USD'); -- corrected
processed_webhooks table and check before processing. Return 200 immediately, process asynchronously. [src7]0.1 + 0.2 !== 0.3 in IEEE 754. Across millions of transactions, rounding errors accumulate into material discrepancies. Fix: use integer cents/minor units for all storage and computation. [src1]# Check Stripe API connectivity and authentication
curl -s https://api.stripe.com/v1/balance -u sk_test_...: | python3 -m json.tool
# Verify webhook signature configuration
stripe listen --forward-to localhost:4242/webhook --log-level debug
# Test idempotency -- second request should return same response
IDEM_KEY=$(uuidgen)
curl -X POST https://api.stripe.com/v1/payment_intents \
-u sk_test_...: \
-H "Idempotency-Key: $IDEM_KEY" \
-d amount=1000 -d currency=usd
# Verify ledger balance (must always be zero)
psql -c "SELECT SUM(CASE WHEN entry_type='debit' THEN amount_cents ELSE -amount_cents END) AS balance FROM ledger_entries;"
# Check for orphaned transactions (in ledger but not in PSP)
psql -c "SELECT transaction_id FROM ledger_entries WHERE transaction_id NOT IN (SELECT psp_transaction_id FROM psp_settlements WHERE settlement_date = CURRENT_DATE - 1);"
# Monitor payment service circuit breaker states
curl -s http://localhost:8080/actuator/circuitbreakers | python3 -m json.tool
| Standard/API | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| PCI DSS v4.0.1 | Current (mandatory since 2025-03-31) | MFA for all CDE access; script controls on payment pages; continuous compliance | All future-dated requirements now mandatory |
| PCI DSS v3.2.1 | Retired (2024-03-31) | -- | Must upgrade to v4.0.1 |
| Stripe PaymentIntents API | Current (recommended) | Replaces Charges API | Use payment_intents.create() instead of charges.create() |
| Stripe Charges API | Legacy (still functional) | -- | Migrate to PaymentIntents for SCA/3DS2 support |
| PSD2/SCA (EU) | Mandatory | 3D Secure 2 required for EU card payments | PaymentIntents API handles SCA automatically |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Building a marketplace or SaaS with custom payment flows | Selling <100 items with simple checkout | Stripe Checkout / Shopify / hosted payment page |
| Need multi-PSP failover for reliability | Single-PSP integration meets all requirements | PSP's official SDK and hosted UI |
| Processing >10K transactions/day with custom business logic | Internal tool with no real money movement | Simple database transactions without payment infrastructure |
| Regulatory requirements demand payment data isolation | Prototyping or MVP with no real payments | Stripe test mode with minimal architecture |
| Need custom reconciliation, reporting, or settlement logic | Standard e-commerce with out-of-box solutions | WooCommerce, Shopify, or platform-native payments |