Blog Engineering

One directory per payment provider

8 min read

We spent the last month doing two unglamorous things: trying to break ReliPay before anyone else does, and rebuilding how payment providers plug into it. This post is the honest version of both — what we found, what we fixed, and why adding a payment provider is now one directory instead of sixteen scattered edits.

First, we audited ourselves

If you run a payments product, the scariest bugs are the quiet ones. So we ran an adversarial pass over the whole surface — 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:

  • Our 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 — 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 was fun to find. All of it was better to find ourselves. Every fix landed with the tests that would have caught it, and the full trail is public in the repository — commits, pull requests, and the design notes.

Then we counted to sixteen

ReliPay 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 when we sat down to answer “what does it take to add a fourth provider?”, the count 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, our three 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 ReliPay 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)
  createProvider()              // outbound: checkout, cancel, plans
  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 we hardened in the audit. 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 satisfying part: once all three built-in providers were ported, we deleted every bespoke handler. The old webhook URLs still work — they forward into the new pipeline, which meant our entire existing webhook test suite validated the rewrite without changing a single expectation.

Why not npm plugins?

We thought hard about letting you npm install a community provider, and decided: 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 we don't ship, 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 is designed to become a published, versioned contract later, once it's been hardened by real use.

Where this sits

A fair question is how this compares to the tools you already know, so here it is plainly. If you want an auth library inside your TypeScript app, better-auth is excellent — bigger community, rich plugin ecosystem, minimal ops, and if that's your shape of problem you should probably use it. ReliPay 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. Hosted platforms like Clerk solve auth well too — but not on your infrastructure, and not with your processor keys. We're 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 readable in full — the audits, the fixes, and the design arguments included.