Webhooks

GOLD PRO receives one webhook: Razorpay's payment and refund events. It is the source of truth for whether a payment happened. The browser callback at POST /api/billing/verify is a convenience so a tenant sees their plan immediately; this endpoint is what makes the system correct when the tab is closed, the network dies, or a customer pays by UPI intent on a phone that never returns to the page.

GOLD PRO does not send webhooks to you. This page documents the endpoint it receives on, so that an operator configuring Razorpay — or anyone auditing the payment path — can get the signature right. See Billing for the endpoints around it.

Razorpay is not configured on this platform today. The keys and the webhook secret are unset, so every delivery to this endpoint currently answers 503. Nothing below is live until those environment variables are set; the mechanics are accurate and unchanged by that.

The payment webhook

POST /api/razorpay/webhook

This endpoint is not session-authenticated. Razorpay has no session to present. It is explicitly allowlisted at the proxy — the /api/razorpay prefix is on the self-authenticating list, so the platform's protect-by-default session check does not run on it.

The HMAC signature over the raw body is the entire authentication. There is nothing else. An implementation that verifies it incorrectly, or skips it, lets anyone on the internet grant themselves a paid subscription by posting a JSON body.

Configure it in the Razorpay dashboard as POST <origin>/api/razorpay/webhook, subscribing to payment.captured and payment.failed, with the dashboard's webhook secret matching the RAZORPAY_WEBHOOK_SECRET environment variable. Refund events are handled as well and are worth subscribing to — without them a refund issued from the Razorpay dashboard never reaches the ledger.

Environment variablePurpose
RAZORPAY_KEY_IDPublishable key id. Also decides test versus live mode, derived from its prefix rather than stored.
RAZORPAY_KEY_SECRETSigns the checkout callback signature. Never leaves the server.
RAZORPAY_WEBHOOK_SECRETSigns this endpoint's signature. Set independently in the dashboard.

If the key pair is unset, or the webhook secret is unset, the endpoint answers 503 and processes nothing. There is nothing to verify against, and accepting unverified payment events would let anyone grant subscriptions — so refusing is the only safe answer.

Verifying the signature

The signature arrives in the x-razorpay-signature header. It is:

HMAC_SHA256(rawRequestBody, RAZORPAY_WEBHOOK_SECRET)

hex-encoded, and compared in constant time. A length mismatch fails immediately rather than throwing, because the comparison itself must not leak the digest length.

The HMAC must be computed over the raw request body, byte for byte. Re-serializing parsed JSON changes key order and whitespace and the digest will not match. The route reads the body as text and parses it only after the signature passes — any reimplementation must do the same. This is the single most common way to break, or silently disable, this check.

Two different signatures exist in this integration and they are not interchangeable. Using the wrong secret or the wrong message is a security bug, not a typo:

WebhookCheckout callback
EndpointPOST /api/razorpay/webhookPOST /api/billing/verify
Carried inx-razorpay-signature headerrazorpay_signature body field
MessageThe raw request body<order_id>|<payment_id>
SecretRAZORPAY_WEBHOOK_SECRETRAZORPAY_KEY_SECRET
ProvesRazorpay sent this eventRazorpay issued these ids

Neither signature proves the money was captured, and neither carries a trustworthy amount. That is why both paths re-check the amount against the order before granting anything.

Verifying a delivery, in Node:

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, signature, secret) {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signature, "utf8");
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

Sending a signed test delivery:

BODY='{"event":"payment.captured","payload":{"payment":{"entity":{"id":"pay_PxK3nWo9RxSu4A","order_id":"order_PxK2mVn8QwRt3Z","amount":2399200,"currency":"INR"}}}}'

SIG=$(printf '%s' "$BODY" \
  | openssl dgst -sha256 -hmac "$RAZORPAY_WEBHOOK_SECRET" -hex \
  | sed 's/^.* //')

curl -X POST https://goldpro.shop/api/razorpay/webhook \
  -H "Content-Type: application/json" \
  -H "X-Razorpay-Signature: $SIG" \
  --data-raw "$BODY"

printf '%s' rather than echo matters: a trailing newline is part of the body and changes the digest.

