One payment environment. Many aggregators. Zero rewrites.
Payenv is an open-source TypeScript library that puts one stable payment API in front of several payment aggregators (FedaPay, Kkiapay, CinetPay…). It routes each payment to a provider that supports it, and falls back to another provider only when it is proven that no money moved: never a double charge.
Status: 🌱 Pre-release 0.x. It works and is published on npm, but the API may still
change between minor versions. Started on 2026-09-26.
When you integrate payments, you usually pick one aggregator. Then:
- It is down, and you have no plan B.
- It doesn't support a network your customer uses (an operator, a country, a currency), while another aggregator does.
- You want to switch providers, and your whole codebase speaks the old one's API.
Every team ends up rewriting the same glue: adapters, fallback, status polling, webhook parsing, error mapping. It's hard to get right, and one subtle mistake means charging a customer twice. Payenv does it once, in the open, with tests.
import { createPayenv, priority } from '@payenv/core';
import { fedapay } from '@payenv/connector-fedapay';
import { cinetpay } from '@payenv/connector-cinetpay';
const payenv = createPayenv({
connectors: [
fedapay({ secretKey: process.env.FEDAPAY_SECRET_KEY! }),
cinetpay({
credentials: {
BJ: {
apiKey: process.env.CINETPAY_API_KEY_BJ!,
apiPassword: process.env.CINETPAY_API_PASSWORD_BJ!,
},
},
notifyUrl: (key) => `https://shop.example/webhooks/cinetpay?key=${key}`,
}),
],
routing: priority(['fedapay', 'cinetpay']), // FedaPay first, CinetPay as a fallback
});
const payment = await payenv.collect({
amount: { value: 5000, currency: 'XOF' }, // integer, in the currency's minor unit
method: { type: 'mobile_money', network: 'mtn', country: 'BJ', phone: '+22997000000' },
customer: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' },
idempotencyKey: 'order_1234', // retries with the same key never charge twice
});
payment.status; // 'pending': the customer confirms on their phone
await payenv.refresh('order_1234'); // later: ask the provider for the latest statusIf FedaPay is down before the push is sent, Payenv tries CinetPay. If FedaPay times out after the push may have been sent, Payenv first asks FedaPay what happened, and only moves on if FedaPay confirms nothing was charged.
| Package | What it does | Countries |
|---|---|---|
@payenv/core |
Unified model, router, safe-fallback engine, in-memory store | — |
@payenv/connector-fedapay |
Mobile money push and payouts (MTN, Moov, Celtiis), signed webhooks | 🇧🇯 Benin |
@payenv/connector-kkiapay |
Payments in the Kkiapay widget, verified server-side (MTN, Moov) | 🇧🇯 Benin |
@payenv/connector-cinetpay |
Mobile money push, hosted payment page, payouts | 🇧🇯 🇧🇫 🇨🇮 🇲🇱 🇳🇪 🇸🇳 🇹🇬 🇨🇲 🇬🇳 |
npm install @payenv/core @payenv/connector-fedapay
# and/or @payenv/connector-kkiapay, @payenv/connector-cinetpayNode.js 20.19+, Deno, Bun or any runtime with fetch and Web Crypto. ESM and require.
No runtime dependencies.
The CinetPay connector is built from CinetPay's official SDK and tested against a fake API; it has not been verified against the CinetPay sandbox yet (see its QUIRKS.md). Check the changelog for what each npm version contains.
Available today
| Feature | API |
|---|---|
| Collect a payment (mobile money push, hosted page, provider widget) | payenv.collect(request) |
| Send money to a mobile money number | payenv.payout(request) |
| Get the latest status from the provider | payenv.refresh(idempotencyKey) |
| Confirm a payment made in a provider's widget (checks reference and amount) | payenv.confirm(idempotencyKey, providerRef) |
| Read a payment from the store | payenv.getPayment(idempotencyKey) |
| Idempotency: the same key always returns the same payment | idempotencyKey |
| Capability-aware routing (operation, method, country, currency, network) | automatic |
| Safe fallback between providers, ambiguous calls checked with the provider first | automatic |
| Routing order, or your own strategy function | priority([...]), routing: (candidates, request) => … |
| Normalized statuses and errors, with the provider's own code kept | payment.status, payment.error |
| Lifecycle events (attempts, fallbacks) for your logs and metrics | onEvent |
| Pluggable store (bring your database; in-memory by default) | Store |
| Webhook verification and parsing, per connector | e.g. verifyFedaPayWebhook |
| Phone numbers typed by customers converted to E.164 | toE164('61 00 00 00', 'BJ') |
Planned (see the roadmap): refunds, @payenv/store-postgres,
circuit breaker, normalized webhook handling with deduplication, weighted / cheapest /
bestSuccessRate strategies, more connectors (PayDunya, Flutterwave, Paystack, Stripe…),
a PHP / Laravel port.
- Getting started: your first payment in 5 minutes.
- Web demo: collects a sandbox payment through FedaPay, with a
fallback to Kkiapay. Run it with
pnpm demo.
- Library first. Payenv runs inside your app. No hosted service, no middleman.
- We never touch your money or your keys. Funds flow directly between your customer, the aggregator and you. Credentials stay in your infrastructure.
- Correctness over cleverness. Never double-charge. Ambiguous states are resolved, not guessed (ADR 0004).
- No lock-in. Not to a provider, and not to Payenv either.
- No telemetry. Payenv doesn't phone home. Ever.
- Free forever. Apache 2.0, community-governed.
Requirements: Node.js 20.19+ and pnpm 9.
pnpm install
pnpm check # lint + typecheck + tests
pnpm test # tests only (sandbox tests run when .env has sandbox keys)
pnpm test:watch # tests in watch mode
pnpm format # format with Biome
pnpm build # compile every package to dist/
pnpm demo # build and start the web demo on http://localhost:3000
pnpm changeset # describe your change for the next releaseSandbox tests are opt-in: copy .env.example to .env (git-ignored) and fill in
sandbox credentials.
packages/
core/ @payenv/core
connector-fedapay/ @payenv/connector-fedapay
connector-kkiapay/ @payenv/connector-kkiapay
connector-cinetpay/ @payenv/connector-cinetpay
examples/
fedapay-demo/ runnable web demo
docs/ guides, architecture, decision records
- Getting started: your first payment in 5 minutes
- Connectors: supported and planned aggregators
- Architecture: connectors, router, statuses, fallback
- Vision · Roadmap · Decision records
- Contributing · Code of Conduct · Security · Governance · Changelog
The project is young: this is the best moment to shape it. Field knowledge of
aggregators (undocumented errors, sandbox quirks, which operators really work) is as
valuable as code. Want a provider? Open an issue with the connector-request label.
See CONTRIBUTING.md.
Payenv is not a payment provider, not a bank, and not affiliated with any of the aggregators it connects to. All trademarks belong to their respective owners. Payenv is provided "as is", without warranty: see the LICENSE.
Copyright 2026 The Payenv Authors. Licensed under the Apache License, Version 2.0.