Supercommerce API Docs
Webhooks

Razorpay Magic Checkout — Webhook & Callbacks

Server-side surface for Razorpay Magic Checkout: the completion webhook (prepaid payment.captured / COD payment.pending, HMAC-verified over the raw body) plus the three unsigned, read-only modal callbacks (get/apply promotions, shipping serviceability).

Server-side surface for Razorpay Magic Checkout: the completion webhook plus the three callbacks Razorpay invokes against our server while its hosted modal is open (list coupons, apply a coupon, shipping serviceability). The webhook verifies the X-Razorpay-Signature HMAC over the raw body and reuses the standard Razorpay account credentials (payment.razorpay settings group); Razorpay does not sign the callbacks.

Source: api-modules/payment-razorpay-magic/src/controllers/razorpay-magic-webhook.controller.ts, razorpay-magic-callbacks.controller.ts

Authentication

Completion webhook

PropertyValue
SchemeHMAC-SHA256 over the raw request body, hex-encoded
Headerx-razorpay-signature
Secret sourceAdmin settings — group payment.razorpay, key webhook_secret
ComparisonConstant-time (crypto.timingSafeEqual)

The webhook rejects (401) any request without a valid signature. Raw body is captured globally by Fastify (apps/api/src/main.ts).

Callbacks

Razorpay calls the three callbacks without an X-Razorpay-Signature header (its Magic Checkout docs require these URLs to "require no authentication"), so they perform no signature check. Each resolves the order_id / razorpay_order_id to an existing magic-session (404 otherwise) and is read-only: they list coupons, preview a coupon, and quote shipping, without mutating the cart or the session. Anything that changes state happens only in the signed completion webhook.

Razorpay Dashboard setup

All four URLs must be publicly reachable over HTTPS and unauthenticated — Razorpay calls them server-to-server. Only the webhook is signed. Admin → Plugins → Razorpay → Configuration renders this same table with the deployment's own base URL and a copy button per row.

Dashboard locationFieldValue
Account & Settings → Webhooks → + Add New WebhookWebhook URL{API}/webhooks/payments/razorpay-magic
Secretsame value as payment.razorpay.webhook_secret
Active Eventspayment.captured, payment.pending, payment.failed
Magic Checkout → Platform Setup → Custom E-Commerce Platform → Setup & Settings → Checkout Settings → Coupon SettingsURL for get promotions{API}/webhooks/payments/razorpay-magic/promotions
URL for apply promotions{API}/webhooks/payments/razorpay-magic/promotions/apply
Enable Get/Apply Promotions Secret KeyDisabled — we verify no shared secret on the callbacks
Magic Checkout → Setup & Settings → Platform Settings → Checkout SetupAuto fetch couponEnabled — Razorpay only calls get-promotions when this is on
Magic Checkout → Setup & Settings → Shipping Setup (Shipping Service type = API)URL for shipping info{API}/webhooks/payments/razorpay-magic/shipping

payment.pending is the event that delivers Cash-on-Delivery completions — omit it and COD orders are never placed. Magic is a separate webhook from standard Razorpay (/webhooks/payments/razorpay, events payment.captured + payment.failed); register both.

Response envelope

The three callbacks are the one place in the API that does not use the platform { data, message, statusCode } envelope — Razorpay parses these bodies against its own fixed schemas, so the controller is marked @SkipResponseWrap() and returns the raw shapes shown below. The webhook acknowledgement is a normal enveloped response (Razorpay only reads the status code).

Coupon callbacks

Magic shows and validates coupons inside its own modal by calling our server. Both bridge to the platform discount engine (DISCOUNT_PORT) against the cart behind the magic-session; they are stateless (no cart mutation) — the cart's authoritative coupon set is reconciled at order placement from the coupons Razorpay actually charged.

Gated by payment.razorpay_magic.coupons_enabled (when off: get-promotions returns [], apply-promotion returns INVALID_COUPON).

The cart's own coupons are already off the order amount as pre-discounts (see the storefront doc), so both callbacks evaluate a modal code on top of them: get-promotions leaves out what the cart already carries, and apply-promotion returns only the discount the new code adds. A code that refuses to stack with a cart coupon is rejected — and so is a code the cart already carries (INVALID_COUPON, "… is already applied — its discount is in the price shown"), which would otherwise discount it twice.

POST /webhooks/payments/razorpay-magic/promotions — get promotions

