# Account and team

Everything about your account (team, stores, settings, the webhook's signing
secret, balances and payouts) is managed through the **dashboard API** under
`/merchant`, signed in as a person. Your servers only ever need an API key
and the `/v1` routes.

Every route on this page except signup, login and refresh takes an access
token from [sign-in](/docs/authentication#dashboard-access-tokens):

```
Authorization: Bearer <access_token>
```

Routes that read test or live data take `?mode=test` for test data; without
it they return **live** data.

| Section | Routes |
|---|---|
| [Signing up and in](#signing-up-and-in) | signup, login, refresh, logout, me, password |
| [Team](#team) | `/merchant/users` |
| [Stores](#stores) | `/merchant/stores` |
| [Payment settings](#payment-settings) | `/merchant/settings`, `/merchant/tokens` |
| [Live mode](#live-mode) | `/merchant/live` |
| [Webhook](#webhook) | `/merchant/webhook`, `/merchant/webhook/roll-secret`, `/merchant/webhooks/deliveries` |
| [Payments and dashboard](#payments-and-dashboard) | `/merchant/dashboard`, `/merchant/payments`, events, trail |
| [Audit log](#audit-log) | `/merchant/audit` |
| [Balances and payouts](#balances-and-payouts) | balances, ledger, payout addresses, payouts |
| [Support chat](#support-chat) | `/merchant/support` |
| [Referral program](#referral-program) | `/merchant/affiliate`, `/merchant/affiliate/payouts`, `/referrals/{code}/click` |
| [Real-time events](#real-time-events) | socket.io `/merchant` |

API keys are on the [Authentication](/docs/authentication#managing-keys) page.

## Roles

| Role | Can |
|---|---|
| `owner` | Everything: team, settings, live mode, the audit log, rolling the webhook secret, payouts, the referral program |
| `developer` | API keys, stores, the webhook URL and test, redelivering webhooks |
| `finance` | Payout addresses and payouts, the referral program |
| `viewer` | Read payments, the dashboard, balances, the ledger, settlements and webhook deliveries; change nothing |

Every role can read payments, the dashboard, balances, the ledger,
settlements, payouts and webhook deliveries, and write in the
[support chat](#support-chat). A route your role may not use answers
`403 {"error": "insufficient permissions"}`.

## Signing up and in

### Open an account

`POST /merchant/signup` · no authentication · 5 a second per IP address (a limit of its own: sign-in and checkout have their own)

| Field | Type | Required | Description |
|---|---|---|---|
| `merchant_name` | string | yes | Your business's name |
| `email` | string | yes | The owner's email address, used to sign in |
| `password` | string | yes | At least 8 characters |
| `referral_code` | string | no | The code of the referral link that brought you (its `?ref=`), in any case. An unknown or suspended code is ignored: it never stops the signup ([Referral program](#referral-program)) |

```bash
curl -s https://api.payitgo.co/merchant/signup \
  -H 'Content-Type: application/json' \
  -d '{"merchant_name": "Acme Store", "email": "you@acme.example", "password": "a-long-password"}'
```

`201 Created`. It holds your first two API keys, **shown only here**, and
signs you in (an access token, and the refresh cookie):

```json
{
  "merchant": {
    "id": "3f8a1c27b94e05d6a2c71e90",
    "name": "Acme Store",
    "status": "active",
    "fee_bps": 100,
    "live_enabled": false,
    "underpaid_tolerance_bps": 50,
    "accept_other_assets": false,
    "settlement": "direct",
    "fees_paid_by_customer": true,
    "convert_into": "cheapest",
    "hide_below_minimum": false,
    "enabled_tokens": ["USDT_TRC20", "TRX", "ETH", "USDT_ERC20", "USDC_ERC20", "BNB", "USDT_BEP20", "USDC_BEP20", "BTC", "LTC", "DOGE", "XRP"],
    "created_at": "2026-10-10T08:57:25.902774252Z",
    "updated_at": "2026-10-10T08:57:25.902774252Z"
  },
  "user": {
    "id": "9b2e4c71d8a05f3e6c1a7b24",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "email": "you@acme.example",
    "role": "owner",
    "created_at": "2026-10-10T08:57:25.902774252Z"
  },
  "api_keys": [
    {
      "api_key": { "id": "4f2a9c1e7b3d5a08c6e1d7b2", "merchant_id": "3f8a1c27b94e05d6a2c71e90", "livemode": false, "label": "default", "last4": "5a8f", "created_by": "9b2e4c71d8a05f3e6c1a7b24", "created_at": "2026-10-10T08:57:25.904339745Z" },
      "secret": "4f9c2a7e1b8d3f6a0c5e9b2d7a4f1c8e3b6d9a2f5c8e1b4d7a0f3c6e9b2d5a8f"
    },
    {
      "api_key": { "id": "8d3b6e2f1a4c9e07b5d2a8c4", "merchant_id": "3f8a1c27b94e05d6a2c71e90", "livemode": true, "label": "default", "last4": "e0f5", "created_by": "9b2e4c71d8a05f3e6c1a7b24", "created_at": "2026-10-10T08:57:25.905901832Z" },
      "secret": "ea2bc4d2247c7159eec631a51a086a4a6bbd156cedb9982ea99510c5a9f3e0f5"
    }
  ],
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJraW5k…",
  "expires_at": "2026-10-10T09:12:25.907004586Z",
  "role": "owner"
}
```

| Status | Message | Cause |
|---|---|---|
| `400` | `merchant_name is required` · `invalid email address` · `password must be at least 8 characters` | A field is missing or invalid |
| `409` | `email already registered` | That email already has an account |
| `429` | `rate limit exceeded` | Too many attempts from your IP address |

#### The merchant object

| Field | Type | Description |
|---|---|---|
| `id` | string | Your account id |
| `name` | string | Your business's name |
| `status` | string | `active`, or `suspended` by payitgo (no API calls, no sign-ins, no payments) |
| `fee_bps` | integer | payitgo's service fee in basis points (100 = 1%), agreed with your account |
| `live_enabled` | boolean | Whether live mode is on ([Live mode](#live-mode)) |
| `underpaid_tolerance_bps` | integer | How far short a payment may fall and still count as paid, in basis points |
| `accept_other_assets` | boolean | Whether a deposit in another accepted coin counts toward a payment |
| `settlement` | string | `direct`, `custodial` or `scheduled` |
| `payout_schedule` | string | Scheduled settlement: `instant`, `daily` or `weekly` (Mondays) |
| `payout_scheduled_at` | string, optional | Scheduled settlement: when your balance was last paid out |
| `fees_paid_by_customer` | boolean | Whether customers pay the fees on top of your price (on for a new account) |
| `convert_into` | string | Direct settlement: what a coin you have no payout address for is converted into: `cheapest` (the default), `order`, or your preferred payout coin ([which coin](/docs/settlement#which-payout-coin)) |
| `hide_below_minimum` | boolean | Whether a coin converted across networks is left out of a payment too small for the exchange's minimum, rather than the customer paying up to it ([minimums](/docs/settlement#minimums)) |
| `enabled_tokens` | array of strings | The coins you accept |
| `created_at`, `updated_at` | string | When the account was opened and last changed |

[Payment settings](#payment-settings) explains each setting.

#### The user object

| Field | Type | Description |
|---|---|---|
| `id` | string | The team member's id |
| `merchant_id` | string | Your account |
| `email` | string | Their sign-in email |
| `role` | string | `owner`, `developer`, `finance` or `viewer` |
| `created_at` | string | When they were added |
| `last_login_at` | string, optional | Their last sign-in |

### Sign in, refresh, sign out

`POST /merchant/login`, `POST /merchant/auth/refresh`,
`POST /merchant/auth/logout` and `POST /merchant/auth/logout-all` are described,
with their responses and errors, on the
[Authentication](/docs/authentication#dashboard-access-tokens) page.

### Who am I

`GET /merchant/me` · any role

```bash
curl -s https://api.payitgo.co/merchant/me -H "Authorization: Bearer $ACCESS_TOKEN"
```

`200 OK`: `{"merchant": {…}, "user": {…}}`, the [merchant](#the-merchant-object)
with its current settings and the signed-in [user](#the-user-object).

### Change your password

`POST /merchant/me/password` · any role

| Field | Type | Required | Description |
|---|---|---|---|
| `current_password` | string | yes | Your password now |
| `new_password` | string | yes | At least 8 characters |

```bash
curl -s https://api.payitgo.co/merchant/me/password \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"current_password": "a-long-password", "new_password": "an-even-longer-password"}'
```

`200 OK`: `{"message": "password changed; sign in again"}`. Every session of
yours ends.

| Status | Message | Cause |
|---|---|---|
| `400` | `current_password and new_password are required` | A field is missing |
| `400` | `password must be at least 8 characters` | `new_password` is too short |
| `403` | `current password is incorrect` | Wrong `current_password` |

## Team

Invite colleagues with the role they need. Each person signs in with their
own email and password. All four routes are **owner** only.

| Method | Path | Body | Response |
|---|---|---|---|
| `GET` | `/merchant/users` | | `200 {"users": [user, …]}` |
| `POST` | `/merchant/users` | `email`, `password`, `role` | `201 {"user": user}` |
| `PATCH` | `/merchant/users/{id}` | `role` | `200 {"user": user}` |
| `DELETE` | `/merchant/users/{id}` | | `200 {"message": "user removed"}` |

```bash
curl -s https://api.payitgo.co/merchant/users \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"email": "dev@acme.example", "password": "a-first-password", "role": "developer"}'
```

`201 Created`:

```json
{
  "user": {
    "id": "5e8a2c9d1f4b7036a2e9c1d8",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "email": "dev@acme.example",
    "role": "developer",
    "created_at": "2026-10-10T08:59:43.427614842Z"
  }
}
```

The owner sets a first password and hands it over; the new member changes it
with `/merchant/me/password`. Changing a role or removing someone ends their
sessions (an access token already issued stays valid for up to 15 minutes).

| Status | Message | Cause |
|---|---|---|
| `400` | `invalid email address` · `password must be at least 8 characters` | Invalid new member |
| `400` | `role must be one of owner, developer, finance, viewer` | Unknown role |
| `404` | `not found` | No such member in your account |
| `409` | `email already registered` | That email already belongs to someone |
| `409` | `the account must keep at least one owner` | Demoting or removing the last owner |

## Stores

Selling from more than one shop (several websites, an app and a website, a few
branches)? Create a store for each and give each its own API keys. Every
payment a store's key creates carries the store's `store_id`, and the store's
backend can send its notifications to its own URL with
[`webhook_url`](/docs/webhooks#per-payment-webhook-url). A merchant with one
shop needs no stores.

| Method | Path | Role | Body | Response |
|---|---|---|---|---|
| `GET` | `/merchant/stores` | any | | `200 {"stores": [store, …]}` |
| `POST` | `/merchant/stores` | owner, developer | `name` (1 to 100 characters) | `201 {"store": store}` |
| `PATCH` | `/merchant/stores/{id}` | owner, developer | `name` | `200 {"store": store}` |

```bash
curl -s https://api.payitgo.co/merchant/stores \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name": "Acme EU shop"}'
```

`201 Created`:

```json
{
  "store": {
    "id": "6d2a9e4c1b7f30a58e2c4d91",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "name": "Acme EU shop",
    "created_at": "2026-10-10T08:59:52.920128021Z",
    "updated_at": "2026-10-10T08:59:52.920128021Z"
  }
}
```

Then give it a key: `POST /merchant/api-keys?mode=test {"label": "eu-shop", "store_id": "6d2a9e4c1b7f30a58e2c4d91"}`
([Create a key](/docs/authentication#create-a-key)). Errors:
`400 name is required (up to 100 characters)`, `404 not found`.

**Each store has its own order ids.** An `order_id` is unique within a store:
a store's key creates and finds its own store's orders only, and a key with
no store those made with no store's key. Two shops on one account can both
number their orders from 1: give each the key of its own store, and their
`order_id`s never meet ([safe retries](/docs/payments#safe-retries)).

## Payment settings

`PATCH /merchant/settings` · owner

Send only the settings you change. A payment keeps the settings in force when
it was created.

| Field | Type | Default | Description |
|---|---|---|---|
| `underpaid_tolerance_bps` | integer | `50` (0.5%) | How far short a payment may fall and still count as paid, 0 to 2000 (20%) ([wrong amount](/docs/payments#when-the-customer-pays-the-wrong-amount)) |
| `accept_other_assets` | boolean | `false` | Whether a deposit in another accepted coin counts toward the payment at its dollar value ([wrong asset](/docs/payments#when-the-customer-pays-with-the-wrong-asset)) |
| `settlement` | string | `direct` | `direct` (your payout address for the coin, or converted into one of your payout coins when you have none ready for it; your balance when it cannot be), `custodial` (your balance, in USDT on Tron) or `scheduled` (your balance, paid out to you on a schedule) ([where the money goes](/docs/settlement#where-the-money-goes)) |
| `payout_schedule` | string | `daily` | Scheduled settlement: `instant` (a payout as soon as each payment is credited), `daily`, or `weekly` (on Mondays) |
| `fees_paid_by_customer` | boolean | `true` | Whether the customer pays the service and network fees on top of your price; off, they come out of what you receive ([passing fees on](/docs/settlement#passing-the-fees-on-to-the-customer)) |
| `convert_into` | string | `cheapest` | Direct settlement: what a coin you have no payout address for is converted into. `cheapest`: whichever of your payout coins costs the least at each payment's amount; `order`: the first of a fixed order; a coin (`BTC`, `USDT_BEP20`…, any coin you have a payout address for): your preferred payout coin, used whenever a payment can be converted into it ([which coin](/docs/settlement#which-payout-coin)) |
| `hide_below_minimum` | boolean | `false` | Direct settlement, with the fees passed on: `false` offers a coin converted across networks even for a payment too small for the exchange's minimum, the customer paying up to it (you receive the extra, `extra_usd`); `true` offers it only from the price that reaches the minimum ([minimums](/docs/settlement#minimums)). When you pay the fees yourself, such a coin is always offered only from that price |
| `enabled_tokens` | array of strings | every token | The coins you accept, at least one ([accepted tokens](#accepted-tokens)) |

```bash
curl -s -X PATCH https://api.payitgo.co/merchant/settings \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"underpaid_tolerance_bps": 100, "enabled_tokens": ["USDT_TRC20", "USDT_BEP20", "USDC_BEP20", "TRX"]}'
```

`200 OK`: `{"merchant": {…}}`, the [merchant object](#the-merchant-object)
with the new settings.

| Status | Message | Cause |
|---|---|---|
| `400` | `nothing to update` | An empty body |
| `400` | `underpaid_tolerance_bps must be between 0 and 2000` | Out of range |
| `400` | `settlement must be "custodial", "direct" or "scheduled"` | Unknown mode |
| `400` | `unsupported token: SHIB` | An unknown entry in `enabled_tokens` |
| `400` | `enabled_tokens must enable at least one token` | An empty list |
| `403` | `insufficient permissions` | Not the owner |

### Accepted tokens

`enabled_tokens` is the list of coins you accept, such as
`["USDT_TRC20", "USDT_BEP20", "USDC_BEP20"]`. Only those are offered to your
customers, listed by `GET /v1/tokens`, and allowed in a payment's `token` or
`tokens` (others get `400 not enabled for your account: …`). A deposit in a
coin you have not enabled still reaches you, but never counts toward the
payment. Enabling every token also accepts tokens payitgo adds later.

`GET /merchant/tokens?mode=test` · any role

Every supported token, marked enabled or not, with where its payments go and
its fee and minimum right now: what a settings screen shows. Without
`mode=test`, the live ones.

```bash
curl -s 'https://api.payitgo.co/merchant/tokens' -H "Authorization: Bearer $ACCESS_TOKEN"
```

`200 OK` (two of the twelve tokens shown; direct settlement, with a live
payout address for USDT on Tron only, so BTC is converted into it):

```json
{
  "tokens": [
    { "token": "USDT_TRC20", "chain": "tron", "min_usd": "6.44", "network_fee_usd": "3.22", "fee_note": "Final in about a minute.", "enabled": true, "settles_to": "wallet" },
    { "token": "BTC", "chain": "bitcoin", "min_usd": "13.2", "network_fee_usd": "6.6", "network_fee_bps": 180, "fee_note": "Final in about 30 minutes (3 confirmations).", "enabled": false, "settles_to": "wallet", "paid_out_in": "USDT_TRC20" }
  ]
}
```

Each entry is a [TokenInfo](/docs/payments#the-tokeninfo-object) with three
more fields:

| Field | Type | Description |
|---|---|---|
| `enabled` | boolean | Whether you accept it |
| `settles_to` | string | `wallet` (direct settlement, with a payout address for it, in use or still in its 24 hours, or one in use for the coin it is converted into) or `balance` (custody or scheduled settlement, or a coin that converts into none of your payout coins, such as any coin without a test payout address in test mode) |
| `paid_out_in` | string, optional | The payout coin a payment in it is converted into on its way to your wallet, when you have no payout address for it ([coins you have no payout address for](/docs/settlement#coins-you-have-no-payout-address-for)). Absent when it is paid out as it is (an address still in its 24 hours included: its payments wait for it), or kept in your balance |

## Live mode

`POST /merchant/live` · owner

| Field | Type | Required | Description |
|---|---|---|---|
| `enabled` | boolean | yes | `true` turns live mode on, `false` off |

```bash
curl -s https://api.payitgo.co/merchant/live \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"enabled": true}'
```

`200 OK`: `{"merchant": {…, "live_enabled": true, …}}`. From now on the live
key works. Turning it off stops new live payments; open ones still complete.
`400 {"error": "enabled (true or false) is required"}` when `enabled` is
missing.

## Webhook

Each mode has one webhook: a URL and a signing secret
([Webhooks](/docs/webhooks)). Your server manages the URL with its API key
(`/v1/webhook`); people do it here, choosing the mode with `?mode=test`
(default live). Rolling the secret is only possible here.

| Method | Path | Role | Response |
|---|---|---|---|
| `GET` | `/merchant/webhook?mode=test` | owner, developer | `200 {"webhook": Webhook}` |
| `PUT` | `/merchant/webhook?mode=test` | owner, developer | `200 {"webhook": Webhook}` |
| `DELETE` | `/merchant/webhook?mode=test` | owner, developer | `200 {"webhook": Webhook}` with `"url": null` |
| `POST` | `/merchant/webhook/test?mode=test` | owner, developer | `202 {"delivery": Delivery}` |
| `POST` | `/merchant/webhook/roll-secret?mode=test` | owner | `200 {"webhook": Webhook}` with a new `secret` |
| `GET` | `/merchant/webhooks/deliveries?mode=test` | any | `200 {"deliveries": [Delivery, …]}` |
| `GET` | `/merchant/webhooks/deliveries/{id}` | any | `200 {"delivery": Delivery}` |
| `POST` | `/merchant/webhooks/deliveries/{id}/redeliver` | owner, developer | `202 {"message": "redelivery queued"}` |

The first five behave exactly like their [`/v1/webhook`](/docs/webhooks#the-webhook-api)
counterparts, with the same body (`{"url": "https://…"}` for `PUT`) and the
same errors. The objects are described there:
[Webhook](/docs/webhooks#the-webhook-object) and
[Delivery](/docs/webhooks#the-delivery-object).

### Roll the signing secret

`POST /merchant/webhook/roll-secret?mode=test` · owner

Replaces the mode's signing secret. Every notification sent from now on,
retries of older deliveries included, is signed with the new secret. The old
one stops working at once, so have your server ready to accept the new one
(for example by trying both for a few minutes) before you roll it.

```bash
curl -s -X POST 'https://api.payitgo.co/merchant/webhook/roll-secret?mode=test' \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

`200 OK`:

```json
{
  "webhook": {
    "livemode": false,
    "url": "https://acme.example/payitgo/ipn",
    "secret": "b81c…07fa",
    "created_at": "2026-10-10T08:58:44.128Z",
    "updated_at": "2026-10-10T08:59:29.906Z"
  }
}
```

A developer gets `403 {"error": "insufficient permissions"}`.

### Delivery history

`GET /merchant/webhooks/deliveries` · any role

Every notification payitgo sent you (or tried to), newest first, with every
attempt.

| Query parameter | Default | Description |
|---|---|---|
| `mode` | live | `test` for test deliveries |
| `status` | all | `pending`, `sending`, `delivered`, `failed` or `resolved` |
| `limit` | 50 | 1 to 200 |

```bash
curl -s 'https://api.payitgo.co/merchant/webhooks/deliveries?mode=test&status=failed&limit=20' \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

`200 OK`: `{"deliveries": [Delivery, …]}` (no paging beyond `limit`). One
delivery, with its full history: `GET /merchant/webhooks/deliveries/{id}`,
`200 {"delivery": Delivery}` or `404 {"error": "not found"}`. An example is
under [Delivery history and redelivery](/docs/webhooks#delivery-history-and-redelivery).

### Redeliver

`POST /merchant/webhooks/deliveries/{id}/redeliver` · owner, developer

Sends a delivery again from the start (attempts back to 0, seven more tries),
whatever its status: after you fixed your handler, or to replay an event.
It is posted to the account's webhook URL as it is now (or, for a payment's
own `webhook_url`, to that URL), signed with the current secret.

```bash
curl -s -X POST https://api.payitgo.co/merchant/webhooks/deliveries/8e3c1a7f5d9b2e4c6a0f1d3b/redeliver \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

`202 Accepted`:

```json
{"message": "redelivery queued"}
```

An unknown id answers `404 {"error": "not found"}`.

## Payments and dashboard

| Method | Path | Role | Response |
|---|---|---|---|
| `GET` | `/merchant/dashboard?mode=test` | any | Today's figures, and confirmed payments over 7 days, 30 days and all time |
| `GET` | `/merchant/payments?mode=test` | any | Payments, as [`GET /v1/payments`](/docs/payments#list-payments) |
| `GET` | `/merchant/payments/{id}` | any | One [payment](/docs/payments#the-payment-object), by payment id, test or live |
| `GET` | `/merchant/payments/{id}/events` | any | The payment's event timeline |
| `GET` | `/merchant/payments/{id}/trail` | any | Everything about one payment, for a dispute |
| `POST` | `/merchant/payments/{id}/simulate` | owner, developer, finance | Plays an outcome on a test payment ([below](#simulate-a-test-payment)) |

`/merchant/payments` takes the same filters as `GET /v1/payments` (`status`,
`token`, `chain`, `store_id`, `limit`, `offset`) plus `mode`. An unknown
payment answers `404 {"error": "payment not found"}`.

Every list on this page answers the `limit` and `offset` it applied. A `limit`
outside its range (1 to 200; 1 to 500 for the ledger and the audit log) is
replaced by the default (50; 100 for the audit log and a payment's events),
and a negative `offset` by 0.

### Dashboard

```bash
curl -s 'https://api.payitgo.co/merchant/dashboard?mode=test' -H "Authorization: Bearer $ACCESS_TOKEN"
```

`200 OK`:

```json
{
  "livemode": false,
  "confirmed_today": 0,
  "volume_usd_today": "0.00",
  "pending": 4,
  "expired_today": 0,
  "failed_webhooks": 0,
  "by_chain": {},
  "periods": {
    "today":        {"confirmed": 0, "volume_usd": "0.00",    "by_chain": {}},
    "last_7_days":  {"confirmed": 2, "volume_usd": "15.50",   "by_chain": {"tron": "10.00", "bsc": "5.50"}},
    "last_30_days": {"confirmed": 3, "volume_usd": "115.50",  "by_chain": {"tron": "110.00", "bsc": "5.50"}},
    "all_time":     {"confirmed": 4, "volume_usd": "1115.50", "by_chain": {"tron": "110.00", "bsc": "5.50", "bitcoin": "1000.00"}}
  },
  "generated_at": "2026-10-10T09:00:04.863003436Z"
}
```

| Field | Description |
|---|---|
| `livemode` | `false` for the test-mode figures (`?mode=test`) |
| `confirmed_today` | Payments confirmed since midnight UTC |
| `volume_usd_today` | Their total in dollars |
| `pending` | Open payments now: `pending`, `partially_paid` and `detecting` |
| `expired_today` | Expired payments (`status: "expired"`) whose deadline passed since midnight UTC |
| `failed_webhooks` | Deliveries that gave up and wait for a redelivery |
| `by_chain` | The confirmed volume since midnight UTC per network, in dollars (the same window as `volume_usd_today`) |
| `periods` | Confirmed payments over four spans: `today`, `last_7_days`, `last_30_days` and `all_time` (below) |
| `generated_at` | When these figures were computed |

Each of `periods` has:

| Field | Description |
|---|---|
| `confirmed` | How many payments confirmed in the span |
| `volume_usd` | Their total in dollars, each at its own dollar value (`usd_amount`), 2 decimals |
| `by_chain` | That total per network, in dollars. Only networks with a confirmed payment in the span appear |

The spans are calendar days in UTC: `today` since midnight UTC,
`last_7_days` today and the 6 days before it, `last_30_days` today and the 29
before it, `all_time` every payment ever confirmed. Only confirmed payments
count, of the mode asked (`?mode=test` for test mode).
`periods.today` is the same as `confirmed_today`, `volume_usd_today` and
`by_chain`.

### Event timeline

`GET /merchant/payments/{id}/events?limit=100`

Every event the payment raised, oldest first: the same events your webhook
receives, kept even when no URL was set.

```json
{
  "events": [
    {
      "id": "9d4b2f7e1c6a3e08b5d2f1c4",
      "merchant_id": "3f8a1c27b94e05d6a2c71e90",
      "payment_id": "2d9b6e14a7c3f0851e4b9a6c",
      "type": "payment.expired",
      "data": { "id": "2d9b6e14a7c3f0851e4b9a6c", "status": "expired", "…": "…" },
      "created_at": "2026-10-10T09:15:04.117Z"
    }
  ]
}
```

`type` is the [event type](/docs/webhooks#events), and `data` is exactly the `data` of the notification: the [payment](/docs/payments#the-payment-object) (or [settlement](/docs/settlement#following-settlements)) as it was at that moment, with the same field names.

### A payment's trail

`GET /merchant/payments/{id}/trail` gathers everything payitgo recorded about a
payment, for when a customer says they paid, or paid twice, or you need to
show where the money went. The answer is `{"trail": {…}}` with:

| Field | Description |
|---|---|
| `payment` | The [payment](/docs/payments#the-payment-object) |
| `deposits` | Every transfer to it, with its sender, block and time |
| `unmatched_deposits` | Transfers made to its address (or destination tag) after it closed that no payment took |
| `events` | Its event timeline, as [above](#event-timeline) |
| `webhook_deliveries` | Every notification sent about it, with every attempt: when, where, and your server's answer |
| `settlements` | Each deposit's `{"settlement": …, "transfers": […]}`: the [settlement](/docs/settlement#following-settlements) and every transaction that sent you money (and any it replaced) |
| `ledger` | Every [ledger entry](#ledger) it made |
| `audit` | Who did what about it: the API key that created it, the customer's coin choice (with their IP), payitgo's actions |
| `generated_at` | When the trail was gathered |

### Simulate a test payment

`POST /merchant/payments/{id}/simulate` · owner, developer, finance

The dashboard's way to [simulate](/docs/testing#simulate-a-payment) an outcome
on a test payment, from its page in test mode: the same as
`POST /v1/payments/{id}/simulate`, with an access token instead of an API key.
`{id}` is the payment id.

```bash
curl -s -X POST https://api.payitgo.co/merchant/payments/c41e7a9d2b05f3e8a6d17c90/simulate \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"outcome": "confirmed"}'
```

`200 OK` with the [payment](/docs/payments#the-payment-object), with
`"simulation": {"outcome": "confirmed", "state": "running"}`. A live payment
answers `400 only test payments can be simulated: use a test key`;
the outcomes, rules and every error are in [Test mode](/docs/testing#outcomes).
A viewer gets `403 {"error": "insufficient permissions"}`.

## Audit log

`GET /merchant/audit` · owner

The account's record of who did what, newest first and never changed:
sign-ins and failed sign-ins, team and role changes, API keys, the webhook URL
and secret, payout addresses (before and after), settings, payouts, payments
created, your [referrals](#referral-program) (`referral.created`,
`referral.voided`), and payitgo's actions on the account.

| Query parameter | Description |
|---|---|
| `action` | An exact action (`webhook.url_set`) or a prefix ending in a dot (`webhook.`) |
| `subject` | The id of the record acted on |
| `payment_id` | Entries about one payment |
| `actor_id` | Entries by one team member or API key |
| `from`, `to` | Dates, `2026-10-01` |
| `limit`, `offset` | Paging; default 100, up to 500 |

```bash
curl -s 'https://api.payitgo.co/merchant/audit?action=webhook.&limit=2' -H "Authorization: Bearer $ACCESS_TOKEN"
```

`200 OK`:

```json
{
  "entries": [
    {
      "id": "8b2d4f6a1c3e5a7c9e0b2d4f",
      "at": "2026-10-10T09:00:04.734Z",
      "action": "webhook.url_set",
      "actor": {
        "kind": "api_key",
        "id": "4f2a9c1e7b3d5a08c6e1d7b2",
        "merchant_id": "3f8a1c27b94e05d6a2c71e90",
        "ip": "203.0.113.24",
        "user_agent": "curl/8.18.0",
        "request_id": "kT4pWq9ZrX2mNc7bLv5sHd8fJg3yAe6R"
      },
      "merchant_id": "3f8a1c27b94e05d6a2c71e90",
      "subject": "3f8a1c27b94e05d6a2c71e90:test",
      "before": { "url": null },
      "after": { "url": "https://acme.example/payitgo/ipn" }
    },
    {
      "id": "1e3c5a7f9b2d4f6a8c0e1b3d",
      "at": "2026-10-10T08:59:29.907Z",
      "action": "webhook.secret_rolled",
      "actor": {
        "kind": "merchant",
        "id": "9b2e4c71d8a05f3e6c1a7b24",
        "email": "you@acme.example",
        "role": "owner",
        "merchant_id": "3f8a1c27b94e05d6a2c71e90",
        "ip": "203.0.113.24",
        "user_agent": "Mozilla/5.0 …",
        "request_id": "Qm7sVb2nXc9kLp4wZr6tHy3dFg8jAe5N"
      },
      "merchant_id": "3f8a1c27b94e05d6a2c71e90",
      "subject": "3f8a1c27b94e05d6a2c71e90:test",
      "note": "signing secret replaced"
    }
  ],
  "total": 6,
  "limit": 2,
  "offset": 0
}
```

| Field | Description |
|---|---|
| `action` | What happened, `<what>.<verb>`: `webhook.url_set`, `webhook.url_removed`, `webhook.secret_rolled`, `payout_address.updated`, `payment.created`… |
| `actor.kind` | Who: `merchant` (a team member), `api_key`, `customer` (a payer on the checkout), `admin` (payitgo's team, without the person's name), `system` (payitgo itself) or `anonymous` (before sign-in) |
| `actor.ip`, `actor.user_agent`, `actor.request_id` | Where the request came from, and its `X-Request-Id` |
| `subject` | The record acted on (the webhook is `<merchant id>:test` or `:live`) |
| `before`, `after` | The values before and after. Secrets are never recorded |
| `note` | A short explanation |

A bad date answers `400 {"error": "from must be a date: 2006-01-02"}`.

## Balances and payouts

Where a confirmed payment's money goes depends on your
[settlement](/docs/settlement#where-the-money-goes): with direct settlement it
is sent to your payout address; in custody it is credited to your balance, in
USDT on Tron, and you request payouts. With direct settlement, what payitgo
could not send you stays in your balance, and a USDT balance is paid out to
you [automatically](#automatic-payouts) once you have a USDT address on BNB
Smart Chain or Ethereum. Test payments credit your test balance, which is
never paid out. Payouts are live only.

Referral commissions are not part of this balance: they build up in your
[referral balance](#referral-balance-and-payouts), which is paid out on its
own.

| Method | Path | Role |
|---|---|---|
| `GET` | [`/merchant/balances?mode=test`](#balances) | any |
| `GET` | [`/merchant/ledger?mode=test`](#ledger) · `/merchant/ledger.csv` | any |
| `GET` | [`/merchant/settlements?mode=test`](/docs/settlement#following-settlements) | any |
| `GET` `POST` `PATCH` `DELETE` | [`/merchant/payout-addresses`](#payout-addresses) | read: any; change: owner, finance |
| `GET` `POST` | [`/merchant/payouts`](#payouts) | read: any; request: owner, finance |
| `GET` | [`/merchant/payouts/quote?address_id=&amount=`](#quote-a-payout) | owner, finance |
| `POST` | [`/merchant/payouts/{id}/cancel`](#payouts) | owner, finance |

### Balances

`GET /merchant/balances?mode=test`

```bash
curl -s https://api.payitgo.co/merchant/balances -H "Authorization: Bearer $ACCESS_TOKEN"
```

`200 OK`:

```json
{
  "balances": [
    { "token": "USDT_TRC20", "chain": "tron", "available": "1043.27", "pending_payout": "250" }
  ]
}
```

| Field | Type | Description |
|---|---|---|
| `token`, `chain` | string | The coin |
| `available` | decimal string | What you can request a payout of now |
| `pending_payout` | decimal string | What is held for payouts not sent yet, on the balance each one draws on: a payout in USDT on another network counts here (its `from_token`) |

An account with nothing credited yet gets `{"balances": []}`. Referral
commissions are not counted here.

### Ledger

`GET /merchant/ledger?mode=test&token=USDT_TRC20&limit=50&offset=0`

Your statement: every movement on your balance, newest first. Referral
commissions and referral payouts are not in it.

```json
{
  "entries": [
    { "id": "5f446eb968ee2af08be79772", "merchant_id": "3f8a1c27b94e05d6a2c71e90", "type": "network_fee", "token": "USDT_TRC20", "chain": "tron", "amount": "-3.212009", "usd_amount": "-3.212009", "reference": "8f3a2c1e9b7d4a6f05e1c2d3:c19240…51bd", "created_at": "2026-10-10T09:04:03.81Z" },
    { "id": "c921ff34b8f23cd4346464ae", "merchant_id": "3f8a1c27b94e05d6a2c71e90", "type": "fee", "token": "USDT_TRC20", "chain": "tron", "amount": "-0.499", "usd_amount": "-0.499", "reference": "8f3a2c1e9b7d4a6f05e1c2d3:c19240…51bd", "created_at": "2026-10-10T09:04:03.81Z" },
    { "id": "23eb41673da1f63b49577d2b", "merchant_id": "3f8a1c27b94e05d6a2c71e90", "type": "payment_credit", "token": "USDT_TRC20", "chain": "tron", "amount": "49.9", "usd_amount": "49.9", "reference": "8f3a2c1e9b7d4a6f05e1c2d3:c19240…51bd", "created_at": "2026-10-10T09:04:03.81Z" }
  ],
  "total": 3,
  "limit": 50,
  "offset": 0
}
```

#### The ledger entry object

| Field | Type | Description |
|---|---|---|
| `id` | string | The entry's id |
| `merchant_id` | string | Your account |
| `type` | string | What it is (below) |
| `token`, `chain` | string | The coin |
| `amount` | decimal string | Signed: credits positive, debits negative |
| `usd_amount` | decimal string | Its dollar value at the time |
| `reference` | string | `<payment id>:<transaction id>` for a deposit, or the payout id |
| `note` | string, optional | Details, such as what a converted deposit was |
| `created_at` | string | When it was recorded |

| `type` | Meaning |
|---|---|
| `payment_credit` | A final deposit to one of your payments |
| `fee` | payitgo's service fee on that deposit |
| `network_fee` | The network fee of moving that deposit |
| `conversion_fee` | The fee for converting the deposit into another coin |
| `conversion_out` · `conversion_in` | The deposit leaving your balance to be converted (noted `converting into USDT_TRC20` while an exchange converts it), and what the conversion bought coming back |
| `settlement` | Direct settlement: your share leaving for your own wallet (`converted into USDT_TRC20 and sent to your wallet` when it was converted across networks on the way) |
| `settlement_return` | A direct settlement that could not be sent, kept in your balance instead |
| `payout_hold` · `payout_release` | A payout request holding the amount, and a rejected or cancelled payout giving it back, in the balance the payout draws on (its `from_token` when it has one) |
| `adjustment` | A correction made by payitgo's team |

Your share of payitgo's fee on a referred merchant's payment (an
`affiliate_commission`), and the payouts of it, are neither listed here nor
counted in `GET /merchant/balances`. They make your
[referral balance](#referral-balance-and-payouts): its history is
[`GET /merchant/affiliate/commissions`](#commissions) and
`GET /merchant/affiliate/payouts`.

`GET /merchant/ledger.csv` takes the same `mode` and `token` and returns the
whole statement, newest first, as a spreadsheet, with the columns `id,
created_at, type, token, chain, amount, usd_amount, reference, note`. Like
every dashboard route it needs the `Authorization` header (an access token is
never accepted in the URL), so a download button fetches it and saves the
response.

### Payout addresses

Your wallets: where payouts go, and, with direct settlement, where each coin's
payments are sent. Add one for each coin you accept: that coin's payments are
then sent to you as they arrive, with no conversion, at the lowest fee for
your customers; a coin without one is converted into one of your payout
coins first, and costs them more
([why](/docs/settlement#coins-you-have-no-payout-address-for)). Test addresses (`?mode=test`) receive test settlements,
on paper: nothing is ever sent to them. A Bitcoin or Litecoin test address
may be in the mainnet or the testnet format (`tb1…`, `m…`, `n…`, `tltc1…`).
Adding an address or changing one asks for your password again.

| Method | Path | Role | Body | Response |
|---|---|---|---|---|
| `GET` | `/merchant/payout-addresses?mode=test` | any | | `200 {"addresses": [address, …]}` |
| `POST` | `/merchant/payout-addresses?mode=test` | owner, finance | `token`, `address`, `tag`, `label`, `password` | `201 {"address": address}` |
| `PATCH` | `/merchant/payout-addresses/{id}` | owner, finance | any of `address`, `tag`, `label`, `default`, and `password` when `address` or `tag` change | `200 {"address": address}` |
| `DELETE` | `/merchant/payout-addresses/{id}` | owner, finance | | `200 {"message": "address deleted"}` |

| Field | Type | Required | Description |
|---|---|---|---|
| `token` | string | yes (POST) | The coin this address receives, such as `USDT_TRC20`. It cannot change later |
| `address` | string | yes (POST) | The wallet address, checked for the token's network |
| `tag` | integer | no | XRP only: the destination tag your exchange asks for |
| `label` | string | no | Your name for it |
| `default` | boolean | no (PATCH) | `true` makes it the address direct settlement pays for its coin |
| `password` | string | when adding, or changing `address` or `tag` | Your sign-in password |

```bash
curl -s https://api.payitgo.co/merchant/payout-addresses \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"token": "USDT_TRC20", "address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU", "label": "Treasury", "password": "a-long-password"}'
```

`201 Created`:

```json
{
  "address": {
    "id": "3e7a1c9f5b2d4e8a6c0f2b1d",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "livemode": true,
    "token": "USDT_TRC20",
    "chain": "tron",
    "address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
    "label": "Treasury",
    "default": true,
    "active_at": "2026-10-11T09:00:29.688370009Z",
    "created_by": "9b2e4c71d8a05f3e6c1a7b24",
    "created_at": "2026-10-10T09:00:29.688370009Z"
  }
}
```

| Field | Description |
|---|---|
| `id` | The address's id |
| `livemode` | `false` for a test address |
| `token`, `chain`, `address`, `tag`, `label` | As you set them |
| `default` | Whether direct settlement pays this coin here. The first address of a coin is its default |
| `active_at` | When direct settlement starts using it. Your first live address of a coin: at once, as there was nowhere else for those payments to go. Any address that could redirect payments (another address of a coin, a changed address or tag, one added after the coin's last address was deleted): **24 hours** later, so whoever might take over your account cannot redirect your payments at once; payments in its coin wait for it meanwhile, and are then sent to it as they are. Test addresses are active at once |
| `created_by`, `created_at` | Who added it, and when |

| Status | Message | Cause |
|---|---|---|
| `400` | `unsupported token in live mode` | Not a token of that mode |
| `400` | `address is not a valid tron address` | The address is not valid for the token's network |
| `400` | `only XRP addresses take a destination tag` | `tag` on a non-XRP address |
| `400` | `nothing to update` | An empty `PATCH` |
| `403` | `password is incorrect` | Wrong or missing `password` |
| `404` | `not found` | No such address |

### Payouts

Ask for your balance to be sent to one of your live payout addresses.
payitgo reviews each payout you request, then sends it: one in USDT on BNB
Smart Chain or Ethereum by itself, usually within minutes of approving it,
any other by payitgo's team. A USDT balance can be paid to your USDT address
on any network ([below](#paying-usdt-out-on-another-network)). Referral
commissions are not paid out here: ask for a
[referral payout](#referral-balance-and-payouts).

#### Quote a payout

`GET /merchant/payouts/quote?address_id=…&amount=…` · owner, finance

What a payout to one of your live addresses would be, before you request
it; nothing is held. `amount` is optional: without it you get the network
fee and the minimum payout only. The answer is `{"quote": {...}}` with
`address_id`, `token`, `chain`, `network_fee` and `network_fee_usd` (what
sending it costs now), `min_payout` and `min_payout_usd`, and for an amount
`amount`, `amount_usd`, `send_amount` and `send_amount_usd` (what reaches
the address) and `from_token`, `from_chain` (the balance it would draw on,
when not the address's own coin's). It answers `400` for what a request
would refuse: under the minimum, an amount the fee eats, too little balance.
Fees move: the request's own answer has the final fee.

`POST /merchant/payouts` · owner, finance

| Field | Type | Required | Description |
|---|---|---|---|
| `address_id` | string | yes | A **live** payout address's id |
| `amount` | number or decimal string | yes | How much of the address's coin to pay out, at least $10 worth. The network fee of sending it comes out of this amount. Taken from your balance of that coin; for a USDT address, from another of your USDT balances when that one has too little |

```bash
curl -s https://api.payitgo.co/merchant/payouts \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"address_id": "3e7a1c9f5b2d4e8a6c0f2b1d", "amount": "250"}'
```

`201 Created`. The amount is held from your balance at once:

```json
{
  "payout": {
    "id": "7e2c9a4f1b3d8e6a05c2f1d9",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "token": "USDT_TRC20",
    "chain": "tron",
    "amount": "250",
    "address_id": "3e7a1c9f5b2d4e8a6c0f2b1d",
    "address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
    "network_fee": "3.21",
    "network_fee_usd": "3.21",
    "send_amount": "246.79",
    "usd_rate": "1",
    "amount_usd": "250",
    "send_amount_usd": "246.79",
    "status": "requested",
    "requested_by": "9b2e4c71d8a05f3e6c1a7b24",
    "created_at": "2026-10-10T10:00:00.31Z",
    "updated_at": "2026-10-10T10:00:00.31Z"
  }
}
```

#### Paying USDT out on another network

USDT is worth $1 on every network, so a USDT balance pays a payout to any of
your USDT payout addresses: `USDT_TRC20`, `USDT_BEP20` or `USDT_ERC20`. Your
custody balance, held in USDT on Tron, can go to your USDT address on BNB
Smart Chain, where sending it costs about a cent instead of a few dollars on
Tron.

The request is the same (`address_id`, `amount`), and payitgo picks the
balance: the address's own coin when it has enough, otherwise another USDT
balance that has, USDT on Tron first. One balance pays the whole payout: it
is never split. The payout says which in `from_token` and `from_chain`, while
`token` and `chain` are the coin and network it is paid in. The minimum
payout ($10) applies as ever, the network fee is that of the network it is
paid on, and the amount is held from the balance drawn on (its
`pending_payout`, and the ledger's `payout_hold`).

The same 250 USDT from your Tron balance, to your USDT address on BNB Smart
Chain:

```json
{
  "payout": {
    "id": "5b9d1f3a7c2e4b6d8f0a1c3e",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "token": "USDT_BEP20",
    "chain": "bsc",
    "amount": "250",
    "from_token": "USDT_TRC20",
    "from_chain": "tron",
    "address_id": "8c4e2a6f1b3d5e7a9c0b2d4f",
    "address": "0x4b7e2d9a1c6f3e8b5a0d7c2e9f4a1b6d3c8e5f2a",
    "network_fee": "0.00412345",
    "network_fee_usd": "0.01",
    "send_amount": "249.99587655",
    "usd_rate": "1",
    "amount_usd": "250",
    "send_amount_usd": "249.99",
    "status": "requested",
    "requested_by": "9b2e4c71d8a05f3e6c1a7b24",
    "created_at": "2026-10-10T10:00:00.31Z",
    "updated_at": "2026-10-10T10:00:00.31Z"
  }
}
```

#### Automatic payouts

With direct settlement, what payitgo could not send you stays in your
balance ([when](/docs/settlement#coins-you-have-no-payout-address-for)).
payitgo pays a USDT balance out to you by itself, as one payout of the whole
`available`: to your USDT address on BNB Smart Chain that direct settlement
uses (past its `active_at`; your default one first), else on Ethereum. It
looks every 10 minutes, and pays as soon as it can send the payout. Such a
payout has `requested_by: "auto"`, is approved at once and sent by payitgo
for the network's fee (about a cent on BNB Smart Chain), with the usual
`payout.approved` and `payout.sent` webhooks.

Nothing is paid out automatically under the minimum payout ($10), to an
address still in its 24 hours, or without a USDT address on BNB Smart Chain
or Ethereum: request a payout of it as usual (to your USDT address on Tron,
say). Custody waits for your requests, and scheduled settlement keeps its
schedule.

#### The payout object

| Field | Type | Description |
|---|---|---|
| `id` | string | The payout's id |
| `token`, `chain` | string | The coin paid out, and its network |
| `from_token`, `from_chain` | string, optional | The balance it draws on, when that is not `token`'s: a USDT balance paid out as USDT on another network (`USDT_TRC20`, `tron` for your custody balance paid to `USDT_BEP20`). Absent when it draws on its own coin |
| `amount` | decimal string | Taken from your balance (`from_token`'s when present) |
| `address_id`, `address`, `tag` | string | Where it goes, fixed when you requested it (changing the payout address later does not move it) |
| `network_fee`, `network_fee_usd` | decimal string | The network fee of sending it, quoted at the request, in the coin and in dollars |
| `send_amount` | decimal string | What reaches your address: `amount` less `network_fee` |
| `usd_rate` | decimal string | The coin's dollar price when you requested the payout, to 8 decimals (`"1"` for USDT) |
| `amount_usd`, `send_amount_usd` | decimal string | `amount` in dollars at `usd_rate`, to the cent, and what `network_fee_usd` leaves of it, so the three add up |
| `status` | string | `requested` → `approved` → `sent`; or `rejected` (by payitgo) or `canceled` (by you), which return the amount to your balance |
| `tx_id` | string, optional | The transaction, once `sent` |
| `review_note` | string, optional | payitgo's note, for example why it was rejected |
| `requested_by` | string | Who asked for it: a user's id, `scheduler` for a [scheduled settlement](/docs/settlement#where-the-money-goes)'s payout, or `auto` for a balance [paid out automatically](#automatic-payouts) |
| `approved_at`, `rejected_at`, `canceled_at`, `sent_at` | string, optional | When each step happened |
| `canceled_by` | string, optional | Who cancelled it |
| `created_at`, `updated_at` | string | When it was requested and last changed |

Each step payitgo takes raises a webhook to your **live** account webhook URL,
with this object as `data`: `payout.approved`, `payout.rejected`,
`payout.sent` (with `tx_id`).

| Method | Path | Response |
|---|---|---|
| `GET` | `/merchant/payouts?status=&limit=&offset=` | `200 {"payouts": [payout, …], "total": n, "limit": 50, "offset": 0}` |
| `GET` | `/merchant/payouts/{id}` | `200 {"payout": payout}` |
| `POST` | `/merchant/payouts/{id}/cancel` | `200 {"payout": payout}` with `"status": "canceled"`, while it is still `requested` |

| Status | Message | Cause |
|---|---|---|
| `400` | `amount must be > 0` | Zero or negative |
| `400` | `unknown payout address (test balances are not paid out)` | Not one of your live addresses |
| `400` | `the minimum payout is $10.00 (about 10 USDT_TRC20)` | Under $10 worth |
| `400` | `amount must be more than the network fee of 3.21 USDT_TRC20` | Nothing would be left to send |
| `400` | `insufficient available balance` | More than `available`; for a USDT address, more than any one of your USDT balances has |
| `404` | `not found` | No such payout |
| `409` | `payout is not in a state that allows this` | Cancelling a payout that is no longer `requested` |

## Support chat

Your account has one chat with payitgo's support, shared by the whole team:
every role can read it and write in it, and everyone sees every message,
whoever on the team wrote it. A message is text, up to 5000 characters
(spaces at either end are trimmed). You send it over REST, and every open
dashboard receives it [live](#live-chat-updates), support's replies
included: there is nothing to poll.

| Method | Path | Role | Body | Response |
|---|---|---|---|---|
| `GET` | `/merchant/support?before=&limit=` | any | | `200 {"messages": [message, …], "more": false, "chat": chat}` |
| `GET` | `/merchant/support/unread` | any | | `200 {"unread": 1}` |
| `POST` | `/merchant/support/messages` | any | `text` | `201 {"message": message}` |
| `POST` | `/merchant/support/read` | any | | `200 {"chat": chat}` |
| `PATCH` | `/merchant/support/messages/{id}` | its author | `text` | `200 {"message": message}` |
| `DELETE` | `/merchant/support/messages/{id}` | its author | | `200 {"message": message}` |

Sending and editing are limited to one message a second per person, in
bursts of up to 5.

```bash
curl -s https://api.payitgo.co/merchant/support/messages \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"text": "Hello, our webhook deliveries have been failing since this morning. Is anything wrong on your side?"}'
```

`201 Created`:

```json
{
  "message": {
    "id": "4c9e1a7f3b2d8e6a05f1c2d9",
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "author": "merchant",
    "author_id": "5e8a2c9d1f4b7036a2e9c1d8",
    "author_name": "dev",
    "text": "Hello, our webhook deliveries have been failing since this morning. Is anything wrong on your side?",
    "created_at": "2026-10-14T09:12:03.118Z"
  }
}
```

### Reading the chat

`GET /merchant/support?before=&limit=` · any role

The latest messages, oldest first. `more` is `true` when older ones remain:
ask for them with `before=<the first message's id>`. `limit` is 1 to 100
(default 50).

```json
{
  "messages": [
    { "id": "4c9e1a7f3b2d8e6a05f1c2d9", "merchant_id": "3f8a1c27b94e05d6a2c71e90", "author": "merchant", "author_id": "5e8a2c9d1f4b7036a2e9c1d8", "author_name": "dev", "text": "Hello, our webhook deliveries have been failing since this morning. Is anything wrong on your side?", "created_at": "2026-10-14T09:12:03.118Z" },
    { "id": "8b2d6f0a4c1e9a3d7f5b2c6e", "merchant_id": "3f8a1c27b94e05d6a2c71e90", "author": "support", "author_name": "Maya", "text": "Hi! Nothing on ours: your server answers 502. Deliveries retry on their own, and you can redeliver the failed ones once it is fixed.", "created_at": "2026-10-14T09:15:40.502Z" }
  ],
  "more": false,
  "chat": { "agent_name": "Maya", "waiting": false, "support_read_at": "2026-10-14T09:15:40.502Z", "unread": 1, "messages": 2 }
}
```

Reading marks nothing read, so a dashboard can load the chat, or show a
badge with `GET /merchant/support/unread` (`{"unread": 1}`: support's
messages your team has not read), without support seeing it read. When
someone has the chat open on screen, call `POST /merchant/support/read`:
support sees your team read it (`merchant_read_at`), and `unread` goes back
to 0 for the whole team. Before its first message the chat is empty:
`"messages": []`, with `"waiting": false` and `"unread": 0`.

#### The message object

| Field | Type | Description |
|---|---|---|
| `id` | string | The message's id |
| `merchant_id` | string | Your account |
| `author` | string | `merchant` (someone on your team) or `support` |
| `author_id` | string, optional | The team member who wrote it (their id); absent on support's messages |
| `author_name` | string | Their name (for a member without one, their email address before the `@`), or the support agent's |
| `text` | string | The message; `""` once deleted |
| `created_at` | string | When it was sent |
| `edited_at` | string, optional | When it was last edited |
| `deleted_at` | string, optional | When it was deleted |

#### The chat object

| Field | Type | Description |
|---|---|---|
| `agent_name` | string, optional | The support agent who has your chat |
| `waiting` | boolean | `true` from your team's message until support replies |
| `merchant_read_at` | string, optional | When your team last marked the chat read |
| `support_read_at` | string, optional | When support last read it: your messages sent before then have been seen |
| `unread` | integer | Support's messages your team has not read |
| `messages` | integer | How many messages the chat holds, deleted ones included |

A support agent takes your chat when you write (`agent_name`) and answers
it. Support sees every message with its writer's name, your account's name,
when your team last read the chat, and when someone on your team is typing.

When support replies while nobody on your team has the dashboard open (no
[socket](#real-time-events) connected), the team is told: a notification in
the `support` category, in the bell of every role and by email to those who
chose it (on by default), with the reply's first 300 characters. At most one
every 10 minutes.

### Editing and deleting

`PATCH /merchant/support/messages/{id}` `{"text": "…"}` and
`DELETE /merchant/support/messages/{id}` · the message's author

Each person edits and deletes only their own messages. An edit sets
`edited_at`. A deleted message stays in the chat with `"text": ""` and
`deleted_at`: show it as deleted. Deleting it again changes nothing, and it
can no longer be edited. payitgo keeps what a message said before each edit
and before it was deleted, for disputes; nobody is shown it.

| Status | Message | Cause |
|---|---|---|
| `400` | `text is required` · `text is too long (5012 characters; up to 5000)` | An empty message, or over 5000 characters |
| `403` | `only its author can change a message` | Editing or deleting someone else's message |
| `404` | `message not found` | No such message in your chat (also a `before` that is none) |
| `409` | `this message was deleted` | Editing a deleted message |
| `429` | `rate limit exceeded` | More than one message a second, past a burst of 5 |

### Live chat updates

Every team member's [dashboard socket](#real-time-events) receives:

| Event | Payload |
|---|---|
| `support:message` | A new [message](#the-message-object): a team member's (the sender's own included: upsert it by `id`) or support's |
| `support:message:update` | A message edited or deleted: the whole message, to replace the one shown |
| `support:read` | `{"side", "read_at", "unread"}`. `side` `support`: support read the chat up to `read_at`. `merchant`: a team member marked it read, and the badge is back to 0 |
| `support:typing` | `{"side": "support", "name", "typing"}`: someone at support started (`true`) or stopped typing |

The dashboard tells support someone is typing with an event of its own,
`support:typing`: `true` (or `{"typing": true}`) while someone types in the
chat box, `false` when they stop or send. Support sees the person's name
while they type. It is acknowledged with `{"ok": true}`. A `true` sent less
than a second after the same person's last `true` is not passed on again
(support is already shown them typing), so there is no need to send it more
than once a second; `false` always goes through.

```js
socket.on("support:message", (m) => upsertMessage(m));
socket.on("support:message:update", (m) => upsertMessage(m));
socket.on("support:read", (r) => (r.side === "support" ? showSeen(r.read_at) : setBadge(0)));
socket.on("support:typing", (t) => showTyping(t.typing ? `${t.name} is typing…` : null));

// while someone types (at most once a second), and when they stop or send
await socket.emitWithAck("support:typing", true);  // {"ok": true}
await socket.emitWithAck("support:typing", false);
```

## Referral program

Every payitgo account can refer other businesses and earn from what they
process. Share your referral link: a business that opens an account through
it is your referral, and each of its live payments earns you a share of
payitgo's [service fee](/docs/settlement#fees) on it:

- **25%** of the service fee on each live payment of a merchant you referred,
- **30%** once you have 5 active referrals (referrals whose payments earned
  you a commission in the last 30 days),
- for **5 years** from each referred merchant's signup,
- credited to your **referral balance** in USDT on Tron as each payment's
  fee is final. That balance is apart from your account's own
  [balance](#balances): ask for it to be paid out, from $50, and payitgo pays
  you within 5 business days ([Referral balance and payouts](#referral-balance-and-payouts)).

Only the service fee is shared, never the network fee or a conversion fee.
payitgo may agree other terms with you (a rate of your own, a longer time,
or for as long as your referrals use payitgo): `GET /merchant/affiliate`
always shows the terms in force.

| Method | Path | Role | Response |
|---|---|---|---|
| `GET` | `/merchant/affiliate` | owner, finance | `200` your code, link, terms, figures and referral balance |
| `GET` | `/merchant/affiliate/referrals?limit=&offset=` | owner, finance | `200 {"referrals": [referral, …], "total": n, "limit": 50, "offset": 0}`, newest first |
| `GET` | `/merchant/affiliate/commissions?limit=&offset=` | owner, finance | `200 {"commissions": [commission, …], "total": n, "limit": 50, "offset": 0}`, newest first |
| `POST` | [`/merchant/affiliate/payouts`](#request-a-referral-payout) | owner, finance | `201 {"payout": payout}` |
| `GET` | [`/merchant/affiliate/payouts?status=&limit=&offset=`](#follow-and-cancel-referral-payouts) | owner, finance | `200 {"payouts": [payout, …], "total": n, "limit": 50, "offset": 0}`, newest first |
| `GET` | [`/merchant/affiliate/payouts/{id}`](#follow-and-cancel-referral-payouts) | owner, finance | `200 {"payout": payout}` |
| `POST` | [`/merchant/affiliate/payouts/{id}/cancel`](#follow-and-cancel-referral-payouts) | owner, finance | `200 {"payout": payout}`, while it is `requested` |
| `POST` | `/referrals/{code}/click` | no authentication | `200 {"valid": true}` |

The referral program is live only: these routes take no `mode`.

### Your code and link

`GET /merchant/affiliate` · owner, finance

The first call gives your account its referral code: 8 characters,
lower-case letters and digits without the ones easy to misread (`0`, `o`,
`1`, `l`, `i`). It never changes. `link` is what you share: payitgo's signup
page with `?ref=<code>`. The answer also has your referral balance and how
it is paid out.

```bash
curl -s https://api.payitgo.co/merchant/affiliate -H "Authorization: Bearer $ACCESS_TOKEN"
```

`200 OK`:

```json
{
  "affiliate": {
    "merchant_id": "3f8a1c27b94e05d6a2c71e90",
    "merchant_name": "Acme Store",
    "code": "k7m2x9qa",
    "status": "active",
    "lifetime": false,
    "created_at": "2026-10-11T14:20:07.331Z",
    "updated_at": "2026-10-11T14:20:07.331Z"
  },
  "link": "https://payitgo.co/signup?ref=k7m2x9qa",
  "terms": {
    "rate_bps": 2500,
    "custom": false,
    "commission_bps": 2500,
    "boosted_bps": 3000,
    "boost_referrals": 5,
    "active_days": 30,
    "duration_months": 60,
    "lifetime": false
  },
  "stats": {
    "clicks": 214,
    "clicks_30d": 61,
    "referrals": 3,
    "referrals_30d": 1,
    "active_referrals": 2,
    "payments": 148,
    "volume_usd": "58240.75",
    "earned_usd": "145.601875",
    "earned_30d_usd": "12.415"
  },
  "balance": {
    "token": "USDT_TRC20",
    "chain": "tron",
    "available": "45.601875",
    "pending_payout": "100",
    "paid_out": "0",
    "earned": "145.601875"
  },
  "payouts": {
    "min_payout_usd": "50",
    "network_fee": "1.5",
    "payout_days": 5,
    "open": {
      "id": "9dfe8f5ae04f2aeab89957db",
      "affiliate_id": "3f8a1c27b94e05d6a2c71e90",
      "merchant_name": "Acme Store",
      "token": "USDT_TRC20",
      "chain": "tron",
      "amount": "100",
      "network_fee": "1.5",
      "send_amount": "98.5",
      "address_id": "3e7a1c9f5b2d4e8a6c0f2b1d",
      "address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
      "status": "requested",
      "due_at": "2026-10-22T06:00:41.03Z",
      "requested_by": "9b2e4c71d8a05f3e6c1a7b24",
      "created_at": "2026-10-15T06:00:41.03Z",
      "updated_at": "2026-10-15T06:00:41.03Z"
    }
  }
}
```

| Field | Description |
|---|---|
| `affiliate.code`, `link` | Your referral code, and the link to share |
| `affiliate.status` | `active`, or `suspended` by payitgo: your link is refused and your referrals earn nothing more |
| `affiliate.commission_bps`, `affiliate.duration_months`, `affiliate.lifetime` | Terms of your own, when payitgo agreed them with you |
| `terms.rate_bps` | What your referrals' payments earn you now, in basis points of payitgo's service fee (2500 = 25%) |
| `terms.custom` | `true` with a rate of your own, which is never boosted |
| `terms.commission_bps`, `terms.boosted_bps` | The rate, and the rate once you have `terms.boost_referrals` active referrals: referrals that earned you a commission in the last `terms.active_days` days |
| `terms.duration_months`, `terms.lifetime` | How long each referral earns you, from its signup; `lifetime`: for as long as it uses payitgo |
| `stats.clicks`, `stats.clicks_30d` | Visits through your link, ever and in the last 30 days |
| `stats.referrals`, `stats.referrals_30d`, `stats.active_referrals` | Merchants who signed up through it, ever and in the last 30 days, and how many are active |
| `stats.payments`, `stats.volume_usd` | Your referrals' live deposits that earned you a commission, and their total in dollars |
| `stats.earned_usd`, `stats.earned_30d_usd` | What you earned, ever and in the last 30 days, in dollars (USDT) |
| `balance.token`, `balance.chain` | Always `USDT_TRC20`, `tron`: your [referral balance](#referral-balance-and-payouts) is held in USDT on Tron (1 USDT = $1) |
| `balance.available` | What you can ask to be paid now |
| `balance.pending_payout` | Asked for and not paid yet (a payout `requested` or `processing`) |
| `balance.paid_out` | Paid to you so far |
| `balance.earned` | Every commission ever: `available` + `pending_payout` + `paid_out` |
| `payouts.min_payout_usd` | The least you can ask to be paid, in USDT |
| `payouts.network_fee` | What sending a payout costs now, in USDT, taken from the payout. A quote: each payout's fee is fixed when you request it. `"0"` when it could not be quoted just now |
| `payouts.payout_days` | The business days (Monday to Friday) within which payitgo pays a request |
| `payouts.open` | Your [payout](#the-referral-payout-object) that is `requested` or `processing`, if any; absent when none. You have one open at most |

A developer or a viewer gets `403 {"error": "insufficient permissions"}`.

### Signing up through a link

When someone opens your link, the signup page calls
`POST /referrals/{code}/click` (no authentication, any origin, 5 a second
per IP address). It counts the visit in your `clicks` and answers
`{"valid": true}` when the code is an active affiliate's, or
`{"valid": false}` (and counts nothing) for an unknown or suspended code. The
page keeps the code and sends it as `referral_code` when the account is
opened ([Open an account](#open-an-account)).

```bash
curl -s -X POST https://api.payitgo.co/referrals/k7m2x9qa/click
```

The new account is your referral from then on. Owners and finance get an
`affiliate:referral` event and a "New referral" notification in the
`affiliates` category (emailed to them by default).

### What earns nothing

- Test payments.
- A referral's payments after its `earns_until`.
- Referring yourself. An account opened through your link that shares a live
  payout address with yours (the same address, in any case) is taken for your
  own: at its next payment its referral is voided (`"status": "void"`,
  `"void_reason": "it shares a payout address with its affiliate"`) and earns
  nothing from then on. payitgo can also void a referral it finds abusive,
  with the reason.
- Anything while your affiliate `status` is `suspended`.

### Referrals

`GET /merchant/affiliate/referrals?limit=50&offset=0` · owner, finance

The merchants you referred, newest first, with what each earned you.

```json
{
  "referrals": [
    {
      "merchant_id": "7c1e5a9d3b2f4086e1a7c5d9",
      "merchant_name": "Globex Games",
      "affiliate_id": "3f8a1c27b94e05d6a2c71e90",
      "code": "k7m2x9qa",
      "signed_up_at": "2026-10-12T08:41:55.120Z",
      "status": "active",
      "created_at": "2026-10-12T08:41:55.297Z",
      "earns_until": "2031-10-12T08:41:55.120Z",
      "payments": 37,
      "volume_usd": "4210.5",
      "earned_usd": "10.52625",
      "last_earned_at": "2026-10-14T10:02:11.402Z"
    }
  ],
  "total": 3,
  "limit": 50,
  "offset": 0
}
```

#### The referral object

| Field | Type | Description |
|---|---|---|
| `merchant_id`, `merchant_name` | string | The merchant you referred |
| `affiliate_id` | string | Your account |
| `code` | string | The code it signed up with |
| `signed_up_at` | string | When it opened its account |
| `status` | string | `active`, or `void`: it earns you nothing more |
| `void_reason`, `voided_at` | string, optional | Why and when it was voided |
| `attached_by` | string, optional | Present when payitgo attached the merchant to your code by hand |
| `created_at` | string | When it became your referral |
| `earns_until` | string or null | When its payments stop earning you commissions; `null`: for as long as it uses payitgo |
| `payments` | integer | Its live deposits that earned you a commission |
| `volume_usd`, `earned_usd` | decimal string | Their total in dollars, and what they earned you |
| `last_earned_at` | string, optional | Its latest commission |

### Commissions

`GET /merchant/affiliate/commissions?limit=50&offset=0` · owner, finance

Once a live deposit to a merchant you referred is settled and payitgo's fee
on it is final, your share of that fee is credited to your
[referral balance](#referral-balance-and-payouts) in USDT on Tron: one
commission per deposit. It is not in your account's balance or
[ledger](#ledger); ask for a [referral payout](#request-a-referral-payout)
to be paid it. Newest first:

```json
{
  "commissions": [
    {
      "id": "5b8e2a1c9d4f7e3a06c2b1d8:4be17c…9c0a",
      "affiliate_id": "3f8a1c27b94e05d6a2c71e90",
      "merchant_id": "7c1e5a9d3b2f4086e1a7c5d9",
      "merchant_name": "Globex Games",
      "payment_id": "5b8e2a1c9d4f7e3a06c2b1d8",
      "deposit_usd": "120",
      "fee_usd": "1.2",
      "rate_bps": 2500,
      "token": "USDT_TRC20",
      "amount": "0.3",
      "created_at": "2026-10-14T10:02:11.402Z"
    }
  ],
  "total": 148,
  "limit": 50,
  "offset": 0
}
```

#### The commission object

| Field | Type | Description |
|---|---|---|
| `id` | string | The deposit it comes from, `<payment id>:<transaction id>` |
| `affiliate_id` | string | Your account |
| `merchant_id`, `merchant_name` | string | The referred merchant who was paid |
| `payment_id` | string | Their payment |
| `deposit_usd` | decimal string | The deposit, in dollars |
| `fee_usd` | decimal string | payitgo's service fee on it, in dollars |
| `rate_bps` | integer | Your share of the fee, in basis points |
| `token` | string | `USDT_TRC20` |
| `amount` | decimal string | What was credited to your referral balance: the fee times your rate |
| `created_at` | string | When it was credited |

### Referral balance and payouts

Your commissions build up in your **referral balance**, in USDT on Tron
(`USDT_TRC20`), apart from your account's own balance:
[`GET /merchant/balances`](#balances) and the [ledger](#ledger) never show
them, and a [payout](#payouts) of your balance cannot take them.
`GET /merchant/affiliate` gives the balance (`balance`) and the payout terms
(`payouts`) ([Your code and link](#your-code-and-link)).

- Ask for a payout of all of `balance.available`, or part of it, from
  `payouts.min_payout_usd` ($50 by default).
- It is paid to one of your live USDT (Tron)
  [payout addresses](#payout-addresses), your default one unless you choose
  another, or to any other Tron address you give with the request, with your
  password.
- The network fee of sending it comes out of it: `network_fee` is fixed when
  you ask, and `send_amount` is what reaches the address.
- The amount leaves `available` at once, and is in `pending_payout` until it
  is paid.
- payitgo pays it within `payouts.payout_days` business days (5 by default,
  Monday to Friday): its `due_at`.
- You have one payout open at a time. You can cancel it while it is
  `requested`: the amount goes back to `available`.
- Referral payouts are paid in USDT on Tron only.

### Request a referral payout

`POST /merchant/affiliate/payouts` · owner, finance · 1 a second per user,
in bursts of 5

| Field | Type | Required | Description |
|---|---|---|---|
| `amount` | number or decimal string | no | How much of `balance.available`, at most 6 decimals. Absent or `0`: all of it |
| `address_id` | string | no | One of your **live** payout addresses, a `USDT_TRC20` one |
| `address` | string | no | Any other Tron address (`T…`, 34 characters) |
| `password` | string | with `address` | Your sign-in password: the money goes somewhere that is not one of your payout addresses |

Give `address_id` or `address`, not both. With neither, the payout goes to
your default `USDT_TRC20` payout address (the oldest one when none is marked
`default`).

```bash
curl -s https://api.payitgo.co/merchant/affiliate/payouts \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"amount": "100"}'
```

`201 Created`. The amount is held from your referral balance at once:

```json
{
  "payout": {
    "id": "9dfe8f5ae04f2aeab89957db",
    "affiliate_id": "3f8a1c27b94e05d6a2c71e90",
    "merchant_name": "Acme Store",
    "token": "USDT_TRC20",
    "chain": "tron",
    "amount": "100",
    "network_fee": "1.5",
    "send_amount": "98.5",
    "address_id": "3e7a1c9f5b2d4e8a6c0f2b1d",
    "address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
    "status": "requested",
    "due_at": "2026-10-22T06:00:41.03Z",
    "requested_by": "9b2e4c71d8a05f3e6c1a7b24",
    "created_at": "2026-10-15T06:00:41.03Z",
    "updated_at": "2026-10-15T06:00:41.03Z"
  }
}
```

To an address that is not one of your payout addresses, with your password
(the answer has no `address_id` then):

```bash
curl -s https://api.payitgo.co/merchant/affiliate/payouts \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"amount": "100", "address": "TSeJkUh4Qv67VNFwY8LaAxERygNdy6NQZK", "password": "a-long-password"}'
```

Owners and finance are told of every request: a "Payout requested"
notification in the `affiliates` category, emailed by default, saying how
much, to which address and by when payitgo pays it. If nobody of your team
asked for it, cancel it and change your password.

Errors, in the order they are checked:

| Status | Message | Cause |
|---|---|---|
| `403` | `insufficient permissions` | A developer or a viewer |
| `429` | `rate limit exceeded` | More than 1 request a second |
| `400` | `invalid request body` | Not JSON, or `amount` is not a number |
| `403` | `password is incorrect` | `address` without your right password |
| `409` | `a payout is open already: cancel it, or wait for it to be paid` | You have a payout `requested` or `processing` |
| `400` | `give address_id or address, not both` | |
| `400` | `address is not a valid Tron address` | |
| `400` | `referral payouts are paid in USDT on Tron: choose a USDT (Tron) payout address` | `address_id` is an address of another coin |
| `400` | `unknown payout address` | `address_id` is not one of your live payout addresses |
| `400` | `add a USDT (Tron) payout address, or give the address to pay` | Neither given, and you have no live `USDT_TRC20` payout address |
| `400` | `amount must be more than 0` | Negative |
| `400` | `amount has more than 6 decimals` | |
| `400` | `the minimum payout is 50.00 USDT_TRC20` | Under `payouts.min_payout_usd`; also when you ask for all of it and `available` is under the minimum |
| `400` | `amount must be more than the network fee of 1.5 USDT_TRC20` | Nothing would be left to send |
| `400` | `your referral balance holds 45.601875 USDT_TRC20` | More than `available` |
| `500` | `could not request the payout` | For example the network fee could not be quoted: try again a little later |

#### The referral payout object

| Field | Type | Description |
|---|---|---|
| `id` | string | The payout's id |
| `affiliate_id`, `merchant_name` | string | Your account |
| `token`, `chain` | string | Always `USDT_TRC20`, `tron` |
| `amount` | decimal string | Taken from your referral balance |
| `network_fee` | decimal string | The network fee of sending it, fixed when you asked, taken from `amount` |
| `send_amount` | decimal string | What reaches `address`: `amount` less `network_fee` |
| `address_id` | string, optional | The payout address you chose (or your default); absent for an address given with the request |
| `address` | string | The Tron address it is paid to, fixed when you asked (changing your payout address later does not move it) |
| `status` | string | `requested`, `processing`, `paid`, `rejected` or `canceled` (below) |
| `due_at` | string | When payitgo pays it by: `payout_days` business days after the request, at the same time of day (UTC; Saturdays and Sundays skipped) |
| `requested_by` | string | The user who asked for it |
| `tx_id` | string, optional | The Tron transaction that paid it, once `paid` |
| `note` | string, optional | Why payitgo rejected it; `sent by hand` when payitgo paid it outside the usual flow |
| `approved_at` | string, optional | When payitgo started sending it |
| `paid_at`, `rejected_at` | string, optional | When it was paid, or rejected |
| `canceled_by`, `canceled_at` | string, optional | Who cancelled it, and when |
| `created_at`, `updated_at` | string | When it was requested and last changed |

| `status` | Means |
|---|---|
| `requested` | Waiting for payitgo. The amount is held: out of `available`, in `pending_payout`. You can cancel it |
| `processing` | payitgo is sending it: it can no longer be cancelled. It becomes `paid` once the transaction is on chain. Should it fail to be sent, payitgo may reject it, and the amount comes back |
| `paid` | Paid: `tx_id` is the transaction. The amount moves to `paid_out` |
| `rejected` | Refused by payitgo; `note` says why. The amount is back in `available` |
| `canceled` | Cancelled by your team. The amount is back in `available` |

`paid`, `rejected` and `canceled` are final.

### Follow and cancel referral payouts

| Method | Path | Role | Response |
|---|---|---|---|
| `GET` | `/merchant/affiliate/payouts?status=&limit=&offset=` | owner, finance | `200 {"payouts": [payout, …], "total": n, "limit": 50, "offset": 0}`. Newest first; with `status=requested` or `status=processing`, soonest due first. `limit` 1 to 200 |
| `GET` | `/merchant/affiliate/payouts/{id}` | owner, finance | `200 {"payout": payout}` |
| `POST` | `/merchant/affiliate/payouts/{id}/cancel` | owner, finance | `200 {"payout": payout}` with `"status": "canceled"`, while it is still `requested`. The amount is back in `available` |

```bash
curl -s https://api.payitgo.co/merchant/affiliate/payouts/9dfe8f5ae04f2aeab89957db -H "Authorization: Bearer $ACCESS_TOKEN"
```

`200 OK`, a payout payitgo has paid:

```json
{
  "payout": {
    "id": "9dfe8f5ae04f2aeab89957db",
    "affiliate_id": "3f8a1c27b94e05d6a2c71e90",
    "merchant_name": "Acme Store",
    "token": "USDT_TRC20",
    "chain": "tron",
    "amount": "100",
    "network_fee": "1.5",
    "send_amount": "98.5",
    "address_id": "3e7a1c9f5b2d4e8a6c0f2b1d",
    "address": "TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU",
    "status": "paid",
    "due_at": "2026-10-22T06:00:41.03Z",
    "requested_by": "9b2e4c71d8a05f3e6c1a7b24",
    "tx_id": "9f3c5a0e7d2b41c8a6e5f0d3b2a1c9e8d7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2",
    "approved_at": "2026-10-19T09:12:05.418Z",
    "paid_at": "2026-10-19T09:13:02.771Z",
    "created_at": "2026-10-15T06:00:41.03Z",
    "updated_at": "2026-10-19T09:13:02.771Z"
  }
}
```

```bash
curl -s -X POST https://api.payitgo.co/merchant/affiliate/payouts/9dfe8f5ae04f2aeab89957db/cancel \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

| Status | Message | Cause |
|---|---|---|
| `403` | `insufficient permissions` | A developer or a viewer |
| `404` | `not found` | No such payout of yours |
| `409` | `payout is not in a state that allows this` | Cancelling a payout that is no longer `requested` |
| `500` | `could not list the payouts` · `could not read the payout` · `could not cancel the payout` | |

Besides "Payout requested", owners and finance get these `affiliates`
notifications:

| Title | When | Body |
|---|---|---|
| Payout paid | It is `paid` | `payitgo paid 98.5 USDT_TRC20 of your referral balance to TWd4Kp9mR2xH7sN3vB6cJ8fL5qY1tZgEaU (transaction 9f3c…a3b2).` |
| Payout rejected | It is `rejected` | `Your payout of 100 USDT_TRC20 was rejected: <reason>. It is back in your referral balance.` |

On the [dashboard socket](#real-time-events), owners and finance get
`affiliate:balance` (the `balance` object) whenever the referral balance
changes, and `affiliate:payout` (the payout) on every change of a payout.

## Real-time events

A dashboard can stay live without polling: connect socket.io (a 4.x client)
to the `/merchant` namespace with an access token. Each event carries the same
object the REST route returns, so you upsert it by `id`. Test and live arrive
on the same connection: filter on `livemode` (`mode` on notifications).

```js
import { io } from "socket.io-client";

// auth as a function: every (re)connection sends the access token you hold now
const socket = io("https://api.payitgo.co/merchant", { auth: (cb) => cb({ token: accessToken }), transports: ["websocket"] });
socket.on("payment:update", (e) => upsertPayment(e.payment)); // e.type: payment.created, payment.confirmed…
socket.on("dashboard:update", (d) => { if (d.livemode === live) showFigures(d); });
socket.on("auth:error", async ({ code }) => {
  if (code === "token_expired") { await refreshAccessToken(); socket.connect(); } // the server has disconnected
  else signOut(); // session_revoked, role_changed, removed, suspended
});
```

| Event | Payload | Who gets it |
|---|---|---|
| `payment:update` | `{"type", "payment"}`: the whole payment after each change, as `GET /merchant/payments` lists it. The [checkout's event](/docs/checkout#follow-it-live) plus `merchant_id` and `payment.created` | Everyone |
| `dashboard:update` | The [dashboard](#dashboard) figures, with `livemode`. At most one every 2 seconds per mode | Everyone |
| `balance:update` | `{"livemode", "balances"}`, as [`GET /merchant/balances`](#balances): your account's own balances, never the referral balance | Everyone |
| `settlement:update` | A [settlement](/docs/settlement#the-settlement-object), on every change | Everyone |
| `payout:update` | A [payout](#the-payout-object): requested, canceled, approved, rejected, sent. It can also arrive with nothing you see changed (payitgo sending it): upsert it all the same | Everyone |
| `payout_address:update` | `{"address", "deleted"}` | Everyone |
| `webhook:delivery` | A [delivery](#delivery-history), on every attempt | Everyone |
| `webhook:update` | The [webhook](#webhook), without its secret | Owners, developers |
| `notification`, `notification:read` | A new notification; the user read some or all of them (`ids`, `all`, `livemode`, `unread`) | By category (`support`: everyone; `affiliates`: owners, finance); the user who read them |
| `merchant:update` | The [merchant](#the-merchant-object) | Everyone |
| `store:update`, `api_key:update` | A [store](#stores); an API key (never its secret) | Everyone; owners and developers |
| `team:update` | `{"action", "user", "invitation"}`: a member or an invitation changed | Owners |
| `link:update`, `customer:update` | A payment link; a customer | Everyone |
| `audit:entry` | An [audit log](#audit-log) entry | Owners |
| `support:message`, `support:message:update` | A new [support chat message](#the-message-object); one edited or deleted, whole, to replace the one shown | Everyone |
| `support:read`, `support:typing` | Support or a team member read the chat (`{"side", "read_at", "unread"}`); someone at support started or stopped typing (`{"side", "name", "typing"}`) ([Live chat updates](#live-chat-updates)) | Everyone |
| `affiliate:referral`, `affiliate:commission` | A [referral](#the-referral-object): a merchant signed up through your link, or a referral was voided; a [commission](#the-commission-object) just credited (an `affiliate:balance` follows) | Owners, finance |
| `affiliate:balance` | Your [referral balance](#referral-balance-and-payouts), the `balance` object of `GET /merchant/affiliate`, whenever it changes: a commission credited, a payout requested, cancelled, rejected or paid | Owners, finance |
| `affiliate:payout` | A [referral payout](#the-referral-payout-object), on every change: requested, cancelled, being sent (`processing`), paid, rejected. It can also arrive with nothing you see changed (payitgo's transfer moving on): upsert it all the same | Owners, finance |

Besides `auth:refresh` (below), the dashboard sends one event of its own:
`support:typing`, while someone types in the support chat
([Live chat updates](#live-chat-updates)).

On connecting, the socket receives `session`: who it is and its token's
`expires_at`. The socket lasts as long as that token. To keep it open,
refresh the session (`POST /merchant/auth/refresh`) a minute before and send
the new token on the open socket:
`await socket.emitWithAck("auth:refresh", newToken)` answers
`{"ok": true, "session": {…}}`. Otherwise, at `expires_at` the server sends
`auth:error` with `token_expired` and disconnects (socket.io-client does not
reconnect after a server disconnect by itself): refresh, then connect again.

Signing out, a password change, a role change, removal from the team and a
suspension end the socket at once, with `auth:error` `session_revoked`,
`role_changed`, `removed` or `suspended`: sign in again. A refused handshake
is a `connect_error` `unauthorized` with `err.data.code` `missing_token`,
`invalid_token` or `wrong_kind`. A connection dropped for under 2 minutes is
recovered: the events missed are replayed, and `session.recovered` is `true`.
After a longer gap, load your screens again.
