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

# Certification

> The UAT gate between preprod and production, as assertions you can work through and a reviewer can sign off.

Work through every assertion below in preprod, capture the evidence named next to it, and your integration lead signs the result off as your UAT gate.

<Info>
  Prerequisites: [What preprod does and does not simulate](/preprod#what-preprod-does-and-does-not-simulate) and
  [Test harness](/preprod#test-harness). You need a funded preprod pool and at least two KYC-approved test
  participants before you start.
</Info>

<Note>
  **This page replaces the UAT framework that has lived in a Google Doc and a companion Sheet.** Those
  two artefacts are no longer the record. A completed copy of this checklist, with the evidence
  attached, is.
</Note>

Evidence means a captured request and response pair, or a captured stream message, with the
environment, the timestamp and the identifier named. A screenshot of a passing test is not evidence;
the response body is.

## The one contradiction you cannot resolve yourself

<Note>
  **If you are an IB, this differs.**
  You have a gate with no ISV equivalent: the **regulatory pack and the NFA/CFTC demo**, with the
  ten reports mapped to a source and the five unsupported ones escalated in writing — see
  [Reporting pack](/regulatory#reporting-pack). Agree the **demo environment before the date is
  set**, because you cannot demonstrate a settled market in preprod. You will also be asserting
  record-level requirements (physical-person legal names, five-digit `postal_code`, `external_id`
  within 49 characters, all three identity strings labelled) and that no flow has your firm
  receiving or holding customer funds.
</Note>

<Warning>
  **UAT §0 and the only available way to manufacture a fill contradict each other.**

  UAT §0 asks you to disavow generic REST/FIX and legacy order entry. Because the partner surface is
  FOK-only and you are always the taker, the only way to manufacture a fill in a thin preprod book is
  to **rest** an order through the legacy `OrderEntryAPI/insertOrder`. Every fill-dependent assertion
  below therefore depends on the thing §0 asks you to disavow.

  Do not guess which one governs. Ask your integration lead to state in writing either that seeding
  depth through `insertOrder` is permitted for certification, or that the fill-dependent assertions
  are waived for preprod and verified in production. Mark the affected rows `Unresolved` on your
  submission until you have that sentence.
</Warning>

<Warning>
  **A second known defect in the existing UAT plan:** it asks you to test non-terminal KYC states via
  webhook. **Only `kyc.approved`, `kyc.rejected` and `webhook.test` fire.** There is no review event.
  Poll `GET /v1/kyc/status` for non-terminal states and mark that UAT row as satisfied by polling.
</Warning>

## Connect

| #  | Assertion                                                                                                                                                                                                 | Evidence to capture                                                                                                                             |
| -- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| C1 | You mint an access token with a **private\_key\_jwt** client assertion, RS256, claims `iss`, `sub`, `aud`, `iat`, `exp`, `jti`, with `exp` at most 5 minutes after `iat` and a unique `jti` per assertion | One successful token response, and the decoded assertion header and claims                                                                      |
| C2 | The assertion's `aud` is the **Auth0 token endpoint**, and the resulting access token's audience is the **API base URL**                                                                                  | The decoded assertion `aud` and the decoded access-token audience, side by side                                                                 |
| C3 | You refresh on `expires_in` minus a 30-second buffer and **do not hardcode 180**                                                                                                                          | The refresh timer code path, plus two consecutive token mints with their `expires_in` values                                                    |
| C4 | You request **no scopes** on the token exchange, and you re-mint after a grant is added                                                                                                                   | The token request body showing no scope parameter                                                                                               |
| C5 | You reach `grpc-api.preprod.polymarketexchange.com:443`, not `grpc-preprod.polymarketexchange.com`                                                                                                        | The connected target string from your client config                                                                                             |
| C6 | Your preprod and production keypairs are **separate**, and preprod credentials are proven not to work against production                                                                                  | The failed production token attempt with preprod credentials                                                                                    |
| C7 | You hold both firm names and can say which is which: the **participant** firm holds your end users, the **clearing-member** firm is the token's `firm_id`                                                 | Both firm strings, labelled                                                                                                                     |
| C8 | **You never construct a participant ID.** No code path builds one from `GET /v1/whoami`                                                                                                                   | The code path that reads `participantId` from the webhook or `GET /v1/kyc/status`, and a grep showing no string concatenation of firm plus user |

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

C8 is the single highest-value row on this page. A constructed participant ID fails
`validateParticipantFirmOwnership` and raises `CROSS_ISV_PARTICIPANT_IMPERSONATION_ATTEMPT`, a
CRITICAL security log and a cross-ISV access metric, against your own legitimate account.

## Onboard

| #   | Assertion                                                                                                                                       | Evidence to capture                                                                                                                                                    |
| --- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| O1  | You submit a KYC payload in `snake_case` and parse a `camelCase` REST response and a `snake_case` webhook                                       | One of each, for the same user                                                                                                                                         |
| O2  | You handle all four KYC outcomes and treat `decision`, `status` and `subStatus` as **informational, not control flow**                          | The branch table in your code, plus a captured `"In Review"` `subStatus` if you see one — it is a real value outside the documented set                                |
| O3  | You set `docv_eligible: true` and pass the Digital Intelligence `session_token`                                                                 | The submitted payload                                                                                                                                                  |
| O4  | You respect `external_id` ≤ 49 characters and a five-digit `postal_code`                                                                        | Your validation rule and one rejected over-length `external_id`                                                                                                        |
| O5  | You wait for the `kyc.approved` webhook before enabling trading, and tolerate an empty `participantId` on the `ACCEPT` for **up to 26 minutes** | A captured `ACCEPT` with an empty `participantId` and the later webhook carrying it. If you cannot reproduce the empty case, the timeout config is acceptable evidence |
| O6  | You record the `participantId` we issue against your own user row at onboarding                                                                 | The stored row                                                                                                                                                         |
| O7  | Your webhook verifies the signature over exactly `"<webhook-id>.<webhook-timestamp>.<raw body>"` with a `whsec_<base64>` secret of ≥24 bytes    | One verified and one deliberately-failed verification                                                                                                                  |
| O8  | You dedupe on `event_id` and tolerate redelivery                                                                                                | Two deliveries of one `event_id` and a single effect                                                                                                                   |
| O9  | You know that registering a webhook is **one URL per firm, last-writer-wins, and re-registering clears the signing secret**                     | Your runbook sentence for rotating the URL                                                                                                                             |
| O10 | A **distinct SSN per test user**, with a registry of burned values                                                                              | The registry                                                                                                                                                           |
| O11 | You present the four QCX LLC / QC Clearing LLC documents **from `polymarketexchange.com`**, unmodified                                          | The links your app renders                                                                                                                                             |

<Warning>
  **The `agreement.version` format is not settled, and this is compliance-relevant.**

  Do not code either format as canonical. Capture whichever value your integration lead confirms in
  writing and cite that message on your submission. There is also no `agreement.version` discovery
  endpoint, no rotation policy and no re-acceptance mechanism for already-approved users.
</Warning>

## Fund

| #  | Assertion                                                                                                                                                                       | Evidence to capture                                                                                     |
| -- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| F1 | Your funding entity exists and is active **before** your credentials were enabled                                                                                               | The confirmation from us                                                                                |
| F2 | `CashMovementService` calls carry **no `x-participant-id`** — cash movement is firm-scoped                                                                                      | The metadata your client sends on a `Transfer`                                                          |
| F3 | You name only the participant account, and you rely on us to resolve your funding account                                                                                       | The `Transfer` request body                                                                             |
| F4 | Direction is fixed by the reason. You use `DEPOSIT`, `WITHDRAWAL` and `VENDOR_FEES` and send no direction of your own                                                           | One of each reason you use                                                                              |
| F5 | You handle all four statuses: `PENDING`, `CONFIRMED`, `REJECTED`, `AMBIGUOUS`                                                                                                   | Your branch table. `CONFIRMED` and at least one non-`CONFIRMED` capture if you can produce one          |
| F6 | **After a terminal rejection you mint a new `idempotency_key`.** A retry on the rejected key replays the stored rejection rather than making a fresh attempt                    | Your retry code path                                                                                    |
| F7 | You send at most 2 decimal places on a transfer, and you have a stated policy for the sub-cent residue that balances carry                                                      | Your rounding rule, plus a balance with more than 2 decimals (`172.395` and `1377.57275` are on record) |
| F8 | `GetFundingAccountBalance` is your source of truth for the pool                                                                                                                 | One response                                                                                            |
| F9 | You know which field you spend against, and your free-cash formula accounts for collateral locked by open orders and positions **and** your own accrued uncollected vendor fees | Your formula, written out                                                                               |

<Warning>
  **The canonical free-cash formula, the transfer rate limit and open-short collateral are
  all unresolved.**

  Until the short-collateral question is answered, **do not let a user withdraw funds backing an open
  short.** State that guard explicitly on your submission.
</Warning>

## Trade

| #   | Assertion                                                                                                                             | Evidence to capture                                                                                                                            |
| --- | ------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| T1  | Every order is a limit order with `TIME_IN_FORCE_FILL_OR_KILL`, and you never send another TIF                                        | One order request, plus the rejection text `InvalidArgument: order.time_in_force must be TIME_IN_FORCE_FILL_OR_KILL` from a deliberate bad TIF |
| T2  | You name the participant **only** by `order.account`, and send no `x-participant-id` on `CreateVendorOrder`                           | The request and its metadata                                                                                                                   |
| T3  | You send `order.clord_id` on every create. `PreviewOrder` ignores it; `Create` requires it                                            | One create request                                                                                                                             |
| T4  | You branch on all three of `ACCEPTED`, `REJECTED`, `PENDING`, and you know `ACCEPTED` includes an order cancelled under fill-or-kill  | Your branch table plus one `ACCEPTED` that did not fill                                                                                        |
| T5  | You detect an exchange-level rejection that arrives as gRPC `OK` with `status = REJECTED`                                             | A captured rejection showing gRPC `OK`                                                                                                         |
| T6  | `PENDING` recovery resubmits with the **same `idempotency_key` and a byte-identical body**, and you never mint a new key to retry     | The recovery code path, and an `ALREADY_EXISTS` from a deliberately altered body — that error is the guard working                             |
| T7  | You treat `EXPIRED` as an FOK that never crossed, not as an error, and you do not surface a reason to your user for it                | Your handling path and one captured `EXPIRED`                                                                                                  |
| T8  | You read `priceScale` and `fractionalQtyScale` **per instrument** from Reference Data and never hard-code a divisor                   | The lookup code path, plus one de-scaling assertion on a `priceScale: 1000` instrument if preprod serves you one                               |
| T9  | `cash_order_qty` is scaled by `priceScale`, and on a SELL it means *spend that much complementary NO collateral*                      | One SELL request with the intended dollar amount in a comment                                                                                  |
| T10 | You read `commission_notional_collected` at `priceScale × fractionalQtyScale`, so `100` on a both-scales-100 instrument is **\$0.01** | The de-scaling line and one fill's fee reconciled to the cent                                                                                  |
| T11 | Your fee model uses the **taker** coefficient, and your pricing does not assume a maker rebate you can never earn                     | Your fee calculation                                                                                                                           |
| T12 | You parse `int64` fields that arrive as JSON **strings**                                                                              | The parse path                                                                                                                                 |
| T13 | You handle `Global Rate Limit Exceeded` arriving as an **execution-report rejection**, not an HTTP error                              | Your handling path                                                                                                                             |

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

Rows T4, T5, T10 and T11 are the rows that have cost real money. T10 in particular: reading
`commission_notional_collected` one scale short turned a $0.01 fee into $1.00 on a 32-cent trade,
which exceeded a customer's entire prefund and suppressed their refund.

<Note>
  **The taker fee coefficient changed on 2026-09-14** and there is no fee-schedule endpoint, so every
  partner hand-edited their systems. Pin the current coefficient from the published Fee Schedule on
  the day you certify, record the value on your submission, and subscribe to the changelog.
  → [Breaking changes and the changelog](/operations#breaking-changes-and-the-changelog)
</Note>

## Stream

| #   | Assertion                                                                                                                                                                                                       | Evidence to capture                                                                        |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| S1  | You are under **20 concurrent streams per firm**, counted across all gRPC subscriptions                                                                                                                         | Your stream count at peak                                                                  |
| S2  | No market-data stream exceeds **1,000 instruments**, and you never pass an empty `symbols` list — that subscribes to all instruments                                                                            | Your subscription request showing an explicit list                                         |
| S3  | Your gRPC ingress stays under **100 msg/sec per firm**                                                                                                                                                          | Your measured peak                                                                         |
| S4  | Drop copy is your source of record for fills and commissions, and you persist its `resume_token`                                                                                                                | A persisted token, and one resumed stream after a restart                                  |
| S5  | Position change is what answers "did a market I hold resolve?", and it is subscribed                                                                                                                            | The subscription                                                                           |
| S6  | You do **not** use instrument state change as a settlement source. It declares a `resume_token` and never populates it, and it does not carry settlement result fields                                          | Your stream-to-purpose mapping                                                             |
| S7  | Drop-copy and balance-ledger metadata include **`x-participant-id`** — both surfaces are account-scoped and 403 without it                                                                                      | The metadata your client sends                                                             |
| S8  | `CreateInstrumentStateChangeSubscription` needs `read:instruments` only and no `x-participant-id`                                                                                                               | The metadata for that one call                                                             |
| S9  | You handle snapshot-then-delta on subscribe, dedupe, and expect redelivery                                                                                                                                      | Your dedupe key and one redelivered message                                                |
| S10 | You survive `SocketError: other side closed` at the 10-minute ALB timeout and reconnect from your checkpoint                                                                                                    | A reconnect log with the resumed position                                                  |
| S11 | You handle `13 INTERNAL: Subscription manager revoked session`, and you know the session is keyed on **caller identity** — token subject plus participant header — not on the accounts or symbols you asked for | Your handling path, plus the explicit market list you pass on `CreateOrderSubscription`    |
| S12 | Exactly one process consumes each stream                                                                                                                                                                        | Your leader-election mechanism. → [Daily reconciliation](/operations#daily-reconciliation) |

## Settle

Every row in this section is blocked in preprod, because **markets do not resolve there**. Agree with
your reviewer which evidence substitutes.

| #  | Assertion                                                                                                                                                                                                                                                         | Evidence to capture                                                                               |
| -- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| X1 | **You do not credit your user from your own ledger at resolution.** We credit the participant's clearing account; if both sides credit, every settled position pays out twice                                                                                     | The code path showing settlement crediting is not performed, or is performed only from our credit |
| X2 | You read settlement values from the market data stream or `GET /v1/orderbook/{symbol}`, not from instrument state change                                                                                                                                          | The read path                                                                                     |
| X3 | You **do not gate a payout on `settlement_preliminary: false`.** A non-binary `settlementPx` on a non-resolved instrument is a mark, and there is no positive `resolved` indicator                                                                                | Your gate condition, written out, and the reasoning                                               |
| X4 | You do not require `settlement_price_calculation_text` to be present. It is not guaranteed on a settled instrument                                                                                                                                                | Your parse path tolerating its absence                                                            |
| X5 | You de-scale `settlementPx` with `priceScale`. `settlementPriceScale` is reserved and reads 0                                                                                                                                                                     | The de-scaling line                                                                               |
| X6 | You do **not** gate market selection on `automaticResolution`. It controls nothing, is reserved on the v2 surface, and reads `false` for all 223 preprod sports including `atp` and `wta`, which do resolve automatically. Gate on event series and symbol prefix | Your selection predicate                                                                          |
| X7 | You tolerate `settlementSetTime` preceding `expirationDate` — a \~13-hour lead is on record on a mark                                                                                                                                                             | Your ordering logic                                                                               |
| X8 | You request no suppressed `LedgerEntryType`. Twelve internal types are suppressed and requesting one returns `Aborted` / 409                                                                                                                                      | The types you request                                                                             |

<Warning>
  **Three settle-section facts are not yet settled, and two of them can pay a user twice.**

  Two partners shipped settlement code switched off in production rather than risk the double payout.
  Until X1 is confirmed in writing, switched-off is the correct state and is an acceptable
  certification outcome. → [Who pays at settlement](/settlement#who-credits-the-user)
</Warning>

## Operate

| #  | Assertion                                                                                                                       | Evidence to capture                                                                                      |
| -- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| P1 | You filter or shard `POST /v1/report/trades/search` by symbol and never run it unfiltered                                       | One filtered call with its latency. → [Reporting and bulk export](/operations#reporting-and-bulk-export) |
| P2 | You dedupe report pages on `symbol`, because page tokens are offset-based                                                       | Your dedupe key                                                                                          |
| P3 | Your `GetTradeStats` calls use `where_clause`, not `field_filter`                                                               | The request body                                                                                         |
| P4 | You treat a zero bucket in trade stats as **no trades**, never as a print at price 0                                            | Your parse path                                                                                          |
| P5 | You persist every execution as it arrives rather than relying on stored execution history, which is archived during maintenance | Your persistence path                                                                                    |
| P6 | A process restart resumes from your persisted checkpoint and does not re-run the day's reads                                    | Your restart path                                                                                        |
| P7 | You subscribe to the changelog by RSS or Slack `/feed subscribe`                                                                | The subscription                                                                                         |

## Sign-off

| Field                     | Value     |
| ------------------------- | --------- |
| Firm                      |           |
| Participant firm name     |           |
| Clearing-member firm name |           |
| Environment certified     | `preprod` |
| Date of run               |           |
| Rows marked `Unresolved`  |           |
| Rows waived, and by whom  |           |
| Reviewer                  |           |

A submission with rows marked `Unresolved` is still a valid submission. A submission that
silently picked a value instead is not.

## What can go wrong

| Symptom                                                     | Cause                                                               | What you do                                                                           |
| ----------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| No fill-dependent row can be evidenced                      | Thin preprod book plus FOK-only, and §0 forbids the only workaround | Get the §0 question answered in writing; mark the rows `Unresolved` meanwhile         |
| A security alert is raised against you during certification | A constructed participant ID hit `validateParticipantFirmOwnership` | Stop, fix C8, and tell us — the alert is a CRITICAL log on your account               |
| `PERMISSION_DENIED` on a row you believe is in scope        | A grant was added and you did not re-mint the token                 | Re-mint. Scopes are granted server-side and are only in tokens issued after the grant |
| A `403` on drop copy or the balance ledger                  | Metadata missing `x-participant-id` on an account-scoped stream     | Add the header; the published examples for both omit it                               |
| The reviewer cannot tell which proto build you used         | The bundle is unversioned with no checksum                          | Record your own checksum of the zip on the submission                                 |

## Next

[Running it](/operations)
