Payment — Razorpay Provider
Razorpay payment provider that plugs into the platform-neutral payment module. Exposes the SDK-bootstrap flow at place-order (via the PaymentProvider port), the customer-side…
Razorpay payment provider that plugs into the platform-neutral payment module. Exposes the SDK-bootstrap flow at place-order (via the PaymentProvider port), the customer-side payment-verify endpoint hit by the Razorpay Checkout SDK's handler callback, and a public webhook endpoint for Razorpay's server-to-server lifecycle events.
Source:
api-modules/payment-razorpay(registered viaRazorpayPaymentModule.forRoot()inapps/api/src/app.module.ts).Registers a
PaymentProviderwith idrazorpayinto the platformPaymentRegistry. The order module knows nothing about Razorpay specifically — it dispatches through the registry. Reconciliation (signature verify → mark-paid / mark-failed) calls intoOrderService.handlePaymentSuccess/handlePaymentFailure, which guarantees idempotency: a verify-then-webhook race (or two webhook retries) is a no-op on the second call.
Conventions
Authentication
| Endpoint | Auth | Permission |
|---|---|---|
POST /store/orders/:id/payment-verify | required (customer session) | — |
POST /webhooks/payments/razorpay | none — HMAC-SHA256 over raw body, header x-razorpay-signature | — |
The verify endpoint also pins the request to the place-time order_payment row (matched by razorpay_order_id) — a valid signature alone is not enough; the Razorpay session must have been created for this order. This guards against cross-order replay where an attacker reuses a valid signature from a different order against ours.
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": null
}Error envelope
Failures are normalized by HttpExceptionFilter:
{
"data": null,
"message": "Human-readable summary",
"statusCode": 400,
"errorCode": "BAD_REQUEST",
"errors": [ /* zod issues, when validation fails */ ],
"debug": { /* dev-only: error + stack */ }
}statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR (razorpay_order_id does not match this order, missing rawBody) |
| 401 | UNAUTHORIZED (invalid HMAC signature, expired customer session) |
| 404 | NOT_FOUND (order not yours, or not Razorpay-driven) |
| 500 | INTERNAL_SERVER_ERROR (Razorpay credentials misconfigured) |
Currency
Razorpay's API uses the same integer subunits the platform uses internally (paise for INR, cents for USD, etc.) — no scaling between the order amount and the Razorpay order amount. The platform passes currency: "INR" by default; tenants on other currencies pass currency in PaymentPlaceContext.metadata (3-letter ISO code, uppercased).
Place-order integration (no HTTP surface)
The Razorpay provider implements the PaymentProvider port and is dispatched by PaymentRegistry during POST /store/checkout/place-order. Storefront callers do not call this directly — they pick paymentProvider: "razorpay" in the place-order body and read pendingClientAction from the response.
PaymentProvider field | Razorpay value |
|---|---|
id | "razorpay" |
methods | ["razorpay"] (single method — Razorpay's hosted Checkout renders card / UPI / netbanking / wallet / EMI internally) |
supportedPlatforms | ["WEB", "APP"] |
Place flow
-
Storefront calls
POST /store/checkout/place-orderwithpaymentProvider: "razorpay",paymentMethod: "razorpay". -
OrderServicecreates the order (statuspending_payment, inventory reserved, no commit). -
OrderServicedispatchesRazorpayPaymentProvider.place():- Creates a Razorpay order via
POST https://api.razorpay.com/v1/orderswithreceipt = <internal order id>andnotes = { internal_order_id: <id> }. - Writes an
order_paymentrow keyed byrazorpay_order_idso verify + webhook can pin back to the order.
- Creates a Razorpay order via
-
The place-order response carries
pendingClientAction:{ "pendingClientAction": { "provider": "razorpay", "payload": { "provider": "razorpay", "razorpayOrderId": "order_NWxYz9LkPq...", "keyId": "rzp_test_AbCd1234", "amount": 125000, // subunits, same as grandTotal "currency": "INR", "prefill": { "name": "Ada Lovelace", "email": "ada@example.com", "contact": "+91-98xxx-xxxxx" } } } } -
Client opens Razorpay Checkout with these fields. On success Razorpay invokes the SDK
handlercallback with(razorpay_order_id, razorpay_payment_id, razorpay_signature), which the client forwards toPOST /store/orders/:id/payment-verifybelow.
If the verify call never happens (browser closed, app crashed), the webhook will reconcile the order independently. If neither lands, the inventory-reservation TTL eventually auto-cancels the pending_payment order.
Prefill
Driven by PaymentPlaceContext.metadata:
| metadata key | Razorpay prefill field |
|---|---|
customerName | prefill.name |
customerEmail | prefill.email |
customerPhone | prefill.contact |
currency | order currency (default INR; uppercased; must be 3 chars) |
Storefront: payment verify
POST /store/orders/:id/payment-verify — Verify a completed payment
Called by the Razorpay Checkout SDK's handler callback after the customer completes payment. Verifies the razorpay_signature against (razorpay_order_id | razorpay_payment_id) using the platform key secret, then transitions the order to confirmed / paymentStatus=paid.
Path params
| Name | Type | Notes |
|---|---|---|
id | string | Internal order id |
Body
{
"razorpay_payment_id": "pay_NXabcDEF12345",
"razorpay_order_id": "order_NWxYz9LkPq...",
"razorpay_signature": "0c4e5c..."
}| Field | Type | Constraints |
|---|---|---|
razorpay_payment_id | string | 1..100 chars, trimmed |
razorpay_order_id | string | 1..100 chars, trimmed. Must match the order_payment.externalReference written at place-time |
razorpay_signature | string | 1..200 chars, trimmed |
Response 200 — the order's OrderResponse (see order.md) after reconciliation. When the webhook already won the race (paymentStatus already paid), returns the same payload — verify is idempotent.
Side effects
- Commits the inventory reservation (parent
status: pending_payment → confirmed). - Stamps
paid_at, flipspaymentStatus → paid. - Writes audit row + emits
order.paid.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 400 | BAD_REQUEST | Order's paymentProvider is not razorpay; razorpay_order_id doesn't match the order's order_payment row |
| 401 | UNAUTHORIZED | Signature mismatch |
| 404 | NOT_FOUND | Order does not exist or belongs to another customer |
Webhook
POST /webhooks/payments/razorpay — Razorpay lifecycle events
Public endpoint hit by Razorpay. HMAC-SHA256 verified against the raw request body using the configured webhook secret. The Fastify content-type parser captures req.rawBody globally (see apps/api/src/main.ts) — verification will fail with BAD_REQUEST if it isn't set.
Always responds 200 after signature verification, including for events we choose not to act on — Razorpay otherwise retries indefinitely.
Headers
| Name | Required | Notes |
|---|---|---|
x-razorpay-signature | yes | HMAC-SHA256 hex digest of the raw body, keyed by the webhook secret |
Body — Razorpay's standard webhook envelope:
{
"event": "payment.captured",
"payload": {
"payment": {
"entity": {
"id": "pay_NXabcDEF12345",
"order_id": "order_NWxYz9LkPq...",
"amount": 125000,
"currency": "INR",
"status": "captured",
"notes": { "internal_order_id": "01J9..." },
"error_code": null,
"error_description": null
}
}
}
}The internal order id is read from payload.payment.entity.notes.internal_order_id (stamped by place()). When it's missing the event is acknowledged but not acted on (warn-logged).
Response 200
{
"data": {
"accepted": true,
"event": "payment.captured",
"handled": true
},
"message": "Success",
"statusCode": 200
}handled: false means the signature was good, the body parsed, but we deliberately did nothing (unhandled event type, missing internal order id, etc.). accepted: true always — that's the ack Razorpay needs to stop retrying.
Errors
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Missing rawBody (Fastify content-type parser not registered) |
| 401 | UNAUTHORIZED | HMAC signature mismatch |
Event handling
| Razorpay event | Action |
|---|---|
payment.captured | Reconcile success: same path as the verify endpoint. Flips order to confirmed / paid, commits reservation, emits order.paid |
payment.authorized | Ignored — manual-capture flow is disabled. Acked 200 |
payment.failed | Reconcile failure: writes failure payload (error_code, error_description, status) onto the order_payment row. The parent stays at pending_payment for reservation-TTL-driven cancellation (storefront can retry payment until the TTL window closes) |
refund.* | Ignored — v1 refunds are admin-driven via POST /admin/orders/:id/mark-refunded. Webhook-driven refunds are a follow-up |
| anything else | Logged + acked. handled: false |
Idempotency
State transitions are funneled through OrderService reconciliation methods, which short-circuit cleanly when the destination state is already set:
- Verify-then-webhook (or webhook-then-verify) race → second caller observes
paymentStatus="paid"and returns the sameOrderResponse. - Razorpay retry after a transient 5xx → second delivery is a no-op (still acked 200).
Configuration
Credentials live in the payment.razorpay settings group (read via SettingsService.getGroup("admin", ...)). They are not environment variables — they're configured per-tenant from the admin settings UI so multiple deployments can share one binary.
| Settings key | Required | Purpose |
|---|---|---|
payment.razorpay.key_id | yes | Razorpay Key ID (rzp_test_* or rzp_live_*). Sent to the client SDK as keyId |
payment.razorpay.key_secret | yes | Razorpay Key Secret. Used to sign API calls + verify the SDK signature. Never sent to clients |
payment.razorpay.webhook_secret | yes | Shared secret with the Razorpay webhook configuration. Used to verify the x-razorpay-signature header |
The key prefix (rzp_test_ vs rzp_live_) selects the Razorpay environment; the REST host (https://api.razorpay.com/v1) is the same for both.
When any of the three settings are missing, the verify and webhook endpoints respond 500 and place-order rejects with PAYMENT_PROVIDER_NOT_ENABLED.
Related modules
payment— neutral provider port (PaymentProvider) +PaymentRegistry. Razorpay registers into this at module init.order— owns reconciliation (handlePaymentSuccess/handlePaymentFailure), event audit, and thependingClientActionenvelope on the place-order response. Seeorder.md.payment-manual— synchronous COD / manual-paid provider for comparison.
Order Module
HTTP surface for the order lifecycle — storefront place-order/list/detail/cancel, vendor sub-order fulfillment and delivery, and admin oversight (cancel, mark-paid, mark-refunded).
Product Attribute Module
HTTP surface for product attributes (descriptive fields attached to products — material, country of origin, ingredient list, etc.) and attribute groups (reusable bundles of…