One checkout.
Every market.
Zero vendor lock.
Reactive payment orchestration for modern web apps. The right provider, the right methods, the right flow — picked in the browser, the moment your app knows enough to decide. Headless, typed, MIT.
A checkout that knows what your app knows.
Every modern SDK lets you take a payment. Tenderlane decides which payment to take, in the browser, the moment context changes — with types that hold across providers, markets, and methods.
Adapts the second your app does.
When the customer changes country, currency, or cart — the checkout reroutes in the same render. Right provider. Right methods. Right flow. No reload. No server round-trip.
Your UI. Your stack. Any PSP.
Headless components, framework-agnostic core, swappable adapters. Start on Stripe. Add Polar for subscriptions. Drop in your local PSP. The router does not care.
Routing rules are data, not code.
A JSON shape with first-match-wins semantics. Edit them in a dashboard. Store them in a DB. Ship them from a feature flag. Catch the typo in "gpb" at compile time.
What checkout routing usually looks like.
Same requirement — "show TWINT in Switzerland, fall back to Stripe Checkout everywhere else." Left: how teams ship it today. Right: how it reads with Tenderlane.
const [provider, setProvider] = useState('stripe');
const [methods, setMethods] = useState(['card']);
const [flow, setFlow] = useState('checkout-session');
// re-run when country changes
useEffect(() => {
if (country === 'CH') {
setFlow('payment-intent');
setMethods(['card', 'twint']);
} else if (country === 'NL') { /* … */ }
}, [country]);
// re-run when currency changes
useEffect(() => { /* … */ }, [currency]);
// 14 more conditionals. Untyped. Sticky. - Untyped strings everywhere — typos surface in production
- Order depends on render timing, not declaration
- Races when context updates mid-fetch
- Rules live in code — ops can't A/B test or hot-patch
import { createRulesRouter } from '@tenderlane/core';
export const routing = createRulesRouter({
rules: [
{ id: 'ch-inline',
when: { country: 'CH', currency: 'chf' },
use: { provider: 'stripe', flow: 'payment-intent',
paymentMethods: ['card', 'twint'] } },
{ id: 'nl-ideal',
when: { country: 'NL' },
use: { paymentMethods: ['card', 'ideal', 'sepa'] } },
],
fallback: { flow: 'checkout-session',
paymentMethods: ['card'] },
});
// re-evaluates automatically when context changes.
// rules are JSON: store anywhere, edit anywhere. - Literal-typed end-to-end — country, currency, method, flow
- First-match-wins is the contract, not a side-effect
- Re-evaluates synchronously with context — no races
- Rules are JSON: A/B test them, store them, ship them from a flag
Four layers. Each one swappable. Each one MIT.
A core that knows nothing about your framework, a headless state machine on top, framework bindings, and provider adapters. TanStack-inspired — and the browser bundle pays only for what each route uses.
Types, rules router, auto router, middleware, errors, catalog, server handler. Zero runtime deps.
Framework-agnostic headless state machine. idle → ready → submitting → success.
React provider + hooks via useSyncExternalStore. Vue/Svelte planned.
Per-PSP. Stripe and Polar today. Browser SDK lazy-loaded only when the active route needs it.
* The browser bundle only loads provider SDKs after the active route resolves to that provider — pure redirect flows can ship without any PSP JS at all.
The whole integration, on screen.
One file. One provider. One rule. Render a button — get a charge. Everything else (A/B tests, custom adapters, server-side rules) composes on top of this exact shape.
import { TenderlaneProvider, useTenderlaneCheckout } from '@tenderlane/react';
import { createRulesRouter } from '@tenderlane/core';
import { stripeProvider } from '@tenderlane/stripe';
const stripe = stripeProvider({ publishableKey: 'pk_…', serverEndpoint: '/api/pay' });
const routing = createRulesRouter({
rules: [
{ id: 'ch-inline',
when: { country: 'CH', currency: 'chf' },
use: { provider: 'stripe',
flow: 'payment-intent',
paymentMethods: ['card', 'twint'] } },
],
fallback: { provider: 'stripe',
flow: 'checkout-session',
paymentMethods: ['card'] },
});
function Checkout() {
const checkout = useTenderlaneCheckout();
return <button disabled={!checkout.canSubmit} onClick={checkout.submit}>Pay</button>;
}
<TenderlaneProvider config={{ context, providers: [stripe], routing }}>
<Checkout />
</TenderlaneProvider> See it route, before you ship it.
A live playground and two cookbooks that match the patterns most teams reach for first.
Reactive routing playground
Change country, currency, and amount — watch the route, methods, and total update in real time.
{ country: 'CH', currency: 'chf' }
// → ch-inline · card, twint Country-based routing
TWINT in Switzerland, iDEAL in the Netherlands, Pix in Brazil. One rules table, declarative and serializable.
when: { country: { in: ['NL', 'BE'] } }
// → ideal, bancontact Stripe Checkout redirect
Wire the Stripe provider, register routing rules, hand the user off — the typed way.
await checkout.submit({ items, successUrl });
// → redirect to Stripe-hosted page Three rules the codebase lives by.
Tenderlane is not a backend payment wrapper. It is the connective tissue between application state and the PSPs that take money — and it has opinions.
Frontend-first orchestration
Routing happens in the browser, where the app already knows the user's country, currency, experiment variant, and cart shape. The server-side handler exists to mint sessions and re-verify price — not to decide which payment method to render. State changes the UI; the UI doesn't wait on state.
Typed end-to-end
Phantom `~types` metadata carries literal provider IDs, capability sets, and context shapes through every generic boundary. Add a provider, get auto-complete for its methods. Tighten a context, get a compile error on a stale rule. Nothing is stringly-typed where it could be a literal.
Provider-agnostic by default
`@tenderlane/core` has zero runtime dependencies and never imports a PSP SDK. Stripe, Polar, and every future adapter live behind the same contract. Swap providers, run two side-by-side, or A/B-test them — without rewriting your checkout UI or your server.
Shipping in the open.
Every tag is a GitHub Release with notes. The live npm registry is the source of truth for what's installable today.
Release notes live on GitHub. The full history — every version, every fix, every breaking change — is one click away.
Honest scope. Pre-1.0.
We'd rather you know what's missing than discover it on launch day. This list reflects the alpha. The roadmap moves in public.
- Core types + rulesRouter + autoRouter
- React bindings via useSyncExternalStore
- Stripe adapter — Checkout + PaymentIntent
- Polar adapter — redirect flow
- Server handler — Web Request/Response
- Catalog primitive (inline · remote · PSP-sourced)
- Phantom-type provider metadata
- Polar iframe embedded flow
- Cross-PSP webhook verification helpers
- Subscriptions, refunds
- Catalog response caching
- lookup_key support for Stripe catalogs
- Stripe Elements (field-level)
- Vue / Solid / Svelte bindings
- Adyen, Paddle, LemonSqueezy, Revolut adapters
- Failover · ML routing · devtools
- First v1.0 stable promise
Don't request a demo.
Just read the code.
MIT. On npm. On GitHub. No sales call. No "talk to us." If you have an hour and a Stripe account, you can have a multi-region routed checkout running this afternoon.