Razorpay
Razorpay is the Indian payment gateway for customer-facing online payments
(card, UPI, netbanking). Two functions: razorpay-order creates the order for
the native app's Pay online (API → Online payment);
razorpay-webhook (this page) records the money.
Switched on by the owner
The code is complete and tested; production has no Razorpay secrets yet.
Until RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET are set, razorpay-order
answers 503 and the app says "Online payment is not switched on yet — use
bank transfer / UPI and tell us the reference". Until
RAZORPAY_WEBHOOK_SECRET is set, the webhook answers 500 and Razorpay
retries. Set all three together (checklist below). The web customer portal
has no Pay online button; only the app does.
Purpose
Customer receipts captured online need to flow into the double-entry ledger the same way manual receipts do:
- Record a verified
Paymentrow with the Razorpay payment id. - Post the receipt journal — Dr the collection account, Cr the payer's own receivable (the partner's for a partner booking), and bring the booking's paid and balance up to date.
- Be idempotent against Razorpay's retry storms (4xx / 5xx responses are retried aggressively by their platform).
Webhook implementation
File: supabase/functions/razorpay-webhook/index.ts.
Signature verification
Razorpay signs the raw request body with HMAC-SHA256 using the shared secret
and sends the hex digest in the X-Razorpay-Signature header. We re-compute
the digest and compare in constant time
(supabase/functions/razorpay-webhook/index.ts:63-93):
export async function verifyRazorpaySignature(
rawBody: string,
signatureHeader: string | null,
secret: string,
): Promise<boolean> {
if (!signatureHeader || !secret) return false;
// ...HMAC-SHA256 over rawBody, hex-encode, timing-safe compare
}
Critical detail: index.ts:389-395 reads await req.text() before JSON
parsing. HMAC is over the exact bytes — reparsing and reserialising would
change whitespace and break verification.
verify_jwt must stay off
supabase/config.toml:87-88 sets verify_jwt = false for this function.
Razorpay does not send a Supabase JWT; the HMAC signature check is the
authentication. Re-enabling the gateway check would 401 every webhook
before our code sees it.
Event handling
handleRequest (index.ts:378-424) routes on the event field:
payment.captured— callshandlePaymentCaptured. All other work happens here.- Anything else (
payment.authorized,payment.failed,order.paid,refund.*, ...) — returns200 { ok: true, ignored: true, event }so Razorpay stops retrying.
Data flow for payment.captured
Razorpay → POST /razorpay-webhook
│
├─ 1. Read raw body, read X-Razorpay-Signature header
├─ 2. verifyRazorpaySignature() — 401 on mismatch
├─ 3. JSON.parse the body
├─ 4. Extract payment.entity (id, order_id, amount, currency, notes)
├─ 5. Validate notes.bookingId is present (400 if missing)
├─ 6. Dedup: SELECT Payment WHERE razorpayPaymentId = :id
│ └─ hit → 200 { deduplicated: true, paymentId }
├─ 7. Load Booking (id, bookingNo, customerId, payerId)
│ └─ miss → 200 { ok: false, reason: "booking_not_found" }
├─ 8. INSERT Payment (status='verified', method='CARD', razorpay*)
│ └─ unique-violation on razorpayPaymentId → dedup success
└─ 9. fin_post_payment_receipt + fin_refresh_receipt_targets — see below
Verified against handlePaymentCaptured in index.ts:268-374.
Journal posting
The webhook does not build a voucher itself. After recording the Payment it calls the
database's own receipt routines, the ones verify_payment uses:
fin_post_payment_receipt(paymentId, null)— one voucher, Dr the collection account (FinanceConfigbank), Cr the payer's receivable: the partner'sAGR-account on a partner booking, the customer's otherwise. It converts a foreign-currency receipt to INR and is idempotent: a retry returns the voucher already posted.fin_refresh_receipt_targets(paymentId)— the booking'spaidAmountandbalanceAmount(fin_booking_billed()− paid − cancellation credit: the price + GST count only once finance approves the booking voucher, FIN-033), and any invoice the receipt pays.
What can be paid online. razorpay-order caps an order at the booking's agreed due —
price + GST − receipts − cancellation credit (fin_booking_agreed_due(), the computed field
booking_agreed_due) — not at balanceAmount. So a customer or partner can pay before finance
approves the booking (owner, 01/10/2026); the payment is recorded as above and the balance
catches up when the voucher is approved. "Nothing is due on this booking." only when the
agreed due is 0.
The function returns { journalEntryId }, or { skipped: reason } if the voucher could not be
posted; the money is counted either way and the Payment stays verified. Until 30/09/2026
postReceiptJournal built the voucher here against CUS-<customer> even when a partner paid,
with no conversion, and the booking was never refreshed (FIN-040).
Idempotency summary
Three independent guards:
Payment.razorpayPaymentIdhas a partial unique index (see migration below). A duplicate insert raises23505and is treated as dedup success (index.ts:340-352).- A
LedgerEntryexistence check prevents double-posting the journal if the Payment insert succeeded but the journal failed on a previous attempt. - Unknown event types return
200 ignoredso Razorpay's retry queue does not compound non-issues.
Required environment variables
| Variable | Location | Purpose |
|---|---|---|
RAZORPAY_WEBHOOK_SECRET |
Supabase function secrets | Shared secret used to HMAC-sign the payload. Must match the value set in the Razorpay dashboard (Settings → Webhooks). |
RAZORPAY_KEY_ID, RAZORPAY_KEY_SECRET |
Supabase function secrets (razorpay-order) |
The API key pair from the Razorpay dashboard (Settings → API Keys). The key id is handed to the phone for Checkout; the secret authenticates the Orders API and never leaves the function. |
SUPABASE_URL |
Function runtime (platform-provided) | Used by getServiceClient() inside the function. |
SUPABASE_SERVICE_ROLE_KEY |
Function runtime (platform-provided) | Same as above. |
Set the webhook secret once with:
Production secret rotation
Rotating RAZORPAY_WEBHOOK_SECRET requires updating both the Razorpay
dashboard and supabase secrets in the same window. Any webhook that
arrives with the old signature while only one side is rotated will 401
and Razorpay will retry — usually fine, but don't leave the two
mismatched for long.
Migration
supabase/migrations/20260418114458_razorpay_payment_fields.sql adds:
Payment.razorpayPaymentId TEXTPayment.razorpayOrderId TEXTPayment.razorpaySignature TEXT- Partial unique index
Payment_razorpayPaymentId_key ON Payment (razorpayPaymentId) WHERE razorpayPaymentId IS NOT NULL
The partial predicate lets non-Razorpay payments keep NULL in the column
without colliding.
Order creation — razorpay-order
supabase/functions/razorpay-order/index.ts does what the webhook needs:
- Creates the Razorpay order through the Orders API with
RAZORPAY_KEY_ID:RAZORPAY_KEY_SECRET(basic auth), for the booking's customer or payer, or its partner — never for staff, who record the customer's payment instead (FIN-032, DECIDED 2026-09-30;online_payment_actor()in the database decides), capped at the booking's balance, throttled like signing in. - Sets
notes: { bookingId, bookingNo, actor, actorKind, purpose? }. The webhook readsnotes.bookingId— without it the webhook returns 400 and no receipt is recorded. - Writes an
OnlinePaymentOrderrow (created); the webhook sets it topaidwith thePaymentid. The app opens Razorpay Checkout in a WebView with the returnedorder_idand, on success, shows "confirming" and polls that row for up to a minute — the browser callback is never trusted; the webhook is the source of truth.
Full contract: API → Online payment.
Order amount is in paise
The webhook divides payload.payment.entity.amount by 100 to get rupees.
razorpay-order takes rupees from the app and sends paise to Razorpay.
Testing
Unit tests: supabase/functions/razorpay-webhook/index.test.ts. Covers
signature roundtrip, signature rejection, missing secret, unknown event
types, HTTP-method rejection, CORS preflight, and the dedup short-circuit
with a stub Supabase client.
Setup checklist
- In the Razorpay dashboard → Settings → Webhooks, point at
https://<project-ref>.functions.supabase.co/razorpay-webhookand subscribe topayment.capturedat minimum. - Pick a strong random secret; paste it into the dashboard webhook config.
supabase secrets set RAZORPAY_WEBHOOK_SECRET=<same-secret>.supabase secrets set RAZORPAY_KEY_ID=<key id> RAZORPAY_KEY_SECRET=<key secret>(Settings → API Keys; use the test pair first, then the live pair).- Deploy:
supabase functions deploy razorpay-webhook razorpay-order. - Trigger a test event from the Razorpay dashboard and confirm a 200
response in the function logs; then pay ₹1 from the app on a test booking
and confirm the
Paymentrow and theOnlinePaymentOrderset topaid.