# Instructions for the AI coding agent

You are helping a developer accept payments on **their** website or app with **ITA Master Pay**. Below is the complete API reference. Read all of it, then build the integration the developer asks for.

## Ground rules

1. **Use only what is documented below.** Do not invent endpoints, parameters, fields or event names. If something you need is not documented, say so and ask the developer.
2. **The API base URL is `https://itamaster-pay.com/api/v1`.** Authenticate every request with `Authorization: Bearer <secret key>`.
3. **Never hard-code or print the secret API key** (`gw_live_…`), and never put it in browser/front-end code, mobile apps, or committed files. Read it from an environment variable named `PAY_API_KEY` and tell the developer to set it. Do the same for the webhook signing secret (`whsec_…`): `PAY_WEBHOOK_SECRET`. If you need a value you do not have, ask the developer; do not guess and do not make one up.
4. **Amounts are integers in minor units** (cents): $49.99 is `4999`. Convert from the developer's own price format carefully (round half up, avoid float errors: use integer or decimal arithmetic).
5. **Customers are redirected to `checkout_url`** (hosted checkout). You are not building a card form and must never ask for or handle card numbers.
6. **Fulfil orders only from the signed `payment.paid` webhook** (verified on the *raw* request body, constant-time compare, 5-minute tolerance, duplicates ignored by event id), never from the `success_url` redirect.
7. Send an `Idempotency-Key` (for example the order id) and a `reference` (the order id) when creating a payment, so retries never double-charge or duplicate orders.
8. If the developer's site runs **WordPress or WooCommerce**, do not write custom code: tell them to install the official plugin from the seller panel (Developers → WordPress plugin) (section 10).

## What to do

1. Ask (or inspect the project to find out): the language/framework, where orders are created, where a "pay" action belongs, and how orders are marked paid.
2. Implement, in this order:
   - a small API client function (create payment, get payment) using the environment variable for the key,
   - the step that creates a payment for an order and redirects the customer to `checkout_url`,
   - a webhook endpoint that verifies the signature, ignores duplicate event ids, checks `amount` and `currency` against the order, then marks the order paid,
   - success and cancel pages (`success_url` / `cancel_url`) that only *display* status.
3. Show the developer how to add the webhook endpoint (seller panel → Developers → Webhooks, or `POST /webhook-endpoints`) and to put the signing secret in `PAY_WEBHOOK_SECRET`.
4. Add a test or a short manual test plan (create a small payment of at least $5.00, pay it, confirm the webhook marks the order paid, cancel unused test payments).

When you finish, summarise what you changed, the environment variables to set, and the exact webhook URL to register.

---

# ITA Master Pay Payments API

Accept payments on your own website or app with ITA Master Pay, the same way you would with Stripe Checkout: your server creates a **payment**, the customer pays on a **hosted checkout page**, and your server is told the result with a **signed webhook**.

- **API base URL:** `https://itamaster-pay.com/api/v1`
- **Documentation:** https://itamaster-pay.com/docs/api (raw Markdown: https://itamaster-pay.com/docs/api.md)
- **Format:** JSON over HTTPS. Send `Content-Type: application/json`.
- **Version:** `v1` (in the URL). Fields may be added to responses; never remove or rename what is documented here.

## 1. How it works

```
 Your server                          ITA Master Pay                       Customer
     |  POST /payments  ------------------>|                                  |
     |<-- { id, checkout_url, ... } -------|                                  |
     |  redirect the customer to checkout_url ------------------------------->|
     |                                     |<--- pays on the hosted page -----|
     |<-- webhook: payment.paid  ----------|                                  |
     |                                     |--- redirect to success_url ----->|
     |  mark the order as paid                                                |
```

1. **Create a payment** with an amount, a currency and a description. You get back a `checkout_url`.
2. **Send the customer to `checkout_url`.** They enter their name and email and pay by card, Apple Pay, Google Pay or any method enabled on the platform.
3. **Wait for the `payment.paid` webhook** (or fetch the payment) and only then deliver the goods or mark the order as paid. **Never trust the redirect to `success_url` alone**: a customer can type that address by hand.

