Built byPhoenix

© 2026 Phoenix

← Blog
TypeScriptPaymentsSDK DesignOpen SourceAI AgentsFintech

Building Payweave: One SDK for Every Payment Provider

Phoenix·July 25, 2026·18 min read

Building Payweave: One SDK for Every Payment Provider

Most of my freelance and contract work has been for teams shipping into more than one market at once: a Nigerian client who needs Paystack, a global product that wants Stripe, a pan-African rollout where Flutterwave is the only realistic rail. Every single time, the second provider integration was billed as "should be quick, we already did this for the first one." Every single time, it wasn't quick. The checkout logic, the webhook handler, the error handling, the subscription bookkeeping: all of it had to be rewritten, because none of it was actually generic. It was just Stripe (or Paystack, or Flutterwave) wearing a "payments module" costume.

Payweave is my attempt to stop rewriting that module. It's an open-source, fully-typed TypeScript SDK that unifies Stripe, Paystack, and Flutterwave behind one client: one config, one set of types, subscriptions and metered usage if you want them, a database layer, and a CLI, all without pretending the providers are identical underneath.

bash
npm install payweave
ts
import { createPayweave } from "payweave";
const payweave = createPayweave({  paystack: { secretKey: process.env.PAYSTACK_SECRET_KEY! },});
const checkout = await payweave.checkout.create({  amount: { value: 500_000, currency: "NGN" },  customer: { email: "ada@example.com" },  reference: "order_8123",  redirectUrl: "https://app.example.com/pay/callback",});// result: checkoutUrl, reference, providerRef, and raw

It's pre-release (0.3.0 on npm as of this writing) and it started smaller than it is now. The original spec was Paystack and Flutterwave only, with a new PaymentSDK({ provider: "paystack" }) constructor, written because neither provider ships an official, maintained TypeScript SDK and everyone integrating them hand-rolls the same fetch calls and copy-pastes the same webhook snippets (frequently getting the signature check wrong). In July 2026 the project pivoted, the docs call it "the v1 pivot," to the config-keyed createPayweave({ ... }) shape, added Stripe, and grew a full billing layer on top: a database, plans and features, metered usage, a CLI. What started as "a Paystack/Flutterwave SDK" became, in the project's own words, a billing platform SDK. The old spec is still in the repo, sections marked superseded rather than deleted, which is a genuinely useful thing to do with a design doc and one I don't see often enough.


The part nobody tells you about "adding a second provider"

Here's what actually differs between payment providers, concretely:

  • Money. Paystack wants amounts in kobo, its native minor unit, so ₦5,000 is 500000. Flutterwave v3 wants amounts in naira, its native major unit, so the same ₦5,000 is 5000. Same currency, same amount, a 100x difference in the number you send, and nothing in either provider's SDK will warn you if you get it backwards.
  • Webhooks. Four different signature schemes across three providers, covered in detail below. Get one wrong, or skip it, or parse-then-re-stringify the body before verifying, and you've built a payment webhook anyone can forge.
  • Errors. A declined card, an invalid API key, and a provider outage are three different failure modes that call for three different responses. Every provider's SDK gives you its own bespoke shape for all three.
  • Everything else. Pagination conventions, how a reference maps to the provider's own identifier, what "successful" is even called.

None of this is because any provider did something wrong: they were never designed to agree with each other. The cost lands on whoever integrates more than one, and it lands as silent risk: code that works fine until the day someone reads a kobo amount as if it were a naira amount.


Two surfaces, one client

The design decision Payweave is built around is that a unifying abstraction is only safe if it doesn't hide the provider underneath. So every configured provider gets exposed two ways.

Surface A is provider-native: a 1:1, fully-typed mapping of the provider's actual API, in the provider's own field names and native units:

