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:- Register — backend opens a Juspay
/sessionand persists anINITIATEDmandatesrow. The response includes the verbatim full Juspay response aspayload, 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. - 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. - Lifecycle — once the mandate reaches
ACTIVE/CREATED, an operatorstarts it to schedule the Kronos autopay job (LIVE), and canpause/resume/revokefrom there. Each transition is validated by a state machine and may schedule or tear down the Kronos job.
/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
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) andfrequencycome from Superposition (overridable per user via a targeting key). - The legacy register/status/list shapes return flat
i64rupee amounts. The V2 / currency-aware shapes returnAmountResponse—{ value, currency }, wherevalueis 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/CREATED → LIVE (and PAUSED → LIVE on resume)
schedules the Kronos autopay job and stamps job_id; LIVE → PAUSED/CANCELLED
cancels the job and clears job_id. An invalid transition returns
400 ME_1210 (InvalidMandateStatusTransition).
Frontend Flow
Oncemandate_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
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.