Skip to content

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

  1. 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.
  2. 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": true is terminal.
  3. Nothing in a browser URL decides anything. The callback page reads only the opaque txnid query parameter; status, hash, contact details, the URL hash and router state are ignored.
  4. 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.failed never fired and the success handler never ran), or
  • the backend returned "terminal": true for 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.