Razorpay sends { order_id, email, contact }. We return the show-on-cart coupons eligible for the cart:

{ "promotions": [ { "code": "DIWALI500", "summary": "₹500 off", "tnc": [] } ] }

POST /webhooks/payments/razorpay-magic/promotions/apply — apply promotion

Razorpay sends { code, order_id, email?, contact? }. On success we return the discount (subunits):

{ "promotion": { "code": "DIWALI500", "reference_id": "<discountId>", "value": 50000 } }

On failure:

{ "failure_code": "REQUIREMENT_NOT_MET", "failure_reason": "Minimum cart value not met." }

failure_codeINVALID_COUPON · LOGIN_REQUIRED · REQUIREMENT_NOT_MET.

Shipping serviceability callback

POST /webhooks/payments/razorpay-magic/shipping

Razorpay sends candidate addresses; we return per-address serviceability + fees. Our shipping is a per-vendor flat customer charge (independent of pincode), so every address is quoted the cart's own shipping total — which already reflects the free-above threshold and any free-shipping coupon on the cart. COD availability follows payment.razorpay_magic.cod_enabled; fees are zeroed when shipping_enabled is off (the flat rate still applies at order placement).

Request: { order_id, razorpay_order_id, email, contact, addresses: [ { id, zipcode, country, state_code } ] }order_id is the receipt (our order number) and razorpay_order_id may arrive without the order_ prefix; the session is resolved with or without it.

Response — serviceability and fees live on a single standard entry in shipping_methods. Magic ignores address-level flags, so an address with no shipping method shows as "Pincode not serviceable":

{
  "addresses": [
    {
      "id": "addr_1", "zipcode": "560001", "state_code": "KA", "country": "IN",
      "shipping_methods": [
        { "id": "standard", "name": "Standard Delivery", "description": "Standard Delivery",
          "serviceable": true, "cod": true, "shipping_fee": 4000, "cod_fee": 0 }
      ]
    }
  ]
}

cod_fee is always an integer (0), never null. Razorpay drops calls that take longer than 10 seconds.

serviceable comes from the delivery deny-list: a pincode in block mode returns false, everything else true. This is the only pre-payment gate on the Magic path — Razorpay collects the address and captures payment inside its own modal, so by the time our completion webhook runs the shopper has already paid. A blocked pincode that slips past this callback is logged and the order is still created, because refusing would strand captured money. When the serviceability plugin is unmounted, every address reports true.

Completion webhook

POST /webhooks/payments/razorpay-magic

EventConditionAction
payment.capturedprepaid capturePlace the internal order, mark paid.
payment.pendingentity.method === "cod"Place the internal order as COD (confirmed, payment pending — settled on delivery).
payment.failedprepaidMark the magic-session failed; the cart's reservation lapses via TTL.
otherAcknowledged (200), not acted on.

On a completion event we:

  1. Resolve the magic-session by payload.payment.entity.order_id (the Razorpay order id).
  2. Atomically claim the session (created → processing) — the unique razorpay_order_id plus this guarded transition make order creation idempotent: a replayed or concurrent webhook that loses the claim no-ops. A failure mid-placement releases the claim back to created so Razorpay's retry re-attempts.
  3. Fetch the Razorpay order (GET /v1/orders/:id) to read customer_details (the collected shipping/billing address + contact) and promotions (the coupons actually charged).
  4. Reconcile the cart's coupons to what Razorpay charged: first remove any cart coupon that is not in promotions and was not a pre-discount, then apply any code Razorpay charged that the cart lacks (one the shopper added in the modal). Capture the contact (guest confirmation email), then place the order via OrderPlacementService.createFromCartExternallyPaidno second Razorpay order; payment is recorded as already captured (prepaid) or pending (COD).
  5. Commit the inventory reservation and mark the session paid.

The order's grand total is recomputed from the cart and reconciled against the captured amount; a drift never drops a paid order — it logs an OPS ALERT error and flags the order_payment row (payload.amountMismatch) and the payment.captured / payment.placed order event for manual reconciliation.

This recompute deliberately reads the cached priced cart rather than rebuilding fresh (unless step 4 changed the coupons, which bumps the cart version and forces a rebuild). The amount Razorpay charged was quoted from the view built when the modal opened; a fresh rebuild here would re-drop a coupon that expired mid-modal and book the order above what was actually collected. Reading the same view keeps the two in agreement.

Always responds 200 after signature verification so Razorpay does not retry handled events.

On this page