One Gateway. Every Payment Provider.
A centralized Payment Gateway that abstracts multiple payment providers behind a single, unified REST API. Instead of every internal service importing Stripe, Midtrans, or Xendit SDKs, they call one API. The gateway handles provider selection, request translation, retry logic, error normalization, and audit logging.
Design principle: "Internal services should never import a payment SDK. They should never know whether a charge went through Stripe or Midtrans. That's the gateway's problem — not theirs."
| Metric | Result |
|---|---|
| Providers unified | 3 (Stripe, Midtrans, Xendit) |
| API surface for all operations | 1 |
| Operations supported | charge · refund · verify · query · list |
| Observability | Correlation IDs, Prometheus metrics, health checks, structured JSON logging |
| Async webhooks | BullMQ queue with exponential backoff + deduplication |
| Provider logic in consumers | 0 |
| Graceful shutdown | SIGTERM/SIGINT, LB drain, resource cleanup |
| Request logging | Structured JSON with hashed API keys, per-route body limits |
| Time to add a new provider | ~1 day |
- Duplicated integration logic — Every team re-implements provider auth, request signing, and error parsing
- Inconsistent error handling — Midtrans returns
status_code: "406"as strings, Stripe throws typed exceptions, Xendit uses HTTP codes - Adding providers touches everything — No single place to add a new regional gateway
- No audit trail — Payment interactions logged inconsistently across services
| Before (Direct Integration) | After (Via Gateway) | |
|---|---|---|
| SDK imports | Each service imports Stripe directly | One REST API for all ops |
| Error handling | Codes differ per provider | Normalized error shape everywhere |
| Retry logic | Duplicated everywhere | Lives in one place |
| New provider | Update N services | Write one adapter |
| Audit trail | Inconsistent or missing | Every transaction logged centrally |
┌─────────────────────────────────────────────────────────────────────┐
│ PAYMENT GATEWAY SERVICE │
└─────────────────────────────────────────────────────────────────────┘
Billing Service ─────┐
Order Service ─────┤──► POST /v1/payments/charge
Subscription Svc ─────┘ POST /v1/payments/refund
POST /v1/payments/verify
GET /health ← provider connectivity
GET /metrics ← Prometheus metrics
│
┌──────────▼──────────┐
│ Gateway Core │
│ ┌─────────────────┐ │
│ │ Provider Router │ │ ← selects adapter
│ │ Retry Manager │ │ ← 3 attempts + jitter
│ │ Circuit Breaker │ │ ← opens after 5 failures
│ │ Error Normalizer│ │ ← unified error shape
│ │ Audit Logger │ │ ← every tx logged
│ │ Metrics │ │ ← latency, errors, retries
│ └─────────────────┘ │
└──────────┬────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
StripeAdapter MidtransAdapter XenditAdapter
(implements IPaymentProvider)
│ │ │
Stripe API Midtrans API Xendit API
Async Webhook Pipeline (BullMQ + Redis)
POST /v1/webhooks/:provider → verify → enqueue → worker processes
- Open/Closed Principle — Adding a provider = one new adapter. Zero changes to core or consumers.
- Dependency Inversion — Core references
IPaymentProvider, never concrete adapters. - Single Responsibility — Each adapter knows only its provider's quirks.
- Encapsulation — Provider auth, webhooks, and error codes are invisible to consumers.
- Observability — Every request gets a correlation ID. Every operation emits Prometheus metrics.
- Resilience — Circuit breaker prevents cascade failures. Jittered backoff avoids thundering herd.
| Layer | Technology |
|---|---|
| Runtime | Node.js 22+ |
| Language | TypeScript 5.6+ (strict mode) |
| Web Framework | Hono |
| Validation | Zod |
| Database | PostgreSQL 16+ |
| ORM | Drizzle ORM |
| Queue | BullMQ + Redis |
| Metrics | Prometheus (prom-client) |
| Testing | Jest + ts-jest |
| Monorepo | pnpm workspaces + Turborepo |
| Linting | Biome |
| CI/CD | GitHub Actions |
- Stripe —
stripe(Payment Intents, Refunds, Webhook HMAC verification) - Midtrans —
midtrans-client(Core API, Snap, notification handling) - Xendit —
xendit-node(Invoices, Refunds, callback token verification)
- Node.js 22+
- pnpm 10+
- PostgreSQL 16+ (or Docker)
- Redis 7+ (or Docker)
git clone https://github.com/yourusername/payment-application-gateway.git
cd payment-application-gateway
pnpm installcp apps/server/.env.example apps/server/.envEdit apps/server/.env:
# Database
DATABASE_URL=postgresql://postgres:password@localhost:5432/payment_gateway
DATABASE_URL_TEST=postgresql://postgres:password@localhost:5432/payment_gateway_test
# Redis (required for rate limiting, queue, and replicated deployments)
REDIS_URL=redis://localhost:6379
# Provider credentials
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
MIDTRANS_SERVER_KEY=SB-Mid-server-...
XENDIT_SECRET_KEY=xnd_test_...
# API Key authentication (comma-separated)
API_KEYS=api-key-1,api-key-2
# Logging
LOG_LEVEL=info
LOG_WEBHOOK_BODIES=false
# Operational Readiness
SHUTDOWN_TIMEOUT_MS=10000
WEBHOOK_DEDUP_TTL_HOURS=72
# Server
CORS_ORIGIN=http://localhost:3001
NODE_ENV=development# Using Docker Compose (PostgreSQL + Redis)
pnpm docker:up
# Or start services individually
pnpm db:start
pnpm redis:startpnpm db:pushpnpm dev:serverThe API is running at http://localhost:3000.
POST /v1/payments/charge{
"provider": "stripe",
"amount": 1000,
"currency": "USD",
"paymentMethod": "pm_card_visa",
"description": "Order #12345",
"metadata": { "orderId": "12345" },
"customerId": "cus_xxx"
}Response:
{
"success": true,
"transactionId": "txn_abc123...",
"amount": 1000,
"currency": "USD",
"provider": "stripe",
"providerRef": "pi_xxx",
"raw": { /* full provider response */ }
}POST /v1/payments/refund{
"provider": "stripe",
"transactionId": "txn_abc123...",
"amount": 1000,
"reason": "requested_by_customer"
}POST /v1/payments/verify{
"provider": "stripe",
"transactionId": "pi_xxx"
}POST /v1/webhooks/:providerProviders accept webhooks at:
/v1/webhooks/stripe— verifiesstripe-signatureheader/v1/webhooks/midtrans— verifies notification signature/v1/webhooks/xendit— verifiesx-callback-tokenheader
Async Processing: When Redis is available, webhooks are verified synchronously and then enqueued for async processing via BullMQ (returns 202 Accepted with queued: true). When Redis is unavailable, they fall back to synchronous processing (200 OK).
GET /healthReturns per-provider connectivity status. 200 if all providers are healthy, 503 if any are degraded.
GET /metricsPrometheus exposition endpoint with operation latency, error rate, and retry counters.
Each provider is configured via environment variables and registered at runtime.
// Adapters are registered in src/app.ts
registry.register(new StripeAdapter({ secretKey: env.STRIPE_SECRET_KEY }));
registry.register(new MidtransAdapter({ serverKey: env.MIDTRANS_SERVER_KEY }));
registry.register(new XenditAdapter({ secretKey: env.XENDIT_SECRET_KEY }));- Create a new adapter in
src/adapters/<provider>/ - Implement
IPaymentProviderinterface - Register in
src/app.ts - Add tests in
src/adapters/<provider>/<provider>-adapter.test.ts
That's it. Zero changes to routes, core, or consumers.
- API Key Authentication — All payment endpoints require
x-api-keyheader against a configured allowlist - Per-Key Rate Limiting — Redis-backed rate limiting restricts each API key to 100 requests/minute
- Webhook Signature Verification — Stripe (HMAC-SHA256), Midtrans (SHA512), and Xendit (callback token) signatures are verified before accepting events
- Circuit Breaker — Prevents cascade failures by opening after repeated provider errors
- Audit Logging — Every transaction is recorded in PostgreSQL with correlation IDs for traceability
All errors are normalized to a unified shape:
{
"error": {
"code": "CARD_DECLINED",
"message": "The card was declined",
"retryable": false
}
}| Code | Description | Retryable |
|---|---|---|
INSUFFICIENT_FUNDS |
Card has insufficient balance | No |
CARD_DECLINED |
Card was declined by issuer | No |
EXPIRED_CARD |
Card has expired | No |
RATE_LIMITED |
Too many requests | Yes |
GATEWAY_ERROR |
Provider API error | Yes |
INVALID_REQUEST |
Bad request data | No |
UNAUTHORIZED |
Authentication failed | No |
NOT_FOUND |
Provider not registered | No |
# Run all tests
pnpm test
# Run with coverage
pnpm test -- --coverage
# Watch mode
pnpm test:watch
# Run specific test file
pnpm test -- --testPathPatterns="stripe-adapter"- Unit tests — Colocated with source (
*.test.ts) - Integration tests —
tests/integration/ - Unit tests —
tests/unit/ - Contract tests — Adapter interface verification
| Suite | Coverage |
|---|---|
| Overall | 90% |
| Core (registry, retry, audit, circuit breaker, metrics, logger) | 99% |
| Adapters | 77-94% |
| Routes | 87% |
| Middleware | 90% |
| Queue | 87% |
payment-application-gateway/
├── apps/
│ └── server/
│ ├── src/
│ │ ├── adapters/ # Provider adapters
│ │ │ ├── stripe/
│ │ │ ├── midtrans/
│ │ │ └── xendit/
│ │ ├── core/ # Gateway core
│ │ │ ├── provider-registry.ts
│ │ │ ├── payment-gateway.ts
│ │ │ ├── retry-manager.ts
│ │ │ ├── circuit-breaker.ts
│ │ │ ├── audit-logger.ts
│ │ │ ├── transaction-store.ts # NEW — query abstraction
│ │ │ ├── metrics.ts
│ │ │ ├── logger.ts # NEW — pino structured logger
│ │ │ └── request-context.ts
│ │ ├── routes/ # API routes
│ │ │ ├── payments.ts
│ │ │ ├── transactions.ts # NEW — query endpoints
│ │ │ ├── providers.ts # NEW — provider introspection
│ │ │ ├── webhooks.ts
│ │ │ ├── webhook-events.ts # NEW — async event history
│ │ │ ├── health.ts
│ │ │ ├── metrics.ts
│ │ │ └── docs.ts # NEW — OpenAPI spec + Scalar UI
│ │ ├── middleware/ # Middleware
│ │ │ ├── error-handler.ts
│ │ │ ├── api-key-auth.ts
│ │ │ ├── rate-limiter.ts
│ │ │ ├── correlation-id.ts
│ │ │ ├── request-logger.ts # NEW — structured HTTP logging
│ │ │ ├── body-size-limit.ts # NEW — per-route body limits
│ │ │ └── pagination.ts # NEW — pagination helper
│ │ ├── queue/ # BullMQ queue + worker
│ │ │ ├── webhook-queue.ts
│ │ │ └── webhook-worker.ts
│ │ ├── types/ # Shared types
│ │ │ ├── api.ts # NEW — API response types
│ │ │ └── payment.ts
│ │ ├── app.ts # App factory
│ │ └── index.ts # Server bootstrap
│ └── tests/
│ ├── __mocks__/ # Test mocks
│ ├── integration/ # Integration tests
│ └── unit/ # Unit tests
├── packages/
│ ├── db/ # Database schema & client
│ ├── env/ # Environment validation
│ ├── redis/ # Redis client
│ └── config/ # Shared TS config
├── docs/
│ ├── use-case.md # Case study
│ └── specs/ # Implementation specs
└── package.json # Workspace config
# Development
pnpm dev:server # Start server in watch mode
pnpm docker:up # Start PostgreSQL + Redis via Docker Compose
pnpm db:start # Start PostgreSQL via Docker
pnpm redis:start # Start Redis via Docker
pnpm db:push # Push schema to database (dev only — use migrations for production)
pnpm db:generate # Generate a versioned migration from schema changes
pnpm db:migrate # Apply pending migrations
pnpm db:studio # Open Drizzle Studio
# Quality
pnpm check-types # Type-check all packages
pnpm check # Run Biome lint + format
pnpm test # Run all tests
pnpm test -- --coverage # Run tests with coverage report
# Build
pnpm build # Build all packages and appsThe retry logic is domain-specific: retryable vs non-retryable is a business decision, not infrastructure. Adapters classify errors. The retry manager just executes with jittered exponential backoff.
Provider failures are isolated. After 5 failures in 60 seconds, the circuit opens for 30 seconds, returning fast failures instead of slow timeouts.
Callers don't pass idempotency keys. The gateway generates txn_ prefixed IDs automatically. Prevents duplicate charges under retry.
Every transaction stores the raw provider response alongside the normalized result. When providers change schemas, historical data is preserved.
The ProviderRegistry uses a Map<ProviderName, IPaymentProvider>. No compile-time imports of adapter classes in the gateway core.
Instead of passing a Hono Context through every function signature, we use Node.js AsyncLocalStorage to propagate correlation IDs into core logic, adapters, and background workers transparently.
Redis is required for full functionality (rate limiting, async webhooks), but the gateway starts and serves requests without it. Missing features log warnings rather than crashing.
- Add Docker Compose for one-command setup
- Implement webhook event queue (Redis/BullMQ)
- Add health check endpoint with provider status
- Add metrics and monitoring (Prometheus)
- Implement API key authentication and rate limiting (Redis)
- Add correlation ID propagation for observability
- Circuit breaker for provider resilience
- Jittered exponential backoff in retry manager
Note: For a payment gateway, reliability is security. A bad deploy or duplicate charge is a financial incident. This roadmap is reordered by actual production risk, not by feature category.
- GitHub Actions CI pipeline — type check, lint, test, build on every PR. Prevents deploying broken code to a financial system.
- Implement idempotency key caching (Redis) — cache
txn_IDs in Redis to short-circuit duplicate charges under retries or network blips. - Secrets management — migrate from flat
.envfiles to a secrets manager (AWS Secrets Manager, HashiCorp Vault). Prevents credential leaks.
- Smoke tests after deployment — verify
/healthand sample charge/refund in staging before promoting to production. Catches config drift and credential issues. - Database migration verification in CI — validate migrations run cleanly and don't corrupt transaction history.
- Admin/ops endpoints —
/admin/queue-status, retry failed jobs, drain queue. Operational visibility without SSH.
- Webhook IP allowlisting — restrict webhook endpoints to known provider IP ranges. Complements signature verification.
- Docker image build and push to registry (GHCR or ECR). Required for deployment; the CI pipeline is the prerequisite.
- Staging deployment pipeline — auto-deploy
mainbranch to staging environment. - Production deployment pipeline — tagged releases with rollback capability.
- Input validation / sanitization hardening — strict bounds checking on all webhook payloads before queueing.
- Sensitive data logging audit — verify raw card numbers or PCI-relevant fields never hit structured logs.
- Database encryption at rest — encrypt transaction data (customer IDs, amounts, raw responses).
- Endpoint-specific rate limiting — differentiate webhook (higher) vs. charge/refund (strict) limits.
- Support for more providers (PayPal, Braintree, etc.)
- Transaction reconciliation job — periodic worker comparing gateway vs. provider state.
- Load testing / performance benchmarks — documented and run in CI.
- Multi-environment deployment config — K8s manifests or Terraform.
- Redis Sentinel / Cluster support — failover for replicated deployments.
- Add Bull Dashboard for queue monitoring.
- Infrastructure as Code — Terraform or Pulumi for staging and production environments.
Contributions are welcome! Please read the following before submitting PRs:
- Follow the existing code style (Biome handles most of this)
- Write tests for new features
- Update the spec if you change architecture
- Ensure
pnpm check-typesandpnpm testpass locally - CI runs automatically on every PR — lint, type check, unit tests, integration tests (with PostgreSQL + Redis), migration verification, and build must all pass before merge
# Before committing
pnpm check # Format & lint
pnpm check-types # Type check
pnpm test # Run testsMIT © Yusup
Built with ❤️ for teams tired of writing payment integrations.