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

# KYC flow

> Submit an end user for verification, handle all four outcomes, and track the three identifiers that decide who can trade.

You collect the end user's payload and post it to us; we run the identity check through Socure, make the decision, and provision the exchange account.

<Info>
  Before this page you need:

  * a working access token — see [/connect#authentication](/authentication),
  * the `kyc:write` scope granted against your client (scopes are granted server-side; you cannot request them on the token exchange),
  * both of your firm IDs, the difference between them, and where `x-participant-id` applies — see [/connect#firms-participants-and-accounts](/identity),
  * a registered webhook endpoint, because the response alone never tells you a user is tradable — see [/webhooks](/webhooks),
  * the end-user acceptance requirements — see [/legal-agreements](/legal-agreements).
</Info>

## Who decides

<Tabs>
  <Tab title="ISV">
    We do. You never make the decision and you cannot supply it.

    **An ISV cannot rely on its own KYC.** Without an Introducing Broker licence, every participant you send must be verified through Socure again, even if you have already KYC'd that person for your own product. This is a regulatory constraint, not a technical one. An IB, once licensed, may be able to remit against its own KYC policy subject to AML sign-off; no timeframe for that is committed.

    Budget for this in your product plan: users you already consider verified will be re-verified, and some of them will be rejected by us after you approved them.
  </Tab>

  <Tab title="Introducing broker">
    **You do.** As an IB you own the end-user identity decision, and a licensed IB may be able to
    remit against its own KYC policy, subject to AML sign-off. That is a genuine difference in
    obligation from an ISV, not a courtesy.

    Until a reliance model is in force for your firm, every participant you send us goes through our
    identity path as well as yours: our check is Socure-backed, we run it, and we make the decision on
    the participant-provisioning path. The rest of this page is that path, and it applies to you today.

    <Note>
      **No timeframe is committed for reliance, and what changes on the day it is approved is not
      published.** Read [You own KYC](/kyc-ownership) before you design onboarding — it records the
      full position, what we still require regardless, and the questions to get answered in writing.
    </Note>
  </Tab>
</Tabs>

## The four outcomes

Every `POST /v1/kyc/start` lands on one of four outcomes.

| Outcome                        | What it means                                                       | What you do                                                                                                            |
| ------------------------------ | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Approved                       | Identity cleared. An exchange account is being provisioned.         | Wait for the `kyc.approved` webhook, then enable trading.                                                              |
| Rejected                       | Terminally declined.                                                | Do not retry. There is no partner-visible decline reason.                                                              |
| Document verification required | Socure wants a government ID. The response carries a `docv` object. | Send the user to `docv.url` — see [/docv#the-docv-state-machine](/docv#the-docv-state-machine).                        |
| Manual review                  | A human reviews the case.                                           | Poll `GET /v1/kyc/status` and wait for a terminal webhook. 1–2 business days if you did not set `docv_eligible: true`. |

Setting `docv_eligible: true` on the request is what converts a review case into a self-service document upload. If you leave it off, review cases go to **manual review, 1–2 business days**, and your signup funnel absorbs that delay.

<Warning>
  **Not yet published.**&#x20;
  `decision: ACCEPT` and `status: CLOSED` are confirmed values. The literal strings for the reject and review outcomes are not published. Confirm them with your integration lead before you pattern-match on them — and see the next section for why you should not pattern-match on them at all.
</Warning>

## `decision`, `status` and `subStatus` are informational, not control flow

Treat all three as telemetry to log, not as branch conditions.

Branch on these instead:

1. **Is there a `docv` object in the response?** If yes, the user has documents to submit. Nothing else matters yet.
2. **Did a terminal webhook arrive?** `kyc.approved` and `kyc.rejected` are the only authoritative terminal signals.
3. **Is `participantId` non-empty?** On an approved decision it can come back empty while provisioning finishes. Observed gaps run from \~500 ms to 26 minutes.

The failure mode this prevents: a partner that gates trading on `decision == "ACCEPT"` enables trading for a user with an empty `participantId` and no account, and every subsequent scoped call fails. There is also a real `subStatus` value, `"In Review"`, that is **not in the documented set** (`none` / `docv_required` / `pending`) — a `switch` over the documented three falls through on it.

## Field naming changes three times in one flow

This is the most common integration bug on this surface, and it is not a typo in your code.

| Surface           | Casing       | Example                                                                    |
| ----------------- | ------------ | -------------------------------------------------------------------------- |
| Requests you send | `snake_case` | `external_id`, `postal_code`, `docv_eligible`                              |
| REST responses    | `camelCase`  | `participantId`, `subStatus`, `provisionedAccount`, `docvTransactionToken` |
| Webhook payloads  | `snake_case` | `event_id`                                                                 |

One object graph, three conventions. Do not write a single serializer for all three, and do not let a case-normalising HTTP client silently rewrite the keys you send.

<Note>
  The participant ID on the `kyc.approved` webhook is recorded as `participantId` while the webhook convention is `snake_case`. Read the field defensively — accept both spellings — until your integration lead confirms which one ships.
</Note>

## Do KYC over REST

There is no supported gRPC path for KYC today.

`KYCAPI` is named in the proto bundle but has **no proto definition** in it, and it is one of nine services the bundle advertises without documenting. Preprod has returned `{"code":12,"message":"unknown service connamara.ep3.v1beta1.KYCAPI"}` against it. gRPC server reflection is separately entitlement-gated and returns `PermissionDenied: method not permitted` without the grant, so you cannot discover the surface yourself either.

Use `POST /v1/kyc/start`, `GET /v1/kyc/status` and `POST /v1/kyc/webhook` over REST against the base URL for your environment:

<Snippet file="endpoints.mdx" />

<Warning>
  **Not yet published.**&#x20;
  Whether KYC will ever reach gRPC parity is not published. Build against REST and do not plan a gRPC migration for this flow.
</Warning>

## Defer KYC to the point of trading

Do not put KYC in your signup form. Trigger it when the user first tries to do something that requires an exchange account.

Three numbers drive this:

* Manual review takes **1–2 business days**, so a KYC gate at signup abandons every review case at the front door.
* `participantId` can be empty for **up to 26 minutes** after an approval, so "signup complete" cannot mean "tradable" anyway.
* Rejections are terminal and carry **no partner-visible reason**, so a user rejected at signup has nothing to fix and no path back.

The pattern that works: let users browse markets, prices and their watchlist unverified, and call `POST /v1/kyc/start` at trading enablement — the first deposit, the first order attempt, or an explicit "enable trading" action. Keep the unverified state a real product state in your own user model, not an error state.

## Start a verification

`POST /v1/kyc/start` submits one end user for verification and returns which of the four outcomes above you are in.

### Endpoint

```
POST https://api.preprod.polymarketexchange.com/v1/kyc/start
POST https://api.prod.polymarketexchange.com/v1/kyc/start
```

Always send the full base URL for the environment you are in. Preprod credentials do not work in production, and a production keypair is generated separately.

Headers: `Authorization: Bearer <access token>` and `Content-Type: application/json`. Do not send `x-participant-id` — the participant does not exist yet.

### Request fields

Requests are `snake_case`. Responses are `camelCase`. See [Field naming changes three times in one flow](#field-naming-changes-three-times-in-one-flow).

| Field             | Type    | Required          | Constraint, and what breaks                                                                                                                                                                                                                                    |
| ----------------- | ------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `external_id`     | string  | Yes               | **Maximum 49 characters.** This is your own user key and the only identifier you control. Pick a format that fits in 49 characters before you launch — see [External IDs, participants and accounts](#external-ids-participants-and-accounts).                 |
| `postal_code`     | string  | Yes               | **Exactly five digits.** ZIP+4 and non-US postal formats do not fit.                                                                                                                                                                                           |
| `docv_eligible`   | boolean | No — but set it   | Set `true`. Left unset, review cases go to **manual review, 1–2 business days** instead of a self-service document upload.                                                                                                                                     |
| `session_token`   | string  | Optional on paper | Treat it as required. This is the Socure Digital Intelligence token captured in your front end, and it is what keeps the REVIEW rate down. Omitting it pushes more users into review.                                                                          |
| National ID / SSN | —       | Yes               | The exchange account is derived from the end user's SSN, so there is nothing to provision without it. **One SSN maps to exactly one exchange account platform-wide** — see [External IDs, participants and accounts](#external-ids-participants-and-accounts). |

<Warning>
  **Not yet published.**&#x20;
  The exact field names for the rest of the identity payload — legal name, date of birth, national ID / SSN, email, phone, address lines and country — are not published. Get the payload schema from your integration lead and treat every snippet below as correct in its plumbing and incomplete in its identity block.
</Warning>

<Warning>
  **`agreement.version` is not settled, and no value is published here.**&#x20;
  The canonical format for `agreement.version` is not yet settled. Do not hardcode either shape you may have seen. Get the canonical value from your integration lead in writing before you collect a single acceptance — see [/legal-agreements](/legal-agreements).
</Warning>

### Start a verification and handle all four outcomes

Every snippet imports the canonical auth client from [/connect#authentication](/authentication) rather than re-implementing private\_key\_jwt.

<CodeGroup>
  ```python Python theme={null}
  import requests
  from polymarket_auth import PolymarketClient  # canonical client from /connect#authentication

  pmx = PolymarketClient()

  BASE_URL = "https://api.preprod.polymarketexchange.com"  # preprod; prod is https://api.prod.polymarketexchange.com
  EXTERNAL_ID = "u-8f21c4a9"  # your own user key. Max 49 characters.
  SESSION_TOKEN = "dit-..."   # Socure Digital Intelligence token from your front end

  # VERIFY: exact field names for legal name, date of birth, national ID / SSN, email,
  # phone and address lines are not published. Get the schema from your integration lead.
  IDENTITY = {}

  def store_participant_id(external_id, participant_id):
      print(f"store participantId={participant_id} against {external_id}")

  def open_docv(url):
      print(f"redirect the user to {url}")

  def mark_pending(external_id):
      print(f"{external_id} is not terminal — poll GET /v1/kyc/status and wait for a webhook")

  r = requests.post(
      f"{BASE_URL}/v1/kyc/start",
      headers={
          "Authorization": f"Bearer {pmx.access_token()}",
          "Content-Type": "application/json",
      },
      json={
          "external_id": EXTERNAL_ID,
          "postal_code": "10013",   # five digits, no ZIP+4
          "docv_eligible": True,    # omit this and review cases take 1-2 business days
          "session_token": SESSION_TOKEN,
          **IDENTITY,
      },
      timeout=30,
  )
  if r.status_code != 200:
      # A duplicate SSN arrives here as HTTP 400, not the documented 409.
      raise SystemExit(f"HTTP {r.status_code}: {r.text}")

  body = r.json()
  print("telemetry:", body.get("decision"), body.get("status"), body.get("subStatus"))

  if body.get("docv"):
      # Outcome 3: document verification. sdkKey is always empty, so send the user to the URL.
      open_docv(body["docv"]["url"])
  elif body.get("decision") == "ACCEPT":
      # Outcome 1: approved. participantId can be empty for up to 26 minutes.
      pid = body.get("participantId") or ""
      if pid:
          store_participant_id(EXTERNAL_ID, pid)
      # Do not enable trading here. Wait for kyc.approved.
  else:
      # Outcomes 2 and 4: rejected, or under review. Never branch on the literals.
      mark_pending(EXTERNAL_ID)
  ```

  ```typescript TypeScript theme={null}
  import { PolymarketClient } from "./polymarketAuth"; // canonical client from /connect#authentication

  const pmx = new PolymarketClient();

  const BASE_URL = "https://api.preprod.polymarketexchange.com"; // prod: https://api.prod.polymarketexchange.com
  const EXTERNAL_ID = "u-8f21c4a9"; // your own user key. Max 49 characters.
  const SESSION_TOKEN = "dit-..."; // Socure Digital Intelligence token from your front end

  // VERIFY: exact field names for legal name, date of birth, national ID / SSN, email,
  // phone and address lines are not published. Get the schema from your integration lead.
  const IDENTITY: Record<string, unknown> = {};

  const storeParticipantId = (externalId: string, participantId: string) =>
    console.log(`store participantId=${participantId} against ${externalId}`);
  const openDocv = (url: string) => console.log(`redirect the user to ${url}`);
  const markPending = (externalId: string) =>
    console.log(`${externalId} is not terminal — poll GET /v1/kyc/status and wait for a webhook`);

  const res = await fetch(`${BASE_URL}/v1/kyc/start`, {
    method: "POST",
    headers: {
      authorization: `Bearer ${await pmx.accessToken()}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      external_id: EXTERNAL_ID,
      postal_code: "10013", // five digits, no ZIP+4
      docv_eligible: true,  // omit this and review cases take 1-2 business days
      session_token: SESSION_TOKEN,
      ...IDENTITY,
    }),
  });

  if (res.status !== 200) {
    // A duplicate SSN arrives here as HTTP 400, not the documented 409.
    throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  }

  const body = await res.json();
  console.log("telemetry:", body.decision, body.status, body.subStatus);

  if (body.docv) {
    // Outcome 3: document verification. sdkKey is always empty, so send the user to the URL.
    openDocv(body.docv.url);
  } else if (body.decision === "ACCEPT") {
    // Outcome 1: approved. participantId can be empty for up to 26 minutes.
    if (body.participantId) storeParticipantId(EXTERNAL_ID, body.participantId);
    // Do not enable trading here. Wait for kyc.approved.
  } else {
    // Outcomes 2 and 4: rejected, or under review. Never branch on the literals.
    markPending(EXTERNAL_ID);
  }
  ```

  ```bash curl theme={null}
  set -euo pipefail

  BASE_URL="https://api.preprod.polymarketexchange.com"   # prod: https://api.prod.polymarketexchange.com
  EXTERNAL_ID="u-8f21c4a9"                                 # your own user key. Max 49 characters.
  TOKEN="$(./token.sh)"                                    # canonical token script from /connect#authentication

  # VERIFY: legal name, date of birth, national ID / SSN, email, phone and address field
  # names are not published. Add them to this body from the schema your integration lead gives you.
  cat > /tmp/kyc-start.json <<JSON
  {
    "external_id": "${EXTERNAL_ID}",
    "postal_code": "10013",
    "docv_eligible": true,
    "session_token": "dit-..."
  }
  JSON

  HTTP="$(curl -sS -o /tmp/kyc-resp.json -w '%{http_code}' \
    -X POST "${BASE_URL}/v1/kyc/start" \
    -H "Authorization: Bearer ${TOKEN}" \
    -H "Content-Type: application/json" \
    --data @/tmp/kyc-start.json)"

  # A duplicate SSN arrives as HTTP 400, not the documented 409.
  [ "${HTTP}" = "200" ] || { echo "HTTP ${HTTP}"; cat /tmp/kyc-resp.json; exit 1; }

  jq -r '"telemetry: \(.decision) \(.status) \(.subStatus)"' /tmp/kyc-resp.json

  if jq -e '.docv' /tmp/kyc-resp.json >/dev/null; then
    echo "docv required -> $(jq -r '.docv.url' /tmp/kyc-resp.json)"
  elif [ "$(jq -r '.decision' /tmp/kyc-resp.json)" = "ACCEPT" ]; then
    echo "accepted, participantId='$(jq -r '.participantId // ""' /tmp/kyc-resp.json)' — wait for kyc.approved"
  else
    echo "not terminal — poll GET /v1/kyc/status and wait for a webhook"
  fi
  ```

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

  import (
  	"bytes"
  	"encoding/json"
  	"fmt"
  	"io"
  	"net/http"
  	"time"

  	pmauth "example.com/yourfirm/pmauth" // canonical client from /connect#authentication
  )

  const (
  	baseURL    = "https://api.preprod.polymarketexchange.com" // prod: https://api.prod.polymarketexchange.com
  	externalID = "u-8f21c4a9"                                 // your own user key. Max 49 characters.
  	sessionTok = "dit-..."                                    // Socure Digital Intelligence token from your front end
  )

  type startResp struct {
  	Decision      string `json:"decision"`
  	Status        string `json:"status"`
  	SubStatus     string `json:"subStatus"`
  	ParticipantID string `json:"participantId"`
  	Docv          *struct {
  		URL    string `json:"url"`
  		SDKKey string `json:"sdkKey"` // always empty
  	} `json:"docv"`
  }

  func main() {
  	// VERIFY: legal name, date of birth, national ID / SSN, email, phone and address
  	// field names are not published. Get the schema from your integration lead.
  	req := map[string]any{
  		"external_id":   externalID,
  		"postal_code":   "10013", // five digits, no ZIP+4
  		"docv_eligible": true,    // omit this and review cases take 1-2 business days
  		"session_token": sessionTok,
  	}
  	body, err := json.Marshal(req)
  	if err != nil {
  		panic(err)
  	}

  	token, err := pmauth.New().AccessToken()
  	if err != nil {
  		panic(err)
  	}

  	httpReq, err := http.NewRequest(http.MethodPost, baseURL+"/v1/kyc/start", bytes.NewReader(body))
  	if err != nil {
  		panic(err)
  	}
  	httpReq.Header.Set("Authorization", "Bearer "+token)
  	httpReq.Header.Set("Content-Type", "application/json")

  	res, err := (&http.Client{Timeout: 30 * time.Second}).Do(httpReq)
  	if err != nil {
  		panic(err)
  	}
  	defer res.Body.Close()
  	raw, _ := io.ReadAll(res.Body)
  	if res.StatusCode != http.StatusOK {
  		// A duplicate SSN arrives here as HTTP 400, not the documented 409.
  		panic(fmt.Sprintf("HTTP %d: %s", res.StatusCode, raw))
  	}

  	var out startResp
  	if err := json.Unmarshal(raw, &out); err != nil {
  		panic(err)
  	}
  	fmt.Println("telemetry:", out.Decision, out.Status, out.SubStatus)

  	switch {
  	case out.Docv != nil:
  		// Outcome 3: document verification. sdkKey is always empty, so send the user to the URL.
  		fmt.Println("redirect the user to", out.Docv.URL)
  	case out.Decision == "ACCEPT":
  		// Outcome 1: approved. participantId can be empty for up to 26 minutes.
  		fmt.Printf("accepted, participantId=%q — wait for kyc.approved\n", out.ParticipantID)
  	default:
  		// Outcomes 2 and 4: rejected, or under review. Never branch on the literals.
  		fmt.Println("not terminal — poll GET /v1/kyc/status and wait for a webhook")
  	}
  }
  ```
</CodeGroup>

### Responses, by outcome

Branch on the three things marked below. Log everything else.

**Approved.** `participantId` is empty here more often than not, and that is normal, not an error.

```json theme={null}
{
  "decision": "ACCEPT",
  "status": "CLOSED",
  "participantId": ""
}
```

Branch on: `decision == "ACCEPT"`, then on `participantId` being non-empty. Do not enable trading until the `kyc.approved` webhook arrives.

**Document verification required.** The response carries a `docv` object. `sdkKey` is **always empty** — there is no native SDK key to initialise, on any platform.

```json theme={null}
{
  "subStatus": "docv_required",
  "docv": {
    "url": "<we return the fully-qualified URL — never construct it>",
    "sdkKey": ""
  }
}
```

Branch on: the presence of the `docv` object. See [/docv#the-docv-state-machine](/docv#the-docv-state-machine).

**Manual review.** `"In Review"` is a real `subStatus` value that is **not in the documented set** (`none` / `docv_required` / `pending`), so a closed `switch` over those three falls through on it.

```json theme={null}
{
  "subStatus": "In Review"
}
```

Branch on: nothing. Poll `GET /v1/kyc/status` and wait for `kyc.approved` or `kyc.rejected`. There is no review webhook.

**Rejected.** Terminal. Do not retry, and do not ask the user to resubmit: **decline reasons are not exposed to partners**, and retrieving one currently requires a human opening a Socure thread.

<Warning>
  **Not yet published.**&#x20;
  The literal `decision` and `status` values on the reject and review outcomes are not published. This does not block you: treat anything that is neither a `docv` object nor `decision: "ACCEPT"` as non-terminal, and let the `kyc.rejected` webhook be your reject signal.
</Warning>

### Rate limits

| Limit                                      | Value                           | Scope                               | On exceed                                                                                    |
| ------------------------------------------ | ------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------- |
| REST, published                            | 100 req/sec, one-minute average | Per firm, across all REST endpoints | `429` carrying `Retry-After`                                                                 |
| Per-firm unary, from cluster configuration | 250 requests / 60 s             | Per firm                            | `429` carrying `Retry-After`                                                                 |
| Per-endpoint rung                          | Not published                   | Per endpoint                        | `{"code":8,"message":"rate limit exceeded for /... (rung \"endpoint\"); retry after 126ms"}` |

There is a third, per-endpoint rate-limiting rung that was unknown even to our own support. Honour the `retry after` value in the message body as well as `Retry-After`.

<Warning>
  **Not yet published.**&#x20;
  There is no documented rate limit for `POST /v1/kyc/start` or `GET /v1/kyc/status` specifically, and no statement of whether the preprod and production values differ. If you are bulk-migrating users, agree a submission rate with your integration lead first rather than discovering the ceiling in production.
</Warning>

## External IDs, participants and accounts

Three identifiers exist for every end user, you own exactly one of them, and mixing them up sends partners down paths that cannot work. (The largest single source of doc-caused tickets is a different mix-up: conflating your two **firms** — see [/connect#firms-participants-and-accounts](/identity).)

### The three identifiers

| Identifier           | Who mints it | Casing                      | What it is for                                                                                                                           |
| -------------------- | ------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `external_id`        | **You**      | Request field, `snake_case` | Your user key. The only identifier that exists on both sides at submission time, and therefore **your join key**. Maximum 49 characters. |
| `participantId`      | Us           | REST response, `camelCase`  | The operational identity. Goes in `x-participant-id` on account-scoped reads. Store it against your user row the moment you receive it.  |
| `provisionedAccount` | Us           | REST response, `camelCase`  | The fully-qualified DCM account name. **Opaque.** Copy it verbatim; never parse it, never build it.                                      |

The chain is one-directional:

```
your external_id  →  our participantId  →  the provisioned account
```

You can go left to right by storing what we return. You cannot go right to left, and you cannot skip a step by computing the next one.

#### `external_id` is your join key — size it now

`external_id` is capped at **49 characters**. A UUID with hyphens is 36 and fits; a prefixed, namespaced, environment-tagged composite key usually does not. Fix your format before your signup form goes live, because `external_id` is how you will match a webhook back to a user for the life of the integration.

#### `participantId` comes from exactly two places

1. `participantId` on the `kyc.approved` webhook — the authoritative source.
2. `GET /v1/kyc/status` after an approved decision.

Nowhere else.

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

`GET /v1/users` does not help you here. It is a **roster read**, it is account-scoped, and it requires `x-participant-id` itself — so it cannot be used to discover your first participant ID. `GET /v1/whoami` does not help either: it resolves your **clearing-member** firm (`ep3_account_firm_name`), not your participant firm.

#### `provisionedAccount` cannot be derived

The account identifier inside `provisionedAccount` is opaque and **cannot be derived from participant or user data**. Examples elsewhere of the shape `firms/.../accounts/user-123-trading` teach the wrong model: the readable suffix is a fiction, and code that constructs it will fail against every real account.

Store the string we return. Pass it back unchanged.

### One SSN, one exchange account, platform-wide

**The trading account is derived from the end user's SSN, and one SSN maps to exactly one exchange account across the entire platform** — the same rule the Polymarket mobile app enforces. It is not scoped to your firm, not scoped to an environment's tenant, and not resettable by you.

Two consequences you have to design around:

* A user who already has an exchange account through any other route already has *the* account for that SSN. **The verification call** returns `HTTP 400` with `{"code": 9, "message": "user already provisioned with a different SSN"}` — note **400, not the documented 409**. **\[VERIFY]** — which endpoint returns it is not published, so branch on the code-9 body rather than on the path.&#x20;
* **Reusing one test SSN across several test users points all of them at a single account.** Transfers then fail with `NOT_FOUND`, and untangling it requires manual database cleanup on our side. This has already produced a 66-reply outage thread. Generate a distinct SSN per test user in preprod, even for throwaway fixtures — see [/webhooks#sandbox-fixtures](/webhooks#sandbox-fixtures).

The related error is `NOT_FOUND: customer relationship claim failed`. The customer relationship is established server-side by KYC and **there is no claim or link RPC in the protos**, so you cannot repair it from your side. Its three observed root causes are a shared test SSN across users, an incomplete server-side setup step, and a half-provisioned firm that can read the pooled balance but cannot move anything out of it.

### The empty-`participantId` window

On an approved decision, `participantId` can come back **empty** while provisioning finishes. Observed gaps run from **\~500 ms to 26 minutes**, per user. **\[VERIFY]** — whether the range differs between preprod and production is not published.&#x20;

The rule: **wait for the `kyc.approved` webhook before you enable trading.** Not the `ACCEPT`, not a fixed sleep, not a retry count.

A partner that treats `decision: "ACCEPT"` as "tradable" ships a race that passes in testing at 500 ms and fails in production at 26 minutes, with the user staring at a funded-looking account whose every scoped call returns an error. Keep an explicit "provisioning" state in your own user model between the `ACCEPT` and the webhook, and make it visible to the user.

### Gaps you must plan around

Say these out loud in your product design; there is no workaround for any of them.

* **No re-KYC.** There is no endpoint to run a user through verification again.
* **No reset.** A user whose SSN is already in use **cannot be re-KYC'd by you**. That case goes to your integration lead.
* **No PII-update path.** There are no documented semantics for correcting a name, address or date of birth after submission.
* **No decline reasons.** Rejections carry nothing partner-visible, and retrieving one requires a human opening a Socure thread.
* `GET /v1/accounts?user=<participant>` returns `invalid user` for the same participant string that works in `x-participant-id`.

## Who you can onboard

Send us US end users with a national ID, and get the jurisdiction list from your integration lead before you open signups.

### US only

Send US traffic. The exchange account is derived from the end user's SSN, so a user without a US national ID has nothing to derive an account from.

<Warning>
  **Not yet published.**&#x20;
  What happens when you send a non-US country code is not published: we cannot tell you whether it is rejected at validation, rejected by the identity check, or accepted and then declined. Do not build a country selector against a guess. Filter to US in your own signup form, and confirm the platform behaviour with your integration lead so your error handling matches it.
</Warning>

### The national ID is required

The national ID / SSN must be present in the payload. Two rules follow from it, and both are permanent:

* **One SSN maps to exactly one exchange account, platform-wide** — not per firm, not per environment. A user who already has an account through any other route already has *the* account for that SSN.
* **There is no re-KYC, no reset and no PII-update path.** A user whose SSN is already in use cannot be re-KYC'd by you.

See [One SSN, one exchange account, platform-wide](#one-ssn-one-exchange-account-platform-wide) for the failure modes, including `HTTP 400` with `{"code": 9, "message": "user already provisioned with a different SSN"}`.

### Excluded states

<Warning>
  **No authoritative excluded-states or jurisdiction list exists in these docs.** One partner asked
  for it three times and never received an answer, so do not treat its absence as "no restrictions".

  **Get the list from your integration lead, in writing, before your signup form goes live.** Then
  implement it in your own form, because we do not publish it and you cannot derive it from the API.
</Warning>

Build the list as configuration, not as a hardcoded array. It is a list you do not control, that you obtained by asking a human, and that will change without a changelog entry.

### Individuals, not entities

At the account and user level **the legal name must be a physical person's name, or NFA reports error.** One partner's production record was entered as their company name and the reports failed.

<Warning>
  **Not yet published.**&#x20;
  Whether a non-individual entity can be onboarded as a participant is unresolved on our side, not merely undocumented. Do not build an entity signup path on the assumption that it will be supported. If you have an entity use case, raise it with your integration lead early — the answer affects your account structure, not only your form.
</Warning>

## What can go wrong

| Part of this page         | Symptom                                                                                   | Cause                                                                                                                               | What you do                                                                                                                                                                                                             |
| ------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Outcomes and setup        | `PERMISSION_DENIED` on `POST /v1/kyc/start`                                               | The `kyc:write` scope is not granted against your client, or it was granted after you minted your current token                     | Ask your integration lead to add the grant, then **re-mint your token**. Scopes are granted server-side and are not requested on the token exchange.                                                                    |
| Outcomes and setup        | Every request 401s                                                                        | You are sending retail credentials (`X-PM-Access-Key` / `X-PM-Timestamp` / `X-PM-Signature`) against `api.*.polymarketexchange.com` | Those belong to a different product. Use the private\_key\_jwt flow in [/connect#authentication](/authentication).                                                                                                      |
| Outcomes and setup        | Token rejected \~3 minutes in                                                             | `expires_in` on the access token is `180` seconds and you hardcoded a longer lifetime                                               | Honour `expires_in` minus a 30-second buffer. Do not hardcode 180.                                                                                                                                                      |
| Outcomes and setup        | `{"code":12,"message":"unknown service connamara.ep3.v1beta1.KYCAPI"}`                    | You are calling KYC over gRPC                                                                                                       | Use the REST endpoints. There is no supported gRPC KYC surface.                                                                                                                                                         |
| Outcomes and setup        | Fields you sent are ignored                                                               | Your client camel-cased the request body                                                                                            | Requests are `snake_case`. Responses are `camelCase`. Serialize them separately.                                                                                                                                        |
| Outcomes and setup        | A `switch` on `subStatus` falls through                                                   | `"In Review"` is a real value outside the documented set                                                                            | Add a default branch that polls rather than failing.                                                                                                                                                                    |
| Start a verification      | `HTTP 400` with `{"code": 9, "message": "user already provisioned with a different SSN"}` | That SSN already maps to an exchange account. One SSN maps to exactly one account platform-wide.                                    | Stop. Do not retry with a different `external_id` — that makes it worse. See [External IDs, participants and accounts](#external-ids-participants-and-accounts). Note this returns **400**, not the documented **409**. |
| Start a verification      | `PERMISSION_DENIED`                                                                       | `kyc:write` is not granted, or was granted after you minted your token                                                              | Get the grant, then re-mint. Scopes are granted server-side and must not be requested on the token exchange.                                                                                                            |
| Start a verification      | Approved user cannot trade; every scoped call fails                                       | You enabled trading on `decision: "ACCEPT"` with an empty `participantId`                                                           | Gate trading on the `kyc.approved` webhook. The empty window runs from \~500 ms to 26 minutes.                                                                                                                          |
| Start a verification      | A user sits in review for two days                                                        | You did not send `docv_eligible: true`                                                                                              | Send it. Review cases without it go to manual review, 1–2 business days.                                                                                                                                                |
| Start a verification      | Your REVIEW rate is much higher than expected                                             | You are not sending `session_token`                                                                                                 | Capture the Digital Intelligence token in your front end and send it on every start.                                                                                                                                    |
| Start a verification      | The fields you sent appear to be ignored                                                  | Your HTTP client camel-cased the body                                                                                               | Requests are `snake_case`.                                                                                                                                                                                              |
| Start a verification      | `{"code":12,"message":"unknown service connamara.ep3.v1beta1.KYCAPI"}`                    | You called KYC over gRPC                                                                                                            | Use REST.                                                                                                                                                                                                               |
| Start a verification      | Rejected user asks why                                                                    | Decline reasons are not exposed to partners, and there is no internal procedure to retrieve one either                              | Tell the user the application was declined. Do not promise a reason or an appeal path.                                                                                                                                  |
| External IDs and accounts | `CROSS_ISV_PARTICIPANT_IMPERSONATION_ATTEMPT` in your logs, calls denied                  | You built a participant ID from the firm in `GET /v1/whoami`, which is your clearing-member firm                                    | Use the `participantId` from the webhook, unmodified. This error also raises a CRITICAL security log and a cross-ISV access metric against your account.                                                                |
| External IDs and accounts | `HTTP 400` / `{"code": 9, "message": "user already provisioned with a different SSN"}`    | That SSN already owns an exchange account                                                                                           | Do not retry under a new `external_id`. Escalate — you cannot re-KYC or reset it.                                                                                                                                       |
| External IDs and accounts | `NOT_FOUND` on a transfer for a user who passed KYC                                       | Shared test SSN across users, an incomplete server-side setup step, or a half-provisioned firm                                      | Check for duplicate SSNs in your fixtures first. The remaining two need us; there is no claim RPC.                                                                                                                      |
| External IDs and accounts | `NOT_FOUND: customer relationship claim failed`                                           | Same three causes as above                                                                                                          | Same. Quote the `external_id` and `participantId` when you escalate.                                                                                                                                                    |
| External IDs and accounts | `invalid user` from `GET /v1/accounts?user=<participant>`                                 | This endpoint does not accept the participant string that `x-participant-id` accepts                                                | Not resolvable from your side. Read accounts from the values we returned at onboarding.                                                                                                                                 |
| External IDs and accounts | Cannot find a `participantId` for an approved user                                        | You are looking in `GET /v1/users` or `GET /v1/whoami`                                                                              | Neither can produce it. Use the webhook or `GET /v1/kyc/status`.                                                                                                                                                        |
| External IDs and accounts | Account name you constructed is rejected                                                  | `provisionedAccount` is opaque                                                                                                      | Copy the returned string verbatim.                                                                                                                                                                                      |
| Who you can onboard       | Non-US users fail in a way your app does not handle                                       | The platform behaviour for non-US country codes is not published                                                                    | Filter to US in your own form and confirm the behaviour before launch.                                                                                                                                                  |
| Who you can onboard       | `HTTP 400` / `{"code": 9, "message": "user already provisioned with a different SSN"}`    | That SSN already owns an exchange account                                                                                           | Stop. No re-KYC and no reset exist. Escalate.                                                                                                                                                                           |
| Who you can onboard       | NFA reports error on a live account                                                       | The legal name is a company name, not a physical person's name                                                                      | Escalate immediately; this is a production data correction, and there is no PII-update path on the API.                                                                                                                 |
| Who you can onboard       | Users sign up from a state you should have blocked                                        | You launched without the excluded-states list                                                                                       | Get the list in writing now and treat it as a compliance incident, not a backlog item.                                                                                                                                  |

<Snippet file="support.mdx" />

## Next

[Document verification](/docv)
