# Paynex Merchant API

Base URL: `https://paynex.xyz`
API version: `v1` · Content type: `application/json` · Amounts: decimal strings with up to 2 decimals (`"1500.00"`)

Paynex is a hosted-checkout payment gateway. Your website or app **creates a payment** through the API, **redirects the customer** to the hosted checkout page Paynex returns, and is **notified by webhook** when the payment is paid. You never handle card or wallet details yourself.

```
Your site ──POST /v1/payments──► Paynex ──checkout_url──► Customer pays on Paynex checkout
    ▲                                                            │
    └──── webhook payment.paid  +  GET /v1/payments/{id} ◄───────┘
```

---

## 1. Quick start

1. In the Paynex admin, open **API Keys** and create a key for your store. You get a `key_id` (`pk_live_…` or `pk_sandbox_…`) and a `secret` (`sk_…`). The secret is shown **once**.
2. In **Store settings**, add the domains your `return_url` / `cancel_url` will use, and set a **Webhook URL**. Copy the **webhook secret**.
3. Create a payment (signed request, see §2):

```bash
POST /v1/payments
Idempotency-Key: order-1042

{
  "amount": "1500.00",
  "currency": "BDT",
  "merchant_ref": "INV-1042",
  "description": "Hosting renewal",
  "customer": { "name": "Md Samiul Alam", "email": "s@example.com", "phone": "01712345678" },
  "return_url": "https://yoursite.com/pay/return",
  "cancel_url": "https://yoursite.com/pay/cancel",
  "expires_in": 1800
}
```

4. Redirect the customer to `checkout_url` from the response.
5. Receive the `payment.paid` webhook, verify its signature, then call `GET /v1/payments/{id}` and mark the order paid **only if** that response says `"status": "paid"`.

---

## 2. Authentication (HMAC)

Every `/v1` request except `/v1/health` must carry four headers:

| Header | Value |
|---|---|
| `Authorization` | `Paynex-HMAC key_id=<key_id>` |
| `X-PG-Timestamp` | Unix seconds. Must be within **±300 s** of server time. |
| `X-PG-Nonce` | Random string (16 random bytes, hex). **Single use** — replays within 10 minutes are rejected. |
| `X-PG-Signature` | Hex HMAC-SHA256, computed as below. |

### Signature recipe

```
message   = timestamp + "\n" + nonce + "\n" + METHOD + "\n" + path + "\n" + sha256_hex(body)
signature = hex( HMAC-SHA256( secret, message ) )
```

- `METHOD` is upper-case (`POST`, `GET`).
- `path` is the URL path only, **including** the `/v1` prefix, **excluding** the query string (`/v1/payments/abc`).
- `body` is the exact raw request body bytes you send. For GET requests it is empty, so `sha256_hex("")` = `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`.
- Send the body **byte-for-byte** as signed. Do not re-encode JSON after signing.

`Idempotency-Key` is **required** on `POST /v1/payments`. Reuse the same key (for example your order id) to safely retry: the same payment is returned with HTTP 200 instead of a new one with 201.

### Sample: PHP

```php
function paynexRequest(string $method, string $path, ?array $json = null): array {
    $base   = 'https://paynex.xyz';
    $keyId  = getenv('PAYNEX_KEY_ID');
    $secret = getenv('PAYNEX_SECRET');

    $body  = $json === null ? '' : json_encode($json, JSON_UNESCAPED_SLASHES);
    $ts    = (string) time();
    $nonce = bin2hex(random_bytes(16));
    $sign  = strtok($path, '?');                       // sign the path only, never the query string
    $msg   = $ts . "\n" . $nonce . "\n" . strtoupper($method) . "\n" . $sign . "\n" . hash('sha256', $body);
    $sig   = hash_hmac('sha256', $msg, $secret);

    $headers = [
        "Authorization: Paynex-HMAC key_id=$keyId",
        "X-PG-Timestamp: $ts",
        "X-PG-Nonce: $nonce",
        "X-PG-Signature: $sig",
        "Content-Type: application/json",
    ];
    if ($method === 'POST' && $path === '/v1/payments') {
        $headers[] = 'Idempotency-Key: ' . ($json['merchant_ref'] ?? $nonce);
    }

    $ch = curl_init($base . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => strtoupper($method),
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 20,
    ]);
    $res  = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    return [$code, json_decode($res, true)];
}
```

