> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gains.trade/llms.txt
> Use this file to discover all available pages before exploring further.

# Referrals

> Attribute traders to a referrer and let them claim USDC rewards

The referral API lets you create referral codes, attach a trader to a referrer, read earnings, and claim rewards. Nothing here requires an API key, and nothing requires your users to send a transaction except the claim itself.

Creating a code and applying a code are **EIP-712 signatures**. The signature is the only authority.

Per-endpoint schemas live under [API Reference → Referrals](/developer/api-reference/endpoint/referral-config). This page covers what the schemas cannot tell you: how to sign, what breaks, and what to ask us for.

<Danger>
  This is the **self-serve referral API** served by `backend-global`.

  It is not the on-chain program documented in [GNSReferrals](/developer/technical-reference/contracts/core/facets/gnsreferrals) and [ReferralsUtils](/developer/technical-reference/contracts/libraries/referralsutils), and it is not the older KOL-slug system behind `/api/referrals/links/…`. Three different things share the word "referral". Do not mix them.
</Danger>

## Check availability first

The API is enabled per deployment. Probe it at startup:

```bash theme={null}
curl https://backend-global.gains.trade/api/referrals/config
```

```json theme={null}
{
  "result": {
    "chainId": 42161,
    "distributor": "0x…",
    "l1RateBps": 1000,
    "l2RateBps": 500
  }
}
```

Anything other than `200` means referrals are unavailable on that host. Disable the feature in your UI rather than falling back to stored values.

<Warning>
  [`GET /api/referrals/config`](/developer/api-reference/endpoint/referral-config) is the **only** valid readiness check. The read endpoints return `200` with zeroes whether or not the system is running, so they tell you nothing.
</Warning>

<Info>
  `chainId` and `distributor` come from this endpoint at runtime by design. A distributor redeploy is meant to be a configuration change on our side, not a client rebuild on yours. Any address you pin elsewhere will eventually be wrong.
</Info>

## Three rules that break integrations