StatusBodyCause
400{"error":"Missing signature."}No x-razorpay-signature header.
400{"error":"Invalid signature."}The digest did not match.
400{"error":"Invalid payload."}The body passed the signature check but is not JSON.
503{"error":"Webhook not configured."}The key pair or RAZORPAY_WEBHOOK_SECRET is unset. This is the current state of the platform.

payment.captured

Money has been taken. This is what grants a subscription period.

The event is acted on only when the payload carries both payload.payment.entity.order_id and payload.payment.entity.id. Missing either, it falls through to the ignored-event response below.

{
  "event": "payment.captured",
  "payload": {
    "payment": {
      "entity": {
        "id": "pay_PxK3nWo9RxSu4A",
        "order_id": "order_PxK2mVn8QwRt3Z",
        "amount": 2399200,
        "currency": "INR"
      }
    }
  }
}

amount is in paise, matching how the billing tables store money. See Billing.

Settlement is idempotent by construction. The webhook and the browser callback both fire for the same successful payment and they race; whoever wins an atomic conditional update does the work, and the loser observes zero rows changed and returns without granting a second period or burning a second coupon redemption. A retried delivery is therefore safe.

Settling a purchase marks it PAID, grants the period, stamps periodStart and periodEnd, and records any coupon redemption — all in one transaction. Issuing the numbered document and emailing the receipt happen outside it, best-effort: the tenant has paid and their plan is granted, so a failure there is a document to re-issue, never a reason to unwind a completed payment. The reconciler picks up documents that were missed.

Four outcomes do not grant, and all four still answer 200:

SituationBehaviour
amount is not a number, or currency is absentNothing to check the order against. Logged; no grant.
No purchase matches the order_idLogged loudly for manual reconciliation. Deliberately not a 4xx.
The amount or currency does not equal the order totalRecorded on the purchase as a mismatch needing a human. Never granted — this is what stops a tenant paying for one plan and receiving another.
The purchase is already PAIDAlready settled. No second grant.

Purchases in REFUNDED or PARTIALLY_REFUNDED are not claimable, so a stray late capture event cannot resurrect a reversed purchase. CREATED and FAILED both are — Razorpay leaves an order open after a failed attempt and the tenant can retry it, so claiming only CREATED would take the money and grant nothing.

payment.failed

An attempt failed. Acted on when payload.payment.entity.order_id is present.

This moves the purchase from CREATED to FAILED and records the gateway's error_description, truncated to 500 characters, or Payment failed at gateway. when absent.

Only CREATED rows are touched. A payment can fail after a successful one on the same order — a retried attempt — and that must never un-pay a settled purchase. FAILED is not a terminal state: the order stays open at Razorpay, the tenant can retry it, and a later payment.captured will settle it.

Refund events

refund.created, refund.processed and refund.failed are all handled, dispatched on the refund. prefix. Razorpay refunds settle asynchronously, so refund.processed — not the API response to the original refund request — is what confirms the money actually went back.

{
  "event": "refund.processed",
  "payload": {
    "refund": {
      "entity": {
        "id": "rfnd_PxL5pYq2TzUw6C",
        "payment_id": "pay_PxK3nWo9RxSu4A",
        "amount": 2399200,
        "currency": "INR",
        "status": "processed",
        "notes": { "purchaseId": "cmf3pur000001s601k9m2n4b7", "refundId": "cmf3ref000001s601x1y3z5a7" }
      }
    }
  }
}

A refund event with no id on the entity is logged and acknowledged with 200. Razorpay guarantees no ordering, so the handler never blind-updates. It resolves the local row three ways, in order:

  1. By gatewayRefundId — the normal path, for a refund we requested and have already stamped.
  2. By notes.refundId — the webhook beat our own update back. The id is written into the gateway notes at creation precisely so this is recoverable.
  3. By adoption — a refund issued straight from the Razorpay dashboard has no local row at all. One is created against the purchase matching payment_id, so the ledger matches the money either way. A refund for a payment we do not know is logged and left alone.

Transitions are forward-only: a late pending event can never undo a refund already PROCESSED or FAILED. Applying a processed refund atomically claims the PENDING row, increments the purchase's refunded total under a guard that cannot be raced past into over-refunding, derives the purchase status from that total, and issues a credit note. Only a full refund can revoke the granted period, and only when the refund was recorded as doing so; a partial or goodwill refund never locks a tenant out.

