# AtmoRouter — Affiliate

Source: https://atmorouter.dev/docs/affiliate

Only endpoints that intentionally differ from the standard API carry a chip; the chip links to the note that explains the difference.

Covers the five live routes under `/api/dashboard/affiliate`. Audience: signed-in dashboard users earning referral commission. Prerequisite for every command below: log in first and keep the session cookie. API keys (`ar-…`) are rejected on all affiliate routes with `401`.

`/api/dashboard/affiliate`

```bash
curl -c jar.txt https://atmorouter.dev/api/dashboard/login \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"..."}'
```

## Two wallets — read this first

There are two balances and money never moves between them on its own:

| Wallet | Response field | Purpose |
| --- | --- | --- |
| Usage balance | balance | Spendable on API calls. Check it with GET /api/dashboard/balance → {"balance":12.5}. |
| Affiliate balance | available_amount | Withdrawable only. Referral commission lands here, never in the usage balance. |

The affiliate balance reaches the usage balance through exactly one path: an explicit payout with `"method":"api_balance"`, recorded in the affiliate ledger. There is no automatic transfer, no matter how large either balance grows.

## 1. Check your affiliate balance

```bash
curl -b jar.txt https://atmorouter.dev/api/dashboard/affiliate/summary
```

The response carries your share link and both halves of the picture (identity values below are illustrative; shapes and policy fields are exact):

```json
{
  "code": "K7Q2XA",
  "enabled": true,
  "rate": 0.1,
  "min_withdraw": 5,
  "withdraw_daily": 1,
  "referral_link": "https://atmorouter.dev/?ref=K7Q2XA",
  "rate_history": [{ "rate": 0.1, "effective_from": "2026-09-01T00:00:00Z" }],
  "wallet": { "available": 12.5, "withdrawn": 5.0, "total_earned": 17.5 },
  "stats": {
    "signups": 9, "spenders": 4, "referred_spend": 125.0,
    "matured_pending": 3.2, "locked_pending": 9.3
  },
  "pending_payout": { "id": 41, "amount": 5.0, "method": "usdt" }
}
```

How to read it:

| Field | Description |
| --- | --- |
| wallet.available | What you can withdraw right now. |
| wallet.withdrawn / wallet.total_earned | Lifetime reporting totals, never used for payouts. |
| stats.matured_pending vs stats.locked_pending | Commission matures gradually as each referred user spends; locked_pending is still waiting on their spending. If your balance looks "stuck", this split is why. |
| pending_payout | Present only while a request is awaiting processing. While it exists, new payout requests are rejected (see 409 below), so the dashboard should disable the withdraw form instead of letting the server refuse it. |
| rate, min_withdraw, withdraw_daily | The live program policy, not hardcoded promises. The numbers above are the current defaults (10% rate, 5 minimum, 1 per day); an admin change is reflected here immediately. rate_history lists past rate periods (shape above) for the footnote: past earnings used the rate in effect when each invite's spend happened. A first entry dated 1970-01-01 is the backfill for pre-history spend — expected, not a corrupt timestamp. |

## 2. See who you referred

```bash
curl -b jar.txt https://atmorouter.dev/api/dashboard/affiliate/referrals
# -> {"data":[{"id":7,"created_at":"...","email":"jo***@example.com",
#      "spent":40.0,"commission":4.0,"matured":1.8}]}
```

Newest first, capped at 200 entries. Emails are masked server-side to `jo***@example.com` form (first two local-part characters kept, or `customer` when no `@` exists). `spent` counts only the referred user's requests that settled with status `success` or `partial` — failed requests are never billed, so they never earn commission.

## 3. Track your payouts

```bash
curl -b jar.txt https://atmorouter.dev/api/dashboard/affiliate/payouts
# -> {"data":[{"id":41,"amount":5.0,"method":"usdt","address":"...",
#      "network":"solana","status":"pending","note":"","tx_hash":"",
#      "requested_at":"...","processed_at":null}]}
```

Newest first, capped at 100 entries. `status` stays `pending` until a human operator confirms it (`completed`) or rejects it (`rejected`) — there is no intermediate state written by any current endpoint (readers also accept a `processing` value defensively, but nothing sets it). A populated `tx_hash` is your on-chain receipt for `usdt` withdrawals.