### Sample: Node.js

```js
import crypto from 'node:crypto';

export async function paynex(method, path, json) {
  const base = process.env.PAYNEX_BASE_URL, keyId = process.env.PAYNEX_KEY_ID, secret = process.env.PAYNEX_SECRET;
  const body = json ? JSON.stringify(json) : '';
  const ts = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(16).toString('hex');
  const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
  const signPath = path.split('?')[0];               // sign the path only, never the query string
  const msg = `${ts}\n${nonce}\n${method.toUpperCase()}\n${signPath}\n${bodyHash}`;
  const sig = crypto.createHmac('sha256', secret).update(msg).digest('hex');
  const headers = {
    Authorization: `Paynex-HMAC key_id=${keyId}`,
    'X-PG-Timestamp': ts, 'X-PG-Nonce': nonce, 'X-PG-Signature': sig,
    'Content-Type': 'application/json',
  };
  if (method === 'POST' && path === '/v1/payments') headers['Idempotency-Key'] = json.merchant_ref;
  const res = await fetch(base + path, { method, headers, body: body || undefined });
  return { status: res.status, data: await res.json() };
}
```

### Sample: Python

```python
import hashlib, hmac, json, os, secrets, time, requests

def paynex(method, path, payload=None):
    base, key_id, secret = os.environ["PAYNEX_BASE_URL"], os.environ["PAYNEX_KEY_ID"], os.environ["PAYNEX_SECRET"]
    body = json.dumps(payload, separators=(",", ":")) if payload is not None else ""
    ts, nonce = str(int(time.time())), secrets.token_hex(16)
    sign_path = path.split("?")[0]  # sign the path only, never the query string
    msg = f"{ts}\n{nonce}\n{method.upper()}\n{sign_path}\n{hashlib.sha256(body.encode()).hexdigest()}"
    sig = hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest()
    headers = {"Authorization": f"Paynex-HMAC key_id={key_id}", "X-PG-Timestamp": ts,
               "X-PG-Nonce": nonce, "X-PG-Signature": sig, "Content-Type": "application/json"}
    if method.upper() == "POST" and path == "/v1/payments":
        headers["Idempotency-Key"] = payload["merchant_ref"]
    r = requests.request(method, base + path, headers=headers, data=body or None, timeout=20)
    return r.status_code, r.json()
```

### Sample: curl (bash)

```bash
#!/bin/bash
# usage: pgcall.sh METHOD PATH [JSON] [IDEMPOTENCY_KEY]
BASE="https://paynex.xyz"; KEY_ID="pk_…"; SECRET="sk_…"
METHOD="$1"; PATH_="$2"; BODY="${3:-}"
TS=$(date +%s); NONCE=$(openssl rand -hex 16)
BODY_SHA=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
SIGN_PATH="${PATH_%%\?*}"   # sign the path only, never the query string
SIG=$(printf '%s\n%s\n%s\n%s\n%s' "$TS" "$NONCE" "$METHOD" "$SIGN_PATH" "$BODY_SHA" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -s -X "$METHOD" "$BASE$PATH_" \
  -H "Authorization: Paynex-HMAC key_id=$KEY_ID" -H "X-PG-Timestamp: $TS" \
  -H "X-PG-Nonce: $NONCE" -H "X-PG-Signature: $SIG" -H "Content-Type: application/json" \
  ${4:+-H "Idempotency-Key: $4"} ${BODY:+-d "$BODY"}
```

### Key scopes

A key only works for the scopes it was created with:

| Scope | Allows |
|---|---|
| `payments:create` | `POST /v1/payments` |
| `payments:read` | `GET /v1/payments`, `GET /v1/payments/{id}`, `POST …/verify`, `GET /v1/refunds/{id}` |
| `payments:cancel` | `POST /v1/payments/{id}/cancel` |
| `refunds:create` | `POST /v1/refunds` |

`GET /v1/store` needs a valid key but no specific scope. A missing scope returns `403 forbidden`.

---

## 3. Payments

### Payment object