Responses and retries

The response body is minimal by design. A handled event returns:

{ "ok": true }

An event that is subscribed but not one this endpoint acts on returns the event name back, so a misconfigured subscription is visible from the dashboard rather than silent:

{ "ok": true, "ignored": "payment.authorized" }

The status code is the contract with Razorpay's retry machinery, and the choices are deliberate:

StatusWhenRazorpay's behaviour
200Handled, ignored, or a problem a retry cannot fix.Stops.
400Missing or invalid signature, or an unparseable body.Stops. None of these get better on retry.
500An unexpected error during processing — typically a transient database failure.Retries with backoff, which is exactly what is wanted. Settlement is idempotent, so the retry is safe.
503The webhook secret is not configured.Retries. Correct: once the secret is set, the delivery should land.

A captured payment with no matching purchase answers 200, not 4xx. This is intentional. Razorpay would retry a 4xx forever on something no retry can fix, so it is logged loudly instead and left for a human to reconcile against the dashboard. Do not mistake a 200 here for "everything matched".

Reconciliation

GET /api/cron/billing-reconcile

The safety net under the webhook. Everything above assumes delivery. This is what makes that assumption survivable: a webhook Razorpay never delivered, or that returned 500 past its retry budget, means a tenant paid and got nothing — and nobody finds out until they complain. Rather than trusting delivery, this asks the gateway directly about anything that looks stuck.

It runs hourly at 15 * * * *. It is idempotent throughout: it only calls the same atomic settle and apply routines used above, so running it twice changes nothing.

With Razorpay unconfigured it does no work and reports why:

{ "ok": true, "skipped": "Razorpay is not configured." }

Otherwise it makes four passes, each capped at 50 rows:

PassSelectsAction
Settlerazorpay purchases in CREATED or FAILED with an order id, created between 15 minutes and 24 hours agoAsk the gateway for the order's payments. If one is captured, settle it now.
AbandonCREATED purchases older than 24 hoursMark FAILED with Checkout abandoned; no payment received. so they stop being re-checked forever.
RefundsPENDING razorpay refunds carrying a gateway refund idFetch each. Apply if processed; mark failed if failed.
DocumentsPurchases in PAID, PARTIALLY_REFUNDED or REFUNDED with no documentIssue the missing one.

The 15-minute floor is there so a tenant slowly typing an OTP is not treated as stuck. The settle pass runs before the abandon pass, so the overlap is covered and a late capture is never abandoned out from under itself.

{
  "ok": true,
  "settled": 1,
  "abandoned": 3,
  "refundsResolved": 0,
  "documentsIssued": 1,
  "errors": 0
}

errors counts rows whose individual reconciliation threw. Those are logged and skipped rather than failing the run. A settled above zero means the webhook path did not do its job for that purchase and is worth investigating.

Cron schedules and authentication

The three scheduled jobs are declared in vercel.json. All are operated by the platform; none is callable by an integrator.

PathScheduleUTC timePurpose
/api/cron/emi-reminders30 4 * * *04:30 daily (10:00 IST)EMI status sync, customer reminders, housekeeping. See EMI.
/api/cron/sync-rates0 */8 * * *Every 8 hours, on the hourRefresh the gold rate per gram. See Gold rates.
/api/cron/billing-reconcile15 * * * *Hourly at :15The reconciliation above.

All three sit behind the /api/cron prefix on the proxy's self-authenticating allowlist and authenticate with a shared secret rather than a session:

curl https://goldpro.shop/api/cron/billing-reconcile \
  -H "Authorization: Bearer $CRON_SECRET"

They fail closed. If CRON_SECRET is not set in the environment, every request is rejected, including a legitimate one from the scheduler. A missing secret can never be read as "no check required".

Rejections are 401, but the body is not uniform across the jobs. billing-reconcile returns the usual envelope, {"error":"Unauthorized"}. emi-reminders returns the plain-text body Unauthorized with no JSON at all. Do not parse a cron 401 as JSON without checking the content type.