One API. Every payment provider. 🌍
📚 Documentation — full guides, provider reference, CLI docs, migration guides, examples.
Unified payment SDK for Node.js that works with multiple payment providers through a single, consistent API. Focus on South African providers first, with support for international gateways.
WaSP is to WhatsApp what PayBridge is to payments — one SDK, multiple backends, zero friction.
- Unified API — Same code works across all providers
- TypeScript-first — Full type safety and autocomplete
- South African focus — SoftyComp, Yoco, Ozow, PayFast ready
- International support — Stripe, PayStack, Peach Payments (coming soon)
- Production-ready — Webhooks, refunds, subscriptions, retries
- Zero lock-in — Switch providers by changing 1 config line
npm install paybridgeWant to see PayBridge in action before writing code? Try our Stripe-style interactive playground:
cd playground
npm install
npm startThen open http://localhost:4020 in your browser.
The playground lets you:
- Create real payments against SoftyComp sandbox
- Watch webhooks arrive in real-time
- Generate code snippets (TypeScript/JavaScript)
- Compare PayBridge vs raw API complexity
- Test all payment operations with a beautiful UI
Perfect for demos, learning, and rapid prototyping. See playground/README.md for details.
Runnable integrations for common Node.js frameworks:
- Express — classic, raw body parsing for webhooks
- Fastify — Fastify plugin pattern, custom content type parser
- Next.js — App Router API routes, multi-provider router
- Hono — edge-runtime ready (Cloudflare Workers, Bun, Deno, Node)
Each example uses PayBridgeRouter with Stripe + PayStack and demonstrates webhook signature verification, idempotency, and provider-specific routing.
Payment providers change their APIs without notice. A field gets renamed, an endpoint moves, a type changes from string to number. Your integration silently breaks.
PayBridge includes drift detection — capture the shape of every provider's sandbox response, store it as a baseline, and get alerted the moment something changes.
# Capture baselines (one-time setup)
npx paybridge drift-check --capture
# Check for drift
npx paybridge drift-check
# Watch continuously (6-hour interval)
npx paybridge drift-watch --interval 6h --webhook-url https://hooks.slack.com/...=== Drift Detection ===
[✓] stripe — no drift
[⚠] mollie — drift detected:
+ new keys: data.expiresAt, _links.dashboard.href
- removed keys: data.metadata.legacy
! type changed: data.amount.value (string → number)
[⚠] square — drift detected:
+ new keys: payment_link.created_at_iso
[ ] paystack — Missing: PAYSTACK_API_KEY
Exit code 1 if drift detected, 0 if clean. Perfect for CI/CD pipelines or cron jobs.
The Square /checkout/payment-links → /online-checkout/payment-links endpoint change would have shipped silently to production. With drift-check running daily, you get a Slack alert the moment it happens.
Your payment stack generates signals across silos: drift detection in one CLI command, reconciliation in another, success rates in third-party dashboards, latency buried in logs. Audit reports unify every observability feature into one comprehensive artifact.
# Generate HTML report (default)
npx paybridge audit --window 30d --ledger-pg postgresql://localhost/db
# JSON for CI/CD pipelines
npx paybridge audit --format json --output - | jq '.summary.anomalyCounts.high' | \
xargs -I {} test {} -eq 0 || exit 1
# Markdown for email
npx paybridge audit --format md --output - | mail -s "Monthly Audit" finance@example.com
# Include reconciliation data
npx paybridge audit --reconcile-input expected.jsonl --window 7d- Success rate analytics — per-provider success/failure/timeout counts with half-window comparison (detects degradation)
- Latency metrics — avg + p95 per provider, with anomaly detection (>2s/5s/10s thresholds)
- Fee estimation — calculated from actual transaction volume × provider fee structure
- Drift events — timeline of baseline captures per provider
- Reconciliation — missed webhook count + mismatch table (if
--reconcile-inputprovided) - Anomaly detection — success rate drops (>10% decline), consecutive failures (3+), high latency, PII in raw responses
- Compliance flags — scans metadata for PII leakage (email, card_number, iban, etc.)
- HTML — dark mode, print-to-PDF friendly (Cmd/Ctrl+P), collapsible per-provider deep-dive. The artifact a CFO opens in a browser.
- Markdown — copy-paste into Notion/Confluence/Slack. Clean GFM tables.
- JSON — machine-readable. Pipe to CI/CD gates, monitoring dashboards, or alerting systems.
0— no high-severity anomalies1— high-severity anomalies detected (fails CI builds)
# Daily 6 AM audit
0 6 * * * cd /app && npx paybridge audit --window 7d --ledger-pg $DB_URL --output /var/reports/audit-$(date +\%Y-\%m-\%d).htmlimport { generateAuditReport, renderAuditAsHtml } from 'paybridge';
import { createPostgresLedgerStore } from 'paybridge';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const ledger = createPostgresLedgerStore({ pool });
const report = await generateAuditReport({
providers: [
{ name: 'stripe', capabilities: stripeProvider.getCapabilities() },
{ name: 'paystack', capabilities: paystackProvider.getCapabilities() },
],
ledger,
windowMs: 30 * 24 * 60 * 60 * 1000, // 30 days
});
const html = renderAuditAsHtml(report);
await sendEmail({ to: 'finance@company.com', html });Webhooks can fail. Networks blip. Your server hiccups. Provider retries don't reach you. Without reconciliation, you discover missed webhooks when a customer complains their account wasn't credited.
PayBridge's reconcile command diffs your database against each provider's current state, catching payments where your local status doesn't match reality.
# From JSONL file
echo '{"provider":"stripe","reference":"pay_001","expectedStatus":"pending"}' > expected.jsonl
npx paybridge reconcile --input expected.jsonl
# From SQL query (Postgres example)
psql -t -c "SELECT provider, reference, status AS \"expectedStatus\" FROM payments WHERE status='pending' AND created_at > now() - interval '24 hours'" \
| npx paybridge reconcile
# CSV format
cat payments.csv | npx paybridge reconcile[✓] stripe:pay_001 — completed (match)
[!] stripe:pay_002 — expected pending, actual completed (MISSED WEBHOOK)
[?] paystack:pay_003 — not-found (no provider record)
[✗] stripe:pay_004 — error (HTTP 503)
[ ] adyen:pay_005 — skipped (missing ADYEN_API_KEY)
Reconciled: 5
Match: 1
Mismatch (missed webhook): 1
Not found: 1
Error: 1
Skipped: 1
Exit code 1 if any mismatch, 0 if clean. Add --webhook-url to POST mismatch reports to Slack/Discord/your ops channel. Use --json for pipeline integration.
The ledger stores every payment attempt and outcome. In-memory and Redis adapters are ephemeral. For durable transaction history, use the Postgres adapter.
Create the table once via your migration tool:
import { getPostgresLedgerTableSql } from 'paybridge';
const sql = getPostgresLedgerTableSql();
await pool.query(sql);Or run the SQL manually:
CREATE TABLE paybridge_ledger (
id TEXT PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL,
operation TEXT NOT NULL,
provider TEXT NOT NULL,
reference TEXT,
provider_id TEXT,
status TEXT NOT NULL,
amount NUMERIC,
currency TEXT,
duration_ms INTEGER,
error_code TEXT,
error_message TEXT,
metadata JSONB
);
CREATE INDEX idx_paybridge_ledger_provider_timestamp
ON paybridge_ledger (provider, timestamp DESC);
CREATE INDEX idx_paybridge_ledger_reference
ON paybridge_ledger (reference) WHERE reference IS NOT NULL;
CREATE INDEX idx_paybridge_ledger_status
ON paybridge_ledger (status);import { Pool } from 'pg';
import { PayBridgeRouter, createPostgresLedgerStore } from 'paybridge';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const ledger = createPostgresLedgerStore({ pool });
const router = new PayBridgeRouter({
providers: [...],
ledger,
});Compatible with pg (Pool) and postgres (porsager) adapter wrappers. No runtime dep on pg — bring your own client.
Upgrading from 0.1 or 0.2? See docs/migration.md.
import { PayBridge } from 'paybridge';
// Initialize with your provider
const pay = new PayBridge({
provider: 'softycomp',
credentials: {
apiKey: process.env.SOFTYCOMP_API_KEY,
secretKey: process.env.SOFTYCOMP_SECRET_KEY
},
sandbox: true
});
// Create payment — same API regardless of provider
const payment = await pay.createPayment({
amount: 299.00, // Always in major currency unit (rands)
currency: 'ZAR',
reference: 'INV-001',
customer: {
name: 'John Doe',
email: 'john@example.com',
phone: '0825551234'
},
urls: {
success: 'https://myapp.com/success',
cancel: 'https://myapp.com/cancel',
webhook: 'https://myapp.com/webhook'
}
});
// Redirect customer to payment page
console.log(payment.checkoutUrl);
// Payment details
console.log(payment.id); // Provider payment ID
console.log(payment.status); // 'pending' | 'completed' | 'failed' | 'cancelled'
console.log(payment.provider); // 'softycomp'const subscription = await pay.createSubscription({
amount: 299.00,
currency: 'ZAR',
interval: 'monthly', // 'weekly' | 'monthly' | 'yearly'
reference: 'SUB-001',
customer: {
name: 'Jane Smith',
email: 'jane@example.com'
},
urls: {
success: 'https://myapp.com/success',
cancel: 'https://myapp.com/cancel',
webhook: 'https://myapp.com/webhook'
},
startDate: '2026-04-01', // Must be future date
billingDay: 1 // Day of month (1-28)
});// Full refund
const refund = await pay.refund({
paymentId: 'pay_123'
});
// Partial refund
const refund = await pay.refund({
paymentId: 'pay_123',
amount: 100.00,
reason: 'Customer request'
});const payment = await pay.getPayment('pay_123');
if (payment.status === 'completed') {
console.log('Payment received!');
}import express from 'express';
const app = express();
// IMPORTANT: Use express.raw() for signature verification
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// Verify webhook signature
if (!pay.verifyWebhook(req.body, req.headers)) {
return res.status(400).send('Invalid signature');
}
// Parse webhook event
const event = pay.parseWebhook(req.body, req.headers);
switch (event.type) {
case 'payment.completed':
console.log('Payment completed:', event.payment);
// Fulfill order, activate subscription, etc.
break;
case 'payment.failed':
console.log('Payment failed:', event.payment);
// Notify customer
break;
case 'payment.cancelled':
console.log('Payment cancelled:', event.payment);
break;
case 'refund.completed':
console.log('Refund completed:', event.refund);
break;
}
res.sendStatus(200);
});| Provider | One-time | Subscriptions | Refunds | Webhooks | Status |
|---|---|---|---|---|---|
| SoftyComp | ✅ | ✅ | ✅ | ✅ | Production |
| Yoco | ✅ | ⛔ | ✅ | ✅ | Production |
| Ozow | ✅ | ⛔ | ⛔ | ✅ | Production |
| PayFast | ✅ | ✅ | ✅ | ✅ | Production |
| PayStack | ✅ | ✅ | ✅ | ✅ | Production |
| Stripe | ✅ | ✅ | ✅ | ✅ | Production |
| Peach Payments | ✅ | ⛔ | ✅ | ✅ | Production |
| Flutterwave | ✅ | ✅ | ✅ | ✅ | Production |
| Adyen | ✅ | ⛔ | ✅ | ✅ | Production |
| Mercado Pago | ✅ | ✅ | ✅ | ✅ | Production |
| Razorpay | ✅ | ✅ | ✅ | ✅ | Production |
| Mollie | ✅ | ⛔ | ✅ | ✅ | Production |
| Square | ✅ | ⛔ | ✅ | ✅ | Production |
| Pesapal | ✅ | ⛔ | ✅ | ✅ | Production |
| Provider | On-ramp | Off-ramp | Quote | Webhooks | Status |
|---|---|---|---|---|---|
| MoonPay | ✅ | ✅ | ✅ | ✅ | Production |
| Yellow Card | Experimental | ||||
| Transak | ✅ | ✅ | ✅ | ✅ | Production |
| Ramp Network | ✅ | ✅ | ✅ | ✅ | Production |
Legend: ✅ Supported | ⛔ Not supported by upstream API |
Notes:
⛔marks features the underlying provider's API doesn't support — those methods throw a clear error explaining the limitation. Use a different provider for that capability or usePayBridgeRouterto route accordingly.- Yellow Card is gated behind
@experimentaluntil partner API documentation is verified — it logs a warning on instantiation. Do not use in production without partner-confirmed spec. - Sandbox testing. PayFast / PayStack / Stripe / Peach / Flutterwave / Adyen / Mercado Pago / Razorpay are wired and unit-tested, but have not yet been validated against live sandbox credentials. To validate against real sandboxes, set the relevant
*_API_KEYenv vars and runnpm run test:e2e:sandbox.
const pay = new PayBridge({
provider: 'softycomp',
credentials: {
apiKey: 'your_api_key',
secretKey: 'your_secret_key'
},
sandbox: true,
webhookSecret: 'optional_webhook_secret'
});Docs: SoftyComp API
const pay = new PayBridge({
provider: 'yoco',
credentials: {
apiKey: 'sk_test_...' // Secret key
},
sandbox: true,
webhookSecret: 'whsec_...'
});Docs: Yoco Developer
const pay = new PayBridge({
provider: 'ozow',
credentials: {
apiKey: 'your_api_key',
siteCode: 'your_site_code',
privateKey: 'your_private_key'
},
sandbox: true
});Docs: Ozow Hub
const pay = new PayBridge({
provider: 'adyen',
credentials: {
apiKey: 'your_api_key',
merchantAccount: 'YourMerchantAccount',
liveUrlPrefix: 'abc123' // Only for live mode
},
sandbox: true,
webhookSecret: 'your_hmac_key_hex'
});Docs: Adyen API Explorer
Note: Adyen subscriptions require recurring tokenization flow (not yet supported). Use Stripe or PayFast for subscriptions.
const pay = new PayBridge({
provider: 'mercadopago',
credentials: {
apiKey: 'TEST-...' // Or APP_USR-... for live
},
sandbox: true,
webhookSecret: 'your_webhook_secret'
});Docs: Mercado Pago Developers
const pay = new PayBridge({
provider: 'razorpay',
credentials: {
apiKey: 'rzp_test_...', // key_id
secretKey: 'your_key_secret'
},
sandbox: true,
webhookSecret: 'your_webhook_secret'
});Docs: Razorpay API Reference
Note: Razorpay webhooks do not include timestamp-based replay protection.
const pay = new PayBridge({
provider: 'mollie',
credentials: {
apiKey: 'test_...' // Or live_... for production
},
sandbox: true,
webhookSecret: 'optional_webhook_secret'
});Docs: Mollie API Reference
Note: Mollie subscriptions require Customer + Mandate setup (not yet supported by paybridge). Mollie webhooks have no signature scheme — security relies on getPayment() round-trip + source IP validation.
const pay = new PayBridge({
provider: 'square',
credentials: {
apiKey: 'EAAAEOuL...', // Access token
locationId: 'LOCATION123',
notificationUrl: 'https://example.com/webhook' // Required for signature verification
},
sandbox: true,
webhookSecret: 'your_webhook_secret'
});Docs: Square API Reference
Note: Square subscriptions require multi-step Catalog + Customer + Plan setup (not yet supported by paybridge). Webhook signature uses notificationUrl + raw body.
const pay = new PayBridge({
provider: 'pesapal',
credentials: {
apiKey: 'qkio1BGG...', // consumer_key
secretKey: 'osGQ364R...', // consumer_secret
notificationId: 'IPN123', // Register IPN URL with Pesapal first
username: 'merchant@example.com' // Required for refunds
},
sandbox: true,
webhookSecret: 'optional_webhook_secret'
});Docs: Pesapal API Reference
Note: Pesapal subscriptions not yet supported. Pesapal IPN has no signature scheme — security relies on getPayment() round-trip + source IP validation. OAuth-style token caching (5min expiry).
// Using SoftyComp
const pay1 = new PayBridge({ provider: 'softycomp', credentials: { ... } });
// Switch to Yoco — SAME API!
const pay2 = new PayBridge({ provider: 'yoco', credentials: { ... } });
// Switch to Ozow — SAME API!
const pay3 = new PayBridge({ provider: 'ozow', credentials: { ... } });
// All methods work identically
const payment = await pay1.createPayment({ ... }); // SoftyComp
const payment = await pay2.createPayment({ ... }); // Yoco
const payment = await pay3.createPayment({ ... }); // OzowSouth Africa's payment landscape is fragmented. Different providers for different use cases:
- SoftyComp — Debit orders and bill presentment
- Yoco — Card payments for SMEs
- Ozow — Instant EFT
- PayFast — Online payments
Each has its own SDK, quirks, and integration patterns. PayBridge unifies them all.
// SoftyComp
const softycomp = new SoftyComp({ ... });
const bill = await softycomp.createBill({ amount: 299.00, frequency: 'once-off', ... });
// Yoco
const yoco = new Yoco({ ... });
const checkout = await yoco.checkouts.create({ amountInCents: 29900, ... });
// Ozow
const ozow = new Ozow({ ... });
const payment = await ozow.initiatePayment({ Amount: '299.00', HashCheck: '...', ... });Different APIs, different amount formats, different field names.
// ONE API for all providers
const payment = await pay.createPayment({
amount: 299.00,
currency: 'ZAR',
reference: 'INV-001',
customer: { ... },
urls: { ... }
});Same code. Every provider.
new PayBridge(config: PayBridgeConfig)createPayment(params: CreatePaymentParams): Promise<PaymentResult>createSubscription(params: CreateSubscriptionParams): Promise<SubscriptionResult>getPayment(id: string): Promise<PaymentResult>refund(params: RefundParams): Promise<RefundResult>parseWebhook(body: any, headers?: any): WebhookEventverifyWebhook(body: any, headers?: any): booleangetProviderName(): stringgetSupportedCurrencies(): string[]
See src/types.ts for full type definitions.
PayBridge supports multi-provider routing with automatic failover and circuit breakers. Use PayBridgeRouter to route requests across multiple providers based on cost, priority, or round-robin.
import { PayBridge, PayBridgeRouter } from 'paybridge';
const softycomp = new PayBridge({ provider: 'softycomp', credentials: {...}, sandbox: true });
const yoco = new PayBridge({ provider: 'yoco', credentials: {...}, sandbox: true });
const router = new PayBridgeRouter({
providers: [
{ provider: softycomp, weight: 1 },
{ provider: yoco, weight: 2 }
],
strategy: 'cheapest',
fallback: {
enabled: true,
maxAttempts: 3,
retryDelayMs: 250
}
});
const payment = await router.createPayment({
amount: 299.00,
currency: 'ZAR',
reference: 'INV-001',
customer: { name: 'John Doe', email: 'john@example.com' },
urls: {
success: 'https://myapp.com/success',
cancel: 'https://myapp.com/cancel',
webhook: 'https://myapp.com/webhook'
}
});
console.log(payment.routingMeta.chosenProvider);
console.log(payment.routingMeta.attempts);By default, each Node process has its own in-memory circuit breaker. To share circuit breaker state across multiple instances (e.g., behind a load balancer), use a Redis-backed store:
import Redis from 'ioredis';
import { PayBridgeRouter, createRedisCircuitBreakerStore } from 'paybridge';
const redis = new Redis(process.env.REDIS_URL!);
const store = createRedisCircuitBreakerStore(redis, { prefix: 'app:cb:' });
const router = new PayBridgeRouter({
providers: [...],
circuitBreakerStore: store,
});The Redis adapter works with both ioredis and redis (node-redis v4+) clients. State is eventually consistent across instances — race conditions during state transitions may cause a few extra failures, but correctness is preserved.
Static fee tables lie. PayBridge routes by actual outcomes when you give it ledger access:
import { PayBridgeRouter, createSuccessRateStrategy, createPostgresLedgerStore } from 'paybridge';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const ledger = createPostgresLedgerStore({ pool });
const strategy = createSuccessRateStrategy({
ledger,
windowMs: 24 * 60 * 60 * 1000,
fallback: 'cheapest',
});
const router = new PayBridgeRouter({
providers: [...],
strategy,
ledger,
});Providers are ranked by their real success rate from your own traffic. Below the minimum sample size, the configured fallback (default cheapest) takes over.
Why this matters: A provider with 1.4% fee but 92% success rate costs more per successful transaction than a 2.5% / 99.5% provider. successRate makes routing decisions based on real outcomes from your traffic, not static fee tables.
PayBridge always uses major currency units (rands, dollars) in the API:
// ✅ Correct
{ amount: 299.00, currency: 'ZAR' }
// ❌ Wrong (don't use cents)
{ amount: 29900, currency: 'ZAR' }PayBridge handles provider-specific conversions internally:
- SoftyComp uses rands → no conversion
- Yoco uses cents → converts to cents
- Ozow uses rands → no conversion
try {
const payment = await pay.createPayment({ ... });
} catch (error) {
console.error('Payment failed:', error.message);
// Handle error (invalid credentials, network error, etc.)
}Always verify webhook signatures in production:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// Verify signature
if (!pay.verifyWebhook(req.body, req.headers)) {
return res.status(401).send('Unauthorized');
}
// Signature valid — process event
const event = pay.parseWebhook(req.body, req.headers);
// ...
});- v0.1 — Core API + SoftyComp provider
- v0.2 — Yoco provider
- v0.3 — Ozow provider
- v0.4 — PayFast provider
- v0.5 — PayStack provider (Nigeria)
- v0.6 — Stripe provider (international)
- v0.7 — Peach Payments provider
- v1.0 — Production-ready with all SA providers
We welcome contributions! To add a new payment provider:
- Create
src/providers/yourprovider.tsextendingPaymentProvider - Implement all abstract methods
- Add provider to
src/index.tsfactory - Update README with provider details
- Submit PR
See src/providers/softycomp.ts for reference implementation.
Join our Discord for support, feature discussions, and updates:
- Discord: https://discord.gg/Y2jCXNGgE
MIT © Kobie Wentzel
- WaSP — Unified WhatsApp API (Baileys, Cloud API, Twilio)
- softycomp-node — Official SoftyComp SDK
Built with ❤️ in South Africa