> ## Documentation Index
> Fetch the complete documentation index at: https://glide-9da73dea.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Bring payments into your app

> Pay your users through Glide. Pre-create accounts by email, collect consent, read verification and wallet state, receive signed webhooks, and push batch payouts from a prefunded treasury.

You owe money to a list of people. You do not want to build banking, hold their identity documents, or run a payout rail. This recipe wires your platform to Glide so each of your users gets a real Glide account, consents to exactly what you may see, and receives USD you push from a prefunded treasury.

Audience: a backend engineer at a creator marketplace, a freelance platform, a payroll product, or anything else that pays its users.

Examples use `https://glide.co`. Supply your own configured credentials, funded treasury and consented recipients before calling the API. Placeholder UUIDs follow the `11111111-1111-4111-8111-111111111111` shape used throughout the [Partner API reference](/partners/index).

## What you are building

```
1. pre-create user by email   ->  Glide user id + claim link
2. user consents               ->  grant with the scopes you asked for
3. read state                  ->  verification, wallets, USD account, ledger
4. push a batch                ->  USD lands in each user's Glide balance
5. transfer.settled webhook    ->  you reconcile on transfer_id
6. user cashes out             ->  USD to USDC to their bank, on their own
```

## How the money moves

Three balances, in order.

**Your treasury USD balance.** Glide binds your partner record to one business entity and one entity-scoped USD balance. You prefund it by settling real USD into the bound reserve account, and a Glide operator records that settlement against evidence. There is no deposit API you call.

**The user's personal Glide USD balance.** A batch payout is a synchronous, atomic book transfer inside Glide: a treasury debit and a user credit, both completed, both zero fee, written in one database transaction. When the call returns 200, the money is spendable. Nothing is queued and nothing is pending. The user sees it as incoming, labelled **Payout from partner**.

**The user's bank.** The user, not you, drives the cash-out. They convert USD to USDC inside Glide, review the quoted fees, then continue to a bank withdrawal.

<Note>
  Receiving a payout does not require the user to be identity verified. Converting USD on the way to a bank withdrawal does. Payout eligibility also requires an active user, the required consent and a funded, enabled partner treasury. Glide prompts users to verify before conversion and bank payout. Do not build a verification gate in front of your own payouts.
</Note>

## Prerequisites

* A backend you can put a secret in. Every call in this recipe is server-side.
* An HTTPS endpoint on port 443 for webhooks, reachable from the public internet.
* Node 20 or later if you want to run the TypeScript samples, plus `curl` and `jq` for the shell ones.
* Partner credentials from Glide. Step 1 covers how to get them.