Method quirk: only `POST` creates a payout — any other method on this path (`PUT`, `PATCH`, …) returns the list above instead of `405`.

## 4. Read the ledger (beta)

```bash
curl -b jar.txt https://atmorouter.dev/api/dashboard/affiliate/ledger
# -> {"data":[{"kind":"payout_usdt","amount":-5.0,"balance_after":7.5,
#      "created_at":"..."}]}
```

Newest first, capped at 100 entries. Every payout reserves from `available_amount` and writes a negative-amount entry (`payout_usdt` or `payout_api_balance`) with the balance after reservation — the audit trail that ties each withdrawal to the wallet movement. The ledger also records maturation credits (`commission_matured`), clawbacks (`commission_clawed`), and reject returns (`payout_rejected_return`), so a payout entry here always has a matching ledger entry.

Beta: response shape may change. The ledger endpoint is in beta. It is safe to poll today, but treat the exact response shape as subject to change.

## 5. Request a payout

Two methods, `usdt` or `api_balance` (surrounding spaces trimmed, case folded; anything else is a 400). Amounts round to 6 decimals and must be at least the live `min_withdraw` (currently 5; the server message reads `"minimum withdrawal is 5 USDC"`).

Withdraw to a Solana wallet (manual transfer by an operator follows):

```bash
curl -b jar.txt https://atmorouter.dev/api/dashboard/affiliate/payouts \
  -H "Content-Type: application/json" \
  -d '{"amount":12.5,"method":"usdt","address":"<32-to-44-char-solana-address>"}'
# -> {"status":"requested","id":41,"amount":12.5}
```

Move earnings into API-call credit instead (no address needed, no daily limit):

```bash
curl -b jar.txt https://atmorouter.dev/api/dashboard/affiliate/payouts \
  -H "Content-Type: application/json" \
  -d '{"amount":12.5,"method":"api_balance"}'
# -> {"status":"requested","id":42,"amount":12.5}
```

Check the address twice before sending. The server validates length only (32–44 characters) — it does not verify the address is real or that you typed it correctly, and the operator transfer is irreversible. `network` defaults to `solana`.

## 6. Limits and errors

The server checks, in order: bad body → program enabled → method → amount → minimum → address → wallet lock (a missing wallet stops here) → one in-flight request → daily limit → sufficient balance. The first failure wins. Limit values below reflect current defaults; the summary endpoint always carries the live policy.

| Situation | Status | Body |
| --- | --- | --- |
| Not signed in (or ar- API key) | 401 | {"error":"not authenticated"} |
| Unparseable POST body (4 KB cap) | 400 | {"error":"invalid request"} |
| Program disabled by admin | 403 | {"error":"affiliate program is disabled"} |
| Unknown sub-path (after auth) | 404 | {"error":"not found"} |
| Method not usdt/api_balance | 400 | {"error":"invalid payout method"} |
| Amount ≤ 0, NaN, or infinite | 400 | {"error":"invalid amount"} |
| Below live minimum | 400 | {"error":"minimum withdrawal is 5 USDC"} |
| usdt address shorter than 32 or longer than 44 chars | 400 | {"error":"a valid Solana wallet address is required"} |
| A payout is already pending/processing | 409 | {"error":"you already have a withdrawal awaiting processing"} — one in flight per user, any method. |
| usdt daily quota reached (UTC day) | 429 | {"error":"wallet withdrawals are limited to 1 per day"} — usdt only, rejected requests excluded from the count; api_balance is never rate-limited. |
| Reserved balance fell short | 400 | {"error":"insufficient affiliate balance"} |

Three rules worth remembering:

| Rule | Description |
| --- | --- |
| 1. One at a time (409) | A second request while one is in flight is rejected, even for a different amount or method. Wait for the operator to complete or reject the pending one — or check pending_payout in the summary first. |
| 2. One usdt withdrawal per UTC day (429) | The day boundary is UTC, the same day the operator sees in the admin queue. api_balance transfers are internal, cost nothing to process, and are never rate-limited. |
| 3. Concurrent requests cannot double-spend | The wallet balance is locked first and the reserve is a single atomic update that fails unless the balance still covers the amount — so two parallel requests cannot both pass. If yours loses the race, you get insufficient affiliate balance, not a partial withdrawal. |

