> ## 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.

# Streams

> Which of the five gRPC streams carries what, which ones resume, and how to subscribe to each.

Five gRPC streams matter to an ISV, and picking the wrong one is a multi-week mistake: one partner built settlement detection on `CreateInstrumentStateChangeSubscription`, which does not carry settlement values, and had to rebuild on `CreatePositionChangeSubscription`. Start at the decision table below, then read the section for the stream it sends you to.

<Info>
  Before this page: a minted access token ([Authentication](/authentication)), the
  `participantId` and the account's `provisionedAccount` from the `kyc.approved` webhook
  ([Firms, participants and accounts](/identity)), and generated stubs
  from your pinned proto bundle ([Protos and SDKs](/environments#protos-and-sdks)).
</Info>

<Info>
  **API reference:** <a href="https://docs.polymarket.us/streaming-endpoints/dropcopy-stream" target="_blank" rel="noreferrer">Drop copy</a> · <a href="https://docs.polymarket.us/streaming-endpoints/balance-ledger-stream" target="_blank" rel="noreferrer">Balance ledger</a> · <a href="https://docs.polymarket.us/streaming-endpoints/market-data-stream" target="_blank" rel="noreferrer">Market data</a> · <a href="https://docs.polymarket.us/streaming-endpoints/proto-reference" target="_blank" rel="noreferrer">Proto reference</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>

## Choose a stream

### The two questions you arrived with

**"Which stream tells me a market I hold resolved?"** `CreatePositionChangeSubscription`. It is resumable and position-driven, so you only hear about markets you actually hold, and a disconnect does not lose the event. → [Position change](#position-change)

**"Which stream carries settlement values?"** The market data stream, or the `GET /v1/orderbook/{symbol}` read. **Not** `CreateInstrumentStateChangeSubscription`. That subscription returns the updated `Instrument` and does not include the settlement result fields. We told a partner the opposite and then corrected it — if you were given the earlier answer, it was wrong, and code written against it will never see a settlement value. → [Detect that a market resolved](/settlement#detect-that-a-market-resolved)

### The five streams

| Stream                                                              | Carries                             | Resumable                                               | Source of record for                                   |
| ------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------- | ------------------------------------------------------ |
| Drop copy (`CreateDropCopySubscription`)                            | Execution reports, trade capture    | **Yes** — `resume_token` populated, \~576 bytes         | Fills and commissions                                  |
| Position change (`CreatePositionChangeSubscription`)                | Position deltas                     | **Yes**                                                 | *"Did a market I hold resolve?"*                       |
| Instrument state change (`CreateInstrumentStateChangeSubscription`) | The updated `Instrument`            | **No** — declares `resume_token` and never populates it | Lifecycle transitions only — **not** settlement values |
| Balance ledger (`CreateBalanceLedgerSubscription`)                  | Ledger entries for one account      | Replay phase then live phase                            | Cash movement                                          |
| Market data                                                         | Book, BBO, stats, settlement values | **No** — there is no resume field at all                | Settlement values                                      |

Each of the four sections after this one is the detail behind one row of that table.

#### When to use drop copy

Use it for fills, commissions and trade capture. It is the only stream with a populated `resume_token`, so it is the only one where you can close the gap over a disconnect exactly. `commission_notional_collected` arrives here, and decoding it wrong is how a partner turned a $0.01 fee into $1.00 — de-scale it per [Money on the wire](/instruments#money-on-the-wire). → [Drop copy](#drop-copy)

#### When to use position change

Use it to learn that something happened on a market a user of yours holds. Because it is driven by positions rather than by symbols, you do not subscribe to instruments and you do not hit the 1000-instrument market-data cap. It tells you *that* a position changed; it is not the source of the settlement price. → [Position change](#position-change)

#### When to use instrument state change

Use it for lifecycle transitions and nothing else. It is the one subscription that does not need `x-participant-id` — it needs `read:instruments` only. It is not resumable, so anything that transitions while you are disconnected is lost and must be recovered with a read. → [Instrument state changes](#instrument-state-changes)

#### When to use balance ledger

Use it for cash movement on a single account. It is per-account and every open one counts against the 20-streams-per-firm cap, so you cannot run one per participant. The firm-level ledger stream is "coming soon" with no date, which leaves no scalable push channel for cash or `RESOLUTION` entries for an ISV at scale. → [Balance ledger](#balance-ledger)

#### When to use market data

Use it for the book, BBO, stats and settlement values. It has no resume field at all: on reconnect you take a fresh snapshot and re-derive state. It is capped at 1000 instruments per stream.

### Caps, with their scopes

| Cap                                | Value           | Scope                                                                                                                                              | What you get when you exceed it                                                                                        |
| ---------------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Concurrent streams                 | **20**          | **Per firm**, across all gRPC subscriptions — drop copy, position change, instrument state, balance ledger and market data all draw on the same 20 | **\[VERIFY]** Not yet published                                                                                        |
| Instruments per market-data stream | **1000**        | **Per stream**                                                                                                                                     | Requests above the cap collide with it; an empty `symbols` list subscribes to all instruments and collides with it too |
| gRPC ingress                       | **100 msg/sec** | **Per firm**                                                                                                                                       | **\[VERIFY]** Not yet published                                                                                        |
| gRPC egress                        | Unlimited       | —                                                                                                                                                  | —                                                                                                                      |
| `StreamRFQEvents` opens            | **1/sec**       | **Per firm**                                                                                                                                       | **\[VERIFY]** Not yet published                                                                                        |

Cluster configuration carries `max_streams_per_firm: 20`, which is the same number from the other side, and `stream_message_rate_limit: 50,000 / 60 s`.

<Note>
  **No separate preprod values are published for any of these caps.** Build against the numbers above
  in both environments.&#x20;
</Note>

<Warning>
  **The concurrent-stream limit is 20 per firm, not 10.** A figure of 10 is in circulation and is
  wrong. Size your fleet against 20; sizing against 10 wastes half your budget.
</Warning>

### More than 20,000 instruments does not fit

1000 instruments per stream × 20 streams per firm = **20,000 instruments**, and that is before you spend any of the 20 on drop copy, position change or balance ledger. One partner ran 7,372 subscriptions before being told the cap and had to re-architect in production. Do the arithmetic for your instrument universe before you write the subscriber — [Reconnect and recover gaps](/reconnect) works it through and says plainly where it stops fitting.

## Drop copy

Drop copy is your source of record for fills and commissions, and the only stream whose `resume_token` is populated, so it is the only one where a disconnect costs you nothing.

<Note>
  **The published drop-copy example omits `x-participant-id` and will 403.** Drop copy is
  account-scoped, so the call metadata needs both `authorization` and `x-participant-id`. If you
  copied the current example from `/trader-guide/streaming-apis`, add the header — the snippets below
  include it. The same defect is in the published balance-ledger example.
</Note>

<Snippet file="participant-id-warning.mdx" />

### Subscribe and process reports

Persist the `resume_token` as you go and pass it back on the next subscribe. It is roughly 576 bytes when populated — store it as opaque bytes, do not parse it, and do not assume a length.

<CodeGroup>
  ```python Python theme={null}
  import grpc
  from google.protobuf.json_format import MessageToDict
  from polymarket.v1 import dropcopy_pb2, dropcopy_pb2_grpc  # generated from polymarket-protos.zip
  from polymarket_auth import PolymarketClient                # canonical client: /connect#authentication

  # The gRPC target is GRPC_TARGET in polymarket_auth.py — one place, both environments.
  pmx = PolymarketClient()
  PARTICIPANT_ID = "firms/20260821-examplefirminc-api-participant/participants/p-0f31c9"  # participantId from the kyc.approved webhook, verbatim
  ACCOUNT        = "PASTE_PROVISIONED_ACCOUNT"  # provisionedAccount from kyc.approved, verbatim — it is opaque, never build it
  RESUME_FILE    = "./dropcopy.resume"          # ~576 bytes of opaque bytes


  def load_resume() -> bytes:
      try:
          with open(RESUME_FILE, "rb") as fh:
              return fh.read()
      except FileNotFoundError:
          return b""


  def save_resume(token: bytes) -> None:
      with open(RESUME_FILE, "wb") as fh:   # fsync before you ack downstream if you need durability
          fh.write(token)


  def on_report(report: dict) -> None:
      # Branch only on fields you have confirmed in your bundle. See the response below.
      print(report.get("clord_id"), report.get("order_state"), report.get("commission_notional_collected"))


  def run() -> None:
      with pmx.channel() as channel:
          stub = dropcopy_pb2_grpc.DropCopyAPIStub(channel)
          request = dropcopy_pb2.CreateDropCopySubscriptionRequest(accounts=[ACCOUNT])
          resume = load_resume()
          if resume:
              request.resume_token = resume

          # x-participant-id is required: drop copy is account-scoped.
          stream = stub.CreateDropCopySubscription(
              request, metadata=pmx.metadata(participant_id=PARTICIPANT_ID)
          )
          try:
              for message in stream:
                  on_report(MessageToDict(message, preserving_proto_field_name=True))
                  if message.resume_token:
                      save_resume(message.resume_token)
          except grpc.RpcError as err:
              # Never swallow this. 13 INTERNAL and ALB closes both land here.
              raise RuntimeError(f"drop copy ended: {err.code().name}: {err.details()}") from err


  if __name__ == "__main__":
      run()
  ```

  ```typescript TypeScript theme={null}
  import { readFileSync, writeFileSync } from "node:fs";
  import * as grpc from "@grpc/grpc-js";
  import * as protoLoader from "@grpc/proto-loader";
  import { PolymarketClient } from "./polymarketAuth"; // canonical client: /connect#authentication

  const pmx = new PolymarketClient();

  const GRPC_TARGET = "grpc-api.preprod.polymarketexchange.com:443";
  const PARTICIPANT_ID =
    "firms/20260821-examplefirminc-api-participant/participants/p-0f31c9"; // participantId from kyc.approved, verbatim
  const ACCOUNT = "PASTE_PROVISIONED_ACCOUNT"; // provisionedAccount from kyc.approved, verbatim — opaque
  const RESUME_FILE = "./dropcopy.resume";     // ~576 bytes of opaque bytes
  const PROTO = "./protos/polymarket/v1/dropcopy.proto"; // from polymarket-protos.zip

  function loadResume(): Buffer {
    try {
      return readFileSync(RESUME_FILE);
    } catch {
      return Buffer.alloc(0);
    }
  }

  async function run(): Promise<void> {
    const pkg = grpc.loadPackageDefinition(
      protoLoader.loadSync(PROTO, { keepCase: true, defaults: true, includeDirs: ["./protos"] }),
    ) as any;

    const client = new pkg.polymarket.v1.DropCopyAPI(
      GRPC_TARGET,
      grpc.credentials.createSsl(),
    );

    // x-participant-id is required: drop copy is account-scoped.
    const metadata = await pmx.metadata(PARTICIPANT_ID);

    const resume = loadResume();
    const request: Record<string, unknown> = { accounts: [ACCOUNT] };
    if (resume.length > 0) request.resume_token = resume;

    await new Promise<void>((resolve, reject) => {
      const stream = client.CreateDropCopySubscription(request, metadata);
      stream.on("data", (report: any) => {
        console.log(report.clord_id, report.order_state, report.commission_notional_collected);
        if (report.resume_token?.length) writeFileSync(RESUME_FILE, report.resume_token);
      });
      stream.on("error", (err: grpc.ServiceError) =>
        reject(new Error(`drop copy ended: ${grpc.status[err.code]}: ${err.details}`)),
      );
      stream.on("end", resolve);
    });
  }

  run().catch((err) => {
    console.error(err);
    process.exit(1);
  });
  ```

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

  import (
  	"context"
  	"fmt"
  	"log"
  	"os"

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

  	pmauth "example.com/yourfirm/pmauth"             // canonical client: /connect#authentication
  	dc "github.com/your-firm/gen/polymarket/v1"      // generated from polymarket-protos.zip
  )

  const (
  	grpcTarget    = "grpc-api.preprod.polymarketexchange.com:443"
  	participantID = "firms/20260821-examplefirminc-api-participant/participants/p-0f31c9" // participantId from kyc.approved, verbatim
  	account       = "PASTE_PROVISIONED_ACCOUNT"                                            // provisionedAccount from kyc.approved, verbatim — opaque
  	resumeFile    = "./dropcopy.resume"                                                    // ~576 bytes of opaque bytes
  )

  func main() {
  	pm := pmauth.New()
  	// x-participant-id is required: drop copy is account-scoped.
  	md, err := pm.Metadata(participantID)
  	if err != nil {
  		log.Fatal(err)
  	}

  	conn, err := grpc.NewClient(grpcTarget, grpc.WithTransportCredentials(
  		credentials.NewClientTLSFromCert(nil, ""),
  	))
  	if err != nil {
  		log.Fatal(err)
  	}
  	defer conn.Close()

  	ctx := metadata.NewOutgoingContext(context.Background(), md)

  	req := &dc.CreateDropCopySubscriptionRequest{Accounts: []string{account}}
  	if resume, err := os.ReadFile(resumeFile); err == nil && len(resume) > 0 {
  		req.ResumeToken = resume
  	}

  	stream, err := dc.NewDropCopyAPIClient(conn).CreateDropCopySubscription(ctx, req)
  	if err != nil {
  		log.Fatal(err)
  	}

  	for {
  		report, err := stream.Recv()
  		if err != nil {
  			// Never swallow this. 13 INTERNAL and ALB closes both land here.
  			log.Fatalf("drop copy ended: %s: %s", status.Code(err), status.Convert(err).Message())
  		}
  		fmt.Println(report.GetClordId(), report.GetOrderState(), report.GetCommissionNotionalCollected())
  		if t := report.GetResumeToken(); len(t) > 0 {
  			if err := os.WriteFile(resumeFile, t, 0o600); err != nil {
  				log.Fatal(err)
  			}
  		}
  	}
  }
  ```
</CodeGroup>

One execution report, as JSON, with the fields you branch on:

```json theme={null}
{
  "clord_id": "examplefirm-7f2c19",
  "order_state": "FILLED",
  "order_qty": "500",
  "commission_notional_collected": "100",
  "resume_token": "CigwMjZhYjE0ZC1mMzQ5LTRk..."
}
```

`int64` fields serialize as **strings** in JSON — `"500"` above is a number on the wire, not a string value.

<Note>
  **Confirm the report field spellings against your own bundle before you branch on them.** The proto
  bundle is an unversioned, anonymous zip with no changelog and no checksum, so neither side can tell
  which build you hold.&#x20;
</Note>

### Order states you will see

`OrderState` is `NEW(1)` accepted and resting, `PARTIALLY_FILLED(2)`, `FILLED(3)`, `CANCELED(4)`, `REJECTED(7)`, `EXPIRED(9)`. There is no `PENDING` order state.

On the ISV surface, `EXPIRED` is the most common outcome you will see: `CreateVendorOrder` is fill-or-kill only, and an FOK that never crossed comes back `EXPIRED` with no reason, by design. One partner reported that \~99% of their failed orders showed only `EXPIRED`. That is expected, not a fault. See [Order outcomes](/outcomes#order-outcomes).

`Global Rate Limit Exceeded` arrives here as an **execution-report rejection**, not as an HTTP error or a gRPC status. If you only watch gRPC statuses you will not see it.

### Decoding the fee off a report

`commission_notional_collected` is scaled by `priceScale × fractionalQtyScale`, read per instrument. Read it at `priceScale` alone and a $0.01 fee becomes $1.00 — that mistake exceeded a real customer's entire prefund and suppressed their refund.

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

The formula, the per-instrument scale lookup and the worked example are on one page: [Money on the wire](/instruments#money-on-the-wire). Do not re-derive them here.

There is no `settlement_fee`. The only fees are the trading fees in the published Fee Schedule — see [Settlement fields](/settlement#settlement-fields).

### Using the resume token

<Steps>
  <Step title="Persist it with the work it covers">
    Write the token in the same transaction as the report it covers. If you store the token
    first and crash, you have acknowledged a report you did not process.
  </Step>

  <Step title="Pass it on the next subscribe">
    Send the last persisted token as `resume_token` on `CreateDropCopySubscriptionRequest`. With no
    token you get a fresh subscription, not a replay.
  </Step>

  <Step title="Dedupe anyway">
    Delivery is at-least-once and redelivery is expected. Dedupe on `clord_id` plus the execution's
    own identifier; do not assume a resumed stream starts exactly one message past your token.
  </Step>
</Steps>

## Position change

`CreatePositionChangeSubscription` is the stream that tells you something happened on a market one of your users holds, and it is the one to build resolution detection on.

### Why this stream and not instrument state change

It is **resumable**. `resume_token` is populated, so a disconnect does not silently drop the one event you care about. `CreateInstrumentStateChangeSubscription` declares a `resume_token` and never populates it — anything that transitions while you are disconnected is gone.

It is **position-driven**. You do not enumerate symbols, so you never hit the 1000-instruments-per-stream market-data cap, and you only hear about markets you actually hold. A partner who built settlement detection on instrument state change was eventually redirected here, and that redirect de-escalated the ticket.

It does **not** carry the settlement price. This stream is the trigger; the value comes from the market data stream or `GET /v1/orderbook/{symbol}`.

<Note>
  **Position-change subscriptions are account-scoped, so the call metadata needs
  `x-participant-id`** as well as `authorization`. The published streaming examples build metadata
  with `authorization` alone and 403.
</Note>

### Subscribe and handle deltas

<CodeGroup>
  ```python Python theme={null}
  import grpc
  from google.protobuf.json_format import MessageToDict
  from polymarket.v1 import position_pb2, position_pb2_grpc  # generated from polymarket-protos.zip
  from polymarket_auth import PolymarketClient                # canonical client: /connect#authentication

  # The gRPC target is GRPC_TARGET in polymarket_auth.py — one place, both environments.
  pmx = PolymarketClient()
  PARTICIPANT_ID = "firms/20260821-examplefirminc-api-participant/participants/p-0f31c9"  # participantId from kyc.approved, verbatim
  ACCOUNT        = "PASTE_PROVISIONED_ACCOUNT"  # provisionedAccount from kyc.approved, verbatim — opaque, never build it
  RESUME_FILE    = "./positions.resume"


  def load_resume() -> bytes:
      try:
          with open(RESUME_FILE, "rb") as fh:
              return fh.read()
      except FileNotFoundError:
          return b""


  def on_position_change(delta: dict) -> None:
      # The first messages after subscribe are the snapshot; deltas follow. Dedupe: delivery is
      # at-least-once. A position going flat is a trigger to read settlement, not proof of resolution.
      print(delta)


  def run() -> None:
      with pmx.channel() as channel:
          stub = position_pb2_grpc.PositionAPIStub(channel)
          request = position_pb2.CreatePositionChangeSubscriptionRequest(accounts=[ACCOUNT])
          resume = load_resume()
          if resume:
              request.resume_token = resume

          # x-participant-id is required: this subscription is account-scoped.
          stream = stub.CreatePositionChangeSubscription(
              request, metadata=pmx.metadata(participant_id=PARTICIPANT_ID)
          )
          try:
              for message in stream:
                  on_position_change(MessageToDict(message, preserving_proto_field_name=True))
                  if message.resume_token:
                      with open(RESUME_FILE, "wb") as fh:
                          fh.write(message.resume_token)
          except grpc.RpcError as err:
              raise RuntimeError(f"position stream ended: {err.code().name}: {err.details()}") from err


  if __name__ == "__main__":
      run()
  ```

  ```typescript TypeScript theme={null}
  import { readFileSync, writeFileSync } from "node:fs";
  import * as grpc from "@grpc/grpc-js";
  import * as protoLoader from "@grpc/proto-loader";
  import { PolymarketClient } from "./polymarketAuth"; // canonical client: /connect#authentication

  const pmx = new PolymarketClient();

  const GRPC_TARGET = "grpc-api.preprod.polymarketexchange.com:443";
  const PARTICIPANT_ID =
    "firms/20260821-examplefirminc-api-participant/participants/p-0f31c9"; // participantId from kyc.approved, verbatim
  const ACCOUNT = "PASTE_PROVISIONED_ACCOUNT"; // provisionedAccount from kyc.approved, verbatim — opaque
  const RESUME_FILE = "./positions.resume";
  const PROTO = "./protos/polymarket/v1/position.proto"; // from polymarket-protos.zip

  function loadResume(): Buffer {
    try {
      return readFileSync(RESUME_FILE);
    } catch {
      return Buffer.alloc(0);
    }
  }

  async function run(): Promise<void> {
    const pkg = grpc.loadPackageDefinition(
      protoLoader.loadSync(PROTO, { keepCase: true, defaults: true, includeDirs: ["./protos"] }),
    ) as any;

    const client = new pkg.polymarket.v1.PositionAPI(GRPC_TARGET, grpc.credentials.createSsl());

    // x-participant-id is required: this subscription is account-scoped.
    const metadata = await pmx.metadata(PARTICIPANT_ID);

    const resume = loadResume();
    const request: Record<string, unknown> = { accounts: [ACCOUNT] };
    if (resume.length > 0) request.resume_token = resume;

    await new Promise<void>((resolve, reject) => {
      const stream = client.CreatePositionChangeSubscription(request, metadata);
      stream.on("data", (delta: any) => {
        console.log(delta);
        if (delta.resume_token?.length) writeFileSync(RESUME_FILE, delta.resume_token);
      });
      stream.on("error", (err: grpc.ServiceError) =>
        reject(new Error(`position stream ended: ${grpc.status[err.code]}: ${err.details}`)),
      );
      stream.on("end", resolve);
    });
  }

  run().catch((err) => {
    console.error(err);
    process.exit(1);
  });
  ```

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

  import (
  	"context"
  	"fmt"
  	"log"
  	"os"

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

  	pmauth "example.com/yourfirm/pmauth"        // canonical client: /connect#authentication
  	pos "github.com/your-firm/gen/polymarket/v1" // generated from polymarket-protos.zip
  )

  const (
  	grpcTarget    = "grpc-api.preprod.polymarketexchange.com:443"
  	participantID = "firms/20260821-examplefirminc-api-participant/participants/p-0f31c9" // from kyc.approved, verbatim
  	account       = "PASTE_PROVISIONED_ACCOUNT"                                            // provisionedAccount from kyc.approved, verbatim
  	resumeFile    = "./positions.resume"
  )

  func main() {
  	pm := pmauth.New()
  	// x-participant-id is required: this subscription is account-scoped.
  	md, err := pm.Metadata(participantID)
  	if err != nil {
  		log.Fatal(err)
  	}

  	conn, err := grpc.NewClient(grpcTarget, grpc.WithTransportCredentials(
  		credentials.NewClientTLSFromCert(nil, ""),
  	))
  	if err != nil {
  		log.Fatal(err)
  	}
  	defer conn.Close()

  	ctx := metadata.NewOutgoingContext(context.Background(), md)

  	req := &pos.CreatePositionChangeSubscriptionRequest{Accounts: []string{account}}
  	if resume, err := os.ReadFile(resumeFile); err == nil && len(resume) > 0 {
  		req.ResumeToken = resume
  	}

  	stream, err := pos.NewPositionAPIClient(conn).CreatePositionChangeSubscription(ctx, req)
  	if err != nil {
  		log.Fatal(err)
  	}

  	for {
  		delta, err := stream.Recv()
  		if err != nil {
  			log.Fatalf("position stream ended: %s: %s", status.Code(err), status.Convert(err).Message())
  		}
  		fmt.Println(delta.String())
  		if t := delta.GetResumeToken(); len(t) > 0 {
  			if err := os.WriteFile(resumeFile, t, 0o600); err != nil {
  				log.Fatal(err)
  			}
  		}
  	}
  }
  ```
</CodeGroup>

<Note>
  **Confirm the request message name and the delta field spellings against your own bundle.** The
  bundle is unversioned and has no checksum, it advertises 14 services while defining 5, and
  `CreatePositionSubscription` has no proto definition anywhere — do not assume the two names are
  interchangeable.&#x20;
</Note>

Subscribe gives you **snapshot then delta**: the first messages describe your current positions, and changes follow. Delivery is at-least-once, so dedupe and expect redelivery.

### Composing it with the settlement read

A position change on a market that has passed its expiration is a **trigger**, not a settlement price. Get the price from the market data stream or the orderbook read.

<Steps>
  <Step title="Take the symbol off the position change">
    You now know which instrument moved for which account.
  </Step>

  <Step title="Read the settlement fields for that symbol">
    `GET /v1/orderbook/{symbol}` or the market data stream carries `settlement_px`,
    `settlement_preliminary`, `settlement_price_calculation_method`,
    `settlement_price_calculation_text` and `settlement_set_time`. Neither `x-participant-id` nor an
    account is needed on `/v1/orderbook/*`.
  </Step>

  <Step title="De-scale settlementPx with priceScale">
    `settlementPriceScale` is reserved and reads 0. Winning contracts settle at $1.00 and losing at
            $0.00 — see [Settlement fields](/settlement#settlement-fields).
  </Step>

  <Step title="Gate before you act">
    `settlement_preliminary: false` does not mean "resolved to an outcome". A non-binary
    `settlementPx` on a non-resolved instrument is a mark, and there is no positive `resolved`
    indicator. Read [Detect that a market resolved](/settlement#detect-that-a-market-resolved) before
    you write any payout logic.
  </Step>
</Steps>

We credit the participant's clearing account at resolution. Do not also credit your user from your own ledger — see [Who credits the user](/settlement#who-credits-the-user).

### Preprod does not resolve markets

Markets do not resolve in preprod. Instruments reach `INSTRUMENT_STATE_EXPIRED` in large batches — 363, 455, 544 and 549 in a single session — with none resolving. One partner asked six times over 40 days to test a settlement flow and never could. Build and unit-test your handler against captured messages; do not plan on an end-to-end preprod resolution.

## Instrument state changes

`CreateInstrumentStateChangeSubscription` carries lifecycle transitions on instruments and nothing else.

<Warning>
  **This stream cannot be resumed, and it is not a settlement source.**

  It declares a `resume_token` and **never populates it**. Anything that transitions while you are
  disconnected is lost, and there is no replay path — you recover by reading instrument state back,
  not by resuming.

  It returns the updated `Instrument` and **does not include the settlement result fields**. We told
  a partner the opposite and then corrected it. Settlement values come from the market data stream or
  `GET /v1/orderbook/{symbol}`; the trigger comes from
  [Position change](#position-change).
</Warning>

This section needs less than the rest of the page: a minted access token with the `read:instruments` scope, and your generated stubs. No participant ID and no provisioned account.

### This is the one subscription with no participant header

`CreateInstrumentStateChangeSubscription` needs `read:instruments` only. Do **not** send `x-participant-id` expecting it to matter — the subscription is not account-scoped, and every other stream on this list is. That asymmetry is why partners copy a working instrument-state subscriber, point it at drop copy, and get a 403.

### Subscribe to instrument state changes

<CodeGroup>
  ```python Python theme={null}
  import grpc
  from google.protobuf.json_format import MessageToDict
  from polymarket.v1 import marketdata_pb2, marketdata_pb2_grpc  # generated from polymarket-protos.zip
  from polymarket_auth import PolymarketClient                    # canonical client: /connect#authentication

  # The gRPC target is GRPC_TARGET in polymarket_auth.py — one place, both environments.
  pmx = PolymarketClient()
  SYMBOLS     = ["aec-cfb-clmsn-lsu-2026-09-05"]  # from /v1/refdata; an empty list subscribes to ALL instruments


  def run() -> None:
      with pmx.channel() as channel:
          stub = marketdata_pb2_grpc.MarketDataAPIStub(channel)
          request = marketdata_pb2.CreateInstrumentStateChangeSubscriptionRequest(symbols=SYMBOLS)

          # read:instruments only. No x-participant-id on this subscription.
          stream = stub.CreateInstrumentStateChangeSubscription(
              request,
              metadata=pmx.metadata(),  # no participant ID on this subscription
          )
          try:
              for message in stream:
                  event = MessageToDict(message, preserving_proto_field_name=True)
                  # No resume_token is ever populated here — do not try to persist one.
                  print(event)
          except grpc.RpcError as err:
              raise RuntimeError(f"instrument state stream ended: {err.code().name}: {err.details()}") from err


  if __name__ == "__main__":
      run()
  ```

  ```typescript TypeScript theme={null}
  import * as grpc from "@grpc/grpc-js";
  import * as protoLoader from "@grpc/proto-loader";
  import { PolymarketClient } from "./polymarketAuth"; // canonical client: /connect#authentication

  const pmx = new PolymarketClient();

  const GRPC_TARGET = "grpc-api.preprod.polymarketexchange.com:443";
  const SYMBOLS = ["aec-cfb-clmsn-lsu-2026-09-05"]; // from /v1/refdata; [] subscribes to ALL instruments
  const PROTO = "./protos/polymarket/v1/marketdata.proto"; // from polymarket-protos.zip

  async function run(): Promise<void> {
    const pkg = grpc.loadPackageDefinition(
      protoLoader.loadSync(PROTO, { keepCase: true, defaults: true, includeDirs: ["./protos"] }),
    ) as any;

    const client = new pkg.polymarket.v1.MarketDataAPI(GRPC_TARGET, grpc.credentials.createSsl());

    // read:instruments only: no participant ID on this subscription.
    const metadata = await pmx.metadata();

    await new Promise<void>((resolve, reject) => {
      const stream = client.CreateInstrumentStateChangeSubscription({ symbols: SYMBOLS }, metadata);
      stream.on("data", (event: any) => console.log(event)); // no resume_token is ever populated here
      stream.on("error", (err: grpc.ServiceError) =>
        reject(new Error(`instrument state stream ended: ${grpc.status[err.code]}: ${err.details}`)),
      );
      stream.on("end", resolve);
    });
  }

  run().catch((err) => {
    console.error(err);
    process.exit(1);
  });
  ```

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

  import (
  	"context"
  	"fmt"
  	"log"

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

  	pmauth "example.com/yourfirm/pmauth"       // canonical client: /connect#authentication
  	md "github.com/your-firm/gen/polymarket/v1" // generated from polymarket-protos.zip
  )

  const grpcTarget = "grpc-api.preprod.polymarketexchange.com:443"

  // From /v1/refdata. An empty slice subscribes to ALL instruments.
  var symbols = []string{"aec-cfb-clmsn-lsu-2026-09-05"}

  func main() {
  	pm := pmauth.New()
  	// read:instruments only: pass "" for the participant ID on this subscription.
  	callMD, err := pm.Metadata("")
  	if err != nil {
  		log.Fatal(err)
  	}

  	conn, err := grpc.NewClient(grpcTarget, grpc.WithTransportCredentials(
  		credentials.NewClientTLSFromCert(nil, ""),
  	))
  	if err != nil {
  		log.Fatal(err)
  	}
  	defer conn.Close()

  	ctx := metadata.NewOutgoingContext(context.Background(), callMD)

  	stream, err := md.NewMarketDataAPIClient(conn).CreateInstrumentStateChangeSubscription(
  		ctx, &md.CreateInstrumentStateChangeSubscriptionRequest{Symbols: symbols},
  	)
  	if err != nil {
  		log.Fatal(err)
  	}

  	for {
  		event, err := stream.Recv()
  		if err != nil {
  			log.Fatalf("instrument state stream ended: %s: %s", status.Code(err), status.Convert(err).Message())
  		}
  		fmt.Println(event.String()) // no resume_token is ever populated here
  	}
  }
  ```
</CodeGroup>

<Note>
  **Confirm the service and request message names against your own bundle.** The bundle is an
  unversioned zip with no changelog or checksum, and it advertises 14 services while defining 5, so
  the generated module path above may differ in your copy.
</Note>

<Warning>
  **An empty `symbols` list subscribes to every instrument**, which collides with the 1000-instrument
  cap that applies per market-data stream. Name your symbols explicitly. Sharding arithmetic is on
  [Reconnect and recover gaps](/reconnect).
</Warning>

### InstrumentState has 9 values

`InstrumentState` has **9** values. Code a default branch: `/concepts/market-data` publishes a lossy 5-state subset, and a partner coding to that subset will miss states it never mentions.

| Value                      | Confirmed                                                          |
| -------------------------- | ------------------------------------------------------------------ |
| `PENDING`                  | Yes                                                                |
| `CLOSED`                   | Yes                                                                |
| `TERMINATED`               | Yes                                                                |
| `MATCH_AND_CLOSE_AUCTION`  | Yes                                                                |
| `INSTRUMENT_STATE_EXPIRED` | Yes — this is the state preprod instruments reach in large batches |
| The remaining 4 values     | **Not yet published** **\[VERIFY]**                                |

<Warning>
  **Not yet published.**&#x20;
  Read the enum out of your own generated code and confirm the list with your integration lead before
  you branch on any state not named above. Do not infer the missing values from the retail surface.
</Warning>

`EXPIRED` on an instrument is not resolution. Preprod instruments reach `INSTRUMENT_STATE_EXPIRED` in batches of 363, 455, 544 and 549 in a single session **with none resolving**, so a state transition tells you the instrument stopped trading and nothing about the outcome.

### Recovering what you missed

There is no replay. After any disconnect, re-read state for the symbols you care about rather than assuming the stream caught up.

<Steps>
  <Step title="Re-subscribe immediately">
    Backoff and cadence are on [Reconnect and recover gaps](/reconnect).
  </Step>

  <Step title="Re-read instrument state for your symbols">
    Use Reference Data for the instruments you track. `ListInstruments` is **6/min per firm** and
    `ListSymbols` is **6/min per firm**, so a full re-read is not something you can do per reconnect
    at scale — track a working set.
  </Step>

  <Step title="Do not use this stream to fill settlement gaps">
    It does not carry settlement result fields. Go to
    [Detect that a market resolved](/settlement#detect-that-a-market-resolved).
  </Step>
</Steps>

## Balance ledger

`CreateBalanceLedgerSubscription` streams ledger entries for a single account in two phases: a replay of existing entries, then live entries as they are written.

<Warning>
  **This stream does not scale, and there is no substitute yet.**

  It is **per account**, and every open stream counts against the **20 concurrent streams per firm**
  cap that is shared across all gRPC subscriptions. With 20 in total — minus drop copy, position
  change and market data — you cannot run one per participant. A firm-level ledger stream is "coming
  soon" with **no date**.

  The consequence: **there is no scalable push channel for cash movements or `RESOLUTION` entries for
  an ISV at scale.** Plan the interim pattern below into your architecture rather than discovering
  the cap at 500 users.
</Warning>

<Note>
  **The published balance-ledger example omits `x-participant-id` and will 403.** This surface is
  account-scoped: the metadata needs `authorization` **and** `x-participant-id`. The same defect is
  in the published drop-copy example.
</Note>

### Replay, then live

<Steps>
  <Step title="Replay phase">
    On subscribe you receive existing ledger entries for the account. Treat this as a snapshot you
    may already have seen — delivery is at-least-once, so dedupe on the entry's own identifier rather
    than on arrival order.
  </Step>

  <Step title="Live phase">
    New entries arrive as they are written. There is no published marker that tells you the replay
    has finished.&#x20;
    Until that is confirmed, make your handler idempotent instead of stateful about the phase change.
  </Step>

  <Step title="Reconnect">
    Reconnecting restarts at the replay phase. That is your gap recovery on this stream — there is no
    populated `resume_token` here to close a gap precisely. See
    [Reconnect and recover gaps](/reconnect).
  </Step>
</Steps>

### Subscribe to the ledger

<CodeGroup>
  ```python Python theme={null}
  import grpc
  from google.protobuf.json_format import MessageToDict
  from polymarket.v1 import ledger_pb2, ledger_pb2_grpc  # generated from polymarket-protos.zip
  from polymarket_auth import PolymarketClient            # canonical client: /connect#authentication

  # The gRPC target is GRPC_TARGET in polymarket_auth.py — one place, both environments.
  pmx = PolymarketClient()
  PARTICIPANT_ID = "firms/20260821-examplefirminc-api-participant/participants/p-0f31c9"  # participantId from kyc.approved, verbatim
  ACCOUNT        = "PASTE_PROVISIONED_ACCOUNT"  # provisionedAccount from kyc.approved, verbatim — opaque, never build it

  # Only request types on the allowlist. A suppressed internal type returns Aborted / 409.
  ENTRY_TYPES = ["ORDER_EXECUTION", "RESOLUTION"]


  def run() -> None:
      with pmx.channel() as channel:
          stub = ledger_pb2_grpc.AccountsAPIStub(channel)
          request = ledger_pb2.CreateBalanceLedgerSubscriptionRequest(
              account=ACCOUNT,
              entry_types=ENTRY_TYPES,
          )
          stream = stub.CreateBalanceLedgerSubscription(
              request,
              # x-participant-id is required: this surface is account-scoped.
              metadata=pmx.metadata(participant_id=PARTICIPANT_ID),
          )
          try:
              for message in stream:
                  entry = MessageToDict(message, preserving_proto_field_name=True)
                  # afterBalance on the newest entry is what partners use as spendable cash today,
                  # because GetAccountBalance rejects a participant clearing account.
                  print(entry.get("entry_type"), entry.get("afterBalance"))
          except grpc.RpcError as err:
              if err.code() == grpc.StatusCode.ABORTED:
                  raise RuntimeError(f"suppressed ledger entry type requested: {err.details()}") from err
              raise RuntimeError(f"ledger stream ended: {err.code().name}: {err.details()}") from err


  if __name__ == "__main__":
      run()
  ```

  ```typescript TypeScript theme={null}
  import * as grpc from "@grpc/grpc-js";
  import * as protoLoader from "@grpc/proto-loader";
  import { PolymarketClient } from "./polymarketAuth"; // canonical client: /connect#authentication

  const pmx = new PolymarketClient();

  const GRPC_TARGET = "grpc-api.preprod.polymarketexchange.com:443";
  const PARTICIPANT_ID =
    "firms/20260821-examplefirminc-api-participant/participants/p-0f31c9"; // participantId from kyc.approved, verbatim
  const ACCOUNT = "PASTE_PROVISIONED_ACCOUNT"; // provisionedAccount from kyc.approved, verbatim — opaque
  const PROTO = "./protos/polymarket/v1/ledger.proto"; // from polymarket-protos.zip

  // Only request types on the allowlist. A suppressed internal type returns Aborted / 409.
  const ENTRY_TYPES = ["ORDER_EXECUTION", "RESOLUTION"];

  async function run(): Promise<void> {
    const pkg = grpc.loadPackageDefinition(
      protoLoader.loadSync(PROTO, { keepCase: true, defaults: true, includeDirs: ["./protos"] }),
    ) as any;

    const client = new pkg.polymarket.v1.AccountsAPI(GRPC_TARGET, grpc.credentials.createSsl());

    // x-participant-id is required: this surface is account-scoped.
    const metadata = await pmx.metadata(PARTICIPANT_ID);

    await new Promise<void>((resolve, reject) => {
      const stream = client.CreateBalanceLedgerSubscription(
        { account: ACCOUNT, entry_types: ENTRY_TYPES },
        metadata,
      );
      stream.on("data", (entry: any) => console.log(entry.entry_type, entry.afterBalance));
      stream.on("error", (err: grpc.ServiceError) => {
        if (err.code === grpc.status.ABORTED) {
          reject(new Error(`suppressed ledger entry type requested: ${err.details}`));
          return;
        }
        reject(new Error(`ledger stream ended: ${grpc.status[err.code]}: ${err.details}`));
      });
      stream.on("end", resolve);
    });
  }

  run().catch((err) => {
    console.error(err);
    process.exit(1);
  });
  ```

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

  import (
  	"context"
  	"fmt"
  	"log"

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

  	pmauth "example.com/yourfirm/pmauth"       // canonical client: /connect#authentication
  	lg "github.com/your-firm/gen/polymarket/v1" // generated from polymarket-protos.zip
  )

  const (
  	grpcTarget    = "grpc-api.preprod.polymarketexchange.com:443"
  	participantID = "firms/20260821-examplefirminc-api-participant/participants/p-0f31c9" // from kyc.approved, verbatim
  	account       = "PASTE_PROVISIONED_ACCOUNT"                                            // provisionedAccount from kyc.approved, verbatim
  )

  // Only request types on the allowlist. A suppressed internal type returns Aborted / 409.
  var entryTypes = []string{"ORDER_EXECUTION", "RESOLUTION"}

  func main() {
  	pm := pmauth.New()
  	// x-participant-id is required: this surface is account-scoped.
  	callMD, err := pm.Metadata(participantID)
  	if err != nil {
  		log.Fatal(err)
  	}

  	conn, err := grpc.NewClient(grpcTarget, grpc.WithTransportCredentials(
  		credentials.NewClientTLSFromCert(nil, ""),
  	))
  	if err != nil {
  		log.Fatal(err)
  	}
  	defer conn.Close()

  	ctx := metadata.NewOutgoingContext(context.Background(), callMD)

  	stream, err := lg.NewAccountsAPIClient(conn).CreateBalanceLedgerSubscription(ctx,
  		&lg.CreateBalanceLedgerSubscriptionRequest{Account: account, EntryTypes: entryTypes})
  	if err != nil {
  		log.Fatal(err)
  	}

  	for {
  		entry, err := stream.Recv()
  		if err != nil {
  			if status.Code(err) == codes.Aborted {
  				log.Fatalf("suppressed ledger entry type requested: %s", status.Convert(err).Message())
  			}
  			log.Fatalf("ledger stream ended: %s: %s", status.Code(err), status.Convert(err).Message())
  		}
  		fmt.Println(entry.GetEntryType(), entry.GetAfterBalance())
  	}
  }
  ```
</CodeGroup>

<Note>
  **Confirm the service, request message and entry field names against your own bundle.** The bundle
  is unversioned, has no checksum, and advertises 14 services while defining 5 — and the published
  `CreateCashMovement` proto shipped with a wrong field number once, so a name that looks right is
  not evidence.
</Note>

### The entry-type allowlist

`LedgerEntryType` is an allowlist. **Twelve internal types are suppressed** — `NETTING`, `GIVE_UP`, `INTEREST` and `SETTLEMENT_FEE` among them — and requesting one returns **`Aborted` / 409**. Handle `Aborted` as a request defect on your side, not as a transport failure to retry: retrying an unentitled type gets the same answer forever.

The types you will actually consume as an ISV are `ORDER_EXECUTION` and `RESOLUTION`.

<Warning>
  **Not yet published.**&#x20;
  Only the four suppressed names above and the two consumable types are confirmed. Confirm the full
  allowlist with your integration lead before you request any other type.
</Warning>

An `ORDER_EXECUTION` entry folds commission into a **single net entry**. Whether the `RESOLUTION` entry is gross with `COMMISSION` deducted separately, or net, is unanswered — do not assume settlement behaves like an execution. See [Settlement fields](/settlement#settlement-fields).

### Interim pattern until the firm-level stream exists

Reserve streams for the accounts where latency matters, and poll for the rest.

<Steps>
  <Step title="Budget your 20 streams explicitly">
    Write the allocation down: 1 drop copy, 1 position change, N market data at 1000 instruments
    each, and whatever is left for balance ledger. The cap is **20 per firm across all gRPC
    subscriptions**, so balance-ledger streams are the residual, not the baseline.
  </Step>

  <Step title="Use position change as your resolution trigger, not the ledger">
    `CreatePositionChangeSubscription` is resumable and position-driven, so one stream tells you
    something happened on any market any of your users hold. That is the scalable signal; the ledger
    is the after-the-fact record. See [Position change](#position-change).
  </Step>

  <Step title="Poll the ledger read for cash, within the REST budget">
    REST is **100 req/sec per firm** on a one-minute average, and ledger CSV downloads are
    **\~5/min per firm**. Size the polling interval against your account count and that budget, and
    back off on a `429`, which carries `Retry-After`.
  </Step>

  <Step title="Derive spendable cash from the newest entry">
    `GetAccountBalance` returns `InvalidArgument: invalid account` for a **participant clearing
    account** while succeeding for a firm account, so partners take the newest ledger entry's
    `afterBalance` instead. Use `GetFundingAccountBalance` for the pool — that one is the pool's
    source of truth. See [Funding](/balances#which-number-is-spendable).
  </Step>

  <Step title="Reconcile on a schedule, not on every event">
    Stored execution history is archived during maintenance and pre-maintenance execution queries
    return empty, so your own persisted ledger is the durable copy. See
    [Preprod](/preprod#what-preprod-does-and-does-not-simulate).
  </Step>
</Steps>

## What can go wrong

| Part             | Symptom                                                     | Cause                                                                                                                                       | What you do                                                                                                                                                                                                                                                    |
| ---------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Choose a stream  | Settlement values never arrive                              | You subscribed to `CreateInstrumentStateChangeSubscription` and expected settlement result fields on it                                     | Move to the market data stream or `GET /v1/orderbook/{symbol}` for values, and `CreatePositionChangeSubscription` for the trigger                                                                                                                              |
| Choose a stream  | `13 INTERNAL: Subscription manager revoked session`         | The subscription session is keyed on caller identity — token subject plus participant header — not on the accounts or symbols you asked for | See [Reconnect and recover gaps](/reconnect); the known workaround is an explicit market list on `CreateOrderSubscription`                                                                                                                                     |
| Choose a stream  | Events lost across a disconnect                             | You are on a stream with no populated `resume_token` (instrument state change, market data)                                                 | Recover with a read, not a replay — per-stream recovery is on [Reconnect and recover gaps](/reconnect)                                                                                                                                                         |
| Choose a stream  | The 21st stream fails to open                               | The 20-stream cap is per firm across all subscription types, not per subscription type                                                      | Consolidate: one market-data stream per 1000 instruments, one position-change stream, one drop copy                                                                                                                                                            |
| Choose a stream  | `403` on subscribe                                          | Account-scoped streams need `x-participant-id` in the call metadata                                                                         | Add it — see [Drop copy](#drop-copy). Only `CreateInstrumentStateChangeSubscription` runs without it                                                                                                                                                           |
| Drop copy        | `403` on subscribe                                          | Metadata carries only `authorization`; drop copy is account-scoped                                                                          | Add `x-participant-id`. The currently published example omits it                                                                                                                                                                                               |
| Drop copy        | `PERMISSION_DENIED`                                         | The scope grant is missing, or you minted the token before we added it                                                                      | Scopes are granted server-side against your client and you must not request them on the token exchange; after a grant, re-mint. See [Authentication](/authentication)                                                                                          |
| Drop copy        | `CROSS_ISV_PARTICIPANT_IMPERSONATION_ATTEMPT`               | `x-participant-id` was built by hand, usually from `GET /v1/whoami`, which returns your clearing-member firm                                | Use the `participantId` from `kyc.approved` unmodified. This raises a CRITICAL security log against your own account                                                                                                                                           |
| Drop copy        | `SocketError: other side closed` after \~10 minutes         | The ALB timeout is 10 minutes                                                                                                               | Reconnect with the persisted `resume_token`. See [Reconnect and recover gaps](/reconnect)                                                                                                                                                                      |
| Drop copy        | `13 INTERNAL: Subscription manager revoked session`         | The session is keyed on caller identity — token subject plus participant header — not on the accounts you requested                         | See [Reconnect and recover gaps](/reconnect)                                                                                                                                                                                                                   |
| Drop copy        | Fee is 100× too large in your ledger                        | You used raw `order_qty` as the contract count on a `fractional_quantity_scale = 100` instrument                                            | De-scale per [Money on the wire](/instruments#money-on-the-wire). Instruments with scale `1` make the naive math accidentally correct, so this often surfaces on your first fill in a scale-100 market                                                         |
| Drop copy        | Executions query returns empty                              | Stored execution history is archived during maintenance, and pre-maintenance execution queries return empty                                 | Treat drop copy as your record of fills and persist them yourself. See [Preprod](/preprod#what-preprod-does-and-does-not-simulate)                                                                                                                             |
| Position change  | `403` on subscribe                                          | Metadata carries only `authorization`                                                                                                       | Add `x-participant-id`; this subscription is account-scoped                                                                                                                                                                                                    |
| Position change  | `CROSS_ISV_PARTICIPANT_IMPERSONATION_ATTEMPT`               | `x-participant-id` built from `GET /v1/whoami`, which returns your clearing-member firm                                                     | Use the `participantId` from `kyc.approved` unmodified                                                                                                                                                                                                         |
| Position change  | Stream opens, no messages                                   | In preprod, books are shallow and intermittent and nothing resolves, so there may genuinely be no position deltas                           | Verify with a fill via drop copy first; see [Preprod](/preprod#what-preprod-does-and-does-not-simulate)                                                                                                                                                        |
| Position change  | `13 INTERNAL: Subscription manager revoked session`         | The session is keyed on caller identity — token subject plus participant header                                                             | See [Reconnect and recover gaps](/reconnect)                                                                                                                                                                                                                   |
| Position change  | `SocketError: other side closed` at \~10 minutes            | ALB timeout is 10 minutes                                                                                                                   | Reconnect with the persisted `resume_token`                                                                                                                                                                                                                    |
| Position change  | You paid a user and the market was not resolved             | You treated a position change, or `settlement_preliminary: false`, as proof of resolution                                                   | Gate per [Detect that a market resolved](/settlement#detect-that-a-market-resolved); hold for review rather than auto-paying                                                                                                                                   |
| Position change  | The 21st stream will not open                               | 20 concurrent streams **per firm** across all gRPC subscriptions, shared with drop copy, market data and balance ledger                     | Consolidate rather than opening one stream per participant. How many accounts one position-change stream can carry is not published **\[VERIFY]**, so confirm it with your integration lead before you size the fleet. See [Choose a stream](#choose-a-stream) |
| Instrument state | `resume_token` is always empty                              | The field is declared and never populated on this stream                                                                                    | Do not build resume logic here. Recover with a read                                                                                                                                                                                                            |
| Instrument state | Transitions missing after a reconnect                       | No replay on this stream                                                                                                                    | Re-read instrument state for your working set                                                                                                                                                                                                                  |
| Instrument state | No settlement fields on the `Instrument`                    | This subscription does not carry settlement result fields                                                                                   | Use the market data stream or `GET /v1/orderbook/{symbol}`                                                                                                                                                                                                     |
| Instrument state | `PermissionDenied: method not permitted` when reflecting    | gRPC server reflection is entitlement-gated                                                                                                 | Ask your integration lead for the grant, or generate from the bundle                                                                                                                                                                                           |
| Instrument state | `PERMISSION_DENIED` on subscribe                            | The `read:instruments` grant is missing, or the token predates the grant                                                                    | Scopes are granted server-side; after a grant, re-mint the token. You must not request scopes on the token exchange                                                                                                                                            |
| Instrument state | Stream drops at \~10 minutes                                | ALB timeout is 10 minutes, surfacing as `SocketError: other side closed`                                                                    | Reconnect per [Reconnect and recover gaps](/reconnect)                                                                                                                                                                                                         |
| Instrument state | A state you have no branch for                              | You coded to the published 5-state subset; the enum has 9 values                                                                            | Add a default branch that halts action on that instrument rather than guessing                                                                                                                                                                                 |
| Balance ledger   | `403` on subscribe                                          | Metadata carries only `authorization`; this surface is account-scoped                                                                       | Add `x-participant-id`. The currently published example omits it                                                                                                                                                                                               |
| Balance ledger   | `Aborted` / `409`                                           | You requested a suppressed internal type such as `NETTING`, `GIVE_UP`, `INTEREST` or `SETTLEMENT_FEE`                                       | Request only allowlisted types. Do not retry — the answer will not change                                                                                                                                                                                      |
| Balance ledger   | The 21st stream will not open                               | Balance-ledger streams are per account and share the **20-per-firm** cap with every other subscription                                      | Use the interim pattern above. The firm-level stream has no date                                                                                                                                                                                               |
| Balance ledger   | `InvalidArgument: invalid account` from `GetAccountBalance` | That call rejects a participant clearing account while succeeding for a firm account                                                        | Take `afterBalance` from the newest ledger entry                                                                                                                                                                                                               |
| Balance ledger   | Your balance sheet will not reach zero                      | Transfers accept at most 2 decimal places while balances carry more — `172.395` and `1377.57275` have both been observed                    | There is no sanctioned handling for the sub-cent residue. Track it and raise it with your integration lead                                                                                                                                                     |
| Balance ledger   | Duplicate entries after a reconnect                         | Reconnecting restarts the replay phase, and delivery is at-least-once                                                                       | Dedupe on the entry identifier; make the handler idempotent                                                                                                                                                                                                    |
| Balance ledger   | `SocketError: other side closed` at \~10 minutes            | ALB timeout is 10 minutes                                                                                                                   | Reconnect per [Reconnect and recover gaps](/reconnect)                                                                                                                                                                                                         |

## Next

[Reconnect and recover gaps](/reconnect)
