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.
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 (resolveGatewayConfigForShop → getPaymentGatewayConfig in shopifyCheckout.service.js), which reads two places:
| Where | Holds |
|---|---|
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.
Endpoints & config
| Purpose | Host |
|---|---|
| Authorize / token / revoke (OAuth) | auth.razorpay.com/{authorize,token,revoke} |
| REST API (orders, payments, refunds) — Bearer | api.razorpay.com |
Config lives in src/config/razorpay.js — dual 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 var | Meaning |
|---|---|
RAZORPAY_OAUTH_MODE | test (staging) or live (production). Selects the client pair below. |
RAZORPAY_PARTNER_CLIENT_ID_TEST / _SECRET_TEST | Dev/test partner-app client pair. |
RAZORPAY_PARTNER_CLIENT_ID_LIVE / _SECRET_LIVE | Production partner-app client pair. |
RAZORPAY_OAUTH_REDIRECT_URI | Must exactly match a redirect URI registered on the partner app (dev & prod allowlists are separate). |
RAZORPAY_PARTNER_WEBHOOK_SECRET | HMAC secret for verifying partner webhooks (see Webhooks). |
ID_ENCRYPTION_KEY | AES-256-GCM key used by secret-box.js to seal tokens at rest. |
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.
| Route | Auth | Does |
|---|---|---|
GET /checkout/razorpay/oauth/start | Store JWT | Builds 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/callback | Public | Verifies 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/status | Store JWT | Returns { connected, account_id, mode, public_token } for the seller panel UI. |
POST /checkout/razorpay/oauth/disconnect | Store JWT | Revokes at Razorpay, deactivates the cred rows, and clears the central index. |
POST /checkout/razorpay/webhook | Public (HMAC) | Partner webhook — raw body, verified with RAZORPAY_PARTNER_WEBHOOK_SECRET. See Webhooks. |
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 key | Sealed? | Holds |
|---|---|---|
razorpay_oauth_access_token | yes | Bearer token for API calls (~90-day life) |
razorpay_oauth_refresh_token | yes | Used to mint a fresh access token (~180-day life) |
razorpay_oauth_public_token | no | Checkout key handed to the browser (rzp_test_oauth_… / rzp_live_oauth_…) |
razorpay_oauth_account_id | no | The merchant's Razorpay account id (acc_xxx) |
razorpay_oauth_expires_at / _mode / _client_id | no | Expiry, issuing mode, and issuing client id (needed to pick the right client pair for refresh/verify/revoke) |
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
- Proactive refresh. Before a payment call, if the access token expires within 24h, Nova refreshes it (
grant_type=refresh_tokenagainst the issuing client pair — that's whyclient_id/modeare stored) and persists the new tokens. - Reactive refresh. A 401 from Razorpay triggers a single refresh-then-retry before giving up.
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:
| Step | OAuth behaviour |
|---|---|
| Create order | Bearer. Do NOT send payment_capture (not permitted on the partner order path). |
Checkout key | Nova 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-authorized | If 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 + refunds | Bearer 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.
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.| Event | Action |
|---|---|
payment.captured / order.paid | Record the payment in the ledger (payment_transactions). If the order isn't already confirmed → browser-drop recovery placement (see below). |
payment.failed | Record a failed ledger row with the failure reason. |
refund.created / refund.processed | Reflect 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_revoked | Merchant revoked the connection → find the store via the central index → mark disconnected (markRevokedByAccount, in razorpayOAuth.controller.js). |
| Table | Role |
|---|---|
checkout_payment_webhooks | Raw event audit (best-effort persist). |
payment_transactions | Payment ledger — the same table the reconciler and /place-order write to. |
…/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
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.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):
- Lossless payload exists. No persisted payload → skip
no_recovery_payload(flagged for manual review; never guesses). - Stub still
pending_payment. Already confirmed / cancelled / expired → skip. - Amount matches. Captured amount must equal the stub's locked total (± ₹1) or it will not place.
- Not already refunded. Checked against the ledger.
- Idempotency claim on the Razorpay payment id. The exactly-once linchpin — see below.
- Fix A stub-row lock in
mirrorCheckoutOrderToStoreDbis 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.
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.| Table | Role 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 var | Default | Meaning |
|---|---|---|
RECONCILE_JOB_ENABLED | 0 | 1 = run the reconciler sweep on this env. |
RECONCILE_ALLOW_PLACEMENT | 0 | 1 = 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.
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.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)
- Set
RAZORPAY_OAUTH_MODE=live+ the live client pair in the productionzelly/fastify-nova/envsecret. Force-redeploy (ECS does not hot-reload secrets). - Register the production redirect URI and the webhook URL on the prod partner app (dev & prod allowlists are separate).
- Reconnect existing merchants. OAuth-only means key-based prod merchants' checkout breaks at cutover unless they reconnect — needs merchant comms + a reconnect window.
- Rotate any client secrets that were pasted into reference configs / chats during development.
- Apply the two SQL tables to the production
astro_primary. - 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. - Only after the staging dry run + live ₹1 test pass, set production
RECONCILE_JOB_ENABLED=1and (once confident)RECONCILE_ALLOW_PLACEMENT=1.