Docs / Payments & ledger▾

Core flows

Payments & ledger

Money has to be right every time, including when networks drop, webhooks arrive twice or the gateway is slow. Razorpay moves the money; a double-entry ledger in Postgres records exactly who is owed what.

Paying for a booking#

Payment sequenceDriver appParkezy APIRazorpayWorkerPOST /bookings/{id}/paymentcreate order (amount in paise)order_id + public key idRazorpay checkout (UPI / card)payment_id + signaturePOST …/payment/confirmHMAC-SHA256(order_id | payment_id) checked server-sidewebhook payment.captured (signed)capture once, confirm bookingevery 5 min: reconcile stuck orders

Swipe sideways to see the whole figure.

The app never decides that a payment succeeded. The server checks a signature from Razorpay, and a signed webhook confirms it independently. Whichever arrives first wins; the second is a no-op.

Webhooks: verified, de-duplicated, idempotent#

Verify signature

HMAC-SHA256
Computed over the raw body with the webhook secret and compared in constant time. A bad signature is a 401 and nothing else happens.

Bloom filter pre-check

Redis bitmap
Answers “definitely new” or “maybe seen” in microseconds. It is only a fast path, never the source of truth.

Record the event id

webhook_events
A unique row per gateway event id. A duplicate insert means we already processed it, so we return 200 and stop.

Apply once

Capture or fail the payment under a row lock; if the checkout confirmation already captured it, this is a no-op.
app/integrations/payments.pypython
def _hmac_ok(secret: str, message: bytes, signature: str) -> bool:
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "") # constant time
def verify_checkout_signature(self, *, order_id, payment_id, signature) -> bool:
return _hmac_ok(self._key_secret, f"{order_id}|{payment_id}".encode(), signature)
def verify_webhook_signature(self, *, body: bytes, signature: str) -> bool:
return _hmac_ok(self._webhook_secret, body, signature)

Reconciliation

If both the app confirmation and the webhook are lost, a booking could stay unpaid even though the driver paid. Every 5 minutes a job asks Razorpay what it actually recorded for orders still open after 5 minutes, and captures or fails them. The gateway is the source of truth for whether money moved.

Double-entry ledger#

Every money event is a ledger transaction: a set of entries that must sum to exactly zero (debits positive, credits negative). Balances such as “what can this host withdraw” are sums over entries, never a number that gets overwritten.

Worked example: a real booking from our tests

2 hours at ₹45/hour on a private listing: subtotal ₹90.00, GST 18% ₹16.20, driver pays ₹106.20. On completion the ledger records:

AccountDebit (+)Credit (−)Meaning
driver_payments₹106.20cash received through the gateway
gst_payable₹16.20tax owed to the government
platform_revenue₹13.5015% commission on ₹90
host_payable₹76.50the host's earnings
sum₹106.20₹106.20balanced: debits = credits

The database itself refuses an unbalanced transaction, even if application code had a bug:

migrations/versions/0001_initial.pysql
-- Double-entry invariant: every transaction must balance to zero.
CREATE FUNCTION ledger_txn_balanced() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF (SELECT COALESCE(SUM(amount), 0) FROM ledger_entries WHERE txn_id = NEW.txn_id) <> 0 THEN
RAISE EXCEPTION 'ledger transaction % does not balance', NEW.txn_id;
END IF;
RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER trg_ledger_balanced AFTER INSERT ON ledger_entries
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION ledger_txn_balanced();

Host payouts and other events#

  • Pay-at-venue bookings book the cash to host_collected instead, so the host owes the commission rather than being owed the fare.
  • Late cancellations post the kept amount, split between the platform and the host the same way.
  • A withdrawal moves money host_payable → payouts_pending; marking it paid moves it to platform_bank; a rejected payout returns it.
  • Refunds are target-based: the job brings a booking's total refunded amount up to a target, so a retried refund never pays out more than once.