Auth guard: JWT self-access (jwt.sub == user_id). Admin key and partner keys are not accepted.

Overview

The mandate module lets an authenticated user set up a recurring debit on a card or bank account via Juspay’s payment page. The flow is two-step:
  1. Register — backend opens a Juspay /session and persists a pending mandate_orders row. The response includes the verbatim full Juspay response as payload, which the frontend hands to Juspay’s SDK (native) or uses for a redirect (payment_links.web).
  2. Poll status — after the user finishes the payment flow on Juspay, the frontend polls /mandate/order_status/{order_id}. Every call hits Juspay’s /orders/{order_id} and syncs the row with the latest state. Frontend controls polling cadence.
Autopay execution (/txns), webhooks, and pause/revoke flows are not part of this module yet — they ship in a later milestone.

Endpoints

POST /users/{user_id}/mandate/register

Open a Juspay session. Returns payload with sdk_payload + payment_links for the frontend to consume.

GET /users/{user_id}/mandate/order_status/{order_id}

Poll for the latest mandate status. Always queries Juspay — no server-side caching.

Identifiers

Three IDs are easy to confuse. This is what each one is:
IdentifierShapeWhere it comes fromWhere it’s used
id (primary key)UUID v7Generated server-side on registerInternal DB only. Never exposed in URL paths.
order_id<user_id>_<unix_ms>Generated server-side on registerSent to Juspay as their order_id. Used as the URL path param for the status endpoint.
customer_id<user_id>Derived server-side on registerSent to Juspay as their customer_id. Stored for audit.
mandate_idJuspay-issued stringReturned by Juspay after the user completes the SDK flowStored on the row once known. Required for future autopay execution.
start_date / end_dateISO-8601 string (nullable)Populated by Juspay after mandate is activeNull until Juspay returns them in the order-status response.

Amount Unit Convention

  • The API accepts and returns amount and max_amount in rupees (integer, minimum 1).
  • The backend converts to paise (amount_paise = amount * 100) only at the Juspay client boundary.
  • Juspay’s wire format is a decimal rupee string — e.g. amount: 10 (rupees) becomes "10.00" on the Juspay request.
  • max_amount is fixed at ₹100 per debit; frequency is fixed at as_presented. Both are applied server-side; they cannot be overridden per request.

Mandate Status

ValueMeaning
pendingDefault on creation. Awaiting user completion on Juspay’s page.
activeUser authorized successfully; Juspay returned mandate_status = ACTIVE.
failedJuspay returned FAILURE / FAILED.
pausedJuspay returned PAUSED.
cancelledJuspay returned REVOKED / CANCELLED, or we soft-cancelled via MandateOrderUpdate::Cancel.
Unknown Juspay statuses map to pending — we wait for the next poll rather than committing to failed on an ambiguous response.

Frontend Flow


Request Validation

FieldRule
amountRequired. Integer rupees, >= 1.
account_idOptional. If present, must be a valid UUID.
path user_idMust equal jwt.sub (enforced by JwtSelfAuth::strict()).
path order_id (status)The string we sent to Juspay, not the internal UUID.
Also: the user row must have a non-null email — registration requires it for Juspay’s session call. Registration returns 400 ValidationError if missing.

Error Responses

CodeStatusErrorSituation
ME 1200500Internal errorUnexpected failure
ME 1201404Mandate order not foundorder_id doesn’t exist or doesn’t belong to user_id
ME 1202404User not foundJWT is valid but user row was deleted
ME 1203404Account not foundaccount_id (if passed) doesn’t exist
ME 1204400Validation erroramount < 1, invalid account_id, missing user email
ME 1205500Provider unavailableJuspay returned 5xx or network timed out

Gotchas

The path param for status polling is the Juspay order_id (format <user_id>_<unix_ms>), not the internal UUID id. Mixing them up returns a 404 / path-parse error.
/mandate/order_status/{order_id} always hits Juspay. There is no server-side short-circuit once mandate_status becomes active. The frontend is responsible for stopping polling at a terminal status.
The payload field on the register response is a verbatim pass-through of Juspay’s /session body. Backend does not interpret its shape, so frontend can freely consume sdk_payload, payment_links.web, or any new fields Juspay adds later without a backend change.