Developer Setup

Payments & Razorpay OAuth

This page documents Zelly's server-side payment architecture in backend-api-fastify-nova ("Nova"): how a merchant connects Razorpay, how a payment is created / verified / captured, how payment webhooks are processed, and how a paid-but-unconfirmed ("browser-drop") order is recovered without ever double-charging or losing an order. The customer-facing UI side is documented separately in Zelly Checkout.

i
Status (staging, test mode). Razorpay Partner OAuth, payment webhooks, and browser-drop recovery are all live on staging (Nova task-def rev with RECONCILE_ALLOW_PLACEMENT=1). Production still runs in the pre-cutover configuration — see Go-live checklist. Razorpay is the primary go-live gateway.

Gateway model

Payment gateways are configured per store. Nova resolves the active gateway config for a shop on every payment through one funnel (resolveGatewayConfigForShopgetPaymentGatewayConfig in shopifyCheckout.service.js), which reads two places:

WhereHolds
checkout_settings (per store)Which gateways/methods are enabled (razorpay_enabled, method toggles like rz_upi), gateway mode
checkouts_credentials (per store)The gateway credentials themselves. For Razorpay OAuth: the sealed access/refresh tokens + public token + account id (see Token lifecycle). Legacy: key_id/key_secret.

Nova supports several gateways (Razorpay, PayU, PhonePe, Cashfree), but Razorpay is the one being taken live first, and it is the only gateway on the OAuth path — everything below is Razorpay-specific.

Razorpay Partner OAuth

Historically, merchants pasted their Razorpay key_id + key_secret into the seller panel, Nova stored them per store, and authenticated to Razorpay with HTTP Basic. Razorpay approved Zelly as a Technology Partner (OAuth), so merchants now click "Connect with Razorpay", log into their own Razorpay account, and authorize — no key sharing. Nova receives a per-merchant access_token / refresh_token / public_token and calls Razorpay with Bearer auth on the merchant's behalf.

!
OAuth-only. The key/secret entry path is deprecated. If a store has no OAuth connection, Razorpay is treated as "not configured." The legacy Basic-auth code paths remain dormant as a safety net but are not the supported flow. This has a real go-live consequence — existing key-based production merchants must reconnect before cutover (see Go-live).

Endpoints & config

PurposeHost
Authorize / token / revoke (OAuth)auth.razorpay.com/{authorize,token,revoke}
REST API (orders, payments, refunds) — Bearerapi.razorpay.com

Config lives in src/config/razorpay.jsdual test/live client pairs, active pair selected by RAZORPAY_OAUTH_MODE. All values come from the zelly/fastify-nova/env Secrets Manager secret, injected as env vars:

Env varMeaning
RAZORPAY_OAUTH_MODEtest (staging) or live (production). Selects the client pair below.
RAZORPAY_PARTNER_CLIENT_ID_TEST / _SECRET_TESTDev/test partner-app client pair.
RAZORPAY_PARTNER_CLIENT_ID_LIVE / _SECRET_LIVEProduction partner-app client pair.
RAZORPAY_OAUTH_REDIRECT_URIMust exactly match a redirect URI registered on the partner app (dev & prod allowlists are separate).
RAZORPAY_PARTNER_WEBHOOK_SECRETHMAC secret for verifying partner webhooks (see Webhooks).
ID_ENCRYPTION_KEYAES-256-GCM key used by secret-box.js to seal tokens at rest.
!
The mode=test gotcha — bakes a whole class of bugs. When exchanging the authorization code for tokens against the dev/test client, the token request must send mode=test. If it doesn't, Razorpay silently issues a live grant that then fails POST /v1/orders with an opaque "%s" error. Sanity check on staging: the returned public_token must have the prefix rzp_test_oauth_.

Connect flow (routes)

Four routes drive the connect lifecycle. Start/status/disconnect are JWT-protected (seller panel calls them for the logged-in store); the callback and webhook are public.

