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

# Document verification

> Drive the hosted document-upload flow and the optional prefill step that sit either side of POST /v1/kyc/start.

DocV and prefill are the two optional flows that sit around the core verification call. Prefill goes in front of it — the user gives a phone number and date of birth, and you submit prefilled PII instead of a typed form. DocV comes out of it — when Socure wants a government ID, `POST /v1/kyc/start` returns a `docv` object and the user finishes verification in a hosted flow you hand them. Neither one changes the four outcomes, and neither one makes the decision.

<Info>
  Before this page you need:

  * a working start call you can drive to a `docv` object — see [/kyc#start-a-verification](/kyc#start-a-verification),
  * `docv_eligible: true` on your start request, or you will never see the DocV flow,
  * the `kyc:write` scope — see [/connect#authentication](/authentication),
  * a registered webhook, because the terminal outcome only arrives there — see [/webhooks](/webhooks).
</Info>

## The DocV state machine

### Set `docv_eligible: true` or you do not get this flow

Leaving `docv_eligible` unset does not skip document verification. It routes the same cases to **manual review, 1–2 business days**, with no user-facing step and no way for the user to unblock themselves.

Set it on every start request in every environment.

### The state machine

```mermaid theme={null}
stateDiagram-v2
    [*] --> Submitted : POST /v1/kyc/start with docv_eligible true
    Submitted --> DocvRequired : response carries a docv object, subStatus docv_required
    Submitted --> Terminal : no docv object (approved, rejected or manual review)
    DocvRequired --> Submitting : you send the user to docv.url
    Submitting --> Settling : user submits documents
    Settling --> Settling : GET /v1/kyc/status still returns the docv object for about 14 seconds
    Settling --> Terminal : kyc.approved or kyc.rejected webhook
    DocvRequired --> Abandoned : user closes the flow and never submits
    Abandoned --> Undefined : session expires un-submitted
    Terminal --> [*]
```

`Undefined` is not a state we support. See [Gaps](#gaps-be-explicit-with-your-product-team).

### Every value you can see

`decision`, `status` and `subStatus` are informational. Log them; do not branch on them.

| Field         | Values                             | Note                                                                                                                                                                |
| ------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `subStatus`   | `none`, `docv_required`, `pending` | The documented set.                                                                                                                                                 |
| `subStatus`   | `"In Review"`                      | **Real, and not in the documented set.** Mixed case, with a space. A closed `switch` over the documented three falls through here. Add a default branch that polls. |
| `status`      | `CLOSED`                           | Confirmed. Appears on approved decisions, and also on the expired-session bug below.                                                                                |
| `decision`    | `ACCEPT`                           | Confirmed.                                                                                                                                                          |
| `docv.sdkKey` | `""`                               | **Always empty.** Never populated, in any environment.                                                                                                              |

<Warning>
  **Not yet published.**&#x20;
  The complete `decision` and `status` enumerations are not published, and `"In Review"` proves the `subStatus` set is larger than the documented one. Write your parser so that an unknown value is a poll-and-wait, never an exception and never a rejection.
</Warning>

### The `docv` object

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

Two handles come back: the hosted `docv.url` you send the user to, and `docvTransactionToken`, the transaction handle.

<Note>
  Whether `docvTransactionToken` sits inside the `docv` object or at the top level of the response is not documented. Read it from both positions until your integration lead confirms which one ships.
</Note>

### Integration points

`sdkKey` is always empty, which settles the client architecture: **you cannot initialise a native Socure SDK.** There is no key to initialise it with. Every platform opens the hosted URL.

| Platform | What you do                                                                                                                                           |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| Web      | Navigate the browser to `docv.url`. Document capture needs camera access, so use a top-level navigation rather than a cross-origin iframe.            |
| iOS      | Open `docv.url` in `SFSafariViewController` or `ASWebAuthenticationSession`. Do not build against a native DocV SDK — the key for it is always empty. |
| Android  | Open `docv.url` in a Custom Tab, or a `WebView` with camera permission granted. Same reason.                                                          |

<Warning>
  **Not yet published.**&#x20;
  There is no documented return-URL or deep-link callback on `docv.url`, so there is no supported way for the hosted flow to hand the user back to your app. Design your UI so the user returns on their own — a "I've finished uploading" screen in your app that polls — and confirm the current behaviour with your integration lead before you ship a native flow.
</Warning>

### Detecting submission: the 14-second trap

`GET /v1/kyc/status` **keeps returning the `docv` object for roughly 14 seconds after the user submits their documents.** During that window the response is indistinguishable from "has not started".

Two failure modes fall out of this, and partners hit both:

* Polling immediately after the user says they are done, seeing the `docv` object, and telling the user the upload failed.
* Re-issuing or re-opening the flow because the `docv` object is still present, which sends a user who has already submitted back into document capture.

Detect submission like this:

1. Treat your own front-end "finished" signal as a **hint**, not a fact. It tells you when to start polling, nothing more.
2. Wait at least **14 seconds** after that hint before you read anything into the `docv` object's presence.
3. Poll `GET /v1/kyc/status` on a fixed interval and require the `docv` object to be **absent on two consecutive polls** before you treat the flow as left behind.
4. Treat `kyc.approved` / `kyc.rejected` as the only authoritative completion. There is **no review webhook**, so the intermediate states are poll-only.

Show the user a neutral "checking your documents" state for the whole window. Do not show success or failure inside it.

### Gaps: be explicit with your product team

State these as known unknowns in your design review rather than discovering them in production.

* **No published TTL for `docvTransactionToken` or `docv.url`.** You cannot tell a user how long their link is good for, and you cannot expire it in your own UI on a matching timer.
* **No documented way to re-issue a DocV session after a user abandons it.** There is no re-KYC and no reset either, so an abandoned flow has no published recovery path.
* **An expired, un-submitted session has been observed returning `decision: ACCEPT` with `status: CLOSED` and an empty `participantId`. That is a bug, not a contract.** Never treat an `ACCEPT` with an empty `participantId` as an approval — that is exactly the shape this bug produces, and it is also the shape a normal in-flight provisioning produces. Gate on the `kyc.approved` webhook and this bug cannot reach your users.

<Warning>
  **Not yet published.**&#x20;
  Confirm the DocV link lifetime and the re-issue path with your integration lead before you launch. Until then, keep a support route for "my ID upload link stopped working" that ends with a human.
</Warning>

## Prefill

Prefill is not on by default; confirm with your integration lead that it is enabled for your firm before you build against it.

Prefill is an optional step that sits in front of the standard flow: the user gives a phone number and date of birth, confirms a one-time code, and you receive prefilled PII to submit to `POST /v1/kyc/start` instead of asking them to type it.

<Warning>
  **Not yet published.**&#x20;
  The prefill endpoint paths, request field names, response field names and date formats are not published anywhere. The snippets below are correct in their auth, error handling and sequencing, and deliberately empty in the two request bodies and the response mapping. Get that schema from your integration lead before you build against it, and do not guess field names from the standard start payload — prefill is a different surface.
</Warning>

### Where prefill sits in the flow

<Steps>
  <Step title="Collect phone and date of birth">
    Two fields, not a full form. This is the whole point of prefill: the user types the minimum, not their address.
  </Step>

  <Step title="Request the one-time code">
    You post the phone number and date of birth. The user receives a code by SMS.
  </Step>

  <Step title="Verify the code">
    You post the code. On success you receive prefilled PII for that person.
  </Step>

  <Step title="Show the user what you got, then submit">
    Present the prefilled values for confirmation and correction, then call `POST /v1/kyc/start` with them. **Prefill does not verify anybody.** The decision still comes from the standard flow and all four outcomes still apply.
  </Step>
</Steps>

Prefill is a data-entry shortcut. It changes your form, not your state machine — you still handle approved, rejected, DocV and manual review exactly as on [/kyc#start-a-verification](/kyc#start-a-verification).

### The SSN rule

The exchange account is derived from the end user's SSN, and **one SSN maps to exactly one exchange account platform-wide**. That does not change under prefill: the national ID / SSN must still be present in the payload you send to `POST /v1/kyc/start`, so keep that field on your form even when every other field arrives prefilled.

<Warning>
  **Not yet published.**&#x20;
  Whether prefill takes the last four digits of the SSN as an input, returns them, or requires a last-four match before releasing PII is not published. Plan your form for the case where the user must still enter their full SSN, and confirm the rule with your integration lead before you build a last-four-only flow.
</Warning>

### Running the prefill flow

<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"  # prod: https://api.prod.polymarketexchange.com
  EXTERNAL_ID = "u-8f21c4a9"  # your own user key. Max 49 characters.

  # VERIFY: prefill endpoint paths are not published. Get both from your integration lead.
  OTP_SEND_PATH = ""    # step 2: phone + date of birth -> code sent by SMS
  OTP_VERIFY_PATH = ""  # step 3: phone + code -> prefilled PII

  # VERIFY: request field names for these two bodies are not published.
  OTP_SEND_BODY = {}    # phone number and date of birth
  OTP_VERIFY_BODY = {}  # phone number and the code the user typed

  def map_prefill_to_start(prefilled):
      # VERIFY: response field names on the prefill response, and their mapping onto
      # the POST /v1/kyc/start payload.
      return {}

  def post(path, body):
      if not path:
          raise SystemExit("prefill path is not configured — see the warning on this page")
      r = requests.post(
          f"{BASE_URL}{path}",
          headers={"Authorization": f"Bearer {pmx.access_token()}", "Content-Type": "application/json"},
          json=body,
          timeout=30,
      )
      if r.status_code != 200:
          raise SystemExit(f"POST {path} -> HTTP {r.status_code}: {r.text}")
      return r.json()

  post(OTP_SEND_PATH, OTP_SEND_BODY)          # one send per user action, never in a loop
  prefilled = post(OTP_VERIFY_PATH, OTP_VERIFY_BODY)

  start = post("/v1/kyc/start", {
      "external_id": EXTERNAL_ID,
      "docv_eligible": True,   # omit this and review cases take 1-2 business days
      **map_prefill_to_start(prefilled),
  })
  print("telemetry:", start.get("decision"), start.get("status"), start.get("subStatus"))
  # Handle all four outcomes exactly as on /kyc#start-a-verification.
  ```

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

  // VERIFY: prefill endpoint paths are not published. Get both from your integration lead.
  const OTP_SEND_PATH = "";   // step 2: phone + date of birth -> code sent by SMS
  const OTP_VERIFY_PATH = ""; // step 3: phone + code -> prefilled PII

  // VERIFY: request field names for these two bodies are not published.
  const OTP_SEND_BODY: Record<string, unknown> = {};
  const OTP_VERIFY_BODY: Record<string, unknown> = {};

  // VERIFY: response field names on the prefill response and their mapping onto /v1/kyc/start.
  const mapPrefillToStart = (prefilled: Record<string, unknown>): Record<string, unknown> => ({});

  async function post(path: string, body: unknown) {
    if (!path) throw new Error("prefill path is not configured — see the warning on this page");
    const res = await fetch(`${BASE_URL}${path}`, {
      method: "POST",
      headers: {
        authorization: `Bearer ${await pmx.accessToken()}`,
        "content-type": "application/json",
      },
      body: JSON.stringify(body),
    });
    if (res.status !== 200) throw new Error(`POST ${path} -> HTTP ${res.status}: ${await res.text()}`);
    return res.json();
  }

  await post(OTP_SEND_PATH, OTP_SEND_BODY); // one send per user action, never in a loop
  const prefilled = await post(OTP_VERIFY_PATH, OTP_VERIFY_BODY);

  const start = await post("/v1/kyc/start", {
    external_id: EXTERNAL_ID,
    docv_eligible: true, // omit this and review cases take 1-2 business days
    ...mapPrefillToStart(prefilled),
  });
  console.log("telemetry:", start.decision, start.status, start.subStatus);
  // Handle all four outcomes exactly as on /kyc#start-a-verification.
  ```

  ```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: prefill endpoint paths are not published. Get both from your integration lead.
  OTP_SEND_PATH=""
  OTP_VERIFY_PATH=""

  post() { # post <path> <json-file> <out-file>
    [ -n "$1" ] || { echo "prefill path is not configured — see the warning on this page"; exit 1; }
    HTTP="$(curl -sS -o "$3" -w '%{http_code}' \
      -X POST "${BASE_URL}$1" \
      -H "Authorization: Bearer ${TOKEN}" \
      -H "Content-Type: application/json" \
      --data @"$2")"
    [ "${HTTP}" = "200" ] || { echo "POST $1 -> HTTP ${HTTP}"; cat "$3"; exit 1; }
  }

  # VERIFY: request field names for the OTP send and OTP verify bodies are not published.
  echo '{}' > /tmp/otp-send.json
  echo '{}' > /tmp/otp-verify.json

  post "${OTP_SEND_PATH}"   /tmp/otp-send.json   /tmp/otp-send-resp.json
  post "${OTP_VERIFY_PATH}" /tmp/otp-verify.json /tmp/prefilled.json

  # VERIFY: mapping from the prefill response onto the /v1/kyc/start payload.
  jq --arg id "${EXTERNAL_ID}" '{external_id: $id, docv_eligible: true}' /tmp/prefilled.json > /tmp/kyc-start.json

  post "/v1/kyc/start" /tmp/kyc-start.json /tmp/kyc-resp.json
  jq -r '"telemetry: \(.decision) \(.status) \(.subStatus)"' /tmp/kyc-resp.json
  # Handle all four outcomes exactly as on /kyc#start-a-verification.
  ```

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

  	// VERIFY: prefill endpoint paths are not published. Get both from your integration lead.
  	otpSendPath   = "" // step 2: phone + date of birth -> code sent by SMS
  	otpVerifyPath = "" // step 3: phone + code -> prefilled PII
  )

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

  func post(path string, body map[string]any) (map[string]any, error) {
  	if path == "" {
  		return nil, fmt.Errorf("prefill path is not configured — see the warning on this page")
  	}
  	raw, err := json.Marshal(body)
  	if err != nil {
  		return nil, err
  	}
  	token, err := pm.AccessToken()
  	if err != nil {
  		return nil, err
  	}
  	req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(raw))
  	if err != nil {
  		return nil, err
  	}
  	req.Header.Set("Authorization", "Bearer "+token)
  	req.Header.Set("Content-Type", "application/json")

  	res, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
  	if err != nil {
  		return nil, err
  	}
  	defer res.Body.Close()
  	out, _ := io.ReadAll(res.Body)
  	if res.StatusCode != http.StatusOK {
  		return nil, fmt.Errorf("POST %s -> HTTP %d: %s", path, res.StatusCode, out)
  	}
  	var parsed map[string]any
  	return parsed, json.Unmarshal(out, &parsed)
  }

  // VERIFY: response field names on the prefill response and their mapping onto /v1/kyc/start.
  func mapPrefillToStart(prefilled map[string]any) map[string]any { return map[string]any{} }

  func main() {
  	// VERIFY: request field names for these two bodies are not published.
  	if _, err := post(otpSendPath, map[string]any{}); err != nil { // one send per user action
  		panic(err)
  	}
  	prefilled, err := post(otpVerifyPath, map[string]any{})
  	if err != nil {
  		panic(err)
  	}

  	start := map[string]any{
  		"external_id":   externalID,
  		"docv_eligible": true, // omit this and review cases take 1-2 business days
  	}
  	for k, v := range mapPrefillToStart(prefilled) {
  		start[k] = v
  	}
  	resp, err := post("/v1/kyc/start", start)
  	if err != nil {
  		panic(err)
  	}
  	fmt.Println("telemetry:", resp["decision"], resp["status"], resp["subStatus"])
  	// Handle all four outcomes exactly as on /kyc#start-a-verification.
  }
  ```
</CodeGroup>

### OTP retries

**No platform-enforced OTP limit is published** — not a send cap, not a verification-attempt cap, not a code lifetime, and no statement of whether any of it is scoped per phone number, per user, per firm or globally. Do not read that as "unlimited". Read it as "you own this, and you will be the one paying for the SMS".

Enforce your own budget in your own application, and make the numbers yours, not ours:

* Cap sends per phone number over a rolling window, and make the resend button visibly disabled with a countdown.
* Cap verification attempts per issued code, then force a fresh send.
* Expire the code in your own UI on your own timer so the user sees a clear "code expired, send a new one" state rather than a generic failure.
* After your cap is reached, fall back to the standard flow on [/kyc#start-a-verification](/kyc#start-a-verification) and let the user type their details. Prefill is optional; never let it become a dead end.

<Warning>
  **Not yet published.**&#x20;
  Ask your integration lead for the platform-side OTP limits and their scope before launch. A limit without a scope is worse than no limit, and an SMS loop that retries server-side is the failure mode this section exists to prevent.
</Warning>

### OTP error cases

There is **no published error catalogue for the OTP steps**. In practice the cases you have to design a screen for are: wrong code, expired code, too many attempts, unreachable or non-mobile number, and a date of birth that does not match the phone number's owner. You will be able to tell them apart only by inspecting the bodies you actually get back.

Until the catalogue exists, treat the OTP steps defensively:

* Log the full response body verbatim on every non-`200`, including the status code. That log is currently the only way either side can identify a new OTP error case.
* Do not map an unknown error onto "wrong code". A user who is told their code is wrong will retype it and burn your attempt budget on a failure that was never about the code.
* Show one generic recoverable message plus a "enter your details instead" escape hatch, and keep the exact error in your logs.

<Warning>
  **Not yet published.**&#x20;
  Send your integration lead the verbatim bodies you collect. There is no published list to check them against, so your logs are the source.
</Warning>

## What can go wrong

| Part of this page      | Symptom                                                                                                  | Cause                                                                                         | What you do                                                                                                                                              |
| ---------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The DocV state machine | Users go to manual review instead of document upload                                                     | `docv_eligible: true` was not sent                                                            | Send it. Manual review is 1–2 business days.                                                                                                             |
| The DocV state machine | Your native SDK integration never initialises                                                            | `sdkKey` is always empty                                                                      | Open `docv.url` in a browser, web view or Custom Tab instead.                                                                                            |
| The DocV state machine | You tell the user their upload failed seconds after it succeeded                                         | `GET /v1/kyc/status` returns the `docv` object for \~14 s after submission                    | Wait 14 s, then require two consecutive polls without the `docv` object.                                                                                 |
| The DocV state machine | A user who already submitted is sent back into document capture                                          | Same 14-second window read as "not started"                                                   | Same fix. Never re-open the flow on a single poll.                                                                                                       |
| The DocV state machine | `ACCEPT` with an empty `participantId`, user never uploaded anything                                     | Expired un-submitted DocV session — a known bug                                               | Do not enable trading. Wait for `kyc.approved`; it will not arrive. Escalate with the `external_id`.                                                     |
| The DocV state machine | A `switch` on `subStatus` throws                                                                         | `"In Review"` is outside the documented set                                                   | Default branch polls.                                                                                                                                    |
| The DocV state machine | A user's DocV link no longer works and you cannot issue another                                          | No TTL and no re-issue path are published                                                     | Escalate. There is no self-service recovery.                                                                                                             |
| The DocV state machine | You are waiting for a webhook that says "in review"                                                      | Only terminal events fire — `kyc.approved`, `kyc.rejected`, `webhook.test`                    | Poll for non-terminal states. See [/webhooks](/webhooks).                                                                                                |
| Prefill                | Every prefill call 404s                                                                                  | Prefill is not enabled for your firm, or the paths you have are wrong                         | Confirm both with your integration lead. The paths are not published.                                                                                    |
| Prefill                | Users report repeated SMS messages                                                                       | Your code retried the send step on a non-`200`, or on a timeout                               | Never auto-retry the OTP send. One send per explicit user action.                                                                                        |
| Prefill                | Prefilled fields are rejected by `POST /v1/kyc/start`                                                    | The prefill response mapping onto the start payload is not published and your guess was wrong | Get the mapping in writing. Do not infer it from field names.                                                                                            |
| Prefill                | A prefilled user is still rejected or sent to DocV                                                       | Prefill fills the form; it does not verify anybody                                            | Handle all four outcomes. Prefill changes nothing downstream.                                                                                            |
| Prefill                | `HTTP 400` / `{"code": 9, "message": "user already provisioned with a different SSN"}` on the start call | That SSN already owns an exchange account                                                     | Escalate. See [/kyc#external-ids-participants-and-accounts](/kyc#external-ids-participants-and-accounts). Note this returns 400, not the documented 409. |
| Prefill                | Users stuck at the code screen with no way forward                                                       | No fallback to manual entry                                                                   | Always offer the standard flow as an escape hatch.                                                                                                       |

<Snippet file="support.mdx" />

## Next

[Webhooks and sandbox](/webhooks)
