# 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](/developers/docs/authentication) 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](/developers/docs/specs).

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

## Create a mandate

```http title="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

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

```bash title="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"
  }'
```

```json title="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.

| 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

```http title="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

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

```bash title="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"
  }'
```

```json title="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`](/developers/docs/webhooks) 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-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

```http title="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.

```bash title="Request"
curl https://api.payer.app/mandates/customer_10482 \
  -H "Authorization: Bearer sk_test_..."
```

```json title="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

```http title="GET /mandates"
GET /mandates
```

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

```bash title="Request"
curl "https://api.payer.app/mandates?status=ACTIVE&limit=50" \
  -H "Authorization: Bearer sk_test_..."
```

## Revoke a mandate

```http title="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.

```bash title="Request"
curl -X POST https://api.payer.app/mandates/customer_10482/revoke \
  -H "Authorization: Bearer sk_test_..." \
  -H "Idempotency-Key: revoke_customer_10482"
```

```json title="200 OK"
{ "message": "Mandate revoked" }
```

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

```json title="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](/developers/docs/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.