RouteAuthDoes
GET /checkout/razorpay/oauth/startStore JWTBuilds a signed-JWT state (store id + nonce + short exp) and 302s to the Razorpay authorize URL. State is stateless CSRF protection — no server storage.
GET /checkout/razorpay/oauth/callbackPublicVerifies the state JWT, exchanges code for tokens (with mode), seals + stores them, then 302s back to the seller panel settings page with ?razorpay=connected or ?razorpay=error.
GET /checkout/razorpay/oauth/statusStore JWTReturns { connected, account_id, mode, public_token } for the seller panel UI.
POST /checkout/razorpay/oauth/disconnectStore JWTRevokes at Razorpay, deactivates the cred rows, and clears the central index.
POST /checkout/razorpay/webhookPublic (HMAC)Partner webhook — raw body, verified with RAZORPAY_PARTNER_WEBHOOK_SECRET. See Webhooks.
Seller panel (Atlas) "Connect with Razorpay"
        │  GET /checkout/razorpay/oauth/start  (store JWT)
        ▼
Nova signs a JWT state → 302 to auth.razorpay.com/authorize
        │
        ▼
Merchant Owner logs into their Razorpay account & authorizes
        │  302 back to RAZORPAY_OAUTH_REDIRECT_URI?code&state
        ▼
GET /checkout/razorpay/oauth/callback  (public)
   verify state JWT → POST auth.razorpay.com/token (grant=authorization_code, mode)
   → seal access/refresh tokens → write checkouts_credentials + checkout_settings
   → upsert razorpay_oauth_accounts (account_id → store_id)
        │  302
        ▼
Seller panel settings ?razorpay=connected  → status shows "Connected · acc_xxx · test"
i
Only the Razorpay account Owner can authorize. The seller panel copy tells the merchant this. The seller-panel Connect UI lives in seller-panel-react-atlas (CheckoutSettings.jsx + razorpayOAuthService.js); it replaced the old key/secret inputs with a Connect button + connection status.

Token lifecycle & storage

Tokens are stored per store in checkouts_credentials. The access & refresh tokens are sealed with secret-box.js (AES-256-GCM, key from ID_ENCRYPTION_KEY) — never stored in plaintext, never in a task definition.

Credential keySealed?Holds
razorpay_oauth_access_tokenyesBearer token for API calls (~90-day life)
razorpay_oauth_refresh_tokenyesUsed to mint a fresh access token (~180-day life)
razorpay_oauth_public_tokennoCheckout key handed to the browser (rzp_test_oauth_… / rzp_live_oauth_…)
razorpay_oauth_account_idnoThe merchant's Razorpay account id (acc_xxx)
razorpay_oauth_expires_at / _mode / _client_idnoExpiry, issuing mode, and issuing client id (needed to pick the right client pair for refresh/verify/revoke)
Central reverse index — razorpay_oauth_accounts (in astro_primary). A single small table mapping razorpay_account_id → store_id (+ mode, client id, status). It holds no secrets. Its only job: the authorization_revoked and payment webhooks carry only the account_id, so this index is how Nova finds which tenant store DB the connection belongs to. Tokens still live per-store.

Refresh & 401 handling

Payment path — Basic → Bearer

Every Razorpay call switches from Basic to Bearer when auth_mode === 'oauth' (via a small razorpayAuthConfig(cfg) helper that returns either a Basic auth block or a Bearer Authorization header). The key behavioural differences from the legacy key path:

StepOAuth behaviour
Create orderBearer. Do NOT send payment_capture (not permitted on the partner order path).
Checkout keyNova returns the public_token as the Checkout key — so checkout-ui needs zero change (it already uses initRes.key_id).
Verify (on /place-order)HMAC-SHA256(order_id|payment_id) signed with the OAuth client_secret of the issuing client (not a merchant key secret), plus an authoritative GET /v1/payments/:id.
Capture-if-authorizedIf the fetched payment is authorized (not yet captured), Nova POST /v1/payments/:id/capture with the locked amount, then treats it as captured. This replaces the auto-capture we lost by dropping payment_capture.
UPI QR + refundsBearer as well (createRazorpayUpiQr, checkRazorpayQrPayments, refunds.service.js).

Payment webhooks (Partner)

Handled in razorpayWebhookHandler.service.js via POST /checkout/razorpay/webhook. Every event is written to an audit table first, then acted on. The handler never re-charges.

