APIs
Mandates API
Charge a customer who isn't there. They approve once in the Payer app, you collect with one server call, inside the caps they agreed to.
A mandate is a standing authorization. Your customer approves once, in the Payer app, that you may charge their wallet up to limits you set, and from then on you take each payment with a single server-side call. No customer session, no second factor per charge. It is how you bill a monthly membership, a utility, or usage at the end of a month.
The mental model is the one you already have from card platforms: the mandate is the payment method, and charging it is an off-session payment. There is no new credential to store. You authenticate with the same secret key you use everywhere else and reference the mandate by its id.
Everything lives under /mandates. Creating, reading and revoking mandates needs the mandates:write scope. Charging needs payments:write, deliberately a separate scope, so a key that manages mandates need not be able to move money. Mandates also have to be enabled on your account: until they are, creating one with a live key returns 403. Test keys are ungated, so you can trial the whole flow in the sandbox before that conversation. All of it is modelled in the machine-readable OpenAPI spec.
Note
Three key styles, one per direction. Request bodies use snake_case (per_charge_cap). Read responses use camelCase (perChargeCap), because they are projected from GraphQL. Webhook objects use snake_case with Unix timestamps. Map the three separately rather than reusing one model across them.
An integration is four steps:
- 1.
POST /mandateswith your caps and your own reference. You get back anidand a hosted approvalurl. - 2.Send your customer to
url. They approve the terms once, with full two-factor. - 3.
mandate.activatedlands on your webhook endpoint. The mandate isACTIVE. - 4.
POST /mandates/{id}/chargeswhenever you need to collect, for the life of the mandate.
Create a mandate
POST /mandatesCreates a mandate and returns the hosted approval page to send your customer to. It is INITIATED until they approve it. Requires the mandates:write scope on a secret key. Send an Idempotency-Key so a retried create replays the first response instead of setting up a second mandate.
The caps you send here are fixed at approval. Your customer approves or declines the terms as written, so send the terms you actually want to bill on: changing them later means a new mandate and a new approval.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
reference | string | Yes | Your own identifier for the customer or agreement. Must be unique across all of your mandates. Max 255 characters. |
title | string | Yes | Shown verbatim on the approval screen. Max 255 characters. |
per_charge_cap | integer | Yes | The most you may take in one charge, in laari (100 laari = 1 MVR). Between 100 and 500000. |
description | string | No | Extra line under the title, shown verbatim on the approval screen. Max 255 characters. |
period_cap | integer | No | The most you may take across one period, in laari. Must be at least per_charge_cap. Strongly recommended: it is the number that bounds your customer's total exposure. |
period | string | With period_cap | The calendar period period_cap is measured over. MONTH is the only value today. |
expires_at | string (date-time) | No | When the mandate stops being chargeable. ISO 8601 with a numeric UTC offset (2027-08-14T12:00:00+05:00); fractional seconds are not accepted. Must be in the future and no more than 365 days away. Defaults to 365 days out. |
return_url | string (url) | No | Where the customer is sent after approving or declining. Must be an http or https URL. |
curl https://api.payer.app/mandates \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: mandate_customer_10482" \
-H "Content-Type: application/json" \
-d '{
"reference": "customer_10482",
"title": "Monthly gym membership",
"description": "Billed on the 1st of each month",
"per_charge_cap": 45000,
"period_cap": 45000,
"period": "MONTH",
"return_url": "https://example.com/mandate/thanks"
}'{
"id": "4a2f7c10-9e3b-4d55-8b21-7c0f5e6d4a33",
"reference": "customer_10482",
"status": "INITIATED",
"url": "https://checkout.payer.app/mandate/4a2f7c10-9e3b-4d55-8b21-7c0f5e6d4a33"
}Store the id: it is what you charge against. Then send the customer to url.
The approval flow
url opens a hosted page at checkout.payer.app/mandate/<id>, which lays out the title, the description and the caps, and asks the customer to approve or decline. Approving is a full two-factor step, once. Every later charge rides on that one approval.
The approval link is good for 24 hours from creation. If it lapses unused, the mandate becomes EXPIRED and collecting means creating a new one.
| Status | Meaning |
|---|---|
INITIATED | Created, waiting on your customer. |
ACTIVE | Approved and chargeable. |
DECLINED | Your customer refused the terms. Terminal. |
REVOKED | Ended by your customer, by you, or by Payer. Terminal. |
EXPIRED | The approval link, or the mandate itself, ran out. Terminal. |
SUSPENDED | Held by Payer while a dispute is handled. |
An INITIATED mandate goes to ACTIVE, DECLINED or EXPIRED. An ACTIVE one can still become REVOKED, EXPIRED or SUSPENDED. Only ACTIVE mandates can be charged.
Tip
Mandates created with a test key auto-approve in the sandbox, so you can reach the charge path in one step without a phone in the loop. In production, nothing is chargeable until a real person approves it.
Don't poll for the transition. Subscribe to mandate.activated and mandate.declined and act when they arrive.
Charge a mandate
POST /mandates/{id}/chargesTakes one payment against an active mandate. No customer session and no second factor: the approval they already gave is the authorization. {id} may be the mandate UUID or your own reference. Requires the payments:write scope. Send an Idempotency-Key so a retried charge replays the first response instead of taking the money twice.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
amount | integer | Yes | How much to take, in laari. At least 100, and no more than the mandate's per_charge_cap. |
description | string | No | What this charge is for. Shown to your customer on their notification. |
reference | string | No | Your own reference for this charge. Defaults to the mandate's reference. |
curl https://api.payer.app/mandates/customer_10482/charges \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: invoice_2026_08" \
-H "Content-Type: application/json" \
-d '{
"amount": 45000,
"description": "August membership",
"reference": "invoice_2026_08"
}'{
"id": "9c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"mandateId": "4a2f7c10-9e3b-4d55-8b21-7c0f5e6d4a33",
"amount": 45000,
"currency": "MVR",
"status": "SUCCEEDED",
"description": "August membership",
"reference": "invoice_2026_08",
"fee": 900,
"netAmount": 44100,
"periodKey": "2026-08",
"periodCharged": 45000,
"createdAt": "2026-08-14T13:05:00+05:00"
}A 201 means the money is already in your wallet. fee is what Payer charged, in laari, and netAmount is what reached you. periodCharged is the running total for periodKey, so you can see how much of a period cap is left. status is always SUCCEEDED: a refused charge is an error response, not a charge.
When a charge is refused
A refusal moves no money, holds nothing, and schedules nothing. The response body carries a message; the matching `mandate.charge.failed` webhook carries a machine-readable code, listed here so you can branch on it.
| Status | code | message | What to do |
|---|---|---|---|
404 | MANDATE_NOT_FOUND | Mandate not found | No mandate with that id or reference exists for your account in this key's environment. Check the id and the key. This is the one code that never reaches mandate.charge.failed, since no mandate was resolved to raise the event against. |
409 | MANDATE_INACTIVE | Mandate is not active | It was revoked, suspended, or never approved. Stop charging and ask for a new mandate. |
409 | MANDATE_EXPIRED | Mandate has expired | Its expiry has passed. Create a fresh mandate and have your customer approve it. |
422 | PER_CHARGE_CAP_EXCEEDED | Amount exceeds the per charge cap | Charge no more than per_charge_cap, or split the bill. |
422 | PERIOD_CAP_EXCEEDED | Amount exceeds the period cap | The period cap is used up. Wait for the period to roll, or collect the rest another way. |
422 | MANDATE_CHARGE_AMOUNT_INVALID | Amount is not chargeable | The amount is below the platform minimum or otherwise unusable. Fix the amount. |
422 | WALLET_LIMIT_EXCEEDED | The charge was blocked by a wallet limit | A limit on your customer's account stopped it. Only they can lift it. |
422 | MANDATE_CHARGE_FAILED | The charge could not be taken | The payment did not go through. Treat it like a decline and decide whether to try again. |
402 | INSUFFICIENT_FUNDS | Insufficient funds | Their wallet couldn't cover it right now. This is the one refusal a later retry could plausibly turn into a payment. |
503 | SERVICE_UNAVAILABLE | This service is temporarily unavailable | Nothing is wrong with the mandate. The same request will work once the service is back. |
Two things to build around:
- There is no retry or dunning engine. Payer never re-attempts a refused charge. When and whether to try again is yours to decide, and your
Idempotency-Keyshould change when you mean a genuinely new attempt. - Your customer can revoke at any time, so an
ACTIVEmandate is a fact about now, not a guarantee about next month. Handle409as a normal outcome, not an exception.
Retrieve a mandate
GET /mandates/{id}Returns the current state of one of your mandates, including how much of the current period is used. {id} may be the mandate UUID or the reference you set at creation. Requires the mandates:write scope.
curl https://api.payer.app/mandates/customer_10482 \
-H "Authorization: Bearer sk_test_..."{
"id": "4a2f7c10-9e3b-4d55-8b21-7c0f5e6d4a33",
"reference": "customer_10482",
"status": "ACTIVE",
"title": "Monthly gym membership",
"description": "Billed on the 1st of each month",
"currency": "MVR",
"perChargeCap": 45000,
"periodCap": 45000,
"period": "MONTH",
"periodKey": "2026-08",
"periodCharged": 45000,
"setupExpiresAt": "2026-08-15T12:00:00+05:00",
"expiresAt": "2027-08-14T12:00:00+05:00",
"approvedAt": "2026-08-14T18:30:00+05:00",
"revokedAt": null,
"createdAt": "2026-08-14T12:00:00+05:00"
}periodKey is the period being counted, such as 2026-08, and is null until the first charge. periodCharged is the laari already taken in it and resets when the period rolls. On a mandate with no period, there is no period accounting at all: periodKey stays null and periodCharged stays 0, however much you charge. The timestamps are ISO 8601, and setupExpiresAt is when the approval link stops working.
A mandate carries no customer identity and no wallet id. You are authorized to charge, not to learn who you are charging beyond your own reference.
List mandates
GET /mandatesReturns your mandates, newest first. Requires the mandates:write scope.
| Parameter | Type | Description |
|---|---|---|
status | string | Return only mandates in this state. |
reference | string | Return only the mandate carrying this reference. |
limit | integer | How many to return. Defaults to 25, capped at 100. |
offset | integer | How many to skip. |
curl "https://api.payer.app/mandates?status=ACTIVE&limit=50" \
-H "Authorization: Bearer sk_test_..."Revoke a mandate
POST /mandates/{id}/revokeEnds a mandate immediately. Nothing can be charged against it afterwards, and the change is irreversible: collecting again means a new mandate and a new approval. Requires the mandates:write scope. Send an Idempotency-Key so a retried revoke is safe.
curl -X POST https://api.payer.app/mandates/customer_10482/revoke \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: revoke_customer_10482"{ "message": "Mandate revoked" }A mandate that is already in a state that cannot be revoked returns 409:
{ "message": "Mandate cannot be revoked" }Revoke when a customer cancels, rather than leaving a live authorization sitting on their wallet.
What your customer controls
A mandate is a permission your customer gives you, and the platform keeps it that way:
- The caps are enforced by us, not by you.
per_charge_capandperiod_capare checked server side on every charge, in the customer's own calendar month for period accounting. A charge over either one is refused, whatever your code intended. - Revocation is instant, and theirs. Every mandate is listed in the Payer app and can be ended there in a tap, with no friction and no second factor. You find out through
mandate.revoked. - Every charge is notified. Your customer gets a notification for each charge you take, carrying your
description. Write it so it makes sense to them. - Nothing is open-ended. A mandate lasts at most 365 days. Renewal is a fresh mandate, which re-confirms the terms.
Design for this. The customer who feels ambushed by a charge revokes the mandate, and a clear title and description are what stop that.
Webhooks
Five events tell you what happened without polling. Register an endpoint on the Developers page in your dashboard and subscribe to the ones you need. The envelope, the signature and the full payload shapes are on the Webhooks page.
| Event | Sent when |
|---|---|
mandate.activated | Your customer approved the mandate. You may start charging. |
mandate.declined | Your customer refused the terms. |
mandate.revoked | The mandate was ended, by anyone. Stop charging. |
mandate.charge.succeeded | A charge settled. |
mandate.charge.failed | A charge was refused, with the code that says why. |
Try it live
The panels below run real requests from your browser, straight to api.payer.app. Your key picks the environment: an sk_test_ key is served by the sandbox, an sk_live_ key by production. Nothing is proxied through this site, and the key stays in this browser tab.
Work down them in order. Create a mandate, approve it on the page the response links to, then charge it. A test key skips the approval step, since sandbox mandates approve themselves. Creating, reading and revoking need mandates:write on the key, and charging needs payments:write, so give the key you paste both scopes to run the whole flow.
Kept in this tab only. Sent straight to api.payer.app.
/mandatesCreateAdd an API key in the bar above to run this request.
/mandates/{id}/chargesChargeAdd an API key in the bar above to run this request.
Only an ACTIVE mandate can be charged, so approve it first. A refusal is an error response and never a charge: the codes are listed above.
/mandates/{id}RetrieveAdd an API key in the bar above to run this request.
/mandatesListAdd an API key in the bar above to run this request.
GET /mandates?limit=25
/mandates/{id}/revokeRevokeAdd an API key in the bar above to run this request.
Ends the mandate immediately, and there is no way back. Collecting again means a new mandate and a new approval.