### Money and currencies

- **Amounts are integers in the smallest unit of the currency** (cents). `4999` with currency `USD` is $49.99. Every supported currency has two decimal places.
- **You can invoice in any supported currency** (list below). **The customer is always charged in USD**, converted at the live exchange rate at the moment they press *Purchase now*. That rate is stored with the payment (`charge.exchange_rate`), so the charged amount never changes afterwards.
- **Limits:** a payment must be worth between **$5.00 and $4,850.00** once converted to USD. `GET /currencies` returns the exact minimum and maximum for each currency today.
- **Your earnings** are always in USD: the amount charged, less payment fees and any platform fee. They appear in your seller panel and in `seller_earning` on a paid payment.

**Supported currencies** (invoice in any of these; customers are charged in USD):

| Code | Currency | Code | Currency |
| --- | --- | --- | --- |
| `USD` | US Dollar | `AED` | United Arab Emirates Dirham |
| `ARS` | Argentine Peso | `AUD` | Australian Dollar |
| `BDT` | Bangladeshi Taka | `BRL` | Brazilian Real |
| `CAD` | Canadian Dollar | `CHF` | Swiss Franc |
| `CNY` | Chinese Renminbi Yuan | `COP` | Colombian Peso |
| `CZK` | Czech Koruna | `DKK` | Danish Krone |
| `EGP` | Egyptian Pound | `EUR` | Euro |
| `GBP` | British Pound | `GEL` | Georgian Lari |
| `GHS` | Ghanaian Cedi | `HKD` | Hong Kong Dollar |
| `HUF` | Hungarian Forint | `IDR` | Indonesian Rupiah |
| `ILS` | Israeli New Shekel | `INR` | Indian Rupee |
| `KES` | Kenyan Shilling | `LKR` | Sri Lankan Rupee |
| `MAD` | Moroccan Dirham | `MXN` | Mexican Peso |
| `MYR` | Malaysian Ringgit | `NGN` | Nigerian Naira |
| `NOK` | Norwegian Krone | `NZD` | New Zealand Dollar |
| `PEN` | Peruvian Sol | `PHP` | Philippine Peso |
| `PKR` | Pakistani Rupee | `PLN` | Polish Złoty |
| `QAR` | Qatari Riyal | `RON` | Romanian Leu |
| `RSD` | Serbian Dinar | `SAR` | Saudi Riyal |
| `SEK` | Swedish Krona | `SGD` | Singapore Dollar |
| `THB` | Thai Baht | `TRY` | Turkish Lira |
| `TWD` | New Taiwan Dollar | `UAH` | Ukrainian Hryvnia |
| `ZAR` | South African Rand |  |  |

## 2. Authentication

Create a secret key in the seller panel under **Developers → API keys**. Send it with every request:

```
Authorization: Bearer gw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

- Keys start with `gw_live_`. The full key is shown **once**, when you create it; only a hash is stored, so a lost key cannot be recovered, only replaced.
- **Keep keys on your server.** Never put one in browser JavaScript, a mobile app, or a public repository. Read it from an environment variable such as `PAY_API_KEY`.
- Create one key per integration so a leak can be revoked without touching the others. Revoking a key stops it immediately.
- A key acts on **your account only**. You can never read or change another seller's data.

## 3. Errors

Every error has the same shape and an HTTP status that matches:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "validation_failed",
    "message": "The minimum invoice amount is $5.00.",
    "param": "amount",
    "errors": { "amount": ["The minimum invoice amount is $5.00."] }
  }
}
```

| HTTP | `type` | When |
| --- | --- | --- |
| 400 | `invalid_request_error` | A malformed request, for example an unknown `starting_after`. |
| 401 | `authentication_error` | `api_key_missing` or `api_key_invalid` (wrong, or revoked). |
| 403 | `permission_error` | `account_suspended` or `email_not_verified`. |
| 404 | `invalid_request_error` | `resource_not_found`: no such payment or endpoint on your account. |
| 405 | `invalid_request_error` | `method_not_allowed`. |
| 409 | `invalid_request_error` | `idempotency_key_reused`, `payment_already_paid`, `webhook_limit_reached`. |
| 422 | `invalid_request_error` | `validation_failed`. `param` names the first bad field; `errors` lists every field. |
| 429 | `rate_limit_error` | `rate_limited`. Wait for the `Retry-After` seconds. |
| 500 / 503 | `api_error` | `server_error`, or `api_unavailable` when the API is switched off. Safe to retry. |

