Payment Router· Documentation
A handbook · payment-router/DOC

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.

00 · Playground

Tune the weight formula. Watch it move.

weight = successRate × latencyPenalty × circuitMult × businessMult

Prior belief (Beta)

3
2

Observations

85
15

Latency penalty

1500
1.2
1800
success rate0.838
latency penalty0.670
→ weight0.561

Latency penalty curve

1.00.750.50.250 observed p95 (ms) target
0 ms2.0× target4.0× target
Reading the 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.

01

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.

Your Service Payment Router Provider API 1. GET /api/route?amount=5000&currency=NGN { provider: "stripe", txn: "abc", weight: 0.85 } 2. You execute the payment stripe.charge(order) — with your VPN, your keys { success: true, latencyMs: 342 }3. POST /api/metrics/report { txn: "abc", provider: "stripe", success: true, latencyMs: 342 } { received, newWeight: 0.87 }
Fig. 1One transaction across the contract. Solid black lines are HTTP calls you make; orange lines are the router's responses. The router never reaches out to the provider — your service is in the middle of every exchange.

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.

For async providers (3DS, bank transfers)

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.

02

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
successRateroute to whichever provider is currently fastestfast-but-flaky beats slow-but-reliable
latencyPenaltyroute to whichever provider is currently most reliable3-second-but-always-works beats 200ms-95%-of-the-time
circuitMultiplierkeep retrying providers that just gave you five failuresweight only decays gradually, not stops
businessMultiplierignore everything you know about cost and contractsthe 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.

03

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.

step 1 / 5
Your Service Router Provider
Step 1

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.

Watch this

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.

04

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:

nametypecurrenciesminmaxbiz mult
stripecardNGN, USD1001,000,0001.0
paystackcardNGN100500,0001.0
flutterwavecardNGN, USD, GBP5002,000,0001.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.

Heads up

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
05

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.

metricbaselineafterdelta
actual tps174.6173.3≈ same (client-capped)
route p5022
route p9543−25%
route p9954−20%
report p5022
report p9543−25%
e2e p5054−20%
e2e p9576−14%
e2e p99108−20%
errors0 / 52370 / 5200clean

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 tpsrouter instancesredis setupnotes
< 1001single, shared OKdev / staging
100–5001single, dedicatedmost production
500–10002single, dedicatedmedium production
1000–20002–4single + replicaslarge production
2000+4+Redis Clusterenterprise

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