PayU Client Contract (web + mobile)¶
What the web and mobile clients from the PayU client tranche (PR #117) require from the backend.
The backend PayU tranche must implement exactly this, or the client must change with it. Razorpay's
existing contract is unchanged, but it is not a fallback while PayU is the active provider:
Production V1 is PayU only with Razorpay disabled (business decision B-28), so a client that cannot
launch the active provider receives 409 PAYMENT_PROVIDER_UNSUPPORTED ("This app version cannot start
a payment right now. Please update the app or use the website.") instead of a Razorpay order.
Today's mobile app (Razorpay-only) is therefore refused under PayU-only - a recorded P1 blocker for
mobile commerce (B-29), never to be hidden by re-enabling Razorpay.
Principles the clients rely on¶
- The server is the only source of payment truth. No client ever computes a hash, holds the PayU salt, or decides that a payment succeeded.
- HTTP status never means "terminal". 401 (session expired after a long bank redirect), 408,
409 (today:
NotCaptured,ProviderLookupFailed), other 4xx, 5xx and timeouts are all treated as ambiguous. Only a response body with"terminal": trueis terminal. - Nothing in a browser URL decides anything. The callback page reads only the opaque
txnidquery parameter;status,hash, contact details, the URL hash and router state are ignored. - A client only receives a provider payload it declared it can launch.
Reservation release rules (both clients)¶
The client calls release-reservation only when:
- Razorpay: the modal was dismissed and no payment attempt was submitted in that modal
session (
payment.failednever fired and the success handler never ran), or - the backend returned
"terminal": truefor that payment, or - mobile: create-order returned a provider the app cannot launch (no payment UI ever opened).
Razorpay payment.failed never releases (the modal stays open for a retry). Because a client
cannot always know whether money moved (e.g. a UPI collect still pending when the modal closes),
the backend must itself refuse to release a reservation whose payment the provider reports as
captured, authorised or pending.
Endpoints¶
POST /api/payment/create-order¶
| Request | Query couponCode?, addressId?. Headers Idempotency-Key (required), X-Payment-Providers |
X-Payment-Providers |
Web: Razorpay,PayU. Mobile: Razorpay. Absent (every mobile build shipped before PayU) = Razorpay only. The backend must never return a PayU payload unless the header lists PayU, and returns only the ACTIVE provider (PaymentProviderNegotiation.Select): while PayU is active, Razorpay-only or absent headers get 409 PAYMENT_PROVIDER_UNSUPPORTED, never a Razorpay fallback. |
| Response, Razorpay | Unchanged: { razorpayOrderId, keyId, amount, currency }. provider: "Razorpay" may be added; clients accept it. |
| Response, PayU | { provider: "PayU", orderId: <txnid>, currency, actionUrl, params: { key, txnid, amount, productinfo, firstname, email, phone, hash, surl, furl, ... } } |
| PayU constraints | provider must be explicit (the web never infers PayU from actionUrl). actionUrl must be exactly https://secure.payu.in/_payment when served to vayzomart.com/www.vayzomart.com and exactly https://test.payu.in/_payment everywhere else. params.txnid must equal orderId and match ^[A-Za-z0-9_-]{1,64}$ (and PayU's own length limit). params must never contain salt, merchant_salt, merchant_secret, secret, key_secret. surl/furl must point at the backend, never the SPA. |
| Status codes | 200; 409 POLICY_ACCEPTANCE_REQUIRED; 409 CHECKOUT_IDEMPOTENCY_CONFLICT; 400 |
| Client behaviour | Web validates everything above and fails closed before navigating. Mobile rejects any provider other than Razorpay and releases that reservation (no payment UI opened). |
PayU surl / furl (backend-owned)¶
| Request | PayU's form POST |
| Backend duty | Verify the reverse hash and confirm with PayU (verify_payment), converge with the webhook on the same idempotent finalisation, persist the outcome |
| Response | 303 to /checkout/payment-callback?txnid=<txnid>. Nothing else in the URL - no status, hash, amount, email, phone or PayU payload |
GET /api/payment/status?provider=PayU&txnid=<txnid> (new)¶
| Auth | Customer JWT; must only answer for the caller's own attempt |
| Response | { status: "success" \| "pending" \| "failed" \| "cancelled" \| "unknown", terminal: bool, orderNumber?: string } |
| Semantics | orderNumber present = order placed. terminal: true only when the backend is certain no money was or can be captured for this attempt. Anything else (including provider unreachable) must be terminal: false. |
| Status codes | 200 for every known attempt, whatever its state. Errors are treated as pending by the client |
| Client behaviour | Success, then clear cart and go to the order. terminal: true, then release and show failure. Otherwise "verification in progress, do not pay again" plus a "Check status again" button that calls this endpoint again (never /verify). |
POST /api/payment/verify (Razorpay only)¶
| Request | Unchanged: { razorpayOrderId, razorpayPaymentId, razorpaySignature, shippingAddress } |
| Response | 200 { orderNumber } on success. To let the client release on a definite failure, return { terminal: true, ... } in the body (any status); without it the client treats every error as ambiguous. |
| PayU | Not called by any client. |
POST /api/payment/release-reservation¶
| Razorpay body | Unchanged: { razorpayOrderId } |
| PayU body (web) | { provider: "PayU", txnid } |
| Mobile PayU-rejection body | { razorpayOrderId: <orderId from the rejected payload> } |
| Response | 204 whether or not anything was released |
| Backend duty | Owner check, and refuse to release when the provider reports the payment captured/authorised/pending |
Web environment rule¶
The same immutable frontend image is deployed to staging and then promoted to production, so the
PayU environment is decided at runtime from the serving hostname: vayzomart.com and
www.vayzomart.com are production; every other host (including stg.vayzomart.com, localhost
and unknown hosts) is test. A cross-environment actionUrl is rejected before navigation.
Double-payment guard (web)¶
While a PayU attempt from the same tab is unresolved (less than 30 minutes old, matching the
backend's stale-pending reconciliation window), the checkout page blocks a new payment and links
to the status page. The stored record holds only { provider, txnid, createdAt }.
Mobile¶
The app ships PayU only via the X-Payment-Providers header (mobileSupportedPaymentProviders in
payment_api.dart) - Razorpay is rejected outright, with no fallback. PayU checkout runs in an
in-app WebView (PayUCheckoutScreen): the WebView's navigation delegate only ever recognises that
the browser has returned (a coarse scheme/host/path match on the callback route - see
payu_return_policy.dart), and never trusts any transaction id, status, amount or hash value that
may appear in that URL. Once a return is detected, the app queries the backend's authoritative
GET /api/payment/status using the transaction id it securely stored locally when the PayU attempt
was created - covering background/kill/resume, duplicate return events, and a cold process
restart while a payment is pending.