A UPI-first payment orchestration layer that sits between a merchant backend and multiple UPI-capable payment processors (Razorpay and Cashfree, both in test/sandbox mode). It exposes one unified API for creating payments, automatically retries and fails over to a different processor when one fails, and normalizes webhook events from different processors into one consistent internal schema.
Note on processor choice: the original spec for this project called for Razorpay + Stripe. New Stripe accounts in India are currently invite-only, so real sandbox credentials aren't obtainable — Cashfree Payments (self-serve India signup, native UPI sandbox support) is used as the live second processor instead.
StripeAdapteris still fully implemented and tested; see "Known limitations".
This is a learning / portfolio-scale reimplementation of real payment orchestration patterns. General-purpose payment orchestration already exists at production scale (Hyperswitch, Orchestra, Kill Bill, and others) — PayHub does not claim to invent orchestration as a category, and it does not compete with those production systems.
PayHub is not a payment gateway or a bank. It holds no banking licenses, does not connect directly to card networks or NPCI, and never touches raw card/UPI credentials. It is an orchestration layer that sits in front of real, licensed processors and adds routing intelligence on top of them.
Its differentiation is narrow and specific: most orchestrators treat UPI as one
payment method among hundreds, bolted onto a global-first routing engine. PayHub is
UPI-first — routing decisions are driven by a real decline-code taxonomy grounded in
NPCI's UPI response categories, not generic timeouts, and by where a decline
actually happened. A decline can be scoped to the processor's own infra
(Razorpay/Cashfree — failover plausibly helps), NPCI's shared network (different
processors may route via different NPCI sponsor-bank paths — failover may help), or
the customer's own bank/VPA (insufficient funds, invalid VPA, wrong MPIN —
switching processor changes nothing, since every processor reaches the exact same
issuing bank via NPCI). Only the first two scopes trigger failover; the third fails
fast with an explanation, because retrying can never succeed and only degrades the
customer's experience. See src/core/declineTaxonomy.ts.
Merchant Backend
|
v
+----------------------------------+
| PayHub Core |
| |
| Routing Engine |
| (decline-code-aware rules) |
| | |
| Adapter Layer |
| (processor-agnostic interface) |
| | | |
| Razorpay Cashfree |
| Adapter Adapter |
| | | |
| Webhook Normalizer |
| -> internal event schema |
| | |
| Transaction Store |
| + Idempotency Keys |
+----------------------------------+
| |
v v
Razorpay Cashfree
(test mode) (test mode)
The Adapter Layer implements the Strategy/Adapter pattern: every processor
adapter exposes the same interface — charge(), verify(), parseWebhook() — so
PayHub Core never contains processor-specific if (processor === 'razorpay')
branching outside the adapters/ and webhooks/ folders. This is the single most
important architectural decision in the codebase.
src/
├── core/
│ ├── declineTaxonomy.ts # Canonical decline codes + scope (processor/npci_network/bank_or_vpa/customer_action)
│ ├── upiHandles.ts # VPA handle -> UPI PSP classification (e.g. @ybl -> PhonePe)
│ ├── routingEngine.ts # isRetryable()/decideNextStep() (failover) + primaryProcessor() (initial pick)
│ ├── routingWeights.ts # Weighted initial-processor selection (e.g. 70/30 razorpay/cashfree)
│ ├── stateMachine.ts # Payment state transitions
│ ├── paymentService.ts # Orchestrates: create -> charge -> failover -> persist
│ └── reconciliation.ts # Per-processor success-rate / time-to-success aggregation
├── adapters/
│ ├── adapter.interface.ts # Shared interface: charge / verify / parseWebhook
│ ├── razorpay.adapter.ts
│ ├── cashfree.adapter.ts # Active fallback processor (Stripe substitute — see note above)
│ └── stripe.adapter.ts # Implemented + tested but not in the active routing order
├── webhooks/
│ ├── normalizer.ts # Maps processor payloads -> internal schema
│ └── verifySignature.ts # HMAC (Razorpay/Cashfree) / native signing (Stripe)
├── routes/
│ ├── payments.routes.ts
│ ├── webhooks.routes.ts
│ └── reconciliation.routes.ts
├── db/
│ ├── models/transaction.model.ts
│ └── connection.ts
├── queue/
│ └── retryQueue.ts # BullMQ safety-net: polls a processor if its webhook never arrives
└── server.ts
public/
└── dashboard.html # Read-only demo dashboard (vanilla JS, no build step)
tests/
created -> processing -> succeeded
-> failed -> retrying -> succeeded
-> failed (exhausted)
-> failed (no retry — non-retryable decline code)
The "fail fast vs. failover" decision is an explicit, independently testable
function: isRetryable(declineCode: string): boolean in src/core/routingEngine.ts,
backed by declineTaxonomy.ts's scope classification.
Headers: Idempotency-Key: <string>, Content-Type: application/json
{ "amount": 100000, "currency": "INR", "paymentMethod": "upi", "customerEmail": "customer@example.com", "payerVpa": "name@ybl" }payerVpa is optional. When provided, its handle is classified to a UPI PSP
(src/core/upiHandles.ts) and stored on the transaction — this is what makes a
bank_or_vpa-scoped decline explainable ("this is about the customer's PhonePe
account specifically, not about Razorpay or Cashfree").
Response 201:
{ "paymentId": "internal-uuid", "status": "processing", "routedTo": "razorpay" }routedTo is picked by weighted selection (src/core/routingWeights.ts, default
70% Razorpay / 30% Cashfree) — not always the same processor. Failover, once a
payment is in flight, is unaffected by weights and stays strictly decline-code-driven.
Query params: limit (default 100), status (filter by payment state). Most
recent first — powers the dashboard's payment list.
[{ "paymentId": "...", "status": "succeeded", "processor": "cashfree", "retriedFrom": "razorpay", "amount": 100000, "currency": "INR", "upiPsp": "phonepe", "createdAt": "...", "updatedAt": "..." }]{ "paymentId": "internal-uuid", "status": "succeeded", "processor": "cashfree", "retriedFrom": "razorpay", "amount": 100000, "currency": "INR", "payerVpa": "name@ybl", "upiHandle": "ybl", "upiPsp": "phonepe" }payerVpa/upiHandle/upiPsp are only present when a VPA was supplied at creation.
Full state timeline:
[
{ "state": "created", "timestamp": "..." },
{ "state": "processing", "processor": "razorpay", "timestamp": "..." },
{ "state": "failed", "processor": "razorpay", "reason": "declineCode:ISSUING_BANK_UNAVAILABLE", "declineScope": "bank_or_vpa", "timestamp": "..." },
{ "state": "retrying", "processor": "cashfree", "timestamp": "..." },
{ "state": "succeeded", "processor": "cashfree", "timestamp": "..." }
]declineScope appears on failed events and makes the routing engine's reasoning
visible: processor/npci_network scopes are why a failover happened;
bank_or_vpa/customer_action scopes are why one didn't.
Per-processor attempt counts, success rate, and average time-to-success, plus overall payment-level stats — the numbers behind "why orchestration matters," not just the architecture story:
{
"perProcessor": [
{ "processor": "razorpay", "totalAttempts": 10, "succeeded": 2, "failed": 4, "successRate": 33.33, "averageTimeToSuccessMs": 137600 },
{ "processor": "cashfree", "totalAttempts": 4, "succeeded": 2, "failed": 1, "successRate": 66.67, "averageTimeToSuccessMs": 15100 }
],
"overall": { "totalPayments": 11, "succeeded": 4, "failed": 2, "inFlight": 5, "successRate": 66.67 }
}Read-only demo dashboard (static HTML + vanilla JS, no build step) — payment
list with filtering, click-to-expand event timelines, and the reconciliation
table above, all hitting PayHub's own API. Open by default; set
DASHBOARD_USERNAME/DASHBOARD_PASSWORD to require HTTP Basic Auth on this
route. See "Known limitations."
Verifies the signature, normalizes the payload, updates the transaction state.
Unverified webhooks are rejected with 401. The Stripe route exists and works
(StripeAdapter is fully implemented) but won't receive real traffic unless you
have Stripe test credentials and flip routingEngine's PROCESSOR_ORDER back.
The routes above are webhooks PayHub receives from processors. Separately,
PayHub can send a webhook of its own to your merchant backend the moment a
payment reaches a terminal state — set MERCHANT_WEBHOOK_URL (and
MERCHANT_WEBHOOK_SECRET) and PayHub POSTs there; leave it unset and PayHub
just doesn't attempt delivery (integrators can still poll via the SDK's
waitForTerminalStatus(), see INTEGRATION.md).
Only terminal outcomes are forwarded — payment.succeeded, and
payment.failed once the routing engine has genuinely given up (no
processing/retrying intermediate events). The body is signed the same way
Razorpay/Cashfree sign their own webhooks to PayHub: HMAC-SHA256 hex over the
raw JSON body, sent as X-PayHub-Signature, alongside X-PayHub-Event.
sdk/payhubClient.ts exports verifyMerchantWebhookSignature() to check it
on the receiving end.
{
"event": "payment.succeeded",
"paymentId": "internal-uuid",
"status": "succeeded",
"processor": "cashfree",
"retriedFrom": "razorpay",
"amount": 100000,
"currency": "INR",
"upiPsp": "phonepe",
"timestamp": "2026-07-23T10:15:00.000Z"
}A payment.failed event additionally carries declineCode/declineScope
from the last processor attempt. Delivery is in-process and best-effort — up
to 4 attempts with backoff (1s/3s/9s), a 5s timeout per attempt — fired
without blocking the response to whichever request caused the transition
(POST /payments or a processor's own POST /webhooks/*). See src/webhooks/merchantNotifier.ts
and "Known limitations" below for what that tradeoff means in practice.
src/core/declineTaxonomy.ts groups every decline code into one of four scopes:
| Scope | Meaning | Failover helps? |
|---|---|---|
processor |
The processor's (Razorpay/Cashfree) own API/gateway infra | Yes |
npci_network |
NPCI's shared switch (e.g. peak-time congestion) — processors may route via different NPCI sponsor-bank paths | Maybe — worth trying |
bank_or_vpa |
The customer's own issuing bank or VPA state (insufficient funds, invalid VPA, wrong MPIN, fraud hold, limits) | No — every processor reaches the same bank via NPCI |
customer_action |
A deliberate customer action (cancelled, dropped) rather than a technical failure | No |
This is the concrete fix for a naive "gateway error -> just retry" model: a
decline meaning "the customer's bank is down" and a decline meaning "Razorpay's
API hiccuped" look superficially similar but call for opposite responses.
Razorpay's own docs, for instance, describe their GATEWAY_ERROR code as
originating "at the bank or wallet provider's end" — so PayHub maps it to
bank_or_vpa (fail fast), while Razorpay's SERVER_ERROR (their own infra) maps
to processor (failover). See the mapping tables and reasoning in
webhooks/normalizer.ts and each adapter.
src/core/upiHandles.ts classifies a customer's VPA handle (@okhdfcbank ->
Google Pay, @ybl -> PhonePe, @paytm -> Paytm, etc. — publicly documented,
well-known mappings) to surface which PSP a bank_or_vpa decline is really
about. This is reasoning, not control: PayHub cannot programmatically switch
a customer from PhonePe to Google Pay mid-transaction — the customer's own UPI
app handles their side of the transaction, always has, and no merchant-side
backend can change that. What handle classification enables is an honest,
specific explanation ("this decline is about the customer's PhonePe account,
not about our processor choice") instead of a generic failure.
src/core/routingWeights.ts splits initial processor selection across
Razorpay/Cashfree by weight (70/30 by default) rather than always starting new
payments on the same processor — a real production pattern for gradually
shifting volume, canary-testing a route, or balancing cost/success-rate
tradeoffs. This is deliberately separate from failover: once a payment is in
flight, decideNextStep() stays strictly decline-code-driven — weights never
influence whether/where a failed payment retries, only which processor a
brand-new payment starts on. routingEngine.ts exposes setRandomFn() for
deterministic testing instead of relying on statistical sampling.
Two further ideas from the original UPI-first differentiation research, kept here as documented "if I had more time" directions rather than built:
- Peak-time routing weight shifts: known high-failure windows (month-end
salary days, festival sale traffic) could shift
routingWeights.ts's weights automatically — e.g. favor whichever processor's NPCI sponsor-bank path empirically holds up better during that window, usingreconciliation.ts's own success-rate data as the signal. - Per-PSP settlement-time transparency: surfacing how long each processor
actually takes to settle funds to the merchant's bank account (distinct from
the customer-facing "succeeded" latency
reconciliation.tsalready tracks), which is a real, underexposed pain point for merchants choosing between PSPs.
render.yaml in the repo root is a Render Blueprint — clicking the button above
walks you through creating the service and prompts for the required env vars
(it doesn't pull any secrets from the repo). You'll need:
- A MongoDB Atlas connection string (
MONGODB_URI) — same free-tier setup as below. - Razorpay test-mode credentials (
RAZORPAY_KEY_ID/_KEY_SECRET/_WEBHOOK_SECRET). - Cashfree test-mode credentials (
CASHFREE_APP_ID/_SECRET_KEY). - Stripe test-mode credentials are optional (
StripeAdapterisn't the active routed processor — see "Known limitations"). MERCHANT_WEBHOOK_URL/MERCHANT_WEBHOOK_SECRETare optional (see "Outbound merchant webhooks" above) — leave unset if you don't have a merchant backend to forward payment outcomes to yet.
Once live, point Razorpay's webhook URL (Razorpay dashboard -> Webhooks) at
https://<your-render-url>/webhooks/razorpay. Cashfree needs no dashboard
webhook config at all — it has no account-level default webhook, so
CashfreeAdapter.charge() sends notify_url on every order instead, using
RENDER_EXTERNAL_URL (auto-injected by Render) or PUBLIC_BASE_URL if set.
The read-only dashboard is served at /dashboard/.
Redis isn't required for deployment — the BullMQ verification queue
(src/queue/retryQueue.ts) exists as a safety-net implementation but isn't
currently wired into server.ts's startup path.
-
Install dependencies
npm install -
MongoDB — run locally (
mongod) or use a free-tier MongoDB Atlas cluster. -
Redis — optional.
src/queue/retryQueue.tsimplements a BullMQ verification-safety-net queue, but it isn't currently wired intoserver.ts's startup path, so Redis isn't needed to run PayHub today. -
Razorpay test mode — sign up at razorpay.com, switch the dashboard to Test Mode, and grab your Test API Key ID/Secret and a webhook secret from Settings -> Webhooks. Razorpay's test mode is free and never touches real money.
-
Cashfree test mode — sign up at merchant.cashfree.com (self-serve, no invite required), switch to Test Mode, and grab your App ID and Secret Key from Developers -> API Keys. Cashfree signs webhooks with that same secret key (no separate webhook secret to configure). Unlike Razorpay, there's no dashboard-level webhook URL to set — Cashfree only sends a webhook for a payment if
notify_urlwas included in that payment's Create Order call, soCashfreeAdaptersends it on every order usingPUBLIC_BASE_URL(set this to your ngrok tunnel URL for local testing).(Optional) Stripe test mode — only needed if you have Stripe test-mode access (e.g. a non-India account) and want to exercise
StripeAdapterinstead: sign up at stripe.com, use your Test mode secret key and a webhook signing secret from the Stripe CLI or Dashboard. -
Copy
.env.exampleto.envand fill in the values from steps 2-5. -
Run tests
npm test -
Run the server
npm run devThen open http://localhost:3000/dashboard/ for the read-only demo dashboard (payment list, filtering, event timelines, per-processor reconciliation). Open by default for local use; set
DASHBOARD_USERNAME/DASHBOARD_PASSWORDin.envto require a login before deploying it anywhere public.
Merchant backends integrating against PayHub should start with
INTEGRATION.md — it covers the official Node.js SDK
(sdk/payhubClient.ts, dependency-free, npm run build:sdk to compile it),
a full worked Express checkout example, how to interpret a decline's
declineScope, and the raw HTTP contract for non-Node integrators.
PayHub.postman_collection.json covers every endpoint — the happy path, both
error paths (400/401/404), and signed webhook requests for all three processors
(Razorpay HMAC, Cashfree timestamp+body HMAC, Stripe's t=...,v1=... scheme).
Import it into Postman, set baseUrl and the *WebhookSecret/*SecretKey
variables to match your .env, then run "Create Payment" first — its test
script captures paymentId for the other requests. The collection's own
description (visible in Postman) has the full walkthrough, including how to
get the real Razorpay/Cashfree order IDs the webhook requests need.
- Unit tests: routing engine decisions,
isRetryable(), decline-code scope classification (declineTaxonomy.ts), VPA handle classification (upiHandles.ts), weighted processor selection (routingWeights.ts), reconciliation aggregation (reconciliation.ts), state machine transitions, webhook normalization, signature verification (Razorpay HMAC, Stripe's native scheme, Cashfree's timestamp+body HMAC). - Adapter tests: Razorpay/Cashfree/Stripe adapters against injected fake SDK/HTTP clients (no network calls, fully deterministic).
- Integration tests:
paymentServiceand the HTTP routes against a real, in-memory MongoDB (mongodb-memory-server) — including the idempotency guarantee (sameIdempotency-Keytwice creates exactly one transaction), a scripted failover demo (primary processor times out, fallback succeeds, and the event timeline shows the complete failover story), and handle-aware fail-fast (abank_or_vpadecline never triggers failover even when a healthy fallback processor exists). - The dashboard (
public/dashboard.html) was manually verified in a real browser against the live dev server — payment list, status filtering, click-to-expand event timelines, and the reconciliation table all confirmed working with real Razorpay/Cashfree sandbox data and zero console errors.
- No refunds, disputes, or chargebacks. Out of scope for v1.
- No subscriptions or recurring billing.
- No real card network or NPCI connectivity. This project sits in front of Razorpay/Cashfree test mode only; it never talks to NPCI or card networks directly.
- Single currency path exercised (INR/UPI). Multi-currency is out of scope.
- Dashboard auth is opt-in, and scoped to the dashboard route only.
Setting
DASHBOARD_USERNAME/DASHBOARD_PASSWORDputs HTTP Basic Auth in front of/dashboard/(seesrc/middleware/dashboardAuth.ts); leaving either unset keeps it open, which is fine for local use but not for a public deployment. Either way, the underlying read endpoints the dashboard calls (GET /payments,/payments/:id,/payments/:id/events,/reconciliation) are not gated by this — they're also the SDK's read methods, so protecting them too would be a breaking change for integrators polling PayHub. This closes the "stumble on the URL, browse a nice UI of everyone's payments" exposure, not every path to the underlying data — someone who already knows the API shape can still query it directly. - Outbound merchant webhook delivery is in-process and best-effort, not
queue-backed. Unlike the BullMQ verification safety-net above, delivery
to
MERCHANT_WEBHOOK_URLisn't persisted anywhere — it's up to 4 retries with backoff, entirely in memory. A process restart mid-retry silently drops a pending delivery, and there's no dead-letter/replay mechanism. Wiring delivery through the existing BullMQ queue would be the natural next step for stronger guarantees. Only terminal outcomes are forwarded, not every state transition. See "Outbound merchant webhooks" above and INTEGRATION.md. - The SDK (
sdk/payhubClient.ts) is a single copy-paste file, not a published package. No npm registry publish step in v1 —npm run build:sdkcompiles it locally for plain-JS consumers. - Weighted routing affects only the initial processor pick, not mid-flight
rebalancing. No ML-based or success-rate-based adaptive routing — weights
are a fixed, manually-set table (
routingWeights.ts), not learned fromreconciliation.ts's own data (see "Peak-time routing" above for that idea). - The BullMQ verification queue is a safety net, not the primary failover path.
The actual decline-code-aware failover happens synchronously/immediately in
paymentService; the queue only guards against a dropped/late webhook. - Decline-code mappings are illustrative. The Razorpay/Cashfree/Stripe
error-code -> internal decline-code tables in
webhooks/normalizer.tsand the adapters cover common cases but are not an exhaustive mapping of every real-world error code any processor can return. - Stripe substituted with Cashfree as the active fallback processor. New
Stripe accounts in India are currently invite-only, so real sandbox credentials
aren't obtainable.
StripeAdapteris fully implemented and unit-tested against a fake client, and the/webhooks/striperoute is live — butroutingEngine'sPROCESSOR_ORDERcurrently routes to Cashfree, not Stripe, so Stripe never receives real traffic in this deployment. - Cashfree's Create Order API requires a customer phone number that PayHub's
unified
/paymentscontract doesn't collect in v1 (only amount/currency/ paymentMethod/customerEmail).CashfreeAdaptersends a fixed sandbox placeholder phone number — fine for test-mode demonstration, but would need a real customer phone field before this adapter could be used in production. - No PCI-DSS compliance claim. PayHub's security posture comes entirely from never touching raw card/UPI credentials — all sensitive input goes through each processor's own hosted fields/SDK — not from any compliance certification.
- Handle-aware routing is reasoning, not control. PayHub cannot programmatically move a customer from one UPI app to another — see "Decline-code taxonomy & handle-aware routing" above for what this feature actually does and why.
- Peak-time routing weight shifts and per-PSP settlement-time transparency remain documented future directions, not built — see that section above.
Licensed under the Apache License, Version 2.0. This applies to
the whole repository, including sdk/payhubClient.ts — you're free to copy
it into your own project under the same terms.