Pick a provider.
Pay them.
Tell us how it went.
The payment router is a recommendation engine for payment providers, not a charge engine. It returns a name. You execute the charge yourself, with your own credentials and your own connections. You report the outcome. Over thousands of transactions, the recommendations sharpen.
Tune the weight formula. Watch it move.
weight = successRate × latencyPenalty × circuitMult × businessMultPrior belief (Beta)
Observations
Latency penalty
Latency penalty curve
Below the target, no penalty — the provider is "fast enough." Above target, exponential decay with sharpness k. The marker shows where your current observed p95 sits.
The three-call contract
Three HTTP calls between your service and the router. Everything else is implementation detail.
The router holds none of your credentials. It owns no provider SDKs. It cannot make a payment even if you asked it nicely. The contract is small on purpose: a recommendation, an execution you do yourself, and a report.
What you owe the router
Every POST /api/metrics/report must be honest. The router has no oracle — if you forget to report a failure, or report the latency wrong, it learns the wrong thing. Especially failures: silent failures look like the provider is fine, which is the opposite of what you want.
What the router owes you
One thing: pick the provider most likely to succeed quickly, given what you've told it so far. Not "the historically best provider" — the formula includes a 5% exploration rate so it discovers when previously-bad providers have recovered.
Step 3 splits in two. First you send { needsWebhook: true } — telling the router an answer is coming later. When the provider's webhook arrives at your service, you forward the final outcome via POST /api/metrics/webhook. The router idempotently merges late webhooks and gives up on ones that never arrive (default: 2 minutes). See chapter 03.
Four numbers, multiplied
Each provider's weight is the product of four terms. Each term defends against a failure mode the others can't catch.
formulaweight = successRate × latencyPenalty × circuitMultiplier × businessMultiplier // successRate = (α + successes) / (α + β + total) ← Beta-smoothed // latencyPenalty = p95 ≤ target ? 1 : exp(-k × (p95/target - 1)) // circuitMult = closed: 1.0 | half-open: 0.02 | open: 0 // businessMult = manual override (cost, contracts, regional pref)
Why four, not one
You could imagine a single metric — "vibes per provider." It would be wrong, because each of these is defending against a different failure mode. Take any one out and you can construct a scenario where the system makes a stupid choice:
| If you drop… | The router will… | Because |
|---|---|---|
| successRate | route to whichever provider is currently fastest | fast-but-flaky beats slow-but-reliable |
| latencyPenalty | route to whichever provider is currently most reliable | 3-second-but-always-works beats 200ms-95%-of-the-time |
| circuitMultiplier | keep retrying providers that just gave you five failures | weight only decays gradually, not stops |
| businessMultiplier | ignore everything you know about cost and contracts | the algorithm has no opinion on dollars or politics |
The Beta prior, in one paragraph
A brand-new provider has zero transactions. Naive success-rate math says 0/0 — undefined, or worse, 0. Adding the Beta prior means new providers start at α/(α+β) = 3/5 = 60% — high enough to get traffic, low enough to be quickly overridden by real data. The slider for α in the playground above changes how optimistic that starting position is.
The latency penalty, in one curve
Below the target, the multiplier is exactly 1.0 — "fast enough." Above the target, exponential decay:
// at the target → penalty = 1.00 (no reduction)
// at 1.33× target → penalty = 0.67 (33% reduction)
// at 2.00× target → penalty = 0.30 (70% reduction)
// at 3.00× target → penalty = 0.09 (91% reduction) The decay constant k controls how harsh the exponential is. k=0.5 is lenient (favors slow providers more), k=2.0 is brutal. The default k=1.2 is calibrated so that 2× target costs you about 70% of your weight — which felt right when we tuned against synthetic traffic, but is the first knob you should re-tune for your own SLA.
The exploration term, hidden in selection
One thing is not in the formula: the ε exploration rate. It doesn't enter the weight itself — it lives in the selection step. The router computes weights normally, then with probability ε (default 0.05) replaces the weighted draw with a uniform draw across eligible providers. That's how the system notices when a previously-bad provider has gotten better without you telling it to look.
Transaction flow: sync, async, the timeout in between
Two paths. The router decides which one you're on by the needsWebhook flag in your report.
Walk through both paths interactively below. The active step glows; use ←/→ to advance or click directly on the diagram.
Click a step to read what's happening at that point in the flow.
The pending webhooks map
The async path is held together by one in-memory Map in core/payment-router.ts:73: pendingWebhooks, keyed by transaction ID. When you report needsWebhook: true, an entry goes in. When the webhook arrives (or the 30-second timeout sweep fires), the entry comes out. The existence of the entry at lookup time is the entire idempotency check — if the entry's gone, the late webhook is silently dropped, because the timeout already counted the transaction as a failure.
The webhookTimeoutMs default is 120 seconds. If your provider's confirmations legitimately take longer (some bank transfers do), raise it before that latency starts costing you false-failure metrics.
Get it running in two minutes
All you need is Bun and a Dragonfly (Redis-compatible) container. The router builds its own SQLite file on first run.
The just recipe
terminal# brings Dragonfly up, applies pending migrations, starts the app on :3000 just dev # or as separate steps just up_dragonfly # Dragonfly only, detached bun run db:migrate:local # apply schema bun run dev # start dev server
Add a few providers
Open http://localhost:3000/admin, sign in (Google OAuth or dev bypass), and add at minimum these three to see weighted selection working:
| name | type | currencies | min | max | biz mult |
|---|---|---|---|---|---|
| stripe | card | NGN, USD | 100 | 1,000,000 | 1.0 |
| paystack | card | NGN | 100 | 500,000 | 1.0 |
| flutterwave | card | NGN, USD, GBP | 500 | 2,000,000 | 1.0 |
Verify by curling the health endpoint:
curl$ curl http://localhost:3000/api/health
{
"status": "healthy",
"providers": ["stripe", "paystack", "flutterwave"],
"pendingWebhooks": 0
} Generate some traffic
terminal# CLI simulator — hits the real router with synthetic transactions bun run simulate # 2 tps, 120s bun run scripts/simulate.ts --tps 10 --duration 60 --verbose # Web-based visual simulator on :3001 bun run simulator
Open http://localhost:3000/admin in another tab while the simulator runs. Watch the weight bars adjust in real time — that's the algorithm learning.
bun run dev does not auto-apply migrations. The canonical entry point is just dev, which runs db:migrate:local first. If you've just added a migration and want to skip the whole bring-up, bun run db:migrate:local against the existing SQLite file is enough.
Environment
.env# core DATABASE_URL=./sqlite/data.db REDIS_HOST=localhost REDIS_PORT=6379 # auth (better-auth + Google OAuth) BETTER_AUTH_SECRET=your-secret-min-32-chars GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... ALLOWED_EMAILS=ops@yourdomain.com # optional # algorithm tuning (all optional, defaults shown) EPSILON=0.05 P95_TARGET_MS=1500 LATENCY_PENALTY_K=1.2 FAILURE_THRESHOLD=5 OPEN_DURATION_MS=30000
What single-instance capacity actually looks like
Bun + native RedisClient + bun:sqlite, on consumer hardware, with the included scripts/benchmark.ts.
Apples-to-apples results from a recent run
200 tps target, 30s duration, 100 max concurrent. MacBook Pro Apple Silicon, Redis on the same host. No real provider integration — these are router-only numbers.
| metric | baseline | after | delta |
|---|---|---|---|
| actual tps | 174.6 | 173.3 | ≈ same (client-capped) |
| route p50 | 2 | 2 | — |
| route p95 | 4 | 3 | −25% |
| route p99 | 5 | 4 | −20% |
| report p50 | 2 | 2 | — |
| report p95 | 4 | 3 | −25% |
| e2e p50 | 5 | 4 | −20% |
| e2e p95 | 7 | 6 | −14% |
| e2e p99 | 10 | 8 | −20% |
| errors | 0 / 5237 | 0 / 5200 | clean |
Baseline: Bun 1.3.10, libsql client, raw EVAL on every Redis call.
After: Bun 1.3.14, bun:sqlite for local files, SCRIPT LOAD + EVALSHA Lua caching.
Throughput is identical because the bench client is rate-capped at the target. The interesting numbers are latency: percentile-wise the EVALSHA upgrade dropped ~20% across every endpoint while keeping zero errors.
Capacity planning rule of thumb
| target tps | router instances | redis setup | notes |
|---|---|---|---|
| < 100 | 1 | single, shared OK | dev / staging |
| 100–500 | 1 | single, dedicated | most production |
| 500–1000 | 2 | single, dedicated | medium production |
| 1000–2000 | 2–4 | single + replicas | large production |
| 2000+ | 4+ | Redis Cluster | enterprise |
The Dragonfly math
Each transaction is roughly four Dragonfly ops (ZRANGE WITHSCORES + Lua selection + HGETALL + ZADD/HMSET — the selection is still a Lua EVALSHA script; the weight write is now plain pipelined commands). A modest single Dragonfly instance handles 50,000+ ops/sec, which puts the store ceiling at ~12,500 router-side tps — usually far above the app-server ceiling. Dragonfly is rarely the bottleneck.
Running it yourself
terminal# light - sanity check bun run scripts/benchmark.ts --tps 10 --duration 30 # standard production simulation bun run scripts/benchmark.ts --tps 50 --duration 60 # heavy - what we used for the table above bun run scripts/benchmark.ts --tps 200 --duration 30 --concurrent 100