Branch on `error.code`, show `error.message` to developers (not to customers).

## 4. Idempotency

Send an `Idempotency-Key` header (any unique string up to 120 characters, for example `order-1042`) when creating a payment. If the request is retried with the same key, you get the **original payment back** (`200` with `Idempotent-Replayed: true`) instead of a duplicate. Reusing a key with a different amount or currency returns `409 idempotency_key_reused`. Use it on every create call: networks fail.

## 5. Rate limits

120 requests per minute per API key. Over the limit you get `429` with a `Retry-After` header.

## 6. Payments

### The payment object

```json
{
  "id": "pay_01k7x2m9q3v8h5n4r6t1w0yzab",
  "object": "payment",
  "status": "open",
  "amount": 4999,
  "currency": "EUR",
  "description": "Order #1042",
  "reference": "order-1042",
  "customer": { "name": "Jane Buyer", "email": "jane@example.com" },
  "metadata": { "cart_id": "abc123" },
  "checkout_url": "https://itamaster-pay.com/checkout/c/…",
  "success_url": "https://shop.example.com/thanks",
  "cancel_url": "https://shop.example.com/cart",
  "expires_at": "2026-10-12T10:00:00+00:00",
  "created_at": "2026-10-11T10:00:00+00:00",
  "paid_at": null,
  "source": "api",
  "livemode": true,
  "charge": null,
  "seller_earning": null
}
```

| Field | Meaning |
| --- | --- |
| `id` | The payment id, `pay_…`. Store it with your order. |
| `status` | `open` (waiting for the customer), `paid`, `cancelled`, or `expired`. |
| `amount`, `currency` | What you invoiced, in minor units and ISO 4217 code. |
| `description` | Shown to the customer on the payment page and receipt (max 120 characters). |
| `reference` | Your own id for this payment, such as an order number (max 255). Searchable with `GET /payments?reference=…`. |
| `metadata` | Up to 20 string key/value pairs you want stored and echoed back in webhooks. |
| `checkout_url` | Send the customer here. Single use: once paid, it shows "already paid". |
| `success_url`, `cancel_url` | Where the customer is sent after paying (a *Return to … * button, with `?payment_id=pay_…` added) and where the *Cancel* link on the checkout page goes. |
| `expires_at` | After this time the link no longer takes payments. Default 24 hours after creation. |
| `customer` | The name and email you gave, or once paid, the ones the customer typed at checkout. |
| `charge` | Once paid: `{ "id": "…", "receipt_url": "…", "currency": "USD", "amount": 5498, "exchange_rate": 1.0996, "exchange_rate_at": "…" }`. `exchange_rate` is USD per 1 unit of your currency, and is `null` for USD payments. `receipt_url` is the customer's receipt page. |
| `seller_earning` | Once paid: `{ "currency": "USD", "amount": 4900 }`, what you earn from this payment. |
| `source` | `api` or `dashboard` (payments you made by hand in the seller panel appear here too). |

### Create a payment: `POST /payments`

| Parameter | Type | Required | Notes |
| --- | --- | --- | --- |
| `amount` | integer | yes | Minor units, at least 1. Must convert to between $5.00 and $4,850.00. |
| `currency` | string | yes | Three-letter code from the supported list. Case-insensitive. |
| `description` | string | yes | Max 120 characters. |
| `customer_email` | string | no | Pre-fills the checkout form. |
| `customer_name` | string | no | Pre-fills the checkout form. |
| `reference` | string | no | Your order id. Max 255. |
| `metadata` | object | no | Up to 20 keys (≤ 40 chars) with string values (≤ 500 chars). |
| `success_url` | string | no | `https://…` address to send the customer back to. |
| `cancel_url` | string | no | `https://…` address for the *Cancel* link. |
| `expires_in` | integer | no | Seconds the link stays open: 300 to 2592000. Default 86400. |

