8 min read

If you self-host Rekey and need a payment provider it doesn't ship, Stripe, PayPal, or something else entirely, adding one is now a single self-describing directory instead of sixteen scattered edits across the API and both UIs. Here is what changed, and the adversarial audit that forced the question in the first place.

First, an audit

If you run a payments product, the scariest bugs are the quiet ones. So the whole surface got an adversarial pass, reviewers briefed to assume the code was guilty, plus fresh-eyes walkthroughs of the dashboard and the hosted customer portal as if they'd never seen either. Some of what came back was humbling:

  • The failed-payment webhook handlers recorded the payment, flipped the subscription, and opened the dunning case as three separate writes. A crash in the middle left the books inconsistent until the provider retried. They're now a single transaction, and replays can't double-fire anything.
  • A whole class of brand-colored focus rings and tints had been silently generating no CSS at all. It was a Tailwind quirk where an opacity modifier on a CSS-variable class compiles to nothing, invisibly. Eighty-plus occurrences, all shipping transparent. Keyboard users effectively had no focus indicator. Fixed with color-mix(), and the convention is now written down so it can't sneak back.
  • The customer portal had four dead-ends: no password recovery, no path for two-factor accounts, no billing history, and an error message that said “contact support” with no way to do so. All four are closed, and it turned out the API already supported most of it; the portal just never wired it up.

None of this is fun to find, but better to find it before you rely on it. Every fix landed with the tests that would have caught it, and the code those tests guard is public in the repository. To be exact about what that does and does not mean: the source is there under MIT and it is the source that runs in production, but the mirror is published one release at a time, day-to-day commits, pull requests and design notes live in a private repository and are not part of it.

Sixteen places to touch

Rekey ships with Stripe, PayPal, and Razorpay behind one BillingProvider interface. That interface was honest about the outbound half, creating checkouts, registering plans, canceling subscriptions. But answering “what does it take to add a fourth provider?” came to roughly sixteen places: a bespoke webhook route, a bespoke signature check, a bespoke event-dispatch switch, hand-written credential types and validators, provider enums repeated across the API and both UIs, and label maps in two SDKs.

Worse, the three built-in providers had each solved the same problems slightly differently. Two verified webhooks with offline HMAC; one phones home to verify. Two scope events by URL; one tucks the application id into payload metadata. One has no native “subscription renewed” event at all, so a workaround synthesized it. Every difference lived in a different file.

One provider, one directory

The fix is a provider module: a single self-describing directory that tells Rekey everything it needs to know about a processor. The outbound interface stays exactly as it was. What's new is that the inbound half, webhook verification and event translation, joins the contract instead of living in per-provider routes:

providers/modules/<name>/
  name, display, capabilities   // label, geo defaults, quirks as flags
  credentialSchema              // drives validation + storage (panel forms next)
  webhook: {
    resolveApplication()        // whose event is this?
    verify()                    // is it really from the processor?
    extractEventId(), extractEventType()  // idempotency + event-type record
    translate()                 // processor event -> normalized events
  }

A module's translate maps processor payloads onto a small fixed set of domain events: payment.succeeded, subscription.past_due, subscription.period_advanced, and friends, and the shared pipeline does the rest: one route for every provider, centralized idempotency, and the same transactional appliers hardened in the audit above. Provider quirks became capability flags. The processor that verifies online declares onlineVerify: true; the one without renewal events declares it, and the pipeline synthesizes the rotation the same way for everyone.

The payoff: once all three built-in providers were ported, every bespoke handler is gone. The old webhook URLs still work, they forward into the new pipeline, which means the existing webhook test suite validated the rewrite without changing a single expectation, and your integration did not need to change either.

Why not npm plugins?

Letting you npm install a community provider was tempting, and the answer is: not yet. This code path moves money and holds decrypted processor keys. Dynamically loading third-party code into that process is a supply-chain risk with no honest isolation story, sandboxing is theater when the plugin legitimately needs the keys and the network. In-tree modules get review, typechecking against the real appliers, and a fixture-replay suite in CI. If you self-host and need a provider that is not built in, a pull request that adds one directory is exactly what the system was built for, the guide ships in the repository at docs/billing-providers.md. The interface will become a published, versioned contract later, once it's been hardened by real use.

Where this sits

A fair question is when this is the right shape at all, so here it is plainly. If what you want is an auth library inside your TypeScript app, with nothing extra to run, a library is the lighter answer and you should probably use one. Rekey is a different shape: a self-hosted service where auth and billing share one tenant model, who a user is and what they pay for live in the same record, so access checks and payment state read from one source of truth instead of drifting apart across two systems. That coupling is the whole point, and it's what a pure auth library (or a billing-only platform) can't give you without you building the sync yourself. A hosted auth service can solve sign-in well too, but not on your infrastructure, and not with your processor keys. Rekey is the option for when you want both halves, running on your own boxes, with the money path treated like the critical section it is.

The shape of the month

A payments product earns trust in the boring places: transactions that can't half-commit, webhooks that can't double-fire, a portal that doesn't strand the customer, and an architecture where the next integration makes the system simpler instead of heavier. That's what this month was for. Everything above is live, self-hostable, and in the source the fixes and the structure they produced, there to read and to run.

Add a payment provider Rekey doesn't ship, in one directory