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) plus the three modal callbacks (get/apply promotions, shipping serviceability), all HMAC-verified over the raw body.

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). All four verify the X-Razorpay-Signature HMAC over the raw body and reuse the standard Razorpay account credentials (payment.razorpay settings group).

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

Authentication

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)

Both the webhook and the three callbacks reject (401) any request without a valid signature — the Razorpay order_id is handed to the browser (checkout.js) and is therefore not a secret, so the HMAC is the sole authenticator. The callbacks additionally resolve the order_id to a magic-session (404 otherwise).

Raw body is captured globally by Fastify (apps/api/src/main.ts).

Razorpay Dashboard setup

All four URLs must be publicly reachable over HTTPS and unauthenticated — Razorpay calls them server-to-server, and the HMAC is what authenticates them. 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
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).

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 gets the same computed fee. 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, addresses: [ { id, zipcode, country, state_code, city } ] }

Response:

{
  "addresses": [
    { "id": "addr_1", "zipcode": "560001", "state_code": "KA", "country": "IN",
      "serviceable": true, "cod": true, "shipping_fee": 4000, "cod_fee": null }
  ]
}

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 those coupons onto the cart, capture the contact (guest confirmation email), then place the order via OrderService.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 is logged and flagged on the order_payment row for ops.

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

On this page