i
Delivery model (confirmed empirically). OAuth-connected merchants' events are delivered to the one partner-app webhook, with account_id at the top level of the body and the entity under payload.<type>.entity. Nova resolves the store via the razorpay_oauth_accounts index from that top-level account_id.
EventAction
payment.captured / order.paidRecord the payment in the ledger (payment_transactions). If the order isn't already confirmed → browser-drop recovery placement (see below).
payment.failedRecord a failed ledger row with the failure reason.
refund.created / refund.processedReflect the refund in the ledger (mark the payment refunded). Covers merchant-initiated refunds from the Razorpay dashboard.
payment.dispute.*Recorded to the audit table + logged. A full dispute workflow (auto-hold, seller notify) is a product decision — recorded for now, no auto-action yet.
account.app.authorization_revokedMerchant revoked the connection → find the store via the central index → mark disconnected (markRevokedByAccount, in razorpayOAuth.controller.js).
TableRole
checkout_payment_webhooksRaw event audit (best-effort persist).
payment_transactionsPayment ledger — the same table the reconciler and /place-order write to.
!
Webhook registration in the Razorpay Partner Dashboard is a separate, manual step. The redirect URI is registered; the webhook URL (…/checkout/razorpay/webhook) for payment + authorization_revoked events must be added on the partner app. Until it is, recovery relies solely on the reconciler (which does not need webhooks).

Browser-drop recovery (paid-but-unconfirmed)

Placing a paid order is a two-step flow: POST /checkout/initiate creates a pending_payment stub and locks the amount; the browser charges the card; POST /place-order verifies and confirms. If the browser is lost after the charge but before /place-order, the money is captured but no order exists. Recovery places that order server-side.

Design: persist a lossless payload, don't reconstruct

!
Why we persist instead of rebuilding from the stub. The stub's order_items.variant_id is a local snapshot id, not the Shopify variant id — you cannot build a placeable Shopify order from it. So at /checkout/initiate (prepaid only) Nova persists the exact place-order payload — cart_items, customer, coupon, meta, locked amount — to checkout_recovery_payloads (central, astro_primary). Recovery replays that payload verbatim.
POST /checkout/initiate  (prepaid)
   → create pending_payment stub  (store orders table)
   → saveRecoveryPayload() → checkout_recovery_payloads (lossless payload, status=pending)
   → payment_transactions link row  (gateway_order_id ⇄ local_order_id = stub)
        │
        ▼  browser charges card … then drops (never calls /place-order)
        │
        ▼  Razorpay captured the payment
   ┌────────────────────────┬─────────────────────────────────┐
   │ Reconciler (primary)   │ Partner webhook (fast path)      │
   │ 5-min sweep, 4-min     │ payment.captured → resolve stub  │
   │ grace, polls Razorpay  │ via payment_transactions link    │
   └───────────┬────────────┴──────────────┬──────────────────┘
               ▼                            ▼
          placeOrderFromStub()  →  enqueue place-order-post worker
               │
               ▼   worker places the Shopify order, attaching the captured payment

Every guard (payments must not fail)

placeOrderFromStub (in stubPlacement.service.js) applies these checks before enqueuing; any failing check skips placement (never throws into the payment path):

  1. Lossless payload exists. No persisted payload → skip no_recovery_payload (flagged for manual review; never guesses).
  2. Stub still pending_payment. Already confirmed / cancelled / expired → skip.
  3. Amount matches. Captured amount must equal the stub's locked total (± ₹1) or it will not place.
  4. Not already refunded. Checked against the ledger.
  5. Idempotency claim on the Razorpay payment id. The exactly-once linchpin — see below.
  6. Fix A stub-row lock in mirrorCheckoutOrderToStoreDb is the final guard inside the worker.

Failure handling: if the enqueue itself fails, the idempotency claim is released so the next tick retries. If shopifyPlaceOrder fails for a captured (prepaid) payment, the worker records a refund request and marks the stub shopify_failed — money is never silently kept, and it never retries forever.

