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

# Balances, fees and reconciliation

> Compute spendable and withdrawable cash, track and collect your own vendor fees, and close a daily cash reconciliation.

`GetAccountBalance` returns six money fields and none of them is named "spendable". This page gives you the rule for cash a user may spend, the fee accruals only you can see, and the daily run that proves both.

<Info>
  **Before this page**

  * You can mint an access token and hold the account-scoped read grants — see [Authentication](/authentication).
  * You know which firm each account belongs to — see [Firms, participants and accounts](/identity).
  * You can move cash and you know that direction is fixed by the reason — see [Move cash](/funding#move-cash).
  * You know the residue you cannot move — see [Sub-cent balances](/funding#sub-cent-balances).
  * You can open a balance-ledger subscription — see [Balance ledger](/streams#balance-ledger).
</Info>

<Info>
  **API reference:** <a href="https://docs.polymarket.us/institutional/positions/overview" target="_blank" rel="noreferrer">Positions</a> · <a href="https://docs.polymarket.us/institutional/report/overview" target="_blank" rel="noreferrer">Report</a>. Opens on the public documentation site in a new tab.
  Where it disagrees with this page, this page is authoritative for the partner surface.
</Info>

## Which number is spendable

<Note>
  **If you are an IB, this differs.**
  These fields feed two of your named reports, **Equity Run / Account Status** and
  **Margin Call / Debit**, so the `InvalidArgument: invalid account` failure on participant
  accounts is a reporting blocker for you rather than a curiosity — see
  [Reporting pack](/regulatory#reporting-pack). Treat the open-short collateral question as a
  stated control: hold back **\$1.00 per open short contract** in your own model and say so in
  your risk-monitoring overview.
</Note>

### The six fields

| Field                | What you may rely on today                                                                                                                                                                                                                                                                               |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `balance`            | The account's cash figure. Every rule below starts from it. It carries more than two decimal places — see [Sub-cent balances](/funding#sub-cent-balances)                                                                                                                                                |
| `capitalRequirement` | A requirement figure held against the account. How it is computed is not published                                                                                                                                                                                                                       |
| `excessCapital`      | One of the two fields current docs point at as "spendable". Treat it as a ceiling, not a definition                                                                                                                                                                                                      |
| `buyingPower`        | The other. Treat it as a ceiling, not a definition                                                                                                                                                                                                                                                       |
| `unsettledFunds`     | Funds not yet settled. Whether they are already included in `balance` is not published                                                                                                                                                                                                                   |
| `marginRequirement`  | The margin requirement figure on **this response**. How it is computed is not published. **\[VERIFY]** Do not equate it with the balance-ledger entry's `margin_requirement`: that is a field on a different object, and it is the one that came back **empty** on an open short — see the warning below |

<Warning>
  **Not yet published.** There is no authoritative per-field definition for `capitalRequirement`,
  `excessCapital`, `buyingPower`, `unsettledFunds` or `marginRequirement`, and no statement of which
  fields overlap.

  Compose the rules below rather than picking a single field, and confirm the definitions with your
  integration lead before you expose any of them to a user.
</Warning>

### Spendable cash

Three different definitions are in circulation across three current pages. Each one covers a real term and none is complete on its own. The real gate composes all three:

| Definition in circulation                                                   | What it actually contributes |
| --------------------------------------------------------------------------- | ---------------------------- |
| `free cash = account cash − collateral locked by open orders and positions` | the collateral term          |
| `spendable balance = account cash − accrued, uncollected vendor fees`       | the fee term                 |
| "use `buyingPower` / `excessCapital`"                                       | the platform's own ceiling   |

Use this, and only this:

```text theme={null}
spendable = min(
    balance
      − collateral locked by open orders and positions   # [VERIFY] — no readable source
      − your accrued, uncollected vendor fees,           # you track this; we do not
    buyingPower,
    excessCapital
)
```

<Warning>
  **\[VERIFY] — this formula is not computable from the API as written.** Its first term, *collateral
  locked by open orders and positions*, **has no readable source**: no field we return exposes it, and
  whether an open short even carries a standing collateral requirement is unanswered.

  So do not present the formula as something you can evaluate. What you can evaluate today is
  `min(balance − your accrued fees, buyingPower, excessCapital)`; the collateral term has to come from
  **your own** order and position store, as an estimate you hold back. The code below leaves that term
  out and says so, rather than pretending a field supplies it.
</Warning>

Two consequences of that composition. First, the accrued-fee term is **yours**: the platform never knows your fee basis, so no field we return has it subtracted — see [Vendor fees](#vendor-fees). Second, taking the minimum against both platform ceilings is deliberate: while the authoritative definition of `buyingPower` and `excessCapital` is unpublished, the lower of the two is the only value that cannot overstate what a user may spend.

### Withdrawable and sweepable cash

Withdrawable cash is spendable cash minus what is backing obligations you have not closed:

```text theme={null}
withdrawable = spendable − 1.00 × open_short_contracts   # USD, your own reserve
```

That reserve is yours to hold. An open short carries an obligation of **up to \$1.00 per contract**, so reserving the full dollar per short contract is the only figure that cannot under-reserve.

At the pool level, sweepable cash is the sum of `withdrawable` across your participant accounts, moved out one `WITHDRAWAL` transfer at a time. Nothing sweeps itself: settlement credits and released collateral stay in the participant's clearing account and become buying power there.

<Warning>
  **Not yet published, and it is a money risk.** Whether an open short carries a collateral
  requirement, and where that requirement is exposed, is unanswered.
  `balance_reservation` and `margin_requirement` both came back **empty** on the ledger entries for an
  open short, and two sources disagree on whether a requirement exists at all.

  **If you gate withdrawals on a platform field alone, you will let a user withdraw the funds backing
  an open short.** Hold the \$1.00-per-contract reserve in your own ledger until you have a written
  answer.
</Warning>

### The read that fails on participant accounts

`GetAccountBalance` returns `InvalidArgument: invalid account` for a **participant clearing account**, while the same call succeeds for a firm account. This is a platform gap, not a malformed request, and no amount of reformatting the account name fixes it.

**There is no point-read RPC for a participant account balance.** No RPC returns one entry, or one field, for that account: the balance ledger is a **stream** with a replay phase and then a live phase, and the workaround is to subscribe, let the replay phase drain, and take the **newest entry's `afterBalance`**.

Be clear about what you are accepting: a ledger scan standing in for a one-field read, with a per-account stream that counts against your firm's cap of 20 concurrent streams. The read budget and the reason this does not scale per participant are under [Reconciling cash](#reconciling-cash).

<Warning>
  **\[VERIFY] — two things the workaround depends on are not published.** The replay phase's **ordering**
  is not stated, so "the newest entry" cannot be identified from the API contract, and there is **no
  published signal that the replay phase has ended**.

  The code below keeps the last entry the replay phase delivers and treats a quiet stream as the
  drain. Confirm both with your integration lead before you show the figure to a user.
</Warning>

### The pool

`GetFundingAccountBalance` is the pool's **source of truth**. Reconcile your own view of the pool against it, not against the sum of your participant balances.

<Warning>
  **Not yet published.** There is no rate limit published for `GetFundingAccountBalance`; the only
  guidance on record is "poll at modest rates".

  Until it is published, poll the pool on a fixed schedule rather than per user action, and stay inside
  the per-firm unary budget of **250 requests / 60 s per firm** that applies to your gRPC reads.
</Warning>

### Read a balance

<Note>
  The channel and the per-call metadata below come from the canonical client published on
  [Authentication](/authentication).

  **\[VERIFY]** — the **owning service and fully-qualified method names** for `GetAccountBalance`,
  `GetFundingAccountBalance` and the balance-ledger subscription are not published, so the stub names
  in the snippets are placeholders.

  Read the real names out of your own proto copy before you compile.
</Note>

<CodeGroup>
  ```python Python theme={null}
  from decimal import Decimal

  import grpc
  from polymarket_auth import PolymarketClient  # published on /connect#authentication

  # protoc output from the protos your integration lead sent you. The owning services
  # are not published — check the stub names against your own copy.
  from pmx_protos import balance_pb2, balance_pb2_grpc
  from pmx_protos import ledger_pb2, ledger_pb2_grpc

  # The gRPC target is GRPC_TARGET in polymarket_auth.py — one place, both environments.
  pmx = PolymarketClient()

  FIRM_ACCOUNT = "firms/.../accounts/..."         # your firm account
  PARTICIPANT_ACCOUNT = "firms/.../accounts/..."  # provisionedAccount, recorded at onboarding
  PARTICIPANT_ID = "..."                          # participantId from the kyc.approved webhook
  OPEN_SHORT_CONTRACTS = 0                        # from your own position store
  ACCRUED_FEES = "0.00"                           # USD, from your own fee ledger
  REPLAY_DEADLINE_SECONDS = 60                    # yours: no replay-to-live signal is published


  def newest_after_balance(channel, account: str, participant_id: str) -> Decimal:
      """The workaround for a participant clearing account.

      There is no point-read RPC for a participant account balance, so this
      subscribes to the balance ledger, drains the replay phase, and keeps the
      newest entry's afterBalance.
      """
      stub = ledger_pb2_grpc.BalanceLedgerStub(channel)
      stream = stub.CreateBalanceLedgerSubscription(
          ledger_pb2.CreateBalanceLedgerSubscriptionRequest(account=account),
          # Account-scoped: x-participant-id is required on this surface.
          metadata=pmx.metadata(participant_id=participant_id),
          timeout=REPLAY_DEADLINE_SECONDS,
      )
      newest = None
      try:
          for entry in stream:
              # Replay ordering is not published, so this keeps the LAST entry the
              # replay phase delivered. Confirm the ordering before you trust it.
              newest = entry
      except grpc.RpcError as err:
          # No published replay-to-live signal: a quiet stream is how you detect the
          # drain. Anything else is a real failure — never swallow it.
          if err.code() is not grpc.StatusCode.DEADLINE_EXCEEDED:
              raise
      # proto3: an absent field and a zero are indistinguishable. Require the field.
      if newest is None or not newest.HasField("after_balance"):
          raise RuntimeError(f"no balance-ledger entry carrying afterBalance for {account}")
      return Decimal(newest.after_balance)


  def spendable(account: str, participant_id: str) -> Decimal:
      with pmx.channel() as channel:
          balances = balance_pb2_grpc.AccountBalanceStub(channel)
          try:
              b = balances.GetAccountBalance(
                  balance_pb2.GetAccountBalanceRequest(account=account),
                  # Account-scoped read: x-participant-id is required.
                  metadata=pmx.metadata(participant_id=participant_id),
              )
              balance = Decimal(b.balance)
              ceilings = [Decimal(b.buying_power), Decimal(b.excess_capital)]
          except grpc.RpcError as err:
              # InvalidArgument: invalid account — expected on a participant clearing account.
              if "invalid account" not in (err.details() or ""):
                  raise
              balance = newest_after_balance(channel, account, participant_id)
              ceilings = []  # no ceilings available on this path; the reserve below is all you have

      locked = Decimal("1.00") * OPEN_SHORT_CONTRACTS  # your own reserve, see the warning above
      # The collateral term is NOT in this sum: no field exposes it. See the warning above.
      return min([balance - locked - Decimal(ACCRUED_FEES), *ceilings])


  print(spendable(FIRM_ACCOUNT, PARTICIPANT_ID))
  print(spendable(PARTICIPANT_ACCOUNT, PARTICIPANT_ID))
  ```

  ```typescript TypeScript theme={null}
  // npm i decimal.js @grpc/grpc-js
  import Decimal from "decimal.js";
  import * as grpc from "@grpc/grpc-js";
  import { PolymarketClient } from "./polymarketAuth"; // published on /connect#authentication

  // protoc output from the protos your integration lead sent you. The owning services
  // are not published — check these names against your own copy.
  import { AccountBalanceClient } from "./gen/balance";
  import { BalanceLedgerClient } from "./gen/ledger";

  // The gRPC target is GRPC_TARGET in polymarketAuth.ts — one place, both environments.
  const pmx = new PolymarketClient();

  const PARTICIPANT_ACCOUNT = "firms/.../accounts/..."; // provisionedAccount, recorded at onboarding
  const PARTICIPANT_ID = "..."; // participantId from the kyc.approved webhook
  const OPEN_SHORT_CONTRACTS = 0; // from your own position store
  const ACCRUED_FEES = "0.00"; // USD, from your own fee ledger
  const REPLAY_DEADLINE_MS = 60_000; // yours: no replay-to-live signal is published

  /**
   * The workaround for a participant clearing account. There is no point-read RPC
   * for a participant account balance, so this subscribes to the balance ledger,
   * drains the replay phase, and keeps the newest entry's afterBalance.
   */
  async function newestAfterBalance(channel: grpc.Channel, account: string): Promise<string> {
    const stream = new BalanceLedgerClient(channel).createBalanceLedgerSubscription(
      { account },
      // Account-scoped: x-participant-id is required on this surface.
      await pmx.metadata(PARTICIPANT_ID),
      { deadline: Date.now() + REPLAY_DEADLINE_MS },
    );

    let newest: { afterBalance?: string } | undefined;
    try {
      // Replay ordering is not published, so this keeps the LAST entry the replay
      // phase delivered. Confirm the ordering before you trust it.
      for await (const entry of stream) newest = entry;
    } catch (err: any) {
      // No published replay-to-live signal: a quiet stream is how you detect the drain.
      // Anything else is a real failure — never swallow it.
      if (err?.code !== grpc.status.DEADLINE_EXCEEDED) throw err;
    }
    if (newest?.afterBalance === undefined) {
      throw new Error(`no balance-ledger entry carrying afterBalance for ${account}`);
    }
    return newest.afterBalance;
  }

  /** Returns a decimal string. Money never touches a binary float here. */
  export async function spendable(account: string): Promise<string> {
    const channel = pmx.channel();
    let balance: string;
    let ceilings: string[] = [];
    try {
      const b = await new AccountBalanceClient(channel).getAccountBalance(
        { account },
        // Account-scoped read: x-participant-id is required.
        await pmx.metadata(PARTICIPANT_ID),
      );
      balance = b.balance;
      ceilings = [b.buyingPower, b.excessCapital];
    } catch (err: any) {
      // InvalidArgument: invalid account — expected on a participant clearing account.
      if (!String(err?.details ?? err).includes("invalid account")) throw err;
      balance = await newestAfterBalance(channel, account);
    }

    // Exact decimal arithmetic: 1377.57275 is not representable as a binary float.
    const reserve = new Decimal("1.00").times(OPEN_SHORT_CONTRACTS); // your own reserve
    // The collateral term is NOT in this sum: no field exposes it. See the warning above.
    const candidates = [new Decimal(balance).minus(reserve).minus(ACCRUED_FEES)]
      .concat(ceilings.map((c) => new Decimal(c)));
    return candidates.reduce((lowest, v) => (v.lt(lowest) ? v : lowest)).toFixed();
  }

  spendable(PARTICIPANT_ACCOUNT).then(console.log);
  ```

  ```bash grpcurl theme={null}
  #!/usr/bin/env bash
  set -euo pipefail

  GRPC_TARGET="grpc-api.preprod.polymarketexchange.com:443"  # prod: grpc-api.prod.polymarketexchange.com:443
  PROTO_FILE="./balances.proto"                               # the proto your integration lead sent you
  PROTO_PACKAGE="$(grep -m1 '^package' "$PROTO_FILE" | sed 's/package \(.*\);/\1/')"
  SERVICE="..."                                               # the service that carries GetAccountBalance
  FIRM_ACCOUNT="firms/.../accounts/..."                       # your firm account
  PARTICIPANT_ID="..."                                        # participantId from the kyc.approved webhook
  TOKEN="$(./token.sh)"                                       # the script on /connect#authentication

  # Account-scoped read, so x-participant-id is required here (unlike CashMovementService).
  # Firm account: expect all six fields. A participant clearing account returns
  # `InvalidArgument: invalid account` here — read afterBalance off the balance ledger instead.
  grpcurl -proto "$PROTO_FILE" \
    -H "authorization: Bearer ${TOKEN}" \
    -H "x-participant-id: ${PARTICIPANT_ID}" \
    -d "{\"account\":\"${FIRM_ACCOUNT}\"}" \
    "$GRPC_TARGET" "${PROTO_PACKAGE}.${SERVICE}/GetAccountBalance" \
    | jq '{balance, capitalRequirement, excessCapital, buyingPower, unsettledFunds, marginRequirement}'
  ```

  ```go Go theme={null}
  // go get github.com/shopspring/decimal google.golang.org/grpc
  package main

  import (
  	"context"
  	"fmt"
  	"log"
  	"strings"
  	"time"

  	"github.com/shopspring/decimal"
  	"google.golang.org/grpc"
  	"google.golang.org/grpc/codes"
  	"google.golang.org/grpc/metadata"
  	"google.golang.org/grpc/status"

  	// Canonical client from /connect#authentication. The gRPC target is
  	// pmauth.GRPCTarget — one place, both environments.
  	pmauth "example.com/yourfirm/pmauth"

  	// protoc output from the protos your integration lead sent you. The owning
  	// services are not published — check these names against your own copy.
  	bal "yourfirm/pmxprotos/balance"
  	lg "yourfirm/pmxprotos/ledger"
  )

  const (
  	participantAccount = "firms/.../accounts/..." // provisionedAccount, recorded at onboarding
  	participantID      = "..."                    // participantId from the kyc.approved webhook
  	openShortContracts = 0                        // from your own position store
  	accruedFees        = "0.00"                   // USD, from your own fee ledger
  	replayDeadline     = 60 * time.Second         // yours: no replay-to-live signal is published
  )

  // One auth client for the life of the process: it caches the token and re-mints
  // at expires_in minus 30 s.
  var pm = pmauth.New()

  // dec parses money exactly. 1377.57275 is not representable as a binary float,
  // so never parse a balance into float64 or big.Float.
  func dec(s string) (decimal.Decimal, error) {
  	d, err := decimal.NewFromString(s)
  	if err != nil {
  		return decimal.Decimal{}, fmt.Errorf("parse money %q: %w", s, err)
  	}
  	return d, nil
  }

  // newestAfterBalance is the workaround for a participant clearing account. There
  // is no point-read RPC for a participant account balance, so this subscribes to
  // the balance ledger, drains the replay phase, and keeps the newest afterBalance.
  func newestAfterBalance(ctx context.Context, conn *grpc.ClientConn, account string) (string, error) {
  	acctMD, err := pm.Metadata(participantID) // account-scoped: header required
  	if err != nil {
  		return "", err
  	}
  	streamCtx, cancel := context.WithTimeout(metadata.NewOutgoingContext(ctx, acctMD), replayDeadline)
  	defer cancel()

  	stream, err := lg.NewBalanceLedgerClient(conn).CreateBalanceLedgerSubscription(streamCtx,
  		&lg.CreateBalanceLedgerSubscriptionRequest{Account: account})
  	if err != nil {
  		return "", err
  	}

  	newest := ""
  	for {
  		entry, rerr := stream.Recv()
  		if rerr != nil {
  			// No published replay-to-live signal: a quiet stream is how you detect the
  			// drain. Anything else is a real failure — never swallow it.
  			if status.Code(rerr) != codes.DeadlineExceeded {
  				return "", rerr
  			}
  			break
  		}
  		// Replay ordering is not published, so this keeps the LAST entry the replay
  		// phase delivered. Confirm the ordering before you trust it.
  		newest = entry.GetAfterBalance()
  	}
  	if newest == "" {
  		return "", fmt.Errorf("no balance-ledger entry carrying afterBalance for %s", account)
  	}
  	return newest, nil
  }

  func spendable(ctx context.Context, account string) (decimal.Decimal, error) {
  	zero := decimal.Decimal{}

  	conn, err := pm.Channel()
  	if err != nil {
  		return zero, err
  	}
  	defer conn.Close()

  	acctMD, err := pm.Metadata(participantID) // account-scoped read: header required
  	if err != nil {
  		return zero, err
  	}

  	var balanceStr string
  	var ceilings []decimal.Decimal

  	b, err := bal.NewAccountBalanceClient(conn).GetAccountBalance(
  		metadata.NewOutgoingContext(ctx, acctMD), &bal.GetAccountBalanceRequest{Account: account})
  	switch {
  	case err == nil:
  		balanceStr = b.GetBalance()
  		for _, raw := range []string{b.GetBuyingPower(), b.GetExcessCapital()} {
  			c, cerr := dec(raw)
  			if cerr != nil {
  				return zero, cerr
  			}
  			ceilings = append(ceilings, c)
  		}
  	case strings.Contains(err.Error(), "invalid account"):
  		// Expected on a participant clearing account. No ceilings on this path.
  		balanceStr, err = newestAfterBalance(ctx, conn, account)
  		if err != nil {
  			return zero, err
  		}
  	default:
  		return zero, err
  	}

  	balance, err := dec(balanceStr)
  	if err != nil {
  		return zero, err
  	}
  	fees, err := dec(accruedFees)
  	if err != nil {
  		return zero, err
  	}
  	reserve := decimal.NewFromInt(openShortContracts) // $1.00 per short contract, your own reserve

  	// The collateral term is NOT in this sum: no field exposes it. See the warning above.
  	out := balance.Sub(reserve).Sub(fees)
  	for _, c := range ceilings {
  		if c.LessThan(out) {
  			out = c
  		}
  	}
  	return out, nil
  }

  func main() {
  	v, err := spendable(context.Background(), participantAccount)
  	if err != nil {
  		log.Fatal(err)
  	}
  	fmt.Println(v.String()) // balances carry more than 2 decimals
  }
  ```
</CodeGroup>

Response on a firm account, with the fields you branch on:

```json theme={null}
{
  "balance": "1377.57275",
  "capitalRequirement": "…",
  "excessCapital": "…",
  "buyingPower": "…",
  "unsettledFunds": "…",
  "marginRequirement": "…"
}
```

<Note>
  Only the field names and `balance` are real here. `1377.57275` is an observed production balance,
  shown because it is the reason this surface needs [Sub-cent balances](/funding#sub-cent-balances);
  the other five values are elided rather than made up, because no published relationship between them
  exists yet.

  These money fields arrive as JSON **strings**. `1377.57275` is **not** an `int64` — the
  "`int64` serializes as a string" rule belongs to the scaled integer fields on the order and
  execution surfaces, not to this response. Either way the handling is the same: parse money into a
  decimal type, never into a binary float you then round. `1377.57275` is not float-safe.
</Note>

## Vendor fees

You charge your own fees, you track every accrual yourself, and you collect with one `VENDOR_FEES` transfer per participant account. The accrued-fee total this section produces is exactly the fee term in the spendable rule above, which is why the two cannot be built independently. This section also assumes you carry `order.clord_id` on every order you place — see [Place an order](/orders).

### The platform does not know your fee basis

**We never know your fee basis, and accrued-fee tracking is not exposed on any endpoint or stream.** No field we return has your fees subtracted, no report we generate computes them, and there is no accrual balance to query. Every number in this lifecycle is one you compute and store.

Your fee basis lives in your Fee Agreement, which is not public and is sent during commercial discussions. It is not discoverable through the API.

### The lifecycle

<Steps>
  <Step title="Declare">
    Agree your fee basis with us in the Fee Agreement, alongside the Vendor Connectivity Agreement.
    Nothing about this step touches the API surface.
  </Step>

  <Step title="Accrue">
    On every fill, compute the fee and write an accrual row keyed on `order.clord_id`. `clord_id` is
    **required** by the runtime on `CreateVendorOrder`, so you always have one; generate it yourself
    and store it before you place the order, because it is the only key that will join your accruals
    to our report.
  </Step>

  <Step title="Report">
    Reconcile your accruals against the daily Vendor Fees report. See the blocker below.
  </Step>

  <Step title="Collect">
    Move the accrued amount out of the participant account with one transfer, reason `VENDOR_FEES`,
    direction participant account → your funding account. Amount is capped at two decimal places, so
    truncate toward zero and carry the remainder forward — see [Sub-cent balances](/funding#sub-cent-balances).
  </Step>
</Steps>

### Accrued fees are your credit exposure

Between accrual and collection the cash sits in the **user's** participant account, and the user can spend it on a trade or take it out with a withdrawal. Nothing on the platform reserves it for you. Every uncollected accrual is unsecured credit you have extended to that user.

Two rules follow.

**Hold a shadow balance per participant account.** Your shadow balance is the accrued, uncollected fee total for that account, and it is the fee term subtracted in [Spendable cash](#which-number-is-spendable) — the platform never supplies it, so an account's spendable figure is wrong by your whole shadow balance if you leave it out.

**Collect on a schedule short enough to bound the exposure.** The longer the gap between accrual and a `VENDOR_FEES` transfer, the larger the balance a user can withdraw out from under you.

### Getting the accrual arithmetic right

<Snippet file="scales-warning.mdx" />

If your fee basis is derived from the exchange commission on a fill, read `commission_notional_collected` at `priceScale × fractionalQtyScale`, not at `priceScale`. Reading it at `priceScale` turns a $0.01 fee into $1.00 on a 32-cent trade, which has already exceeded a real customer's entire prefund and suppressed their refund. One dollar is `priceScale × fractionalQtyScale` notional units, so with both at 100, `commission_notional_collected = 100` means **$0.01, not $1.00**.

Two further facts change fee models built on exchange fees. Your surface is fill-or-kill only, so you are structurally always the taker and **can never earn the maker rebate** — the taker coefficient is the only rate you will ever pay. And the taker coefficient Θ changed from `0.06` to `0.0695` on 2026-09-14 with **no fee-schedule endpoint** to read it from, so every partner hand-edited their systems. A fee basis expressed as a multiple of exchange fees needs a hand-edit path and an owner.

### The daily report

The join key is `order.clord_id`. Key your accrual rows on it and join the report to them on it.

<Warning>
  **Not yet published.** The report's column specification is not published.

  Build your reconciliation to join on `clord_id` and to tolerate columns you do not recognise. Ask
  your integration lead for the column list and a sample file before you write the parser.
</Warning>

<Warning>
  **The fee-collection loop has no defined channel today.** The daily Vendor Fees report has **no
  delivery mechanism** — the specification says "Delivery method TBD". There is no endpoint, no bucket
  and no stream that hands you the report, so the reconciliation step of this lifecycle cannot be
  automated end to end yet.

  What to do in the meantime: treat **your own accrual ledger as the collection basis**, collect on
  your own schedule with `VENDOR_FEES` transfers, and keep every accrual row joinable on `clord_id` so
  that a retroactive reconciliation is possible the day delivery exists. Agree the interim delivery
  with your integration lead in writing before go-live, and do not plan a launch around an automated
  report you cannot yet receive.
</Warning>

## Reconciling cash

Reconcile your pool and every participant account once a day against the balance ledger, with a checkpoint you resume from rather than re-read. A run needs both halves of this page: the balance figures above are what you anchor on, and your own accrued-fee ledger is what explains the cash the platform cannot account for.

### Anchors and the system of record

Two different kinds of number go into a reconciliation, and mixing them up is how a run double-counts.

**Anchors are point-in-time reads.** You take them once, at the start of a reconciliation series, and then never again unless you have lost your checkpoint:

* `GetFundingAccountBalance` — the pool's source of truth, and the only authoritative pool figure. Do not substitute the sum of your participant balances.
* `GetAccountBalance` on a **firm** account — the six-field read.
* The newest balance-ledger entry's `afterBalance` on a **participant clearing** account, because `GetAccountBalance` returns `InvalidArgument: invalid account` there.

**The balance-ledger stream is the system of record for cash movement.** Every movement you apply comes from a ledger entry, not from a read. It delivers a replay phase followed by a live phase, so a subscription that starts mid-history catches up before it goes live.

### The pool identity

Close every run on this identity:

```text theme={null}
GetFundingAccountBalance (now)
  = pool at checkpoint
  − Σ CONFIRMED DEPOSIT amounts        since checkpoint   # pool → participant
  + Σ CONFIRMED WITHDRAWAL amounts     since checkpoint   # participant → pool
  + Σ CONFIRMED VENDOR_FEES amounts    since checkpoint   # participant → pool
  + Σ wire deposits credited           since checkpoint
```

Only `CONFIRMED` movements belong in those sums. `PENDING` and `AMBIGUOUS` movements are the residual: carry them as in-flight, name them in the run, and do not net them into either side. A run that does not close is either missing an in-flight movement or missing a wire credit.

<Warning>
  **Not yet published.** Whether an inbound wire credit to your funding account appears as a
  balance-ledger entry, or only as a change in `GetFundingAccountBalance`, is not published.

  Until it is confirmed, take your wire credits from your bank's records and treat the pool read as the
  check on them rather than the source of them.
</Warning>

### A restart is not a cold start

**When your reconciliation process restarts, resume from your checkpoint. Do not re-run the anchor reads.** Re-anchoring is not a harmless refresh: it discards in-flight movements, re-applies ledger entries you have already applied, and produces a break that looks like a platform problem.

A checkpoint is per account: the last ledger entry you applied, plus the `afterBalance` you derived from it, plus your in-flight movement list keyed by `idempotency_key`. Write it in the same transaction as the ledger entry you applied. Anchor reads happen exactly twice in the life of an integration — at first run, and after you have genuinely lost the checkpoint.

### The structural problem

<Warning>
  **There is no scalable push channel for cash today, and that is a platform gap, not a configuration
  you can tune.** The balance-ledger stream is **per account**, and it counts against the cap of
  **20 concurrent streams per firm** shared across every gRPC subscription you hold — drop copy,
  position change and market data included. Twenty accounts is not a partner-scale number, so the
  ledger stream cannot be run per participant.

  The **firm-level ledger stream does not exist**. It is "coming soon" with no date.

  The consequence to plan around: `RESOLUTION` credits land in participant accounts with no push
  channel that scales to your account count, so settlement-driven balance changes have to be
  discovered by targeted reads.
</Warning>

### The interim pattern

<Steps>
  <Step title="Spend your stream slots deliberately">
    Subscribe the balance ledger for your highest-turnover accounts only, and keep the total across
    **all** gRPC subscriptions under 20 per firm. Decide the split between drop copy, position change
    and ledger streams once, in writing, and hold a slot in reserve for reconnects.
  </Step>

  <Step title="Poll a targeted set, not every account">
    You already know most of the accounts that changed: you initiated every transfer, so the accounts
    you touched since your checkpoint are exactly the accounts whose cash moved. Add accounts holding
    positions in instruments that settled. Poll that set, not your whole roster.
  </Step>

  <Step title="Size the poll against the unary budget">
    Your gRPC unary budget is **250 requests / 60 s per firm**, shared across every unary call your
    firm makes, and there is a second per-endpoint rung on top of it. One read per account per cycle
    means your cycle cannot be shorter than `accounts ÷ 250` minutes if reads are all you do — so
    5,000 accounts cannot be swept inside 20 minutes, and cannot be swept at all if the same budget
    is carrying your trading reads. Budget the sweep first, then give the remainder to everything else.
  </Step>

  <Step title="Reconcile the rest daily, not continuously">
    For accounts outside the polled set, one read per day is the pattern. Ledger CSV downloads are
    limited to about **5/min per firm**, so a bulk daily pull is a scheduled job with retries, not a
    fan-out.
  </Step>
</Steps>

### The loop

<Note>
  There is no `curl` tab here: the balance ledger is a gRPC stream and there is no REST equivalent. The
  command-line path is `grpcurl` with a local proto file, which is a debugging aid rather than a
  reconciliation process — it has no checkpoint.
</Note>

<CodeGroup>
  ```python Python theme={null}
  from decimal import Decimal

  import grpc
  from polymarket_auth import PolymarketClient  # published on /connect#authentication
  from mystore import load_checkpoint, save_checkpoint, in_flight  # your own storage

  # protoc output from the ledger and funding protos your integration lead sent you.
  from pmx_protos import funding_pb2, funding_pb2_grpc
  from pmx_protos import ledger_pb2, ledger_pb2_grpc

  # The gRPC target is GRPC_TARGET in polymarket_auth.py — one place, both environments.
  pmx = PolymarketClient()

  PARTICIPANT_ID = "..."          # participantId from the kyc.approved webhook
  ACCOUNT = "firms/.../accounts/..."  # provisionedAccount for that participant
  REPLAY_DEADLINE_SECONDS = 60    # yours: the replay-to-live signal is not published

  def reconcile(account: str, participant_id: str) -> Decimal:
      checkpoint = load_checkpoint(account)
      pool_request = funding_pb2.GetFundingAccountBalanceRequest()

      with pmx.channel() as channel:
          ledger = ledger_pb2_grpc.AccountsAPIStub(channel)
          funding = funding_pb2_grpc.FundingServiceStub(channel)

          if checkpoint is None:
              # First run only. A restart is NOT this branch.
              # Firm-scoped read: no participant ID on the pool.
              anchor = funding.GetFundingAccountBalance(pool_request, metadata=pmx.metadata())
              checkpoint = {"entry_id": None, "after_balance": "0", "pool": anchor.balance}

          request = ledger_pb2.CreateBalanceLedgerSubscriptionRequest(account=account)
          if checkpoint["entry_id"]:
              request.resume_token = checkpoint["entry_id"]

          stream = ledger.CreateBalanceLedgerSubscription(
              request,
              # Account-scoped: x-participant-id is required on this surface.
              metadata=pmx.metadata(participant_id=participant_id),
              timeout=REPLAY_DEADLINE_SECONDS,
          )
          try:
              for entry in stream:
                  if entry.entry_id == checkpoint["entry_id"]:
                      continue  # at-least-once delivery: dedupe, expect redelivery
                  # proto3: an absent field and a zero are indistinguishable. Require the field.
                  if not entry.HasField("after_balance"):
                      raise RuntimeError(f"ledger entry {entry.entry_id} has no afterBalance")
                  checkpoint = {"entry_id": entry.entry_id, "after_balance": entry.after_balance,
                                "pool": checkpoint["pool"]}
                  save_checkpoint(account, checkpoint)  # same transaction as applying the entry
          except grpc.RpcError as err:
              # There is no published replay-to-live signal, so a quiet stream is how you
              # detect the drain. Anything else is a real failure — never swallow it.
              if err.code() is not grpc.StatusCode.DEADLINE_EXCEEDED:
                  raise

          pool_now = Decimal(
              funding.GetFundingAccountBalance(pool_request, metadata=pmx.metadata()).balance
          )

      expected = Decimal(checkpoint["pool"])
      for movement in in_flight(account):           # PENDING / AMBIGUOUS: residual, never netted
          print(f"in flight: {movement['idempotency_key']} {movement['status']}")
      break_amount = pool_now - expected
      if break_amount != 0:
          raise RuntimeError(f"pool does not close: {break_amount} — check wires and in-flight list")
      return Decimal(checkpoint["after_balance"])

  print(reconcile(ACCOUNT, PARTICIPANT_ID))
  ```

  ```typescript TypeScript theme={null}
  import * as grpc from "@grpc/grpc-js";
  import { PolymarketClient } from "./polymarketAuth"; // published on /connect#authentication
  import { loadCheckpoint, saveCheckpoint, inFlight } from "./mystore"; // your own storage

  // protoc output from the ledger and funding protos your integration lead sent you.
  import { AccountsAPIClient } from "./gen/ledger";
  import { FundingServiceClient } from "./gen/funding";

  // The gRPC target is GRPC_TARGET in polymarketAuth.ts — one place, both environments.
  const pmx = new PolymarketClient();

  const PARTICIPANT_ID = "..."; // participantId from the kyc.approved webhook
  const ACCOUNT = "firms/.../accounts/..."; // provisionedAccount for that participant
  const REPLAY_DEADLINE_MS = 60_000; // yours: the replay-to-live signal is not published

  export async function reconcile(account: string, participantId: string): Promise<string> {
    const channel = pmx.channel();
    const ledger = new AccountsAPIClient(channel);
    const funding = new FundingServiceClient(channel);

    // Firm-scoped read: no participant ID on the pool.
    const poolBalance = async (): Promise<string> =>
      (await funding.getFundingAccountBalance({}, await pmx.metadata())).balance;

    let checkpoint = await loadCheckpoint(account);
    if (!checkpoint) {
      // First run only. A restart is NOT this branch.
      checkpoint = { entryId: null, afterBalance: "0", pool: await poolBalance() };
    }

    const stream = ledger.createBalanceLedgerSubscription(
      { account, resumeToken: checkpoint.entryId ?? undefined },
      // Account-scoped: x-participant-id is required on this surface.
      await pmx.metadata(participantId),
      { deadline: Date.now() + REPLAY_DEADLINE_MS },
    );

    try {
      for await (const entry of stream) {
        if (entry.entryId === checkpoint.entryId) continue; // at-least-once: dedupe
        if (entry.afterBalance === undefined) {
          throw new Error(`ledger entry ${entry.entryId} has no afterBalance`);
        }
        checkpoint = { entryId: entry.entryId, afterBalance: entry.afterBalance, pool: checkpoint.pool };
        await saveCheckpoint(account, checkpoint); // same transaction as applying the entry
      }
    } catch (err: any) {
      // There is no published replay-to-live signal, so a quiet stream is how you detect
      // the drain. Anything else is a real failure — never swallow it.
      if (err?.code !== grpc.status.DEADLINE_EXCEEDED) throw err;
    }

    // Compare the decimal strings. Never parse money into a binary float.
    const poolNow = await poolBalance();
    for (const m of await inFlight(account)) console.log(`in flight: ${m.idempotencyKey} ${m.status}`);
    if (poolNow !== checkpoint.pool) {
      throw new Error(
        `pool does not close: have ${poolNow}, expected ${checkpoint.pool} — check wires and in-flight list`,
      );
    }
    return checkpoint.afterBalance;
  }

  reconcile(ACCOUNT, PARTICIPANT_ID).then(console.log);
  ```

  ```go Go theme={null}
  package main

  import (
  	"context"
  	"fmt"
  	"log"
  	"time"

  	"google.golang.org/grpc/codes"
  	"google.golang.org/grpc/metadata"
  	"google.golang.org/grpc/status"

  	// Canonical client from /connect#authentication. The gRPC target is
  	// pmauth.GRPCTarget — one place, both environments.
  	pmauth "example.com/yourfirm/pmauth"

  	// protoc output from the protos your integration lead sent you.
  	fd "yourfirm/pmxprotos/funding"
  	lg "yourfirm/pmxprotos/ledger"

  	store "yourfirm/reconcile/store"
  )

  const (
  	participantID = "..."                    // participantId from the kyc.approved webhook
  	account       = "firms/.../accounts/..." // provisionedAccount for that participant
  	// Yours: the replay-to-live signal is not published.
  	replayDeadline = 60 * time.Second
  )

  // One auth client for the life of the process: it caches the token and re-mints
  // at expires_in minus 30 s.
  var pm = pmauth.New()

  func reconcile(ctx context.Context) (string, error) {
  	conn, err := pm.Channel()
  	if err != nil {
  		return "", err
  	}
  	defer conn.Close()

  	ledger := lg.NewAccountsAPIClient(conn)
  	funding := fd.NewFundingServiceClient(conn)

  	// Firm-scoped read: pass "" for the participant ID on the pool.
  	firmMD, err := pm.Metadata("")
  	if err != nil {
  		return "", err
  	}
  	firmCtx := metadata.NewOutgoingContext(ctx, firmMD)

  	cp, err := store.LoadCheckpoint(account)
  	if err != nil {
  		return "", err
  	}
  	if cp == nil {
  		// First run only. A restart is NOT this branch.
  		anchor, aerr := funding.GetFundingAccountBalance(firmCtx, &fd.GetFundingAccountBalanceRequest{})
  		if aerr != nil {
  			return "", aerr
  		}
  		cp = &store.Checkpoint{Pool: anchor.GetBalance()}
  	}

  	// Account-scoped: x-participant-id is required on this surface.
  	acctMD, err := pm.Metadata(participantID)
  	if err != nil {
  		return "", err
  	}
  	streamCtx, cancel := context.WithTimeout(metadata.NewOutgoingContext(ctx, acctMD), replayDeadline)
  	defer cancel()

  	stream, err := ledger.CreateBalanceLedgerSubscription(streamCtx,
  		&lg.CreateBalanceLedgerSubscriptionRequest{Account: account, ResumeToken: cp.EntryID})
  	if err != nil {
  		return "", err
  	}
  	for {
  		entry, rerr := stream.Recv()
  		if rerr != nil {
  			// There is no published replay-to-live signal, so a quiet stream is how you
  			// detect the drain. Anything else is a real failure — never swallow it.
  			if status.Code(rerr) != codes.DeadlineExceeded {
  				return "", rerr
  			}
  			break
  		}
  		if entry.GetEntryId() == cp.EntryID {
  			continue // at-least-once delivery: dedupe
  		}
  		if entry.GetAfterBalance() == "" {
  			return "", fmt.Errorf("ledger entry %s has no afterBalance", entry.GetEntryId())
  		}
  		cp = &store.Checkpoint{EntryID: entry.GetEntryId(), AfterBalance: entry.GetAfterBalance(), Pool: cp.Pool}
  		if err := store.SaveCheckpoint(account, cp); err != nil { // same txn as applying the entry
  			return "", err
  		}
  	}

  	pool, err := funding.GetFundingAccountBalance(firmCtx, &fd.GetFundingAccountBalanceRequest{})
  	if err != nil {
  		return "", err
  	}
  	// Compare the decimal strings. Never parse money into a binary float.
  	if pool.GetBalance() != cp.Pool {
  		return "", fmt.Errorf("pool does not close: have %s, expected %s — check wires and in-flight list",
  			pool.GetBalance(), cp.Pool)
  	}
  	return cp.AfterBalance, nil
  }

  func main() {
  	balance, err := reconcile(context.Background())
  	if err != nil {
  		log.Fatal(err)
  	}
  	fmt.Println(balance)
  }
  ```
</CodeGroup>

The entry you branch on, with the two fields the loop needs:

```json theme={null}
{
  "entryId": "...",
  "afterBalance": "172.395"
}
```

<Warning>
  **Not yet published.** The resume field on the balance-ledger subscription, the ledger entry's id
  field, and the signal that the replay phase has ended are not published.

  The loop above keeps the checkpoint in your own storage for that reason, which is the right design
  either way. Confirm the field names against your proto copy before you compile.
</Warning>

### What can go wrong in a run

These rows stay with the procedure above rather than in the page's consolidated table, because each one is a failure of this loop specifically.

| Symptom                                                                                                                                | Cause                                                                                                                                                                                    | What you do                                                                                                                           |
| -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| A break appears the first run after a deploy                                                                                           | The process re-ran the anchor reads instead of resuming                                                                                                                                  | Resume from the checkpoint. Anchors run at first run only                                                                             |
| `13 INTERNAL: Subscription manager revoked session`                                                                                    | The stream session is keyed on **caller identity** — token subject plus participant header — not on the accounts or symbols you asked for. Nine partners investigated this independently | Pass an explicit list of markets on `CreateOrderSubscription`, which has no symbol cap on that path, and keep one identity per stream |
| A stream will not open                                                                                                                 | You are at **20 concurrent streams per firm**, across all gRPC subscriptions                                                                                                             | Reduce streamed accounts and move the rest to the polled set                                                                          |
| `SocketError: other side closed` after about ten minutes                                                                               | The ALB timeout is 10 minutes; gRPC streams bypass the API Gateway's 30-second idle timeout                                                                                              | Reconnect and resume from your checkpoint. A 504 instead means you spent over 30 seconds at the edge                                  |
| The same ledger entry applied twice                                                                                                    | At-least-once delivery                                                                                                                                                                   | Dedupe on the entry id inside the same transaction that writes the checkpoint                                                         |
| A balance reads as zero that should not be                                                                                             | proto3 does not populate a scalar at its default value, so an absent field and a zero are indistinguishable                                                                              | Require the field explicitly, as the loop does, and fail rather than assume zero                                                      |
| Execution queries return empty for a period you know had trades                                                                        | Stored execution history is **archived during maintenance**, and pre-maintenance execution queries return empty                                                                          | Reconcile around the maintenance window and take that day's executions from your drop-copy record                                     |
| `POST /v1/report/trades/search` times out at 30 seconds                                                                                | The unfiltered call hits the CloudFront 504 about two thirds of the time; with a symbol filter it returns in about 130 ms                                                                | Always filter by symbol and page with `nextPageToken`                                                                                 |
| `Aborted` or `409` requesting a ledger entry type                                                                                      | `LedgerEntryType` has an allowlist; twelve internal types are suppressed, including `NETTING`, `GIVE_UP`, `INTEREST` and `SETTLEMENT_FEE`                                                | Request only allowlisted types                                                                                                        |
| `{"code":8,"message":"rate limit exceeded for /polymarket.v1.PositionAPI/ListAccountBalances (rung \"endpoint\"); retry after 126ms"}` | A per-endpoint rung on top of the per-firm budget                                                                                                                                        | Honour the retry delay, and size the sweep against 250 unary requests / 60 s per firm                                                 |
| A sub-cent difference that never clears                                                                                                | The unmovable residue                                                                                                                                                                    | Report it as a known standing balance — see [Sub-cent balances](/funding#sub-cent-balances)                                           |

## What can go wrong

| Where                     | Symptom                                                                                                                                | Cause                                                                                                      | What you do                                                                                                                                                                 |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Which number is spendable | `InvalidArgument: invalid account` on a participant clearing account, while your firm account works                                    | Platform gap on `GetAccountBalance`                                                                        | Read the newest balance-ledger entry's `afterBalance` for that account                                                                                                      |
| Which number is spendable | A user's spendable figure is higher in your app than what an order accepts                                                             | You used `balance`, or one platform ceiling, without the collateral and accrued-fee terms                  | Use the composed `spendable` rule above                                                                                                                                     |
| Which number is spendable | A withdrawal succeeds and leaves an open short uncovered                                                                               | You gated on a platform field; the open-short requirement is unpublished and came back empty on the ledger | Hold the \$1.00-per-contract reserve yourself                                                                                                                               |
| Which number is spendable | Your balance sheet never nets to zero                                                                                                  | Balances carry more than two decimals; transfers accept two                                                | See [Sub-cent balances](/funding#sub-cent-balances)                                                                                                                         |
| Which number is spendable | `403` on an account-scoped read                                                                                                        | `x-participant-id` missing on an account-scoped read                                                       | Send it on account reads. Do **not** send it on `CashMovementService` — see [Move cash](/funding#move-cash)                                                                 |
| Which number is spendable | `{"code":8,"message":"rate limit exceeded for /polymarket.v1.PositionAPI/ListAccountBalances (rung \"endpoint\"); retry after 126ms"}` | A per-endpoint rate-limit rung, in addition to the per-firm one                                            | Honour the retry delay in the message, and keep balance polling on a schedule rather than per user action                                                                   |
| Vendor fees               | You cannot find an accrued-fee balance on any endpoint                                                                                 | It is not exposed. The platform does not know your fee basis                                               | Track accruals in your own ledger, keyed on `clord_id`                                                                                                                      |
| Vendor fees               | A user withdrew cash that covered fees you had accrued                                                                                 | Nothing on the platform reserves your accruals                                                             | Subtract your shadow balance in the spendable and withdrawable rules, and shorten your collection cycle                                                                     |
| Vendor fees               | Your fee is 100× too large                                                                                                             | Raw `order_qty` used as the contract count on an instrument with `fractional_quantity_scale = 100`         | Divide by the instrument's scales. Instruments with scale `1` make the naive math accidentally correct, so this often surfaces only on the first fill in a scale-100 market |
| Vendor fees               | Your fee is 100× too large in dollars from the commission field                                                                        | `commission_notional_collected` read at `priceScale` instead of `priceScale × fractionalQtyScale`          | Read it at the product of both scales                                                                                                                                       |
| Vendor fees               | You cannot get the daily report                                                                                                        | It has no delivery mechanism                                                                               | Reconcile from your own accrual ledger and agree interim delivery with your integration lead                                                                                |
| Vendor fees               | Fee revenue diverges from your model after 2026-09-14                                                                                  | Taker Θ changed from `0.06` to `0.0695` and there is no fee-schedule endpoint                              | Pin the coefficient in one place in your code and own the hand-edit                                                                                                         |
| Vendor fees               | A `VENDOR_FEES` transfer is rejected and the identical retry returns the same rejection                                                | The terminal rejection is stored against your `idempotency_key`                                            | Retry with a new `idempotency_key` — see [Move cash](/funding#move-cash)                                                                                                    |
| Reconciling cash          | Any failure of the daily run itself                                                                                                    | Eleven distinct causes, each tied to a step of the loop                                                    | The rows are kept with the procedure, under [What can go wrong in a run](#what-can-go-wrong-in-a-run)                                                                       |

<Snippet file="support.mdx" />

## Next

[Instruments and money on the wire](/instruments)