<AccordionGroup>
  <Accordion title="A code must be applied before the trader's first trade">
    Once a wallet has traded on any chain covered by the program, [`POST /api/referrals/bind`](/developer/api-reference/endpoint/referral-bind-code) returns `409 AlreadyTraded` and no referrer can ever be attached. This is permanent.

    Bind at onboarding, before the wallet reaches the exchange. Binding shortly after the first trade earns nothing.

    There is no endpoint that answers "is this wallet still bindable". Derive it from trading history, and **ask us for the exact list of chain ids in the attribution window** so your check covers the same set as ours. Cover fewer chains than we do and you will show an eligible state that the API then refuses.

    Treat any chain you cannot resolve as **not eligible** and disable the bind UI. Failing open here produces a link that is accepted and then earns nothing.
  </Accordion>

  <Accordion title="Never hardcode the distributor address or the chain id">
    Read both from `/config` on every session.

    A stale `chainId` or `distributor` silently changes the EIP-712 domain. The recovered signer becomes a different address, so the failure surfaces as `401 unauthorized-signer` rather than as anything that mentions the domain. If you see that error and your subject really is the signer, re-fetch `/config` before looking anywhere else.
  </Accordion>

  <Accordion title="Neither `claimableMicroUsdc` nor `unpaidMicroUsdc` is claimable">
    Despite the names:

    * `claimableMicroUsdc` is **lifetime gross earnings**. It ignores everything already paid out.
    * `unpaidMicroUsdc` is lifetime minus already claimed. It still includes earnings that no published batch carries yet, which the contract will refuse to pay.

    Gate the claim on a `200` from [`/proof/{address}`](/developer/api-reference/endpoint/referral-proof) **and** a positive delta. See [Claiming](#claiming).
  </Accordion>
</AccordionGroup>

## Signing

Both write operations share one EIP-712 domain.

```js theme={null}
const domain = {
  name: "GainsReferral",
  version: "1",
  chainId: config.chainId,           // from /api/referrals/config
  verifyingContract: config.distributor,
};
```

There is no `salt`, and you must not add `EIP712Domain` to the `types` object.

### Types

Field order is part of the hash. Do not reorder.

```js theme={null}
const RegisterCode = {
  RegisterCode: [
    { name: "owner", type: "address" },
    { name: "code", type: "string" },
    { name: "nonce", type: "uint256" },
    { name: "validUntil", type: "uint64" },
  ],
};

const BindCode = {
  BindCode: [
    { name: "referee", type: "address" },
    { name: "code", type: "string" },
    { name: "nonce", type: "uint256" },
    { name: "validUntil", type: "uint64" },
  ],
};
```

The structs differ only in the first field name and are not interchangeable.

### Message fields

| Field               | Type      | How to build it                                                                                                              |
| ------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `owner` / `referee` | `address` | The signing wallet. Anything else returns `401`.                                                                             |
| `code`              | `string`  | Must match `/^[a-z0-9_]{1,31}$/`. **Normalize with `trim().toLowerCase()` before signing** — we reject, we do not normalize. |
| `nonce`             | `uint256` | `Date.now()`, in **milliseconds**. Must be strictly greater than your last accepted nonce.                                   |
| `validUntil`        | `uint64`  | Unix **seconds**. Use `now + 600`.                                                                                           |

<Warning>
  `nonce` is milliseconds and `validUntil` is seconds. Swapping them gives `400 expiry-too-far` or `409 BadNonce`.
</Warning>

The nonce high-water mark is **per signer and shared across both message kinds**. A bind sent after a register must carry a strictly larger nonce, and you must never have two writes in flight for the same wallet.

`validUntil` is checked against our clock: it must be in the future and at most 900 seconds ahead. Sign immediately before submitting rather than pre-signing and queueing.

<Warning>
  Smart-contract wallets are not supported. Signatures are verified by ECDSA recovery only, with no EIP-1271 path, so the recovered address must be an EOA.
</Warning>

### Sending it

Both transports are equivalent; the signature is the only thing that matters.

<CodeGroup>
  ```ts POST theme={null}
  const res = await fetch(`${base}/api/referrals/code`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ message: { kind: "register_code", ...message }, signature }),
  });
  ```

  ```ts GET theme={null}
  // base64url, unpadded
  const encoded = btoa(JSON.stringify({ kind: "register_code", ...message }))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");

  const res = await fetch(
    `${base}/api/referrals/code?message=${encodeURIComponent(encoded)}&signature=${encodeURIComponent(signature)}`,
    { method: "GET" }
  );
  ```
</CodeGroup>

Use `bind_code` and `/api/referrals/bind` to bind. `kind` must be present and must match the path, but it is **transport metadata only** and is not part of the signed struct.

<Info>
  The GET transport exists so the request stays a CORS simple request and survives edge rules that block POST. If you use it, send **no custom headers**, or you reintroduce the preflight you were avoiding.
</Info>

Only `201` is success.

### Worked example

```ts theme={null}
import { createWalletClient, custom } from "viem";

const cfg = await fetch(`${base}/api/referrals/config`)
  .then((r) => r.json())
  .then((j) => j.result);

const code = raw.trim().toLowerCase();
if (!/^[a-z0-9_]{1,31}$/.test(code)) throw new Error("invalid code");

const message = {
  referee: account,
  code,
  nonce: Date.now(),
  validUntil: Math.floor(Date.now() / 1000) + 600,
};

const signature = await wallet.signTypedData({
  account,
  domain: {
    name: "GainsReferral",
    version: "1",
    chainId: cfg.chainId,
    verifyingContract: cfg.distributor,
  },
  types: {
    BindCode: [
      { name: "referee", type: "address" },
      { name: "code", type: "string" },
      { name: "nonce", type: "uint256" },
      { name: "validUntil", type: "uint64" },
    ],
  },
  primaryType: "BindCode",
  message,
});

const res = await fetch(`${base}/api/referrals/bind`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ message: { kind: "bind_code", ...message }, signature }),
});
```

## Write errors

| Status | Error                                       | Meaning                                                                    | What to do                                |
| ------ | ------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------------- |
| `400`  | *prose*                                     | Malformed field. These strings are not stable — do not switch on them.     | Fix the client.                           |
| `400`  | `expired`                                   | `validUntil` is in the past.                                               | Re-sign. Check your clock.                |
| `400`  | `expiry-too-far`                            | More than 900 seconds ahead.                                               | Use `now + 600`.                          |
| `400`  | `malformed`                                 | Signature could not be recovered.                                          | Check encoding, domain and types.         |
| `401`  | `unauthorized-signer`                       | Signer is not the subject. **Usually a stale `chainId` or `distributor`.** | Re-fetch `/config`.                       |
| `403`  | `origin not allowed`                        | Your `Origin` is not allowlisted.                                          | Not retryable. Ask us to add it.          |
| `409`  | `AlreadyTraded`                             | The wallet has traded. Bind only.                                          | Terminal.                                 |
| `409`  | `AlreadyBound`                              | Already has a referrer.                                                    | Terminal.                                 |
| `409`  | `CodeUnknown`                               | Code is not registered.                                                    | Register first, or check spelling.        |
| `409`  | `CodeTaken`, `OwnerHasCode`, `CodeReserved` | Register conflicts.                                                        | Choose another code.                      |
| `409`  | `SelfReferral`, `Cycle`                     | Not permitted.                                                             | Terminal.                                 |
| `409`  | `BadNonce`                                  | Nonce not strictly greater.                                                | See below.                                |
| `429`  | `too many requests…`                        | 30 writes per minute.                                                      | Back off; read the `RateLimit-*` headers. |
| `503`  | `signed referral writes are disabled`       | Not enabled on this host.                                                  | Disable the feature.                      |
| `503`  | `TradeHistoryUnavailable`                   | We could not check trade history, so we refused.                           | Retryable.                                |
| `500`  | `failed to apply signed write`              | State is indeterminate.                                                    | Re-read before retrying.                  |

<Warning>
  **A `409` does not mean the write failed.**

  If a request times out and you retry, `BadNonce` means the original write already landed. So do `AlreadyBound` and `OwnerHasCode` when you retry with a fresh nonce.

  After any non-`201`, re-read [`/binding/{address}`](/developer/api-reference/endpoint/referral-binding) or [`/address/{address}/code`](/developer/api-reference/endpoint/referral-owner-code) before deciding what happened.
</Warning>

## Reading

Every response is wrapped in `{"result": …}`; errors are `{"error": "…"}`. Reads are not rate limited and CORS is open, but there is no server-side cache, so poll conservatively. Accruals move at most every 30 seconds, and the claimable amount changes roughly once a day.

### Units

<Warning>
  `lifetimeUsd` and `claimableMicroUsdc` are the **same quantity in different units**. Do not add them, and do not divide `lifetimeUsd` by 1e6.
</Warning>

* **Micro-USDC integers as strings** — divide by 1e6, parse with `BigInt`: `claimableMicroUsdc`, `settledMicroUsdc`, `unpaidMicroUsdc`, `cumulativeMicroUsdc`, `amountMicroUsdc`.
* **Decimal USD strings**, already human-scaled: `lifetimeUsd`, `l1Usd`, `l2Usd`, `cashbackUsd`, `accruedUsd`, `earnedUsd`, `volume30dUsd`.
* **Basis points** as integers, 10000 = 100%: `l1RateBps`, `l2RateBps`, `cashbackBps`.
* **Unix seconds**: `boundAt`, nullable. **`YYYY-MM-DD` UTC**: `day`.

### What to display

| Label           | Value                                                                |
| --------------- | -------------------------------------------------------------------- |
| Ready to claim  | `proof.cumulativeMicroUsdc − rewards.settledMicroUsdc`, floored at 0 |
| Lifetime earned | `rewards.claimableMicroUsdc` ÷ 1e6                                   |
| Claimed         | `rewards.settledMicroUsdc` ÷ 1e6                                     |
| Pending         | `rewards.unpaidMicroUsdc` − ready to claim                           |
| Rate            | `effectiveRate.l1RateBps / 100` %                                    |

<Note>
  `effectiveRate` reports the standard programme rate. A negotiated partner rate is applied to actual earnings but is not reflected in that field today, so do not present it as a contractual rate.
</Note>

## Claiming

<Steps>
  <Step title="Fetch the proof">
    [`GET /api/referrals/proof/{address}`](/developer/api-reference/endpoint/referral-proof) returns `day`, `cumulativeMicroUsdc` and `proof`.

    A `404 nothing claimable` is a **normal state**, not an error.
  </Step>

  <Step title="Compute the payout">
    The contract pays `cumulativeAmount − claimed[account]`. Read it exactly with the `claimableAmount(account, cumulativeAmount)` view, or approximate it as `cumulativeMicroUsdc − settledMicroUsdc`.

    Prefer the on-chain view for the amount next to the button: `settledMicroUsdc` is only as fresh as our indexer, so it can over-report just after a claim.
  </Step>

  <Step title="Send the transaction">
    Call `claim` on `config.distributor`, on `config.chainId`. Pass `cumulativeMicroUsdc` unchanged — the contract computes the delta itself.

    ```json theme={null}
    {
      "type": "function",
      "name": "claim",
      "stateMutability": "nonpayable",
      "inputs": [
        { "name": "account", "type": "address" },
        { "name": "cumulativeAmount", "type": "uint256" },
        { "name": "proof", "type": "bytes32[]" }
      ],
      "outputs": []
    }
    ```

    Payment is native USDC on the hub chain, 6 decimals. The call is permissionless: anyone can submit it and funds always go to `account`, so a relayer can pay the gas.
  </Step>
</Steps>

Re-fetch the proof immediately before sending. Proofs go stale when a new batch is published, and a stale one reverts `InvalidProof`. Handle `NothingToClaim`, `InvalidProof` and `IsPaused`.

## Referral links

To match the gTrade app:

|                 |                              |
| --------------- | ---------------------------- |
| Query parameter | `ref`                        |
| Cookie name     | `referral_v2`                |
| Cookie value    | the code, lowercased         |
| Lifetime        | 604800 seconds (7 days)      |
| Precedence      | `?ref=` wins over the cookie |

Validate against `/^[a-z0-9_]{1,31}$/` before storing.

<Note>
  The `by` and `referredBy` parameters and the `referral` cookie belong to the older KOL-link system and are unrelated to this API.
</Note>

## Before you go live

Ask us for the following. The first three are blocking.

1. **Your exact origins added to the write allowlist.** Matching is exact string comparison on the `Origin` header: no wildcards, no subdomain matching. Without it, every write returns `403`. Tell us whether you call from a browser or server-side, because a request with no `Origin` header is also rejected.
2. **Confirmation that signed writes are enabled** on the environment you target.
3. **Confirmation that accrual indexing is enabled.** If it is off, writes succeed and reads return zeroes indefinitely, with no HTTP signal to detect it.
4. **The API base host** for each environment.
5. **The chain ids in the attribution window**, so your eligibility check matches ours.
6. **Reserved code namespaces**, if you want branded codes protected from squatting.
7. **Rate-limit headroom**, if your traffic egresses from a small set of IPs. The limit is 30 writes per minute per IP, shared across both write endpoints and both transports.

## Integration checklist

1. Probe `/config`. Non-`200` disables the feature.
2. Capture `?ref=`, validate it, store it in `referral_v2` for 7 days.
3. Resolve the code with `/code/{code}/address`. A `null` owner means do not attempt a bind.
4. Check eligibility across every attribution chain. Treat anything unresolved as not eligible.
5. Check `/binding/{address}`. Non-null means already bound.
6. Sign with values from `/config`, one write in flight per wallet.
7. On `201` you are done. On `409` or `500`, re-read state before concluding anything.
8. Offer the claim only on a `200` proof with a positive delta, with the wallet on `config.chainId`.
