Browse documentation

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.

View as Markdown

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. 1.POST /mandates with your caps and your own reference. You get back an id and a hosted approval url.
  2. 2.Send your customer to url. They approve the terms once, with full two-factor.
  3. 3.mandate.activated lands on your webhook endpoint. The mandate is ACTIVE.
  4. 4.POST /mandates/{id}/charges whenever you need to collect, for the life of the mandate.

Create a mandate

POST /mandates
POST /mandates

Creates 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

FieldTypeRequiredDescription
referencestringYesYour own identifier for the customer or agreement. Must be unique across all of your mandates. Max 255 characters.
titlestringYesShown verbatim on the approval screen. Max 255 characters.
per_charge_capintegerYesThe most you may take in one charge, in laari (100 laari = 1 MVR). Between 100 and 500000.
descriptionstringNoExtra line under the title, shown verbatim on the approval screen. Max 255 characters.
period_capintegerNoThe 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.
periodstringWith period_capThe calendar period period_cap is measured over. MONTH is the only value today.
expires_atstring (date-time)NoWhen 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_urlstring (url)NoWhere the customer is sent after approving or declining. Must be an http or https URL.
Request
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"
  }'
201 Created
{
  "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.

StatusMeaning
INITIATEDCreated, waiting on your customer.
ACTIVEApproved and chargeable.
DECLINEDYour customer refused the terms. Terminal.
REVOKEDEnded by your customer, by you, or by Payer. Terminal.
EXPIREDThe approval link, or the mandate itself, ran out. Terminal.
SUSPENDEDHeld 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}/charges
POST /mandates/{id}/charges

Takes 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

FieldTypeRequiredDescription
amountintegerYesHow much to take, in laari. At least 100, and no more than the mandate's per_charge_cap.
descriptionstringNoWhat this charge is for. Shown to your customer on their notification.
referencestringNoYour own reference for this charge. Defaults to the mandate's reference.
Request
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"
  }'
201 Created
{
  "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.

StatuscodemessageWhat to do
404MANDATE_NOT_FOUNDMandate not foundNo 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.
409MANDATE_INACTIVEMandate is not activeIt was revoked, suspended, or never approved. Stop charging and ask for a new mandate.
409MANDATE_EXPIREDMandate has expiredIts expiry has passed. Create a fresh mandate and have your customer approve it.
422PER_CHARGE_CAP_EXCEEDEDAmount exceeds the per charge capCharge no more than per_charge_cap, or split the bill.
422PERIOD_CAP_EXCEEDEDAmount exceeds the period capThe period cap is used up. Wait for the period to roll, or collect the rest another way.
422MANDATE_CHARGE_AMOUNT_INVALIDAmount is not chargeableThe amount is below the platform minimum or otherwise unusable. Fix the amount.
422WALLET_LIMIT_EXCEEDEDThe charge was blocked by a wallet limitA limit on your customer's account stopped it. Only they can lift it.
422MANDATE_CHARGE_FAILEDThe charge could not be takenThe payment did not go through. Treat it like a decline and decide whether to try again.
402INSUFFICIENT_FUNDSInsufficient fundsTheir wallet couldn't cover it right now. This is the one refusal a later retry could plausibly turn into a payment.
503SERVICE_UNAVAILABLEThis service is temporarily unavailableNothing 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-Key should change when you mean a genuinely new attempt.
  • Your customer can revoke at any time, so an ACTIVE mandate is a fact about now, not a guarantee about next month. Handle 409 as a normal outcome, not an exception.

Retrieve a mandate

GET /mandates/{id}
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.

Request
curl https://api.payer.app/mandates/customer_10482 \
  -H "Authorization: Bearer sk_test_..."
200 OK
{
  "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 /mandates
GET /mandates

Returns your mandates, newest first. Requires the mandates:write scope.

ParameterTypeDescription
statusstringReturn only mandates in this state.
referencestringReturn only the mandate carrying this reference.
limitintegerHow many to return. Defaults to 25, capped at 100.
offsetintegerHow many to skip.
Request
curl "https://api.payer.app/mandates?status=ACTIVE&limit=50" \
  -H "Authorization: Bearer sk_test_..."

Revoke a mandate

POST /mandates/{id}/revoke
POST /mandates/{id}/revoke

Ends 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.

Request
curl -X POST https://api.payer.app/mandates/customer_10482/revoke \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: revoke_customer_10482"
200 OK
{ "message": "Mandate revoked" }

A mandate that is already in a state that cannot be revoked returns 409:

409 Conflict
{ "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_cap and period_cap are 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.

EventSent when
mandate.activatedYour customer approved the mandate. You may start charging.
mandate.declinedYour customer refused the terms.
mandate.revokedThe mandate was ended, by anyone. Stop charging.
mandate.charge.succeededA charge settled.
mandate.charge.failedA 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.

Modetest

Kept in this tab only. Sent straight to api.payer.app.

POST/mandatesCreate

Add an API key in the bar above to run this request.

POST/mandates/{id}/chargesCharge

Add 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.

GET/mandates/{id}Retrieve

Add an API key in the bar above to run this request.

GET/mandatesList

Add an API key in the bar above to run this request.

GET /mandates?limit=25

POST/mandates/{id}/revokeRevoke

Add 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.