v0.4.0 · pre-1.0 alpha MIT frontend-first orchestration

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.

$ pnpm add @tenderlane/core @tenderlane/react @tenderlane/stripe
routing.ts re-evaluating
// context
{ country: ' ' , currency: 'chf' , amount: 2900 }
// rules · first-match-wins
// fallback
{ flow: 'checkout-session', methods: [ 'card'] }
Total · CHF
CHF 29.00
ROUTE · ch-inline
Flow
payment-intent
Provider
stripe
Try it · change country
Currently shipping with
StripePolarReactNext.jsBunDenoVite
docs →
What's different

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.

01

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.

02

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.

03

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.

Before → after

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.

Hand-rolled checkout.tsx · brittle, untyped, growing
hand-rolled/checkout.tsx
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
tenderlane routing.ts · declarative, typed, portable
tenderlane/routing.ts
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
Architecture

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.

YOUR APP STATE country currency cart.amount user.tier ab.variant L1 · CORE @tenderlane/core types · errors rulesRouter first-match-wins autoRouter remote delegate middleware onEvaluated · onSuccess L2 · CLIENT client (state machine) framework-agnostic idle evaluating ready preparing prepared submitting success / error L3 · BINDINGS react TenderlaneProvider vue planned svelte planned vanilla use client directly L4 · ADAPTERS stripe shipped · v0.4 polar shipped · v0.4 adyen planned paypal planned your-psp write the adapter SERVER · isomorphic Web Request/Response handler @tenderlane/core/server accepts { provider, action, payload } — runs anywhere Web APIs run Next.js Bun Deno CF Workers Vercel SvelteKit
L1 · core
@tenderlane/core

Types, rules router, auto router, middleware, errors, catalog, server handler. Zero runtime deps.

L2 · client
@tenderlane/client

Framework-agnostic headless state machine. idle → ready → submitting → success.

L3 · bindings
@tenderlane/react

React provider + hooks via useSyncExternalStore. Vue/Svelte planned.

L4 · adapters
@tenderlane/stripe · @tenderlane/polar

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.

From npm to charge

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.

01
Install
Three packages. Zero peer-dep hell.
02
Declare your context
The browser state your rules read.
03
Write rules — or import them
JSON in code, in a DB, in a feature flag. Same shape.
04
Mount the provider
A React tree subscribes; UI updates when context does.
05
Render headless checkout
You own the button. We own the state machine.
src/checkout.tsx · the whole thing
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>
What's not done yet

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.

shipped
  • 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
in design
  • Polar iframe embedded flow
  • Cross-PSP webhook verification helpers
  • Subscriptions, refunds
  • Catalog response caching
  • lookup_key support for Stripe catalogs
not yet
  • Stripe Elements (field-level)
  • Vue / Solid / Svelte bindings
  • Adyen, Paddle, LemonSqueezy, Revolut adapters
  • Failover · ML routing · devtools
  • First v1.0 stable promise
CONTRIBUTING
If you need an adapter or binding that isn't here, open an issue. The provider contract is four methods.
Open issues

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.

$ pnpm add @tenderlane/core @tenderlane/react @tenderlane/stripe