```bash
curl https://itamaster-pay.com/api/v1/payments \
  -H "Authorization: Bearer $PAY_API_KEY" \
  -H "Idempotency-Key: order-1042" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 4999,
    "currency": "EUR",
    "description": "Order #1042",
    "reference": "order-1042",
    "customer_email": "jane@example.com",
    "success_url": "https://shop.example.com/thanks",
    "cancel_url": "https://shop.example.com/cart",
    "metadata": { "cart_id": "abc123" }
  }'
```

Returns `201` with the payment object. Redirect the customer to `checkout_url`.

### Retrieve a payment: `GET /payments/{id}`

Returns the payment object, or `404`. Use it to confirm the outcome when a customer returns to your site, and from your webhook handler before you act.

### List payments: `GET /payments`

Every invoice you made, whether through the API, the seller panel or the WordPress plugin, with its status. Query parameters (all optional, combine freely):

| Parameter | Meaning |
| --- | --- |
| `status` | `open`, `paid`, `cancelled` or `expired`. |
| `reference` | Exactly your reference, such as an order number. |
| `currency` | The invoice currency, e.g. `EUR`. |
| `customer_email` | Payments for this email address. |
| `q` | Search the payment id, description, reference, customer name and email. |
| `created_after`, `created_before` | A date or date-time, e.g. `2026-10-01` or `2026-10-01T00:00:00Z`. |
| `limit` | 1 to 100, default 10. |
| `starting_after` | A payment id, for the next page. |

Newest first.

```json
{ "object": "list", "url": "/api/v1/payments", "has_more": true, "total_count": 42, "data": [ { "id": "pay_…" } ] }
```

`total_count` is how many payments match your filters. To read everything, repeat the call with `starting_after` set to the last `id` until `has_more` is `false`.

### Summary for a period: `GET /payments/summary`

Takes the same filters as the list (except `status`) and answers "how did I do?" in one call:

```json
{
  "object": "payment_summary",
  "count": 42,
  "by_status": { "open": 3, "paid": 36, "cancelled": 2, "expired": 1 },
  "paid": {
    "count": 36,
    "invoiced_by_currency": { "EUR": 120000, "USD": 45000 },
    "charged_usd": 178500,
    "earned_usd": 160650
  },
  "filters": { "created_after": "2026-10-01" }
}
```

`invoiced_by_currency` is what you invoiced, per currency, in minor units. `charged_usd` is what customers were charged in USD, and `earned_usd` is what you keep after fees.

### Update a payment: `PATCH /payments/{id}`

Changes a payment that is still `open` or `expired`. Send only what should change: `description`, `customer_name`, `customer_email`, `reference`, `metadata`, `success_url`, `cancel_url`, `expires_in`. Sending `expires_in` re-opens an expired payment for that many more seconds. The `amount` and `currency` cannot change: cancel the payment and create a new one. A `paid` payment returns `409 payment_already_paid`, a cancelled one `409 payment_cancelled`.

### Cancel a payment: `POST /payments/{id}/cancel`

Stops an open payment from taking money; the checkout link then shows as unavailable. Cancelling twice is harmless. A payment that is already `paid` returns `409 payment_already_paid`. Sends the `payment.cancelled` webhook.

## 7. Webhooks

A webhook is an HTTPS `POST` to your server whenever something happens to one of your payments. **Webhooks are how you reliably learn that a customer paid.**

Add endpoints in the seller panel under **Developers → Webhooks**, or with the API (section 8). Each endpoint has its own signing secret (`whsec_…`) and can listen to some events or all of them.

### Events

| Event | Sent when |
| --- | --- |
| `payment.created` | A payment is created (through the API or by hand in the seller panel). |
| `payment.paid` | The customer's payment was confirmed. **Fulfil the order on this event.** |
| `payment.cancelled` | An unpaid payment was cancelled. |
| `webhook.test` | You pressed *Send test event* in the panel. |

### The request you receive