```json
{
  "id": "92189b87-829f-441d-a2cb-6c21b9aab38d",
  "status": "created",
  "amount": "1500.00",
  "currency": "BDT",
  "merchant_ref": "INV-1042",
  "description": "Hosting renewal",
  "checkout_token": "FLeipf0FGgJ2agDku55a7JhNfoCeWSmD",
  "checkout_url": "https://paynex.xyz/c/FLeipf0FGgJ2agDku55a7JhNfoCeWSmD",
  "provider_txn_id": null,
  "customer": { "name": "Md Samiul Alam", "email": "s@example.com", "phone": "01712345678" },
  "metadata": { "order_id": 1042 },
  "expires_at": "2026-09-06T00:23:35+06:00",
  "paid_at": null,
  "created_at": "2026-09-05T23:23:35+06:00",
  "attempts": [ { "id": "…", "provider_account_id": "…", "status": "redirected", "provider_ref": null, "started_at": "…" } ]
}
```

`checkout_token` / `checkout_url` are returned by **create** and **get** (not in list results). `attempts` is only returned by **get**.

### Create a payment — `POST /v1/payments`

Scope `payments:create`. Header `Idempotency-Key` **required**.

| Field | Type | Required | Notes |
|---|---|---|---|
| `amount` | string / number | yes | `> 0`, up to 2 decimals. Send as a string to avoid float issues. |
| `currency` | string | no | ISO code, default = store's default currency (`BDT`). |
| `merchant_ref` | string | recommended | Your invoice / order number. Shown to the customer and in admin; searchable. |
| `description` | string | no | Shown on the checkout page. |
| `customer` | object | no | `name`, `email`, `phone` are shown on checkout and the customer may correct them. Any other keys are stored as-is. |
> **Method fee.** The payment method's fee (set per method in the admin) is paid by the **customer** on top of `amount`. Once the customer picks a method, the payment carries `fee` and `total_charged` (= `amount` + `fee`; BDT totals are rounded to whole taka, and the fee absorbs the rounding). The store is always credited the full `amount`; a full refund returns `total_charged`.

| `metadata` | object | no | Free-form JSON, returned unchanged. `invoice_number` (or `invoice_no` / `invoice`) is shown to the customer on the checkout page as **Invoice**, instead of `merchant_ref`. |
| `return_url` | string | yes | Where the customer lands after a **successful** payment. Host must be in the store's allowed domains — or a mobile deep link (`myapp://paynex/return`) whose scheme is registered in the store's **Mobile app URL schemes**. |
| `cancel_url` | string | yes | Where the customer lands after cancelling / failure / expiry. Same rule. |
| `expires_in` | integer seconds | no | Default 1800 (30 min), max 86400. |

Responses: `201` new payment · `200` same `Idempotency-Key` seen before, existing payment returned · `400` malformed · `422` validation (e.g. domain not allowed).

### Get a payment — `GET /v1/payments/{id}`

Scope `payments:read`. Returns the full object including `attempts`. This is the **source of truth**: always confirm `status == "paid"` here before delivering goods.

### List payments — `GET /v1/payments?status=&merchant_ref=&limit=&cursor=`

Scope `payments:read`. Newest first. `limit` 1–100 (default 50). Response:

```json
{ "data": [ …payment objects without checkout_url… ], "next_cursor": "2026-09-05T23:23:35.667561+06:00" }
```

Pass `next_cursor` back as `cursor` to get the next page. Empty `data` means you reached the end.

### Cancel — `POST /v1/payments/{id}/cancel`

Scope `payments:cancel`. Only while the payment is still open (`created`, `redirected`, `pending`). Returns the updated payment; `409 conflict` if it cannot be cancelled anymore.

### Verify — `POST /v1/payments/{id}/verify`

Scope `payments:read`. Forces Paynex to re-check the payment with the provider right now and returns the current payment. Useful if a webhook seems late. Never marks a bank-transfer payment paid — those are confirmed by the store's staff in the review queue.

---

## 4. Refunds

### Create — `POST /v1/refunds`

Scope `refunds:create`.

```json
{ "payment_id": "92189b87-…", "amount": "500.00", "reason": "Customer request" }
```