ts
// Paystack: amounts in kobo, its native minor unitconst tx = await payweave.paystack.transactions.initialize({  email: "ada@example.com",  amount: 500_000, // NGN 5,000  currency: "NGN",});
// Flutterwave v3: amounts in naira, its native major unitconst link = await payweave.flutterwave.payments.create({  tx_ref: "order_8123",  amount: 5000, // NGN 5,000  currency: "NGN",  redirect_url: "https://app.example.com/pay/callback",  customer: { email: "ada@example.com" },});
// Pagination is an async iterator on every listable resource:for await (const t of payweave.paystack.transactions.iterate({ perPage: 100 })) {  // ...}

Surface B is the unified layer: a normalized checkout/verify/banks API that's portable across whichever providers you've configured, always in minor units:

ts
const checkout = await payweave.checkout.create({  amount: { value: 500_000, currency: "NGN" }, // always minor units  customer: { email: "ada@example.com" },  reference: "order_8123",  redirectUrl: "https://app.example.com/pay/callback",  provider: "flutterwave", // optional per-call override of defaultProvider});
const result = await payweave.verify({ reference: "order_8123" });// status is one of success, failed, pending, abandoned, or reversed,// plus amount, customer, paidAt, channel, and raw

Every unified response carries a raw field with the untouched provider payload, so the abstraction never traps you.

Not every provider supports every unified operation. Stripe has no concept of Paystack/Flutterwave-style bank transfers, so it doesn't implement transfers or banks. That's encoded as data, not left to fail at the network layer. The real capability matrix, from unified/mappings.ts, looks like this:

ts
export const UNIFIED_CAPABILITY_MATRIX = {  paystack: ALL_SUPPORTED,  flutterwave: ALL_SUPPORTED,  stripe: {    "checkout.create": { supported: true },    verify: { supported: true },    "refunds.create": { supported: true },    "transfers.create": { supported: false, reason: "transfers are not supported on stripe" },    "banks.list": { supported: false, reason: "banks.list is not supported on stripe" },    "banks.resolveAccount": { supported: false, reason: "banks.resolveAccount is not supported on stripe" },  },};

payweave.capabilities() exposes this directly, and an internal guard checks it before any unified call goes out. Call transfers.create on a Stripe-only client and you get a typed validation error naming exactly why, without a single byte reaching the network. That's a meaningfully different failure mode from "the request 404'd, go read the docs to find out why."


Providers you didn't configure don't exist at compile time

If you only configured Paystack, payweave.stripe shouldn't just be undefined at runtime. It shouldn't type-check at all:

ts
const payweave = createPayweave({  paystack: { secretKey: process.env.PAYSTACK_SECRET_KEY! },  flutterwave: { secretKey: process.env.FLW_SECRET_KEY! }, // v3 by default  defaultProvider: "paystack",});
payweave.providers;   // ["paystack", "flutterwave"]payweave.environment; // "test" | "live", inferred from the key prefixes// payweave.stripe is a compile-time error here: not configured

The actual mechanism is a mapped type over the config object's own keys:

ts
type ConfiguredProvider<C> = {  [K in "stripe" | "paystack" | "flutterwave"]: C extends Record<K, object> ? K : never;}["stripe" | "paystack" | "flutterwave"];

and the client type is that result intersected with a conditional branch per provider, so stripe only appears on the returned object's type if the config actually had a stripe key. The same trick reaches into the billing layer: subscribe(), check(), and report() take a planId/featureId typed to the literal ids declared in your own products array, so a typo'd plan id is a compile error, not a 3am Slack message. The codebase is refreshingly honest that this narrowing isn't complete everywhere. One internal type's doc comment admits, in a full paragraph, that a specific case around metered-feature ids doesn't yet narrow due to how a nested array is typed, and that the runtime validation error is the actual unconditional guarantee in the meantime. I'd rather a library tell me exactly where its type safety ends than imply blanket coverage it doesn't have.

Test-vs-live isn't a separate flag to keep in sync with your keys, either. It's inferred from the key prefix itself (sk_test_/sk_live_ for Paystack and Stripe, FLWSECK_TEST-/FLWSECK- for Flutterwave v3), and mixing a test key for one provider with a live key for another on the same client is rejected at construction time: one client is all-test or all-live, on purpose, because a client that's silently half-live is a much worse failure mode than a config error.


Money that can't drift

The rule in the unified layer is simple and absolute: amounts are always integer minor units. Surface A stays deliberately provider-native (Flutterwave v3 calls still take naira, matching its real API), but the moment you cross into the unified layer, everything is normalized and the adapters do the conversion.

This is more than "always use integers" as a slogan. Not every currency has two decimal places. The SDK carries an explicit exponent table for the exceptions: JPY, KRW, and VND (plus the West and Central African CFA francs, XOF and XAF, directly relevant to Paystack/Flutterwave coverage) have zero decimal places, and the Gulf dinars (BHD, IQD, JOD, KWD, LYD, OMR, TND) have three. And the major-to-minor conversion doesn't just multiply and truncate: it rejects an amount with more precision than the currency actually supports (50.555 for a 2-decimal currency throws, it doesn't silently become 50.55 or 50.56). Floating-point currency math is a well-worn footgun (0.1 + 0.2 !== 0.3 in every language using IEEE 754 floats), and this is what actually closing that door looks like, rather than just saying "we use integers" and hoping every code path agrees on the scale.


Webhooks as a first-class citizen

I think this is where most home-grown multi-provider integrations quietly fail, because correct verification requires several details to be right simultaneously, and getting any one of them wrong looks fine right up until it isn't.

ts
import express from "express";
const app = express();
app.post("/webhooks", express.raw({ type: "*/*" }), (req, res) => {  let event;  try {    event = payweave.webhooks.constructEvent({ rawBody: req.body, headers: req.headers });  } catch {    res.sendStatus(400); // bad signature, a PayweaveWebhookVerificationError    return;  }  res.sendStatus(200); // ack fast, then process asynchronously  switch (event.unifiedType) {    case "payment.succeeded":      // never grant value from the webhook alone, re-verify first      break;  }});app.use(express.json()); // mounted AFTER the webhook route, on purpose

That route ordering is deliberate and the SDK's own example app calls it out explicitly: signature verification has to run against the raw request bytes, so the webhook route has to see them before any body-parsing middleware touches them. Parse-then-re-stringify can change whitespace or key order and silently break a signature that was actually valid.

Underneath constructEvent, there are four completely independent, timing-safe verifiers, one per signature scheme: Paystack signs an HMAC-SHA512 hex digest of the raw body with the API secret key (it has no separate webhook secret); Flutterwave v3 uses a static verif-hash header that's just the dashboard's secret hash, compared directly; Flutterwave v4 moved to an HMAC-SHA256 base64 digest; Stripe parses a t=<timestamp>,v1=<hex> header, tolerates key rotation by accepting if any v1 candidate matches, rejects anything that isn't a v1 scheme to block downgrade attacks, and enforces a plus-or-minus 300 second replay window. Paystack's verifier is representative of the pattern all four follow:

ts
function verifyPaystack(rawBody: string, signatureHeader: string, secret: string): boolean {  const digest = createHmac("sha512", secret).update(rawBody).digest("hex");  const expected = Buffer.from(digest, "utf8");  const received = Buffer.from(signatureHeader ?? "", "utf8");  return expected.length === received.length && timingSafeEqual(expected, received);}

Length-checked before the timing-safe comparison, so a mismatched-length input can't throw or leak anything through the exception path. Stripe's version goes a step further: it compares every candidate v1 signature timing-safely with no early exit, specifically so that how long the check takes never depends on which candidate, if any, matched.

The genuinely interesting design problem is what happens with one webhook endpoint serving several configured providers. constructEvent detects the provider from the signature header name only, never the body, against a fixed lookup table (x-paystack-signature, verif-hash, flutterwave-signature, stripe-signature). It then fails closed in every ambiguous case: zero recognized headers, a header for a provider you didn't configure, a Flutterwave header whose version doesn't match your client's configured version, and, most interestingly, more than one recognized header on the same request, which it rejects outright as "ambiguous and likely forged," even if one of the signatures would otherwise have verified. That last rule is the kind of thing that only gets written down after actually thinking through what an attacker sending a crafted multi-header request would try.

Once verification passes, event.unifiedType gives you a provider-agnostic event name and event.dedupeKey gives you a stable idempotency key for the fact that every provider will redeliver the same event, a deliberate design choice on their end, not a bug, and one every webhook consumer needs to handle. None of the three providers hand you a uniformly usable id for this, so the SDK computes one: a native webhook id where one exists, a composite of the resource id and status where it doesn't, and, for Paystack, which has no webhook id at all, a SHA-256 hash of the event type, resource id, and status.


Errors that tell you whose fault it is

Every failure Payweave can produce is a typed subclass of PayweaveError:

ts
import {  PayweaveValidationError, // 400/422 or local Zod validation, your fault  PayweaveAuthError,       // 401/403, bad key  PayweaveNotFoundError,   // 404, unknown reference or recipient  PayweaveRateLimitError,  // 429, exposes retryAfterMs  PayweaveProviderError,   // 5xx or provider processing failure  PayweaveNetworkError,    // timeout, DNS, reset; isRetryable is true} from "payweave";

Retry policy follows from the same taxonomy rather than being bolted on separately, and the actual rule is small enough to read in full:

ts
function isRetryableRequest(method: string, idempotencyKey?: string): boolean {  return method.toUpperCase() === "GET" || idempotencyKey != null;}
function backoffDelay(attempt: number, policy = DEFAULT_RETRY_POLICY, rng = Math.random): number {  const ceiling = Math.min(policy.capMs, policy.baseMs * 2 ** attempt);  return Math.floor(rng() * ceiling); // full-jitter exponential backoff}

A bare POST (a charge) is never auto-retried by the SDK itself under any circumstances; that decision belongs to the caller, with an idempotency key, never to a retry loop making it silently. GETs and idempotency-keyed requests do retry, with full-jitter backoff, honoring the provider's Retry-After when it sends one.

And because errors inevitably end up in logs, error.toJSON() is always safe to call. Secrets get caught two ways: by field name (anything matching secret, key, token, plus card numbers, CVVs, PINs, and database connection strings, regardless of what shape the value is), and by value pattern (sk_test_..., Bearer ..., and full postgres:///mongodb:// connection-string regexes, scrubbed wherever they appear even under an innocuous key name). The redaction module's own doc comment states the philosophy in four words: fail safe, on any doubt, mask.


Hard problems, solved once

A few pieces of this codebase exist specifically because a "simple" payments feature turns out to hide a genuinely hard problem, and I'd rather solve each one carefully in one place than watch every consumer rediscover it independently.

Flutterwave v3's card-charge endpoint requires 3DES-EDE3-ECB-encrypted, base64-encoded payloads, an old, provider-specific crypto quirk that's easy to get subtly wrong. It lives in exactly one file, whose own doc comment calls it the single place that touches plaintext card data: isolate it, never log the plaintext, never widen its surface.

Billing periods drift if you compute them naively. A subscription anchored on January 31st has no January 31st equivalent in February, so a naive "add one month to the last period's end" implementation clamps to the 28th, and then keeps clamping every month after, permanently losing a few days from every cycle. Payweave always derives the next period from the original anchor date, never from a previous, already-clamped result, so a January 31st anchor yields Feb 28/29, then March 31st, then April 30th: the correct sequence, not a slow drift toward the start of the month.

Syncing plans to a provider has to survive a crash mid-sync. payweave.sync() pushes your plan/price definitions to Stripe and Paystack, and has to answer: what happens if the process dies after the provider object is created but before that fact is written to your local database? The sync engine uses a content hash to skip providers entirely when nothing changed, and on a hash miss, searches for an already-tagged provider object before creating a new one, so a retried sync after a crash adopts the object it already half-created instead of duplicating it. Paystack's Plan API has no metadata field to tag an object with at all, so the adapter round-trips a small JSON tag through the description field instead, a workaround the code names as exactly that, with a note about replacing it once Paystack ships a real update endpoint.

Flutterwave v4 authenticates with OAuth2 client credentials, not a static secret key, which means every request needs a valid access token and naively fetching one per request would be both slow and a great way to get rate-limited. The auth strategy caches the token, refreshes it proactively at 80% of its TTL, and, this is the detail that actually matters under load, de-duplicates concurrent refreshes into a single in-flight request, so a burst of parallel calls that all notice an expired token don't each fire their own token request.


Billing, if you want it

Configure a database adapter and a products array and you get subscriptions and feature-gated usage without hand-rolling a billing state machine:

ts
import { createPayweave, feature, plan } from "payweave";import { sqliteAdapter } from "payweave/db/sqlite";
const seats = feature({ id: "seats", type: "boolean" });const apiCalls = feature({ id: "api-calls", type: "metered" });
const free = plan({  id: "free",  group: "tier",  default: true,  includes: [seats(), apiCalls({ limit: 1_000, reset: "month" })],});
const pro = plan({  id: "pro",  group: "tier", // shares the tier group with free, mutually exclusive per customer  price: { amount: 29, currency: "USD", interval: "month" }, // major units, converted for you  includes: [seats(), apiCalls({ limit: 100_000, reset: "month" })],});
const payweave = createPayweave({  stripe: { secretKey: process.env.STRIPE_SECRET_KEY! },  database: sqliteAdapter({ url: "file:./payweave.db" }),  products: [free, pro],});
await payweave.sync();await payweave.subscribe({ customerId: "user_1", planId: "pro" });await payweave.check({ customerId: "user_1", featureId: "api-calls", consume: true });

feature() defines a capability once, boolean or metered, and returns something callable: calling it is what actually includes it in a plan. That's not incidental; passing the bare, uncalled feature into includes is a common enough mistake that the SDK detects it specifically (via a hidden marker on the callable) and raises a message telling you exactly what you meant to write, instead of a generic type error three layers removed from the actual mistake.

Notice the one deliberate exception to "amounts are always minor units": a plan's price.amount is written in major units (29, not 2900) because that's how a human actually writes a price, and it's converted exactly once at config-parse time. The codebase names this itself, in a comment, as "the money deviation," a codebase documenting its own deliberate exception to its own rule, rather than leaving it as an inconsistency for someone else to puzzle over later.

Billing state stays in sync with the provider because webhooks apply themselves to it. event.apply() runs inside the same constructEvent flow, gated by its own idempotency claim (so a crashed apply gets retried on redelivery instead of silently dropped) and protected against out-of-order delivery (period fields only ever move forward; a canceled subscription can't be resurrected by a late "updated" event that was actually sent first).


One contract, six databases

The billing layer needs somewhere to persist state, and rather than pick one database, Payweave defines a single DatabaseAdapter contract and ships adapters for SQLite, Postgres, MySQL, MongoDB, Drizzle, and Prisma. The interesting constraint is that the contract itself demands atomicity, not just data shape: balances.consume() and the webhook idempotency claim have to be atomic under concurrent calls, and the conformance suite every adapter runs against actually races them and asserts zero lost updates and zero double-resets. Atomicity is a tested requirement here, not an assumption.

Different databases satisfy that requirement differently, and the SQLite adapter's own comment explains a real tradeoff rather than hand-waving it: reproducing the billing-period clamp math as portable raw SQL would risk the SQL silently drifting from the JavaScript that's the actual source of truth for that logic, so SQLite does read-decide-write inside a transaction instead, leaning on its single serialized connection for correctness. Postgres and MongoDB, which can express more logic server-side, push the entire consume-plus-reset into one atomic statement instead: a single INSERT ... ON CONFLICT ... RETURNING for Postgres, a single aggregation-pipeline update for MongoDB. Same invariant, deliberately different implementations, because the two databases are good at different things.


Testing without hitting a real payment API

ts
import { signWebhook } from "payweave/testing";
const { rawBody, headers } = signWebhook("paystack", payload, webhookSecret);

signWebhook produces a correctly-signed payload for any of the four schemes, so you can exercise your own webhook handler, including its signature-failure path, without a live endpoint or a tunnel into your laptop. payweave/testing also ships fixture loaders and an MSW (Mock Service Worker) server helper, and the project's own contribution rules require its test suite to use the same approach internally: mock at the network edge with MSW, never stub the HTTP client or fetch directly.

The numbers behind that suite: 101 test files, 1,233 individual test cases, 152 committed and sanitized HTTP fixtures, backing roughly 28,000 lines of source across 166 files. Coverage gates sit at 80% statements and lines and 75% branches overall, with a higher bar, 90%, carved out specifically for the core HTTP client and the webhook-verification modules, because that's where a coverage gap is most expensive. There's a separate suite of compile-time type tests (files that assert a call doesn't type-check, not just that it does) and a genuinely live end-to-end suite that runs real checkout, verify, refund, and webhook flows against Stripe's test-mode API. It's gated behind a check for a real test secret so it's skipped by default and only actually runs on a nightly schedule, with a hard guard that refuses to run at all against anything that doesn't look like a test-mode key, specifically so this suite can never accidentally fire a real charge against a live account.

The monorepo also ships three complete, independently runnable example apps under examples/: Express with Paystack, Next.js App Router with Stripe, and NestJS with Flutterwave, deliberately three different providers, to make the point that the only thing that changes between them is the createPayweave({ ... }) config block. The NestJS example wires the client through dependency injection and reads the webhook route's raw body from Nest's own rawBody option, which has to be explicitly enabled at bootstrap or signature verification never sees real bytes to verify against.


The unglamorous engineering

None of this shows up in a quickstart, but it's the part that made me trust the package enough to publish it:

  • One runtime dependency. zod is the only thing Payweave pulls in at runtime. Database drivers and MSW are optional peer dependencies; CLI-only tools like its interactive prompts library are bundled into the CLI's own build output instead of shipped as a dependency of the library.
  • Independently tree-shakeable subpaths, checked by a custom script whose entire job is asserting that importing payweave/webhooks in an edge function never drags in a database driver or a provider adapter you didn't ask for.
  • Are the types actually right? @arethetypeswrong/cli runs against the packed tarball to catch the class of bug where a package's type declarations resolve to the wrong thing for a given consumer, an easy mistake in an ESM-only, subpath-heavy package like this one.
  • Publishing is a pipeline, not a laptop. Releases go through Changesets and GitHub Actions using npm's trusted publishing, so there's no long-lived npm token anywhere and nothing is ever published from a local machine.
  • CLI telemetry that actually respects opt-out. payweave init|push|status|listen report only counts (command name, success or failure, duration, versions), never keys, config, file paths, or argument values. They honor both a project-specific env var and the community-standard DO_NOT_TRACK, and are disabled by default in CI and in the test suite itself.

How it's actually built

This is the part I find genuinely more interesting than the SDK itself: Payweave is built almost entirely by AI coding agents, working against a written constitution rather than loose prompting.

The root of the repo has an AGENTS.md (the first file any agent is pointed at) laying out ten numbered rules (zod as the only runtime dependency is rule three; never sharing Flutterwave v3 and v4 schemas without proving the payloads are actually identical is rule eleven), a Definition-of-Done checklist, and an explicit local gate that has to pass before anything gets committed: lint, typecheck, build, test, type-tests, and two custom export/import-consistency checks, all in one sequence. Feature work moves through a real pipeline: a backlog of tickets mirrored to GitHub Issues, a written brief per ticket, a live status board, and, for the cases where more than one agent is touching overlapping files, a conflict map that coordinates who's allowed to change what. The source itself carries the evidence: doc comments where an agent worked through a genuinely ambiguous spec and left a paragraph explaining which interpretation it chose and why, rather than silently picking one.

I set the architecture, the API shape, the invariants that aren't allowed to break, and the constitution those agents build against; they implement it, ticket by ticket, against tests I gate on before anything merges. It's a different division of labor than writing every line myself, and it's also, genuinely, the only reason a project with 28,000 lines of source and 1,200-plus tests exists as a side project alongside client work at all. If you want to see what the workflow looks like from the other side, I wrote about separately. Payweave is that workflow applied to a domain where correctness actually matters enough to test it this hard.


What's not done yet

I'd rather undersell this than oversell it. As of writing, Stripe, Paystack, and Flutterwave v3, including their webhooks, plus the unified layer, the SQLite/Postgres/MongoDB/Drizzle database adapters, the billing layer, and the CLI are implemented and tested. Still landing: the MySQL and Prisma database adapters, Flutterwave v4's full resource surface, and the Express/Next/Fastify framework adapters. Their subpaths already exist in the package's export map, but the implementations behind them are still in progress. None of that is hidden; it's in the package's own README, and I'd rather you find out from the docs than from a missing export.


Wrapping up

The thing I keep coming back to with Payweave is that a good multi-provider abstraction isn't the one that hides the providers. It's the one that's honest about where they actually agree (money should always be an integer, a webhook should always be verified against raw bytes, an error should always tell you who's at fault) and stays out of the way where they don't (Surface A is right there, raw is on every response, and the capability matrix tells you before you ship what's actually portable).

If you're gluing together more than one payment provider and finding yourself rewriting the same webhook handler for the second time, this is the itch Payweave was built to scratch:

bash
npm install payweavenpx payweave init

Further reading

  • : source, issues, and the full monorepo (SDK, docs site, and examples).
  • : the full API reference and guides.
  • : install instructions and version history.
← All postsShare on X
npm install payweave
import { createPayweave } from "payweave";
const payweave = createPayweave({  paystack: { secretKey: process.env.PAYSTACK_SECRET_KEY! },});
const checkout = await payweave.checkout.create({  amount: { value: 500_000, currency: "NGN" },  customer: { email: "ada@example.com" },  reference: "order_8123",  redirectUrl: "https://app.example.com/pay/callback",});// result: checkoutUrl, reference, providerRef, and raw
// Paystack: amounts in kobo, its native minor unitconst tx = await payweave.paystack.transactions.initialize({  email: "ada@example.com",  amount: 500_000, // NGN 5,000  currency: "NGN",});
// Flutterwave v3: amounts in naira, its native major unitconst link = await payweave.flutterwave.payments.create({  tx_ref: "order_8123",  amount: 5000, // NGN 5,000  currency: "NGN",  redirect_url: "https://app.example.com/pay/callback",  customer: { email: "ada@example.com" },});
// Pagination is an async iterator on every listable resource:for await (const t of payweave.paystack.transactions.iterate({ perPage: 100 })) {  // ...}
const checkout = await payweave.checkout.create({  amount: { value: 500_000, currency: "NGN" }, // always minor units  customer: { email: "ada@example.com" },  reference: "order_8123",  redirectUrl: "https://app.example.com/pay/callback",  provider: "flutterwave", // optional per-call override of defaultProvider});
const result = await payweave.verify({ reference: "order_8123" });// status is one of success, failed, pending, abandoned, or reversed,// plus amount, customer, paidAt, channel, and raw
export const UNIFIED_CAPABILITY_MATRIX = {  paystack: ALL_SUPPORTED,  flutterwave: ALL_SUPPORTED,  stripe: {    "checkout.create": { supported: true },    verify: { supported: true },    "refunds.create": { supported: true },    "transfers.create": { supported: false, reason: "transfers are not supported on stripe" },    "banks.list": { supported: false, reason: "banks.list is not supported on stripe" },    "banks.resolveAccount": { supported: false, reason: "banks.resolveAccount is not supported on stripe" },  },};
const payweave = createPayweave({  paystack: { secretKey: process.env.PAYSTACK_SECRET_KEY! },  flutterwave: { secretKey: process.env.FLW_SECRET_KEY! }, // v3 by default  defaultProvider: "paystack",});
payweave.providers;   // ["paystack", "flutterwave"]payweave.environment; // "test" | "live", inferred from the key prefixes// payweave.stripe is a compile-time error here: not configured
type ConfiguredProvider&lt;C&gt; = {  [K in "stripe" | "paystack" | "flutterwave"]: C extends Record&lt;K, object&gt; ? K : never;}["stripe" | "paystack" | "flutterwave"];
import express from "express";
const app = express();
app.post("/webhooks", express.raw({ type: "*/*" }), (req, res) => {  let event;  try {    event = payweave.webhooks.constructEvent({ rawBody: req.body, headers: req.headers });  } catch {    res.sendStatus(400); // bad signature, a PayweaveWebhookVerificationError    return;  }  res.sendStatus(200); // ack fast, then process asynchronously  switch (event.unifiedType) {    case "payment.succeeded":      // never grant value from the webhook alone, re-verify first      break;  }});app.use(express.json()); // mounted AFTER the webhook route, on purpose
function verifyPaystack(rawBody: string, signatureHeader: string, secret: string): boolean {  const digest = createHmac("sha512", secret).update(rawBody).digest("hex");  const expected = Buffer.from(digest, "utf8");  const received = Buffer.from(signatureHeader ?? "", "utf8");  return expected.length === received.length && timingSafeEqual(expected, received);}
import {  PayweaveValidationError, // 400/422 or local Zod validation, your fault  PayweaveAuthError,       // 401/403, bad key  PayweaveNotFoundError,   // 404, unknown reference or recipient  PayweaveRateLimitError,  // 429, exposes retryAfterMs  PayweaveProviderError,   // 5xx or provider processing failure  PayweaveNetworkError,    // timeout, DNS, reset; isRetryable is true} from "payweave";
function isRetryableRequest(method: string, idempotencyKey?: string): boolean {  return method.toUpperCase() === "GET" || idempotencyKey != null;}
function backoffDelay(attempt: number, policy = DEFAULT_RETRY_POLICY, rng = Math.random): number {  const ceiling = Math.min(policy.capMs, policy.baseMs * 2 ** attempt);  return Math.floor(rng() * ceiling); // full-jitter exponential backoff}
import { createPayweave, feature, plan } from "payweave";import { sqliteAdapter } from "payweave/db/sqlite";
const seats = feature({ id: "seats", type: "boolean" });const apiCalls = feature({ id: "api-calls", type: "metered" });
const free = plan({  id: "free",  group: "tier",  default: true,  includes: [seats(), apiCalls({ limit: 1_000, reset: "month" })],});
const pro = plan({  id: "pro",  group: "tier", // shares the tier group with free, mutually exclusive per customer  price: { amount: 29, currency: "USD", interval: "month" }, // major units, converted for you  includes: [seats(), apiCalls({ limit: 100_000, reset: "month" })],});
const payweave = createPayweave({  stripe: { secretKey: process.env.STRIPE_SECRET_KEY! },  database: sqliteAdapter({ url: "file:./payweave.db" }),  products: [free, pro],});
await payweave.sync();await payweave.subscribe({ customerId: "user_1", planId: "pro" });await payweave.check({ customerId: "user_1", featureId: "api-calls", consume: true });
import { signWebhook } from "payweave/testing";
const { rawBody, headers } = signWebhook("paystack", payload, webhookSecret);
npm install payweavenpx payweave init