```
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: PayGateway-Webhooks/1.0
X-Pay-Event: payment.paid
X-Pay-Event-Id: evt_01k7x3…
X-Pay-Delivery-Attempt: 1
X-Pay-Signature: t=1760176800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

```json
{
  "id": "evt_01k7x3b6c9d2e4f8g1h5j7k0mn",
  "object": "event",
  "type": "payment.paid",
  "created": 1760176800,
  "data": { "object": { "id": "pay_…", "object": "payment", "status": "paid", "amount": 4999, "currency": "EUR", "reference": "order-1042", "charge": { "currency": "USD", "amount": 5498, "exchange_rate": 1.0996 } } }
}
```

`data.object` is the full payment object from section 6. `id` is unique per event: **use it to ignore duplicates**, because the same event can be delivered more than once.

### Verify the signature (always)

Anyone can POST to your URL, so check that the request really came from ITA Master Pay:

1. Read the **raw request body** exactly as received (before any JSON parsing or re-encoding).
2. Read the `X-Pay-Signature` header: `t=<unix time>,v1=<hex signature>`.
3. Compute `HMAC-SHA256` of the string `"<t>.<raw body>"` using your endpoint's secret (`whsec_…`) as the key, as lowercase hex.
4. Compare with `v1` using a **constant-time comparison**.
5. Reject the request if `t` is more than 5 minutes from your clock (this stops replays of captured requests).

**Node.js (Express):**

```js
const crypto = require('crypto');

// Use express.raw so the body stays untouched: app.post('/webhooks/pay', express.raw({ type: 'application/json' }), handler)
function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!parts.t || !parts.v1 || age > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

**PHP:**

```php
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_PAY_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts);          // t=…&v1=…
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $raw, $secret);

if (! isset($parts['t'], $parts['v1'])
    || abs(time() - (int) $parts['t']) > 300
    || ! hash_equals($expected, $parts['v1'])) {
    http_response_code(400);
    exit;
}
$event = json_decode($raw, true);
```

**Python:**

```python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if "t" not in parts or "v1" not in parts or abs(time.time() - int(parts["t"])) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

### Reply and retries

- **Reply with any `2xx` status within 8 seconds.** Do slow work after replying (queue it).
- Any other answer, a timeout or a connection error counts as a failure and the event is **retried** after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours (7 attempts in total). After that it is marked failed; you can resend it from **Developers → Webhooks → (endpoint) → Resend**.
- Redirects are **not followed**. The URL must be `https://`, on a public address (not `localhost` or a private network).
- Deliveries are not guaranteed to arrive in order. Fetch the payment (`GET /payments/{id}`) if you need the current state.

### A safe handler, step by step

1. Verify the signature. Reject with `400` if it fails.
2. Parse the JSON. If you have already processed `id` (the event id), reply `200` and stop.
3. On `payment.paid`: look up your order by `data.object.reference` (or `metadata`), check that `amount` and `currency` match what you expect, then mark it paid and fulfil it. For high-value orders, also call `GET /payments/{id}` and require `status` = `paid`.
4. Reply `200`.

## 8. Webhook endpoints API

Manage endpoints from code (the seller panel does the same). The `secret` is returned **only when an endpoint is created**.

| Method and path | Purpose |
| --- | --- |
| `POST /webhook-endpoints` | Create. Body: `url` (required), `events` (array, default all), `description`, `enabled`. Returns `201` including `secret`. |
| `GET /webhook-endpoints` | List your endpoints. |
| `GET /webhook-endpoints/{id}` | Retrieve one (`we_…`). |
| `PATCH /webhook-endpoints/{id}` | Change `url`, `events`, `description`, `enabled`. |
| `DELETE /webhook-endpoints/{id}` | Delete. |
| `POST /webhook-endpoints/{id}/test` | Send a test event now. Returns the delivery with your server's answer. |
| `POST /webhook-endpoints/{id}/rotate-secret` | Create a new signing secret (returned once). The old one stops verifying at once. |
| `GET /webhook-endpoints/{id}/deliveries` | What was sent and how your server answered, newest first. Filters: `status` (`pending`, `delivered`, `failed`), `limit`, `starting_after`. |
| `POST /webhook-endpoints/{id}/deliveries/{id}/resend` | Try one delivery again right now. The second `{id}` is the delivery's `evt_…` id. |

