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
| Property | Value |
|---|---|
| Scheme | HMAC-SHA256 over the raw request body, hex-encoded |
| Header | x-razorpay-signature |
| Secret source | Admin settings — group payment.razorpay, key webhook_secret |
| Comparison | Constant-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 location | Field | Value |
|---|---|---|
| Account & Settings → Webhooks → + Add New Webhook | Webhook URL | {API}/webhooks/payments/razorpay-magic |
| ″ | Secret | same value as payment.razorpay.webhook_secret |
| ″ | Active Events | payment.captured, payment.pending, payment.failed |
| Magic Checkout → Platform Setup → Custom E-Commerce Platform → Setup & Settings → Checkout Settings → Coupon Settings | URL 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 Key | Disabled — we verify no shared secret on the callbacks |
| Magic Checkout → Setup & Settings → Platform Settings → Checkout Setup | Auto fetch coupon | Enabled — 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_code ∈ INVALID_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
| Event | Condition | Action |
|---|---|---|
payment.captured | prepaid capture | Place the internal order, mark paid. |
payment.pending | entity.method === "cod" | Place the internal order as COD (confirmed, payment pending — settled on delivery). |
payment.failed | prepaid | Mark the magic-session failed; the cart's reservation lapses via TTL. |
| other | — | Acknowledged (200), not acted on. |
On a completion event we:
- Resolve the magic-session by
payload.payment.entity.order_id(the Razorpay order id). - Atomically claim the session (
created → processing) — the uniquerazorpay_order_idplus 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 tocreatedso Razorpay's retry re-attempts. - Fetch the Razorpay order (
GET /v1/orders/:id) to readcustomer_details(the collected shipping/billing address + contact) andpromotions(the coupons actually charged). - Reconcile the cart's coupons to what Razorpay charged: first remove any cart coupon that is not in
promotionsand 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 viaOrderPlacementService.createFromCartExternallyPaid— no second Razorpay order; payment is recorded as already captured (prepaid) or pending (COD). - 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.
Webhook — PhonePe (Payments)
Public endpoint PhonePe calls to deliver payment and refund lifecycle events. Verified with SHA256(username:password), deduplicated on a composed event id, and never trusted for money.
Webhook — Razorpay (Payments)
Public HTTP surface that Razorpay's servers call to deliver payment lifecycle events for orders placed through the platform's Razorpay provider. Verifies HMAC-SHA256 against the…