# API reference

> The Mintage Pay merchant API. Create invoices, read payments and refunds, and create payment links.

Source: https://mintagepay.com/docs/api/ (last updated 2026-10-25)

Base URL: `https://app.mintagepay.com/v1` (sandbox: `https://sandbox.mintagepay.com/v1`). All request and
response bodies are JSON. Amounts are decimal strings.

A machine-readable description of every route is at
[`/v1/openapi.json`](https://app.mintagepay.com/v1/openapi.json). No key is needed to read it.

## Authentication

Send your key as a bearer token:

```
Authorization: Bearer mp_live_YOUR_KEY
```

Create keys in the dashboard under **Developers > API keys**. A key is shown once; only its hash is stored. Revoked keys
get `401`.

`GET /v1/me` works with any key. It tells an integration where it is and what the account can do:

- `mode`: `"live"` (real money) or `"test"` (the sandbox);
- `name`: the business name;
- `rails`: what the account can be paid with here, for example `[{ "id": "base-usdc", "network": "base", "token": "USDC" }]`;
- `merchant`: `id`, `name`, `fee_bps` and `underpayment_tolerance_bps`;
- `api_key`: the key's `id` and `scopes`.

### Scopes

Each key has scopes, chosen when it is created. A call the key's scopes do not allow gets `403 insufficient_scope`,
and `details.required` names the missing scope.

| Scope | Allows |
|---|---|
| `invoices:read` | `GET /v1/invoices`, `GET /v1/invoices/{id}` (with its payments and refunds) |
| `invoices:write` | `POST /v1/invoices`, `POST /v1/invoices/{id}/send`, `POST /v1/invoices/{id}/cancel` |
| `links:read` | `GET /v1/payment-links` |
| `links:write` | `POST /v1/payment-links` |
| `orders:read` | `GET /v1/orders/{id}` (the customer's name, email and address) |
| `refunds:read` | `GET /v1/invoices/{id}/refunds` |
| `webhooks:manage` | `/v1/webhook-endpoints` (list, add, change, remove) |

The Developers page offers presets: **Full access**, **Read only** and **AI agent: read + create invoices**. Only team
members who may see customer details (owner, admin) can give a key `orders:read`.

## Errors

Errors look like this:

```json
{ "error": "not_found", "message": "Invoice not found" }
```

Invalid input is `400` with `"error": "validation_failed"` and one `details` item per problem:

```json
{ "error": "validation_failed", "message": "Invalid request",
  "details": [{ "path": "redirect_url", "code": "invalid_url", "message": "Invalid URL" }] }
```

- `path` is the request field as you sent it. Nested fields are joined with dots (`shipping.postal_code`).
- `code` is stable, so you can write your own wording for it: `required`, `too_short`, `too_long`, `too_small`,
  `too_big`, `invalid_email`, `invalid_url`, `invalid_format`, `invalid_choice`, `invalid_type`, `invalid_amount`
  and others.
- `message` is English, for developers.

| Status | Meaning |
|---|---|
| 400 | Invalid input |
| 401 | Bad key |
| 403 | The key lacks the scope (`insufficient_scope`), or the account may not use the feature (for example `review_required`) |
| 404 | Not found |
| 409 | Conflict |
| 410 | Expired |
| 429 | Rate limited. The limit is 120 requests a minute per key |

## Create an invoice

`POST /v1/invoices` (scope `invoices:write`)

Send the header `Idempotency-Key` with your order id or any unique string. Repeating the same key returns the same
invoice (`200`), so a retry never creates a second one. Reusing the key with a different amount or order id returns `409`.
So does reusing it with a different `metadata.order_key`, when both requests carry one: put a value that belongs to the
one order there if your order numbers can start again (a rebuilt store, or two stores on one account).

```json
{
  "amount": "15.00",
  "currency": "USD",
  "order_id": "1042",
  "description": "Order #1042",
  "buyer_email": "customer@example.com",
  "redirect_url": "https://example.com/thank-you",
  "cancel_url": "https://example.com/cart",
  "metadata": { "any": "json" },
  "expires_in_minutes": 60,
  "send_email": false,
  "send_receipt": true,
  "locale": "es"
}
```

| Field | Required | Notes |
|---|---|---|
| `amount` | Yes | In `currency`, above zero, at most 2 decimals (none for JPY, KRW, VND, CLP, PYG, ISK, XAF and XOF, VND, CLP, PYG, ISK, XAF and XOF) |
| `currency` | No | Default `USD`. See [Prices in other currencies](#prices-in-other-currencies) |
| `order_id` | No | Your reference, up to 200 characters |
| `description` | No | Up to 500 characters |
| `buyer_email` | No | Used for the receipt and for payment request emails |
| `redirect_url` | No | Shown as "Return to store" after payment |
| `cancel_url` | No | Where the page's "Cancel" link takes a customer who has not paid yet |
| `metadata` | No | Any JSON, up to 4,000 characters |
| `expires_in_minutes` | No | 5 to 10080. Default 60 |
| `send_email` | No | `true` emails the payment link to `buyer_email` now |
| `send_receipt` | No | Default `true`. `false` if your store sends its own receipt |
| `locale` | No | The customer's language. See [Checkout language](#checkout-language) |

Returns `201` with an [invoice object](#the-invoice-object). Send the customer to its `checkout_url`.

With `"send_email": true` the response also has `"email_sent": true`, or `false` with an `email_error`. The invoice is
created either way, and you can retry with `POST /v1/invoices/{id}/send`.

### Checkout language

The payment page and the customer emails come in English, Spanish, Portuguese (Brazil), French, German, Italian,
Turkish, Indonesian, Vietnamese, Thai, Arabic (right to left), Hindi and Russian. `locale` sets the customer's language:
`en`, `es`, `pt-BR`, `fr`, `de`, `it`, `tr`, `id`, `vi`, `th`, `ar`, `hi` or `ru`. Any language tag works
(`es-MX`, `de_DE`), so a store can pass its own language as it is. A language that is not offered never fails the
invoice; the page then follows the customer's browser.

On the page, the first match wins: the language the customer picks in the page's switcher, `?lang=` in the URL, the
language they picked on an earlier visit, the invoice's `locale`, the browser language, then English.

### Prices in other currencies

Customers always pay in US dollar stablecoins. You can still price in your own currency. Send `currency` with one of:
USD, EUR, GBP, CAD, AUD, CHF, JPY, MXN, BRL, INR, SGD, AED, PLN, SEK, NOK, DKK, NZD, ZAR, TRY, KRW, HKD, NGN, ARS, PHP, IDR, VND, PKR, EGP, BDT, KES, GHS, TZS, COP, CLP, PEN, UYU, PYG, BOB, DOP, GTQ, CRC, THB, MYR, TWD, LKR, NPR, KZT, UAH, ILS, SAR, QAR, CZK, HUF, RON, ISK, XAF, XOF.

- **Conversion:** when the invoice is created, your price is converted to the USD amount the customer pays, rounded up
  to the cent, so you never receive less than your price. That USD amount is `amount`. It does not change afterwards.
- **The rate:** a live rate, checked against central bank reference rates. If the two differ by more than 1%, or no
  recent rate is available, the request fails with `503` and `"error": "fx_unavailable"`, and nothing is created. Try
  again later or price in USD.
- **On the invoice:** `price_currency` and `price_amount` are your price. `fx_rate` is USD per 1 unit of your currency,
  exactly as applied. `fx_source` and `fx_at` say where and when the rate came from.
- **At checkout:** the customer sees your price, the amount to pay and the rate.

## Other invoice calls

| Call | Does |
|---|---|
| `GET /v1/invoices/{id}` | Returns the invoice, with its `payments` and `refunds` |
| `GET /v1/invoices?order_id=1042&status=paid&limit=20` | Lists invoices, newest first, at most 100 |
| `GET /v1/invoices/{id}/refunds` | Returns `{ "data": [refund, ...] }` |
| `POST /v1/invoices/{id}/cancel` | Cancels an `open`, `expired` or `partially_paid` invoice while nothing is confirming. Otherwise `409`. Money already received stays in your wallet |
| `POST /v1/invoices/{id}/send` | Emails the customer the payment request. Body `{"email": "customer@example.com"}`, or empty to use the invoice's `buyer_email`. Open invoices only |

Payment request emails are limited to 50 per store per hour and 10 per recipient per day (`429 rate_limited`).

## The invoice object

```json
{
  "id": "Kp3...",
  "object": "invoice",
  "status": "open",
  "amount": "15.00",
  "currency": "USD",
  "price_currency": "USD",
  "price_amount": "15.00",
  "fx_rate": "1",
  "fx_source": null,
  "fx_at": null,
  "amount_paid": "0.00",
  "amount_pending": "0.00",
  "amount_remaining": "15.00",
  "overpaid": "0.00",
  "amount_short": "0.00",
  "paid_late": false,
  "held_for_review": false,
  "order_id": "1042",
  "description": "Order #1042",
  "metadata": {},
  "locale": "es",
  "checkout_url": "https://pay.mintagepay.com/i/Kp3...",
  "redirect_url": "https://example.com/thank-you",
  "expires_at": "2026-10-01T19:00:00.000Z",
  "paid_at": null,
  "created_at": "2026-10-01T18:00:00.000Z",
  "payments": [
    { "network": "base", "token": "USDC", "amount": "15.000000", "status": "confirmed",
      "tx": "0x...", "explorer_url": "https://basescan.org/tx/0x...", "late": false,
      "from_address": "0x..." }
  ],
  "refunds": [],
  "amount_refunded": "0.00",
  "send_receipt": true,
  "subscription": null
}
```

| Field | Meaning |
|---|---|
| `amount` | The USD amount due |
| `amount_paid` | Confirmed on the network |
| `amount_pending` | Seen on the network, not final yet |
| `amount_remaining` | What is still due. `"0.00"` once paid |
| `overpaid` | Paid above `amount` |
| `amount_short` | Paid within your underpayment tolerance: how much less than `amount` arrived |
| `paid_late` | `true` if money arrived after the invoice expired or was cancelled |
| `held_for_review` | `true` if a payment came from a sanctioned address. Do not fulfil. See [Webhooks](https://mintagepay.com/docs/webhooks/#sanctions-holds) |
| `payments[].from_address` | The sending address. It may be an exchange's address, not the customer's own |

Amounts inside `payments` and `refunds` have up to 6 decimals, for example `"15.000000"`.

### Status

| Status | Meaning |
|---|---|
| `open` | Waiting for payment |
| `partially_paid` | Some money arrived but not enough. The payment page shows the remainder |
| `paid` | Confirmed payments cover `amount`, or fall short by no more than your underpayment tolerance. Fulfil the order |
| `expired` | Time ran out before the invoice was fully paid. `amount_paid` is above zero if the customer paid part of it |
| `cancelled` | You cancelled it |

Payments are still watched for after an invoice expires or is cancelled. Money sent anyway still reaches your wallet. If
it completes the amount, the invoice becomes `paid` with `paid_late: true`. If it still falls short, the status stays
as it was, `paid_late` becomes `true` and `invoice.partially_paid` is sent.

### Underpayments and overpayments

- **Tolerance** (dashboard, Settings > Payments, 0% to 5%, default 0%): an invoice short of `amount` by no more than this
  share becomes `paid`, with `amount_short` set. Some wallets and exchanges take their fee from what the customer sends;
  this covers that.
- **Overpayment:** `overpaid` shows the extra. You also get a notice with the amount and the address the customer paid
  from, so you can send the extra back.
- **Fees** are always charged on what arrived.

## Refunds

Mintage Pay never holds your money, so you send a refund yourself, from any wallet or exchange, on the network the
customer paid on. Then record it in the dashboard: open the payment under **Payments**, choose **Refund**, enter the
amount and address, send it, and paste the transaction hash.

A refund counts once it is final on the network and sends the right stablecoin, at least the amount, to that address.
Refunds can never add up to more than was paid. The Mintage Pay fee is not refunded. When a refund is verified, your
endpoint receives `invoice.refunded`.

A refund object:

```json
{ "id": "...", "network": "base", "token": "USDC", "amount": "5.000000", "to": "0x...",
  "amount_micro": "5000000", "amount_units": "5000000", "decimals": 6,
  "status": "confirmed",
  "tx": "0x...", "explorer_url": "https://basescan.org/tx/0x...", "created_at": "...", "confirmed_at": "..." }
```

`status` is `requested` or `confirmed`. Add up `amount_micro` (millionths of a dollar) for totals.

## Payment links

A payment link is a product or donation page hosted by Mintage Pay. Most stores create them in the dashboard; see
[Payment links and the buy button](https://mintagepay.com/docs/integrations/#payment-links-and-the-buy-button). They can also be created
through the API.

`POST /v1/payment-links` (scope `links:write`) creates a link and returns it (`201`) with the page address in `url`.

| Field | Notes |
|---|---|
| `name` | Required |
| `description` | Optional |
| `kind` | `product` (default) or `donation` |
| `price_mode` | `fixed` (default) or `open` for an amount the customer chooses. Donations are `open` |
| `currency` | Default: your default currency |
| `unit_price` | The price, for `fixed` |
| `open_min`, `open_max` | The lowest and highest amount, for `open` |
| `shipping` | A shipping fee |
| `qty_min`, `qty_max`, `stock_limit` | Quantity limits and stock |
| `collect_name`, `collect_phone`, `collect_shipping` | What to ask the customer for. Email is always asked |
| `custom_field_label` | One extra question |
| `success_url` | An https page to return to after payment |
| `presets`, `allow_custom` | Donation amounts |
| `locale` | The page's language |

`GET /v1/payment-links` (scope `links:read`) returns `{ "data": [...] }`, active links first.

The answer is `403 review_required` until your account has been reviewed.

### Orders from a link

Invoices created from a link carry a `payment_link` object: `id`, `name`, `kind`, `order_id`, `quantity`, `currency`,
`unit_price` and `shipping`.

`GET /v1/orders/{invoice id}` (scope `orders:read`) returns the order with a `customer` object: `email`, `name`,
`phone`, `custom`, `message` and `shipping_address`. Customer details are kept for 2 years, then removed.

## Bank transfer

Stores approved for business-to-business sales can also offer bank transfer (ACH or wire) on the payment page, after
adding a bank account in the dashboard.

- When the customer chooses it, the page shows your account details and a reference unique to the invoice.
  `invoice.awaiting_bank_transfer` is sent, the status stays `open`, and `expires_at` moves out to your payment terms
  (1 to 30 days, 7 by default). Hold the order; do not cancel it.
- Mintage Pay never sees your bank. When the money arrives, record it in the dashboard under **Bank transfers**. Each
  amount counts as a confirmed payment, so the invoice becomes `partially_paid` or `paid` and the usual events are sent.
- Single invoice reads and webhooks carry a `bank_transfer` object with the `reference`, the `amount` due, the
  `amount_received` and the `receipts` you recorded.

## Not covered here

Subscriptions and payments by AI agents (x402) can be tried in the sandbox. They are not available on live accounts
yet, so they are not part of this reference.
