Auth guards are per-route. User-facing routes (register, status, list, active, pause, resume, revoke) use require_self_or_trusted_backend — the user themselves (jwt.sub == user_id) or a trusted backend / admin. The admin-pinned lifecycle routes (update, start) and the cross-user list (/admin/mandates) require an admin actor.
One live mandate per user. A user may hold at most one live mandate at a time — PENDING, CREATED, ACTIVE, LIVE, PAUSED, or EXTERNALLY_PAUSED. This is enforced by an application-level check before insert (no DB constraint). INITIATED rows (a registration started but never completed on the payment page) and terminal FAILED / CANCELLED / COMPLETED rows do not count and can stack freely. A second register while a live mandate exists returns 409 ME_1207.

Overview

The mandate module lets an authenticated user set up a recurring debit (autopay) on a card or bank account via Juspay’s payment page, then drives that mandate through its lifecycle. The flow:
  1. Register — backend opens a Juspay /session and persists an INITIATED mandates 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). Amounts are server-derived (config + Superposition) — the request body is empty.
  2. Poll status — after the user finishes the payment flow, the frontend polls. Status routes hit Juspay’s /orders/{id} and sync the row with the latest state.
  3. Lifecycle — once the mandate reaches ACTIVE/CREATED, an operator starts it to schedule the Kronos autopay job (LIVE), and can pause / resume / revoke from there. Each transition is validated by a state machine and may schedule or tear down the Kronos job.
Autopay execution itself (the /txns debit + reconciliation) lives in the Mandate Execution Module.

Endpoints

POST /users/{user_id}/mandate/register

Open a Juspay session (legacy, flat-rupee amounts). Returns payload for the frontend SDK / redirect.

POST /users/{user_id}/mandates/register

Same flow as register, but the response carries AmountResponse (value + currency) instead of flat rupees.

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

Poll a mandate’s status by its UUID id. Always queries Juspay (legacy flat-amount shape).

POST /users/{user_id}/mandates/{mandate_id}/status

Refresh a mandate from Juspay, currency-aware MandateResponse.

GET /users/{user_id}/mandates/active

Fetch the user’s single live mandate. DB-only, no Juspay round-trip. 404 ME_1208 when there is none.

GET /users/{user_id}/mandates

Paginated, currency-aware list of the user’s mandates.

GET /users/{user_id}/mandate_orders

Legacy list — no pagination, flat-amount items.

POST /users/{user_id}/mandates/{mandate_id}

Admin: set mandate_status directly (validated against the state machine).

POST /users/{user_id}/mandates/{mandate_id}/start

Admin: activate autopay — → LIVE, schedules the Kronos job, stamps job_id.

POST /users/{user_id}/mandates/{mandate_id}/pause

Pause a live mandate — cancels the Kronos job, → PAUSED.

POST /users/{user_id}/mandates/{mandate_id}/resume

Resume a paused mandate — schedules a fresh Kronos job, → LIVE.

POST /users/{user_id}/mandates/{mandate_id}/revoke

Revoke at Juspay then re-sync — → CANCELLED, tears down the Kronos job.

GET /admin/mandates

Admin cross-user list. user_id is an optional query filter, not a path label.
Two register endpoints exist. Legacy POST /users/{user_id}/mandate/register returns flat i64 rupee amounts (no currency envelope). V2 POST /users/{user_id}/mandates/register returns the same flow with AmountResponse (value + currency). Likewise the legacy status poll GET …/mandate/order_status/{id} and list GET …/mandate_orders return flat-amount shapes; new integrations should use POST …/mandates/{id}/status and GET …/mandates for the paginated, currency-aware shapes.

Identifiers

The path routes are split: the status-poll routes are keyed on the internal UUID id (the legacy route’s {id}, or the V2 route’s {mandate_id} which is also the internal UUID mandates.id), not the Juspay-issued mandate_id string. The internal id is the value sent to Juspay as order_id.

Amount Convention

  • Amounts are server-derived, not supplied in the request body. The initial order amount comes from config (mandate.order_amount, in paise) and the per-debit ceiling (mandate_max_amount) and frequency come from Superposition (overridable per user via a targeting key).
  • The legacy register/status/list shapes return flat i64 rupee amounts. The V2 / currency-aware shapes return AmountResponse{ value, currency }, where value is a major-unit float.
  • Juspay’s wire format is a decimal rupee string — the backend converts at the Juspay client boundary.

Mandate Status

mandate_status is the coarse internal lifecycle (common_enums::MandateStatus, wire values SCREAMING_SNAKE_CASE), derived from the raw Juspay status. The full set: Alongside mandate_status, the row carries external_mandate_status — the raw Juspay vocabulary (ACTIVE, PAUSED, REVOKED, EXPIRED, …) stored verbatim so new Juspay statuses don’t require schema changes.

State machine

update / start / pause / resume / revoke all flow through one transition validator. Allowed transitions: Side effects: ACTIVE/CREATEDLIVE (and PAUSEDLIVE on resume) schedules the Kronos autopay job and stamps job_id; LIVEPAUSED/CANCELLED cancels the job and clears job_id. An invalid transition returns 400 ME_1210 (InvalidMandateStatusTransition).
A mandate in OTHER (an unrecognised Juspay provider status) has no outbound transitions — any lifecycle op returns 400 ME_1211 (MandateInUnknownProviderStatus). An operator must reconcile the mandate at the provider first.

Frontend Flow

Once mandate_status reaches ACTIVE/CREATED, an operator calls start to activate autopay.

Request validation

Also: the user must have an active PBA-backed (HSA) account (else 400 ME_1204) and a non-null email (the Juspay session needs it; else 400 ME_1205).

Error responses


Gotchas

The status-poll routes always hit Juspay. There is no server-side short-circuit once mandate_status becomes ACTIVE/LIVE. The frontend is responsible for stopping polling at a terminal status. (GET /mandates/active is the exception — it is DB-only.)
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.