!
Exactly-once linchpin — order_idempotency.uniq_payment_transaction_id. The browser /place-order, the webhook, and the reconciler all claim order_idempotency with payment_transaction_id = the Razorpay payment id. claimIdempotencyKey does an INSERT IGNORE; a second path with a different key but the same payment id gets affectedRows=0, then finds no row for its own key and returns state:'conflict' — so it skips. Result: exactly one Shopify order regardless of which path wins, in both orderings. No path produces a duplicate.
!
Known, safe-by-construction edge. In a sub-second window a webhook can beat a live browser to the claim. The order is still placed exactly once, but that browser session gets a 409 and may briefly show "failed" though the order succeeded. This is not a payment failure. The reconciler's 4-minute grace means the reconciler never races a live browser; only the webhook fast-path can, and only in that tiny window. If it proves annoying in practice, add a short browser head-start guard to the webhook path and let the reconciler own placement.
TableRole in recovery
checkout_recovery_payloads (central)Lossless place-order payload per stub (uniq_store_stub). Contains order context, no payment secrets. Apply src/sql/checkout_recovery_payloads.sql to astro_primary.
payment_transactions (central)The gateway_order_id ⇄ local_order_id link the webhook fast-path uses to resolve the stub, and the ledger the reconciler polls.
order_idempotency (central)The uniq_payment_transaction_id dedup that makes placement exactly-once across all three paths.

Enabling & flags

Two non-secret env vars (wired as Terraform variables on the fastify_nova module, shared by the reconciler and the webhook fast-path):

Env varDefaultMeaning
RECONCILE_JOB_ENABLED01 = run the reconciler sweep on this env.
RECONCILE_ALLOW_PLACEMENT01 = actually place recovered orders (reconciler and webhook). 0 = detect + log only.

Staging has both set to "1" in terraform/environments/staging/main.tf; production keeps them off until validated on staging.

!
CI owns the task-def image; Terraform owns the env. Mind the deploy order. All ECS services use lifecycle { ignore_changes = [task_definition] }, and CI deploy clones the latest task-def revision as its base and swaps only the image. So a Terraform env-var change does not reach the running service by itself — it reaches it only when a subsequent deploy re-bases on the new revision, or when you clone the current running revision and flip the single env var directly (how the staging flag was set). A bare --force-new-deployment just redeploys the current revision and will not pick up a freshly-applied env change.
# Flip a single env var on the running staging service without a code deploy:
aws ecs describe-task-definition --task-definition zelly-staging-fastify-nova:<N> \
  --region ap-southeast-1 --query taskDefinition > td.json
# edit RECONCILE_ALLOW_PLACEMENT → "1", strip read-only fields, then:
aws ecs register-task-definition --region ap-southeast-1 --cli-input-json file://td.json
aws ecs update-service --cluster zelly-staging --service fastify-nova \
  --task-definition zelly-staging-fastify-nova:<N+1> --region ap-southeast-1
Also apply src/sql/checkout_recovery_payloads.sql and src/sql/razorpay_oauth_accounts.sql to astro_primary for the target env. Missing tables degrade gracefully (recovery logs a warning and skips — it never crashes a payment), but recovery won't actually place until the tables exist.

Go-live checklist (production cutover)

  1. Set RAZORPAY_OAUTH_MODE=live + the live client pair in the production zelly/fastify-nova/env secret. Force-redeploy (ECS does not hot-reload secrets).
  2. Register the production redirect URI and the webhook URL on the prod partner app (dev & prod allowlists are separate).
  3. Reconnect existing merchants. OAuth-only means key-based prod merchants' checkout breaks at cutover unless they reconnect — needs merchant comms + a reconnect window.
  4. Rotate any client secrets that were pasted into reference configs / chats during development.
  5. Apply the two SQL tables to the production astro_primary.
  6. Validate a real ₹1 live payment end-to-end (order → checkout with public_token → verify → capture-if-authorized), confirm exactly one order, then refund it.
  7. Only after the staging dry run + live ₹1 test pass, set production RECONCILE_JOB_ENABLED=1 and (once confident) RECONCILE_ALLOW_PLACEMENT=1.
i
Related. Customer-facing checkout UI, the loader, and the client-side C4/C5 fixes are in Zelly Checkout. Service topology and env/secret conventions are in Service Catalog and AWS Architecture.