- Only `paid` or `partially_refunded` payments can be refunded.
- Omit `amount` for a full refund of the remaining balance. Partial refunds are allowed; the sum cannot exceed the payment amount.
- Response `202 Accepted`: `{ "id", "payment_id", "amount", "currency", "status" }`.
- `status` is `succeeded`, `requested` (provider does refunds manually, e.g. bank transfer — the store's staff completes it) or `failed`.
- You receive `refund.succeeded` / `refund.failed` webhooks when the result is known.

### Get — `GET /v1/refunds/{id}`

Scope `payments:read`. Returns `id, payment_id, amount, currency, status, reason, provider_refund_id, created_at`.

---

## 5. Store — `GET /v1/store`

Returns your store's slug, name, default currency and the enabled payment methods. Useful if you build your own method picker before redirecting.

```json
{ "slug": "demo", "name": "Demo Store", "default_currency": "BDT",
  "methods": [ { "id": "4e5b…", "provider": "bkash", "label": "bKash", "mode": "live" } ] }
```

Providers: `bkash`, `nagad`, `rocket`, `upay`, `sslcommerz`, `aamarpay`, `stripe`, `bank` (manual transfer with receipt upload), `banglaqr` (Bangla QR shown on checkout, receipt upload), `crypto` (USDT TRC20/BEP20/ERC20, BTC, ETH — unique deposit address per payment, confirmed on-chain automatically), `sandbox` (test).

---

## 6. Hosted checkout & the return redirect

Send the customer to `checkout_url`. They pick a method, pay, and Paynex redirects them:

- **Paid** → `return_url` with signed query parameters:
  `?pg_ref=<payment id>&pg_status=paid&pg_ts=<unix>&pg_sig=<hex>`
  where `pg_sig = hex(HMAC-SHA256(webhook_secret, pg_ref + "." + pg_status + "." + pg_ts))`.
- **Cancelled / failed / expired** → `cancel_url` (no signature).
- **Bank transfer / Bangla QR receipt submitted** (awaiting staff confirmation) → the customer stays on a "receipt submitted" page with a link back to `return_url`. The payment is `needs_review` until approved.

Use the return page for UX only ("Thanks, we're confirming your payment…"). **Never mark an order paid from the redirect** — use the webhook + `GET /v1/payments/{id}`. The signature lets you safely show a success message; verify it and check that `pg_ts` is recent.

The checkout link stays valid until `expires_at`. A failed attempt does **not** kill the payment: the customer can retry another method on the same link.

---

## 7. Webhooks

Set a **Webhook URL** in store settings. Paynex sends `POST` requests with a JSON body:

```
POST {webhook_url}
Content-Type: application/json
X-PG-Event: payment.paid
X-PG-Delivery: 5a4c…            (unique per delivery; use for de-duplication)
X-PG-Signature: t=1757092800,v1=8f3e…
```

Payload (payment events):

```json
{ "event": "payment.paid",
  "data": { "id": "92189b87-…", "status": "paid", "amount": "1500.00", "currency": "BDT",
            "merchant_ref": "INV-1042", "provider_txn_id": "TRX9A2K…", "paid_at": "2026-09-05T23:40:12+06:00" } }
```

Payload (refund events): `data` = `{ "id", "payment_id", "amount", "currency", "status" }`.

### Events

| Event | When |
|---|---|
| `payment.paid` | Money confirmed. **Deliver the order.** |
| `payment.needs_review` | Bank-transfer receipt submitted, awaiting staff approval (or a review rule triggered). |
| `payment.failed` | Rejected in review, or provider reported failure with no retry possible. |
| `payment.cancelled` | Customer or API cancelled. |
| `payment.expired` | Link expired unpaid. |
| `refund.succeeded` / `refund.failed` | Refund result. |

### Verifying the signature

```
expected = hex( HMAC-SHA256( webhook_secret, t + "." + raw_body ) )
```

Compare `expected` with `v1` using a constant-time comparison, and reject if `t` is older than ~5 minutes.

```php
[$t, $v1] = [null, null];
foreach (explode(',', $_SERVER['HTTP_X_PG_SIGNATURE'] ?? '') as $part) {
    [$k, $v] = explode('=', $part, 2) + [null, null];
    if ($k === 't') $t = $v; if ($k === 'v1') $v1 = $v;
}
$raw = file_get_contents('php://input');
$ok  = $t && $v1 && abs(time() - (int)$t) < 300
    && hash_equals(hash_hmac('sha256', $t . '.' . $raw, $webhookSecret), $v1);
if (!$ok) { http_response_code(401); exit; }
$evt = json_decode($raw, true);
// Then: [$code, $p] = paynexRequest('GET', '/v1/payments/' . $evt['data']['id']);
// Only if $p['status'] === 'paid' → mark the order paid (idempotently, keyed by $p['id']).
http_response_code(200);
```

### Delivery rules

- Respond with any `2xx` within 30 s. Anything else is retried: **1 m, 5 m, 30 m, 2 h, 12 h, 24 h**, then given up and flagged in admin (staff can redeliver manually).
- Deliveries may arrive **more than once** or **out of order**. Make your handler idempotent (store `X-PG-Delivery` or the payment id).
- **Receiver contract:** verify the signature → `GET /v1/payments/{id}` → trust only that response.

---

## 8. Payment lifecycle

```
created ──► redirected ──► pending ──► paid ──► partially_refunded / refunded
   │             │            │
   │             │            └──► needs_review ──► paid (staff approve)
   │             │                       └──────► failed (staff reject)
   │             └──► (attempt failed) ──► created   ← customer can retry
   ├──► cancelled
   └──► expired
```

| Status | Meaning |
|---|---|
| `created` | Payment exists, customer hasn't chosen a method (or last attempt failed and they can retry). |
| `redirected` | Customer chose a method and was sent to the provider / bank page. |
| `pending` | Provider hasn't confirmed yet; Paynex re-checks automatically. |
| `needs_review` | Awaiting staff decision (bank receipts, or a review rule). |
| `approved` | Staff approved, finalising. Transient. |
| `paid` | **Final.** Money confirmed. |
| `partially_refunded` / `refunded` | Paid, then refunded in part / full. |
| `failed` | Final. Rejected or provider failure. |
| `cancelled` | Final. |
| `expired` | Final. Link expired unpaid. |

---

## 9. Errors

All errors share one shape:

```json
{ "error": { "code": "invalid_request", "message": "return_url: host \"x.com\" is not in the store's allowed domains" } }
```

| HTTP | `code` | Typical cause |
|---|---|---|
| 400 | `invalid_request` | Bad JSON, missing `Idempotency-Key`, bad amount / URLs. |
| 401 | `unauthorized` | Missing headers, bad signature, timestamp outside ±300 s, nonce replay, revoked key, inactive store. |
| 403 | `forbidden` | Key lacks the required scope. |
| 404 | `not_found` | Payment / refund not found **or belongs to another store**. |
| 409 | `conflict` | Illegal state change (e.g. cancelling a paid payment). |
| 413 | `too_large` | Body over 1 MB. |
| 422 | `invalid_request` | Semantic validation failed (domain, refund exceeds balance, …). |
| 502 | `provider_error` | Upstream provider error during verify. |
| 500 | `internal` | Retry later. |

**Signature debugging checklist:** upper-case method · path includes `/v1` and excludes `?query` · `\n` (LF) separators, no trailing newline · sha256 of the **exact** bytes sent · hex lower-case · clock within 5 minutes · fresh nonce per request.

---

## 10. Test (sandbox) mode

Keys created in **sandbox** mode (`pk_sandbox_…`) work exactly like live keys. If the store has the **Sandbox Pay** method enabled, the checkout shows a simulator where you choose "success" or "failure" — no real money moves, and the full flow (webhook, verify, refund) runs. The `sandbox` provider is marked `"mode": "sandbox"` in `GET /v1/store`.

---

## 11. Building a plugin / module (e.g. WHMCS)

A typical gateway module needs only three pieces:

1. **Payment link** — on invoice view, call `POST /v1/payments` with `Idempotency-Key = "<invoice id>-<amount>"`, `merchant_ref = invoice id`, `return_url` = invoice page, `cancel_url` = invoice page. Redirect to `checkout_url`. Because of idempotency, refreshing the page reuses the same payment.
2. **Callback** — a public URL that receives the webhook: verify `X-PG-Signature`, `GET /v1/payments/{id}`, if `status == "paid"` and `provider_txn_id` (or the payment `id`) has not been applied before, add the payment to the invoice. Respond `200`.
3. **Refund** — `POST /v1/refunds` with `payment_id` (store it on the transaction when you apply the payment) and the amount.

Store the Paynex payment `id` on your order/transaction so callbacks and refunds can find it. Never rely on the customer's return redirect to record money.

---

## 12. Mobile apps (Android / iOS / Flutter / React Native)

The API key **never** goes into the app. The app talks to *your* backend; your backend talks to Paynex.

```
app ──POST /api/pay──▶ your backend ──POST /v1/payments──▶ Paynex
app ◀── checkout_url ── your backend ◀── checkout_url ──── Paynex
app opens checkout_url in an in-app browser (Chrome Custom Tabs / SFSafariViewController — not a WebView)
customer pays → Paynex redirects to return_url = myapp://paynex/return?pg_ref=…&pg_status=paid&pg_ts=…&pg_sig=…
app receives the deep link → asks your backend → backend GET /v1/payments/{id} (or the webhook already marked it paid)
```

1. **Register the scheme** — store settings → Domains → *Mobile app URL schemes* (e.g. `myapp`). Then `return_url` / `cancel_url` may be `myapp://…`. Universal Links / App Links (`https://yourdomain.com/pay/return`) also work with no extra setup because they are normal https URLs on a store domain — prefer them in production.
2. **Open the checkout in an in-app browser**, not an embedded WebView (bKash/Nagad/bank apps and 3-D Secure pages expect a real browser; the redirect to your scheme also works reliably there).
3. **Handle the deep link** — show "confirming…", then ask your backend. Optionally verify `pg_sig` (`hex(HMAC-SHA256(webhook_secret, pg_ref.pg_status.pg_ts))`) on the backend for an instant success screen; never mark the order paid from it.
4. **Webhook on the backend** marks the order paid (§7). The app can also poll a public, token-scoped JSON status while the browser is open:

```
GET {checkout_url}/status      (no auth — the checkout token is the secret)
{ "id": "…", "status": "paid", "amount": "1500.00", "currency": "BDT", "merchant_ref": "INV-1042",
  "paid_at": "2026-09-12T18:02:11+06:00", "expires_at": "…", "redirect": "myapp://paynex/return?pg_ref=…" }
```

`status` values are the payment lifecycle states (§8). This endpoint is for UX only.

**HTTPS is mandatory for apps** — iOS App Transport Security and Android 9+ block plain `http://` checkout URLs.

Ready-made client code for the backend side: see §13.

---

## 13. SDKs

Server-side clients that sign requests, verify webhooks and the return redirect. Each is dependency-free and a single file you can vendor.

| Language | Download | Notes |
|---|---|---|
| Go | [paynex-go.zip](https://paynex.xyz/docs/sdk/paynex-go.zip) | `paynex.New(base, keyID, secret)` · `WebhookHandler` for net/http |
| Node.js 18+ | [paynex-node.zip](https://paynex.xyz/docs/sdk/paynex-node.zip) | CommonJS + `.d.ts` · Express `webhookHandler` |
| Dart 3 | [paynex-dart.zip](https://paynex.xyz/docs/sdk/paynex-dart.zip) | `dart:io` client + README with the Flutter side (url_launcher + app_links) |
| PHP 7.4+ | [paynex-php.zip](https://paynex.xyz/docs/sdk/paynex-php.zip) | single `Paynex.php`, cURL · WHMCS / Laravel / WordPress |

Browse the sources at [https://paynex.xyz/docs/sdk](https://paynex.xyz/docs/sdk). All SDKs expose the same surface: `createPayment` (idempotency key required), `getPayment`, `listPayments`, `cancelPayment`, `verifyPayment`, `createRefund`, `getRefund`, `getStore`, `verifyWebhook`, `verifyReturn`.

---

## 14. Reference

| Endpoint | Scope | Purpose |
|---|---|---|
| `GET /v1/health` | none | Liveness: `{"status":"ok"}` |
| `POST /v1/payments` | `payments:create` | Create payment, get `checkout_url` |
| `GET /v1/payments` | `payments:read` | List / search |
| `GET /v1/payments/{id}` | `payments:read` | Full detail (source of truth) |
| `POST /v1/payments/{id}/cancel` | `payments:cancel` | Cancel open payment |
| `POST /v1/payments/{id}/verify` | `payments:read` | Force provider re-check |
| `POST /v1/refunds` | `refunds:create` | Full / partial refund |
| `GET /v1/refunds/{id}` | `payments:read` | Refund status |
| `GET /v1/store` | any key | Store info + enabled methods |

Time values are RFC 3339 with timezone. IDs are UUIDs. All requests and webhooks are JSON, UTF-8.