## 7. What happens after {"status":"requested"}

Your request is parked as `pending`. Nothing else moves until a human operator acts — the operator queue is server-side only (there is no public operator endpoint: /api/admin answers 404 on the public gateway):

| Outcome | What happens |
| --- | --- |
| api_balance confirm | Credits the usage balance — this is the only moment affiliate money becomes API-call credit. |
| usdt confirm | Off-platform manual transfer by an operator; the tx_hash is then recorded on your payout request. |
| Reject | Returns the reserved amount to available_amount and writes a payout_rejected_return ledger entry, freeing you to request again. |

There is no email-confirmation step on this path: nothing on the payout route sends mail, and no confirmation record is ever read back. Request, then wait. Settlement time depends on operator review, so `pending` lasting hours is normal.

## 8. Commission maturity and payout-safety notes

How the money behind this page behaves: the maturity rule, when rate changes take effect, and the payout-safety edges the live service enforces. Nothing here moves money — behavior fixes, if any, ship in a separate lane.

| Rule | What happens |
| --- | --- |
| 1. Commission matures only from spend | Each referred deposit opens a commission worth deposit x rate at deposit time; it unlocks as the referred user spends: matured is the smaller of total and spend-since-deposit x rate. Oldest deposits consume spend first from one shared pool (FIFO, oldest deposits first). Unspent deposits pay nothing. |
| 2. Rate changes apply the moment they take effect | Spend prices at the rate in effect when it happened (periods oldest-first; new deposits use the live policy rate). Each usage entry is attributed by its own timestamp, so a same-day change prices the morning at the old rate and the afternoon at the new one. rate_history lists past periods; the 1970-01-01 line is the backfill for pre-history spend, not a corrupt timestamp. |
| 3. Fully-matured earnings show as ready, not stuck | matured_pending / locked_pending count pending plus fully-matured-but-unpaid commissions; a fully-matured commission flips to matured and still shows as ready here as well as in wallet.available. Payouts reserve from available_amount, so a zero split with positive available means request a payout — nothing is stuck. |
| 4. Commission opens shortly after the deposit, never twice — retries close the gap | Accrual opens a commission shortly after the deposit on both rails with fail-open guards, so a just-credited deposit may show its commission seconds later — wait and refresh, do not re-deposit. If the process stops between credit and the follow-up write, the commission is not lost: the write retries inline 3 times with 100 ms backoff, a pending intent is persisted durably, and a per-minute pass replays open intents and picks up completed rail deposits older than 2 minutes that still have no commission entry (batch cap 200). Replays only ever open pending, zero-matured commissions, so a retry can never mint spendable money — maturity still unlocks exclusively when the referred user spends. Both rails claim pending-to-credited exactly once and the commission write is idempotent per source deposit. |
| 5. Refunds claw matured money back; a reject returns only what the claw left behind | A refund on spent deposits reduces available_amount / total_earned with a floor at zero and writes a commission_clawed ledger entry. A payout already parked as pending is resolved by the operator by hand — so expect a delay and matching ledger entries, not an automatic adjustment. The returned amount is capped, not full: it can never exceed the still-unpaid matured amount left after the claw, so a refund that landed mid-flight keeps its clawed part out of your wallet instead of minting it back. A capped return is marked clawback-reconciled on the payout note with its payout_rejected_return ledger entry; when the claw took everything there is no ledger entry and the note is the only pointer. Only an api_balance confirmation credits your usage balance. |
| 6. A rejected USDT payout frees today's quota for a retry | The daily count excludes rejected requests: a rejected usdt request returns its reservation to the wallet and does not consume withdraw_daily = 1, so a second request the same UTC day is allowed — just retry. Completed requests still consume quota (confirm-then-retry answers 429 {"error":"wallet withdrawals are limited to 1 per day"}). api_balance moves are never rate-limited, only one payout may be in flight at a time (409), and the minimum stays policy-live, currently 5 ({"error":"minimum withdrawal is 5 USDC"}). |