<Steps>
  <Step title="Get partner credentials from Glide">
    Ask Glide to create your partner record. You will be asked for:

    * Your partner name and a URL-safe slug. The name is what users see on the consent screen.
    * The exact webhook URLs you want allowlisted. Delivery only goes to an exact match.
    * Whether you intend to push payouts, which decides the `treasury:write` entitlement.

    Glide returns a `client_id` and a `client_secret` **once**. Client IDs look like `gpc_<slug>_<8 hex>`, secrets like `gps_` followed by 32 random bytes in base64url.

    ```bash theme={null}
    # .env on your server, never in source control
    GLIDE_BASE_URL=https://glide.co
    GLIDE_CLIENT_ID=gpc_example-market_a1b2c3d4
    GLIDE_CLIENT_SECRET=gps_...
    GLIDE_PAYER_ENTITY_ID=aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa
    GLIDE_WEBHOOK_SECRET=...
    ```

    Glide stores only a salted scrypt hash of the secret and can never show it to you again. If you lose it, ask Glide to rotate it. Rotation keeps your `client_id` and immediately kills the old secret, though tokens already minted stay valid for up to an hour.
  </Step>

  <Step title="Mint and cache an access token">
    Glide issues native `client_credentials` JWTs. There is no authorization server to configure.

    `POST /api/partner/oauth/token` with `Content-Type: application/x-www-form-urlencoded`, a required `grant_type=client_credentials`, and an optional space-separated `scope`. Authenticate with HTTP Basic, form-encoding each credential before base64-encoding `client_id:client_secret`.

    <CodeGroup>
      ```bash curl theme={null}
      curl --request POST https://glide.co/api/partner/oauth/token \
        --user "$GLIDE_CLIENT_ID:$GLIDE_CLIENT_SECRET" \
        --header 'Content-Type: application/x-www-form-urlencoded' \
        --data-urlencode 'grant_type=client_credentials' \
        --data-urlencode 'scope=kyc:read wallet:read' \
        | jq .
      ```

      ```ts TypeScript theme={null}
      // glide-token.ts
      const BASE = process.env.GLIDE_BASE_URL ?? 'https://glide.co';

      let cached: { token: string; expiresAt: number } | null = null;

      export async function getAccessToken(): Promise<string> {
        if (cached && Date.now() < cached.expiresAt) return cached.token;

        const basic = Buffer.from(
          `${encodeURIComponent(process.env.GLIDE_CLIENT_ID!)}:${encodeURIComponent(
            process.env.GLIDE_CLIENT_SECRET!
          )}`
        ).toString('base64');

        const res = await fetch(`${BASE}/api/partner/oauth/token`, {
          method: 'POST',
          headers: {
            Authorization: `Basic ${basic}`,
            'Content-Type': 'application/x-www-form-urlencoded',
          },
          // Omit `scope` to request every entitlement your client currently has.
          body: new URLSearchParams({ grant_type: 'client_credentials' }),
        });

        if (!res.ok) throw new Error(`glide token ${res.status}: ${await res.text()}`);

        const body = (await res.json()) as { access_token: string; expires_in: number };
        cached = {
          token: body.access_token,
          // Refresh 60 seconds early; still handle token expiry on each request.
          expiresAt: Date.now() + (body.expires_in - 60) * 1000,
        };
        return cached.token;
      }

      export function clearTokenCache() {
        cached = null;
      }
      ```
    </CodeGroup>

    ```json theme={null}
    {
      "access_token": "<jwt>",
      "token_type": "Bearer",
      "expires_in": 3600,
      "scope": "kyc:read wallet:read"
    }
    ```

    Send `Authorization: Bearer <access_token>` on every other call.

    Cache it. Tokens last exactly one hour, and issuance is limited to 30 requests a minute per client, counting failed attempts. Minting one per request will rate-limit you under any real load.

    On a single `401 invalid_token`, clear the cache and mint once more. Do not loop. If the second attempt fails too, your credentials or your client status is the problem. Clear the cache on deploy when you rotate the secret.

    <Warning>
      Send credentials one way only. Combining HTTP Basic with form-field credentials, or repeating any parameter, returns `invalid_request`. Form bodies are capped at 4096 bytes.
    </Warning>
  </Step>

  <Step title="Pre-create a user and send the claim link">
    You bring the email. Glide creates a placeholder account and returns a stable user id plus a link to the consent screen.

    <CodeGroup>
      ```bash curl theme={null}
      curl --request POST https://glide.co/api/partner/users \
        --header "Authorization: Bearer $GLIDE_ACCESS_TOKEN" \
        --header 'Content-Type: application/json' \
        --data '{ "email": "creator@example.com" }' | jq .
      ```

      ```ts TypeScript theme={null}
      export async function precreateUser(email: string) {
        const res = await fetch(`${BASE}/api/partner/users`, {
          method: 'POST',
          headers: {
            Authorization: `Bearer ${await getAccessToken()}`,
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ email }),
        });

        if (!res.ok) throw new Error(`glide precreate ${res.status}: ${await res.text()}`);

        return (await res.json()) as {
          user_id: string;
          created: boolean;
          claim_link: string;
        };
      }
      ```
    </CodeGroup>

    ```json theme={null}
    {
      "user_id": "11111111-1111-4111-8111-111111111111",
      "created": true,
      "claim_link": "https://glide.co/partner/consent?partner=example-market&scopes=kyc%3Aread+wallet%3Aread+us_account%3Aread+transfers%3Aread+payouts%3Areceive"
    }
    ```

    Store `user_id` against your own user row. It is stable forever, including after the person claims the account.

    `created` describes the **first** call for your partner and that email. It does not flip on replay, so a repeated call returns the same id and the same `created` value. If the person already has a Glide account, you get `created: false` and their existing id. Pre-creation is limited to 60 requests a minute.

    Glide sends no email. Put `claim_link` in your own onboarding email or dashboard, and tell the person which address to sign in with.

    ### What the user sees

    They land on the consent screen, sign in, see your partner name and the exact permissions you asked for, and click **Allow access**. Approving replaces their scope set for you with exactly what they approved, so a narrower re-consent narrows your access. They can revoke any time under **Settings** then **Integrations**.

    | Scope             | Consent screen wording                                          | Lets you                                                                      |
    | ----------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------- |
    | `kyc:read`        | View verification status and request a hosted verification link | Read verification status, request a hosted link, receive `kyc.status_changed` |
    | `wallet:read`     | View your wallet addresses                                      | Read EVM and Solana public addresses                                          |
    | `us_account:read` | View your USD receiving account details                         | Read provisioned USD receiving details, receive `us_account.provisioned`      |
    | `transfers:read`  | View your personal transfer history                             | Read the personal ledger, receive `transfer.settled` and `transfer.failed`    |
    | `payouts:receive` | Receive payouts from this partner                               | Include the user as a batch recipient                                         |

    <Note>
      To pay someone you need **both** `payouts:receive` and `transfers:read` on their grant. The payout writes a transfer the user has to be able to see, so receive consent alone is not enough.
    </Note>

    The claim link is navigation to a consent screen, not a bearer capability and not a login bypass. It carries no email and no user id, so it is safe to render in your UI. The person must sign in with the email you pre-created. Glide attaches their verified identity only where the email matches and no identity is attached yet, which preserves the Glide user id, your receipt and any grants. Signing in with a different address consents for that other account instead. If someone uses the wrong address, pre-create the one they actually used and send them that link.

    Partner placeholders have no account type. The owner picks Individual or Company after claiming, through Glide's normal one-time selection. You cannot set it and you cannot change it later.
  </Step>

  <Step title="Read verification, wallets and ledger state">
    Four reads, each gated by its own consent scope, each limited to 300 requests a minute.

    ```bash theme={null}
    # Verification status
    curl -s "https://glide.co/api/partner/users/11111111-1111-4111-8111-111111111111/kyc" \
      -H "Authorization: Bearer $GLIDE_ACCESS_TOKEN" | jq .

    # Wallet addresses
    curl -s "https://glide.co/api/partner/users/11111111-1111-4111-8111-111111111111/wallets" \
      -H "Authorization: Bearer $GLIDE_ACCESS_TOKEN" | jq .

    # USD receiving account
    curl -s "https://glide.co/api/partner/users/11111111-1111-4111-8111-111111111111/us-account" \
      -H "Authorization: Bearer $GLIDE_ACCESS_TOKEN" | jq .
    ```

    ```json theme={null}
    {
      "user_id": "11111111-1111-4111-8111-111111111111",
      "status": "review",
      "provider": "noah",
      "updated_at": "2026-09-06T00:00:00.000Z"
    }
    ```

    Verification status is one of `not_started`, `in_progress`, `review`, `approved` or `rejected`. With no record, it is `not_started` with null provider and timestamp. You never receive documents, rejection reasons or provider tokens.

    Wallets come back as an array of `evm` and `solana` addresses. An unclaimed user normally has an empty array, so treat empty as "not ready yet" rather than an error.

    The USD account response is a discriminated union on `status`. Branch on it before reading `account`:

    ```json theme={null}
    {
      "user_id": "11111111-1111-4111-8111-111111111111",
      "status": "not_provisioned"
    }
    ```

    Reading this endpoint never starts provisioning. If the user has no receiving account, they have to complete the deposit-details flow inside Glide.

    Need the user to verify? Request a hosted link with `POST /api/partner/kyc/:id/link`, no body, limited to 10 requests a minute. It returns `status: "created"` with a URL, `already_approved` with a null URL, or `pending` with a null URL while the provider is still reviewing. Glide handles the callbacks, so you learn the outcome from `kyc.status_changed` rather than by polling.

    ### Polling the ledger

    `GET /api/partner/users/:id/transfers` returns the user's personal ledger. Use `since` as an exclusive lower bound on `updated_at`, `limit` between 1 and 100, and follow `cursor` to the end.

    ```ts theme={null}
    export async function fetchTransfersSince(userId: string, since: string) {
      const rows: { id: string; updated_at: string; amount_cents: string }[] = [];
      let cursor: string | null = null;

      do {
        const url = new URL(`${BASE}/api/partner/users/${userId}/transfers`);
        url.searchParams.set('since', since);
        url.searchParams.set('limit', '100');
        if (cursor) url.searchParams.set('cursor', cursor);

        const res = await fetch(url, {
          headers: { Authorization: `Bearer ${await getAccessToken()}` },
        });
        if (!res.ok) throw new Error(`glide transfers ${res.status}`);

        const page = (await res.json()) as {
          transfers: typeof rows;
          next_cursor: string | null;
        };
        rows.push(...page.transfers);
        cursor = page.next_cursor;
      } while (cursor);

      return rows;
    }
    ```

    Results sort by `updated_at` then `id`, both ascending, at full timestamp precision. Preserve `since` while paging and only advance your watermark once `next_cursor` comes back null.

    <Warning>
      This is an incremental view, not a frozen snapshot. Rows can change while you page. Overlap your watermark by a safe margin and dedupe on `(id, updated_at)` before applying anything. An invalid cursor returns `400 invalid_cursor`: restart from your watermark rather than trying to repair it.
    </Warning>

    `amount_cents` is an exact string-encoded minor-unit integer, selected as text so JavaScript cannot round it. Parse it as a big integer or a decimal type, never a float. On older rows, `status`, `currency` and `completed_at` can be null.
  </Step>

  <Step title="Receive and verify webhooks">
    Ask Glide to allowlist your exact URL and configure the endpoint. Glide returns the signing secret once and keeps only encrypted ciphertext. Each allowlisted URL gets its own secret.

    Four event types arrive, each gated by the matching consent scope:

    | Type                     | Scope             | `data`                                              |
    | ------------------------ | ----------------- | --------------------------------------------------- |
    | `kyc.status_changed`     | `kyc:read`        | `status`, `provider`                                |
    | `us_account.provisioned` | `us_account:read` | `account_id`, `currency`, `provider`                |
    | `transfer.settled`       | `transfers:read`  | `transfer_id`, `status`, `amount_cents`, `currency` |
    | `transfer.failed`        | `transfers:read`  | `transfer_id`, `status`, `amount_cents`, `currency` |

    ```json theme={null}
    {
      "id": "44444444-4444-4444-8444-444444444444",
      "type": "transfer.settled",
      "user_id": "11111111-1111-4111-8111-111111111111",
      "occurred_at": "2026-09-06T00:00:00.000Z",
      "data": {
        "transfer_id": "22222222-2222-4222-8222-222222222222",
        "status": "completed",
        "amount_cents": "2500",
        "currency": "USD"
      }
    }
    ```

    Each request carries `Content-Type: application/json`, `Idempotency-Key` set to the event `id`, and `X-Glide-Signature: t=<unix-seconds>,v1=<hex HMAC-SHA256>`.

    Verify the HMAC over the exact string `${timestamp}.${rawRequestBody}` with a constant-time compare:

    ```ts theme={null}
    // webhook.ts
    import crypto from 'node:crypto';
    import express from 'express';

    const app = express();
    const SECRET = process.env.GLIDE_WEBHOOK_SECRET!;
    const TOLERANCE_SECONDS = 300;

    function verifySignature(header: string | undefined, rawBody: Buffer): boolean {
      if (!header) return false;

      let timestamp = '';
      let signature = '';
      for (const part of header.split(',')) {
        const [key, value] = part.trim().split('=');
        if (key === 't') timestamp = value ?? '';
        if (key === 'v1') signature = value ?? '';
      }
      if (!timestamp || !signature) return false;

      const sent = Number(timestamp);
      if (!Number.isFinite(sent)) return false;
      if (Math.abs(Math.floor(Date.now() / 1000) - sent) > TOLERANCE_SECONDS) return false;

      const expected = crypto
        .createHmac('sha256', SECRET)
        .update(`${timestamp}.${rawBody.toString('utf8')}`)
        .digest('hex');

      const a = Buffer.from(expected, 'utf8');
      const b = Buffer.from(signature, 'utf8');
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    }

    // express.raw keeps the body as bytes. Never mount express.json on this path:
    // re-serializing parsed JSON changes whitespace and breaks the signature.
    app.post(
      '/webhooks/glide',
      express.raw({ type: 'application/json' }),
      async (req, res) => {
        if (!verifySignature(req.header('X-Glide-Signature'), req.body)) {
          return res.status(400).send('invalid signature');
        }

        const event = JSON.parse(req.body.toString('utf8')) as {
          id: string;
          type: string;
          user_id: string;
          occurred_at: string;
          data: Record<string, unknown>;
        };

        // Dedupe before any side effect. Insert the id under a unique constraint
        // and treat a duplicate-key error as "already handled".
        const isNew = await recordEventOnce(event.id);
        if (!isNew) return res.status(200).send('duplicate');

        // Persist durably, then do slow work on your own queue.
        await enqueue(event);

        return res.status(200).send('ok');
      }
    );

    app.listen(3000);
    ```

    Four rules make this correct:

    1. **Sign over the raw bytes.** Capture the body before any JSON middleware touches it.
    2. **Enforce a five minute tolerance.** The timestamp and signature are regenerated on each retry while the event id and payload stay identical, so tolerance blocks replay without breaking legitimate retries.
    3. **Dedupe by event id before side effects.** Delivery is at least once, and your receiver can accept an event before Glide records the success.
    4. **Return 2xx only after durable acceptance.** Network errors, 408, 429 and 5xx are retried. Every other non-2xx, redirects included, is a terminal failure.

    Retries run at 30, 60, 120, 240, 480, 960 and 1920 seconds after the previous attempt, for at most eight attempts including any interrupted by a worker restart. The cron cadence can add up to a minute to each delay. Glide requires HTTPS on port 443, resolves your host to public addresses only, pins the connection to the validated IP, and follows no redirects.

    Before every attempt Glide rechecks your partner status, the endpoint allowlist and the user's grant. A revoked or replaced grant cancels queued events, and re-granting does not release events queued under the prior consent.

    <Warning>
      Ordering is not guaranteed, and an event can beat the API response that caused it. When order matters, fetch current state from the read endpoints instead of folding events into a state machine.
    </Warning>
  </Step>

  <Step title="Pay your users">
    `POST /api/partner/treasury/transfers/batch` moves USD from your treasury into 2 to 50 user balances in one atomic transaction.

    Before the first call, Glide has to have bound your treasury to your business entity and external payer UUID, recorded verified funding, and activated it. That sequence is [Go live](/partners/go-live). Your token needs the `treasury:write` entitlement, which never appears in user consent.

    Each recipient needs a pre-creation receipt for your partner, an active account, and both `payouts:receive` and `transfers:read` on their grant. There is no verification, wallet or receiving-account gate on this endpoint.

    ### The request

    Send `Content-Type: application/json` and a required `Idempotency-Key` of 1 to 128 characters from `[A-Za-z0-9._:-]`.

    ```bash theme={null}
    curl --request POST https://glide.co/api/partner/treasury/transfers/batch \
      --header "Authorization: Bearer $GLIDE_ACCESS_TOKEN" \
      --header 'Content-Type: application/json' \
      --header 'Idempotency-Key: payout-run-2026-09-06-a' \
      --data '{
        "payer_entity_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
        "currency": "USD",
        "items": [
          { "execution_id": "11111111-1111-4111-8111-111111111111", "payout_id": "22222222-2222-4222-8222-222222222222", "user_id": "33333333-3333-4333-8333-333333333333", "amount_cents": "2500", "reference": "invoice-104" },
          { "execution_id": "44444444-4444-4444-8444-444444444444", "payout_id": "55555555-5555-4555-8555-555555555555", "user_id": "66666666-6666-4666-8666-666666666666", "amount_cents": "4000", "reference": "invoice-105" }
        ]
      }' | jq .
    ```

    | Field             | Rule                                                                             |
    | ----------------- | -------------------------------------------------------------------------------- |
    | `payer_entity_id` | Must equal your binding's external payer id. It cannot select another balance    |
    | `currency`        | Exactly `"USD"`                                                                  |
    | `items`           | 2 to 50 strict objects. Unknown fields are rejected                              |
    | `execution_id`    | UUID, unique in the request. One attempt to move one amount to one user          |
    | `payout_id`       | UUID, unique in the request. Your obligation identifier                          |
    | `amount_cents`    | Positive integer string. No sign, decimals, exponent, whitespace or leading zero |
    | `reference`       | 1 to 200 characters                                                              |

    Amounts and the request total are each capped at `9007199254740991`. The streamed body limit is 64 KiB, and the rate limit is 10 requests a minute. The same user may appear twice if each entry is a distinct obligation.

    Generate `execution_id` and `payout_id` once, and persist them next to the obligation **before** you make the call. They are what makes a lost response recoverable.

    ### The response

    HTTP 200, including on replay, with one result per item in request order:

    ```json theme={null}
    {
      "batch_id": "77777777-7777-4777-8777-777777777777",
      "treasury_entity_id": "88888888-8888-4888-8888-888888888888",
      "currency": "USD",
      "total_amount_cents": "6500",
      "items": [
        { "execution_id": "11111111-1111-4111-8111-111111111111", "user_id": "33333333-3333-4333-8333-333333333333", "transfer_id": "99999999-9999-4999-8999-999999999999", "amount_cents": "2500", "state": "processed", "version": 1, "fee_cents": "0", "fee_included": false },
        { "execution_id": "44444444-4444-4444-8444-444444444444", "user_id": "66666666-6666-4666-8666-666666666666", "transfer_id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "amount_cents": "4000", "state": "processed", "version": 1, "fee_cents": "0", "fee_included": false }
      ]
    }
    ```

    Store `transfer_id` against your payout. It is the correlation key for the ledger read and for `transfer.settled`. `state` is always `processed`, `version` always `1`, `fee_cents` always `"0"`. `total_amount_cents` includes reused items, so on a partial replay it can exceed the money this call actually debited.

    The success audit, batch response, receipts, treasury debit, user credits, activity entry and webhook outbox all commit together. Audit or outbox failure rolls the whole new batch back. You will never receive a successful subset.

    ### Retries, exactly

    ```ts theme={null}
    export async function sendBatch(idempotencyKey: string, body: unknown) {
      const res = await fetch(`${BASE}/api/partner/treasury/transfers/batch`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${await getAccessToken()}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey,
        },
        body: JSON.stringify(body),
      });

      if (res.status === 200) return { outcome: 'settled', body: await res.json() };

      // 5xx, 408 and transport failures are UNCONFIRMED, not failed.
      // Retry the identical body with the identical key.
      if (res.status >= 500) return { outcome: 'unconfirmed', status: res.status };

      const error = (await res.json()) as {
        error: { code: string; message: string; request_id: string };
      };
      return { outcome: 'rejected', status: res.status, ...error.error };
    }
    ```

    | Situation                                                                                         | Result                                                               |
    | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
    | Same key, same canonical request                                                                  | Original stored response. No new money, receipt, usage or event      |
    | Same key, changed request, including reordered items                                              | 409 `idempotency_conflict`                                           |
    | Different key, identical previously executed item                                                 | Reuses that item's `transfer_id`. Only unseen executions are debited |
    | Reused `execution_id` with a changed recipient, payer, `payout_id`, amount, currency or reference | 409 `execution_conflict`. No item in the batch executes              |
    | Validation, policy or funds rejection                                                             | Audited as a rejection. No settled batch, no execution ids consumed  |
    | Commit succeeded but you lost the response                                                        | Retry the same key and payload to recover the original body          |
    | Timeout or any uncertain outcome                                                                  | Keep the execution ids and retry                                     |

    <Warning>
      Never mint a fresh `execution_id` to retry, and never mark an unconfirmed payout failed. A new id is a new payment. On a timeout, a dropped connection, a 5xx or an unparseable response, resend the identical body with the identical `Idempotency-Key`. A replayed 200 reconciles you against the transfers that already exist.
    </Warning>

    ### What each error means

    | HTTP      | Code                                                      | Do this                                                                                                          |
    | --------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
    | 400       | `invalid_request`, `invalid_json`                         | Fix the request. Nothing executed and no ids were consumed                                                       |
    | 401       | `invalid_token`                                           | Clear the token cache and mint once                                                                              |
    | 403       | `grant_required`                                          | Send the user back through consent with both required scopes                                                     |
    | 403       | `user_inactive`                                           | Stop paying that user until it is resolved                                                                       |
    | 403       | `partner_inactive`                                        | Your partner is suspended. Contact Glide                                                                         |
    | 403       | `treasury_inactive`                                       | Suspended, disabled or an expired mandate. Reactivate, then retry the same ids                                   |
    | 403       | `payer_not_allowed`                                       | `payer_entity_id` does not match your binding. Fix your configuration                                            |
    | 403       | `policy_denied`                                           | A cap or the step-up amount was exceeded. Shrink the batch or ask Glide to raise the cap. No token bypasses this |
    | 404       | `user_not_found`                                          | No receipt for your partner, or the id belongs to another partner                                                |
    | 404       | `treasury_not_configured`                                 | No treasury is bound. Complete binding with Glide                                                                |
    | 409       | `insufficient_funds`                                      | Fund the reserve, have Glide record it, then retry the same ids                                                  |
    | 409       | `idempotency_conflict`                                    | The key was reused with a different request. Investigate before changing anything                                |
    | 409       | `execution_conflict`                                      | A reused execution id was mutated. Investigate. Nothing executed                                                 |
    | 413       | `body_too_large`                                          | Over 64 KiB. Split the batch                                                                                     |
    | 429       | `rate_limited`                                            | Back off for the full `Retry-After: 60` window                                                                   |
    | 500 / 503 | `internal_error`, `auth_unavailable`, `audit_unavailable` | Unconfirmed. Retry the same key and payload                                                                      |

    Every error body carries a `request_id` that matches the `X-Request-Id` header and the audit row. Log it.

    ### Caps

    Under the treasury lock Glide enforces per-item, per-batch, rolling 24 hour and lifetime amount caps, plus transfer counts per hour and per 24 hours. Only **new** transfers consume allowance, so replays are free. Hitting a cap exactly is allowed. Cap errors name the violated limit without revealing remaining allowance.

    ### Correlating the webhook

    `transfer.settled` fires from the user's credit. Match `data.transfer_id` to the `transfer_id` you stored from the batch response.

    ```ts theme={null}
    async function onTransferSettled(event: {
      data: { transfer_id: string; amount_cents: string };
    }) {
      const payout = await findPayoutByTransferId(event.data.transfer_id);
      if (!payout) {
        // The event beat your saved batch response, or it belongs to a movement
        // you did not cause. Retain it and retry the correlation on a backoff.
        await retainForRetry(event);
        return;
      }
      await markPayoutConfirmed(payout.id, event.data.amount_cents);
    }
    ```

    An unmatched event is not necessarily an error. Transfer events cover the user's whole personal ledger, so movements you did not cause also arrive, and a user may have authorized other platforms. Retain the event, retry the correlation on a backoff, and escalate to a human if it stays unmatched rather than discarding it.
  </Step>

  <Step title="Let the user cash out">
    Once the batch returns 200, the money is in the user's personal Glide USD balance and is spendable. They see it under **Glide balance** in the Glide app, and the payout appears as incoming, labelled **Payout from partner**.

    From there the user drives everything:

    1. Convert USD to USDC inside Glide, reviewing the quoted fees. **This step requires approved verification.** Glide explains that on the balance block and links unverified users to identity verification.
    2. Continue to a bank withdrawal from the conversion receipt, or open the bank flow directly against available USDC.

    You do not initiate or observe the withdrawal, and you should not gate your payout on it. Track any failed or uncertain withdrawal through the user's cash-out flow. Do not treat the completed partner payout receipt as proof of bank settlement or a returned balance.

    If you want to nudge verification, request a hosted link with `POST /api/partner/kyc/:id/link` and watch for `kyc.status_changed` with `status: "approved"`.
  </Step>
