Skip to content

kobie3717/paybridge

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

41 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PayBridge

One API. Every payment provider. 🌍

npm version CI License: MIT TypeScript Discord

📚 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.

Features

  • 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

Installation

npm install paybridge

Interactive Playground

Want to see PayBridge in action before writing code? Try our Stripe-style interactive playground:

cd playground
npm install
npm start

Then 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.

Framework Examples

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.

Drift Detection

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.

Quick Start

# 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/...

Example Output

=== 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.

Why It Matters

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.

Audit Reports

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

What's Included

  • 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-input provided)
  • 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.)

Output Formats

  • 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.

Exit Codes

  • 0 — no high-severity anomalies
  • 1 — high-severity anomalies detected (fails CI builds)

Cron Integration

# 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).html

Programmatic API

import { 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 });

Reconciliation

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.

Quick Start

# 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

Example Output

[✓] 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.

Postgres Ledger

The ledger stores every payment attempt and outcome. In-memory and Redis adapters are ephemeral. For durable transaction history, use the Postgres adapter.

Schema Setup

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);

Usage

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.

Quick Start

Upgrading from 0.1 or 0.2? See docs/migration.md.

One-time Payment

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'

Recurring Subscription

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)
});

Refund

// 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'
});

Check Payment Status

const payment = await pay.getPayment('pay_123');
if (payment.status === 'completed') {
  console.log('Payment received!');
}

Webhooks

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);
});

Supported Providers

Fiat providers

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

Crypto on/off-ramp providers

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 | ⚠️ Experimental (spec unverified)

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 use PayBridgeRouter to route accordingly.
  • Yellow Card is gated behind @experimental until 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_KEY env vars and run npm run test:e2e:sandbox.

Provider Configuration

SoftyComp

const pay = new PayBridge({
  provider: 'softycomp',
  credentials: {
    apiKey: 'your_api_key',
    secretKey: 'your_secret_key'
  },
  sandbox: true,
  webhookSecret: 'optional_webhook_secret'
});

Docs: SoftyComp API

Yoco (Coming Soon)

const pay = new PayBridge({
  provider: 'yoco',
  credentials: {
    apiKey: 'sk_test_...' // Secret key
  },
  sandbox: true,
  webhookSecret: 'whsec_...'
});

Docs: Yoco Developer

Ozow (Coming Soon)

const pay = new PayBridge({
  provider: 'ozow',
  credentials: {
    apiKey: 'your_api_key',
    siteCode: 'your_site_code',
    privateKey: 'your_private_key'
  },
  sandbox: true
});

Docs: Ozow Hub

Adyen

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.

Mercado Pago

const pay = new PayBridge({
  provider: 'mercadopago',
  credentials: {
    apiKey: 'TEST-...' // Or APP_USR-... for live
  },
  sandbox: true,
  webhookSecret: 'your_webhook_secret'
});

Docs: Mercado Pago Developers

Razorpay

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.

Mollie

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.

Square

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.

Pesapal

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).

Switch Providers in 1 Line

// 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({ ... }); // Ozow

Why PayBridge?

South 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.

Before PayBridge

// 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.

With PayBridge

// ONE API for all providers
const payment = await pay.createPayment({
  amount: 299.00,
  currency: 'ZAR',
  reference: 'INV-001',
  customer: { ... },
  urls: { ... }
});

Same code. Every provider.

API Reference

PayBridge

Constructor

new PayBridge(config: PayBridgeConfig)

Methods

  • 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): WebhookEvent
  • verifyWebhook(body: any, headers?: any): boolean
  • getProviderName(): string
  • getSupportedCurrencies(): string[]

Types

See src/types.ts for full type definitions.

Multi-Provider Routing

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);

Multi-instance deployments (Redis circuit-breaker)

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.

Intelligent routing — successRate strategy

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.

Currency Handling

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

Error Handling

try {
  const payment = await pay.createPayment({ ... });
} catch (error) {
  console.error('Payment failed:', error.message);
  // Handle error (invalid credentials, network error, etc.)
}

Webhook Security

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);
  // ...
});

Roadmap

  • 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

Contributing

We welcome contributions! To add a new payment provider:

  1. Create src/providers/yourprovider.ts extending PaymentProvider
  2. Implement all abstract methods
  3. Add provider to src/index.ts factory
  4. Update README with provider details
  5. Submit PR

See src/providers/softycomp.ts for reference implementation.

Community

Join our Discord for support, feature discussions, and updates:

License

MIT © Kobie Wentzel

Related Projects

  • WaSP — Unified WhatsApp API (Baileys, Cloud API, Twilio)
  • softycomp-node — Official SoftyComp SDK

Built with ❤️ in South Africa