Supercommerce API Docs
Full Module Docs

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 via RazorpayPaymentModule.forRoot() in apps/api/src/app.module.ts).

Registers a PaymentProvider with id razorpay into the platform PaymentRegistry. The order module knows nothing about Razorpay specifically — it dispatches through the registry. Reconciliation (signature verify → mark-paid / mark-failed) calls into OrderService.handlePaymentSuccess / handlePaymentFailure, which guarantees idempotency: a verify-then-webhook race (or two webhook retries) is a no-op on the second call.


Conventions

Authentication

EndpointAuthPermission
POST /store/orders/:id/payment-verifyrequired (customer session)
POST /webhooks/payments/razorpaynone — 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 */ }
}
statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR (razorpay_order_id does not match this order, missing rawBody)
401UNAUTHORIZED (invalid HMAC signature, expired customer session)
404NOT_FOUND (order not yours, or not Razorpay-driven)
500INTERNAL_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 fieldRazorpay value
id"razorpay"
methods["razorpay"] (single method — Razorpay's hosted Checkout renders card / UPI / netbanking / wallet / EMI internally)
supportedPlatforms["WEB", "APP"]

Place flow

  1. Storefront calls POST /store/checkout/place-order with paymentProvider: "razorpay", paymentMethod: "razorpay".

  2. OrderService creates the order (status pending_payment, inventory reserved, no commit).

  3. OrderService dispatches RazorpayPaymentProvider.place():

    • Creates a Razorpay order via POST https://api.razorpay.com/v1/orders with receipt = <internal order id> and notes = { internal_order_id: <id> }.
    • Writes an order_payment row keyed by razorpay_order_id so verify + webhook can pin back to the order.
  4. 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"
          }
        }
      }
    }
  5. Client opens Razorpay Checkout with these fields. On success Razorpay invokes the SDK handler callback with (razorpay_order_id, razorpay_payment_id, razorpay_signature), which the client forwards to POST /store/orders/:id/payment-verify below.

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 keyRazorpay prefill field
customerNameprefill.name
customerEmailprefill.email
customerPhoneprefill.contact
currencyorder 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

NameTypeNotes
idstringInternal order id

Body

{
  "razorpay_payment_id": "pay_NXabcDEF12345",
  "razorpay_order_id":   "order_NWxYz9LkPq...",
  "razorpay_signature":  "0c4e5c..."
}
FieldTypeConstraints
razorpay_payment_idstring1..100 chars, trimmed
razorpay_order_idstring1..100 chars, trimmed. Must match the order_payment.externalReference written at place-time
razorpay_signaturestring1..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, flips paymentStatus → paid.
  • Writes audit row + emits order.paid.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod
400BAD_REQUESTOrder's paymentProvider is not razorpay; razorpay_order_id doesn't match the order's order_payment row
401UNAUTHORIZEDSignature mismatch
404NOT_FOUNDOrder 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

NameRequiredNotes
x-razorpay-signatureyesHMAC-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

StatusCodeWhen
400BAD_REQUESTMissing rawBody (Fastify content-type parser not registered)
401UNAUTHORIZEDHMAC signature mismatch

Event handling

Razorpay eventAction
payment.capturedReconcile success: same path as the verify endpoint. Flips order to confirmed / paid, commits reservation, emits order.paid
payment.authorizedIgnored — manual-capture flow is disabled. Acked 200
payment.failedReconcile 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 elseLogged + 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 same OrderResponse.
  • 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 keyRequiredPurpose
payment.razorpay.key_idyesRazorpay Key ID (rzp_test_* or rzp_live_*). Sent to the client SDK as keyId
payment.razorpay.key_secretyesRazorpay Key Secret. Used to sign API calls + verify the SDK signature. Never sent to clients
payment.razorpay.webhook_secretyesShared 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.


  • payment — neutral provider port (PaymentProvider) + PaymentRegistry. Razorpay registers into this at module init.
  • order — owns reconciliation (handlePaymentSuccess / handlePaymentFailure), event audit, and the pendingClientAction envelope on the place-order response. See order.md.
  • payment-manual — synchronous COD / manual-paid provider for comparison.

On this page