</Steps>

## Go-live checklist

Work through [Go live](/partners/go-live) for the full ordered sequence. The short version:

* [ ] Partner created, credentials stored in a secret manager, `treasury:write` enabled, and a native token verified against a read endpoint.
* [ ] Custody and redemption mandate confirmed, reserve account and settlement evidence identified.
* [ ] Business entity created through its normal owner flow, with its real verification status.
* [ ] Treasury bound with approved caps and mandate expiry, external funding verified and recorded against its receipt.
* [ ] Recipients pre-created, active, and consenting to both `payouts:receive` and `transfers:read`.
* [ ] The exact webhook URL allowlisted, its secret configured, and the receiver validated end to end.
* [ ] Proven live in the configured environment: a funded reserve, a small two-recipient batch, the credit visible in each personal USD balance, a signed webhook received and correlated, and a user-authorized bank withdrawal completing.
* [ ] Treasury activated only after all of the above, with monitoring on receipts, audits, rolling usage, webhook delivery and reserve reconciliation.

Two switches when something goes wrong. Treasury suspension stops new payouts while leaving reads, webhooks and authorized replays working. Partner suspension stops all API access and delivery immediately. If a secret leaked, rotate **and** suspend, because rotation alone leaves already-issued tokens valid for up to an hour.

## Reading list

* [Partner API overview](/partners/index) gives the model and every endpoint in one table.
* [Authentication](/partners/authentication) covers token claims, scopes, rotation and issuance limits.
* [Users and consent](/partners/users-and-consent) covers pre-creation, claiming and how access is checked.
* [Payouts](/partners/payouts) is the full treasury contract, including caps and admin operations.
* [Webhooks](/partners/webhooks) covers signing, retries and consent binding in detail.
* [Errors](/partners/errors) lists every code with its remediation.
