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