A delivery looks like `{ "id": "evt_…", "object": "webhook_delivery", "endpoint": "we_…", "event": "payment.paid", "status": "delivered", "attempts": 1, "last_status_code": 200, "last_error": null, "next_attempt_at": null, "delivered_at": "…", "created_at": "…" }`.

```bash
curl https://itamaster-pay.com/api/v1/webhook-endpoints \
  -H "Authorization: Bearer $PAY_API_KEY" -H "Content-Type: application/json" \
  -d '{ "url": "https://shop.example.com/webhooks/pay", "events": ["payment.paid", "payment.cancelled"] }'
# => { "id": "we_…", "secret": "whsec_…", "events": ["payment.paid","payment.cancelled"], "enabled": true, ... }
```

You can have at most 10 endpoints. Valid event names: `payment.created`, `payment.paid`, `payment.cancelled`.

## 9. Account, balance, payouts and currencies

| Request | Returns |
| --- | --- |
| `GET /account` | `{ "object": "account", "id", "name", "email", "created_at", "api_version": "v1", "api_key": { "name", "last_four", "created_at" } }`. A quick check that your key works. |
| `GET /payouts` | Your withdrawal requests, newest first. Filters: `status` (`pending`, `approved` = paid out, `rejected`, `cancelled`), `limit`, `starting_after`. |
| `GET /payouts/{id}` | One payout (`WD-00012`): `{ "id", "object": "payout", "status", "amount", "currency": "USD", "method", "note", "admin_note", "reference", "created_at", "processed_at" }`. |
| `GET /balance` | `{ "object": "balance", "currency": "USD", "available", "pending_withdrawals", "withdrawn", "total_earned", "sales_count" }`, all in USD cents. Withdrawals are requested in the seller panel. |
| `GET /currencies` | Every supported currency with `usd_rate` (USD value of one unit, live) and today's `min_amount` / `max_amount` in that currency's minor units. |

Requesting a payout is deliberately not in the API: it stays in the seller panel, so a leaked API key can never move money out.

## 10. WordPress and WooCommerce

No code needed for WordPress sites: download the **ITA Master Pay plugin** from the seller panel (**Developers → WordPress plugin**), install it under *Plugins → Add New → Upload*, and paste your API key.

- **WooCommerce:** adds ITA Master Pay as a payment method at checkout (classic and block checkout). Orders are marked paid automatically by webhook.
- **Invoices from your WordPress dashboard:** a *ITA Master Pay Invoices* screen lets you create an invoice, copy its pay link and email it to your customer, like the invoice screen in the seller panel. Your customer pays through the link; they need no account or key.
- **Any WordPress page:** the shortcode `[pay_gateway_button amount="25.00" currency="EUR" description="Consulting hour" label="Pay now"]` shows a pay button for a fixed amount.

## 11. Integration checklist

- [ ] The secret key is in an environment variable on the server, not in code or the browser.
- [ ] Payments are created with an `Idempotency-Key` and your `reference`.
- [ ] The customer is redirected to `checkout_url`.
- [ ] A webhook endpoint exists, the signature is verified on the **raw body**, and duplicates are ignored by event `id`.
- [ ] Orders are fulfilled on `payment.paid`, after checking `amount` and `currency`, not on the redirect to `success_url`.
- [ ] Your handler replies `2xx` quickly and does slow work afterwards.
- [ ] You tested with a real small payment (minimum $5.00) and cancelled unused test payments with `POST /payments/{id}/cancel`.

### Common mistakes

- Sending amounts in major units (`49.99`) instead of minor units (`4999`).
- Parsing the webhook body to JSON and re-serialising it before verifying the signature. It must be the exact bytes received.
- Marking an order paid from the browser redirect.
- Putting the secret key in front-end code.
- Using a currency that is not on the supported list, or an amount outside the $5.00 to $4,850.00 range once converted.
- Expecting the customer to be charged in the invoice currency: they are charged in USD at the live rate (see `charge`).
