Skip to main content
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.
Before this page you need:
  • a working access token — see /connect#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,
  • a registered webhook endpoint, because the response alone never tells you a user is tradable — see /webhooks,
  • the end-user acceptance requirements — see /legal-agreements.

Who decides

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.

The four outcomes

Every POST /v1/kyc/start lands on one of four outcomes. 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.
Not yet published. 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.

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

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:
Not yet published. Whether KYC will ever reach gRPC parity is not published. Build against REST and do not plan a gRPC migration for this flow.

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

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.
Not yet published. 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.
agreement.version is not settled, and no value is published here. 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.

Start a verification and handle all four outcomes

Every snippet imports the canonical auth client from /connect#authentication rather than re-implementing private_key_jwt.

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.
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.
Branch on: the presence of the docv object. See /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.
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.
Not yet published. 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.

Rate limits

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.
Not yet published. 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.

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

The three identifiers

The chain is one-directional:
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. 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.
  • 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.
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. 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.
Not yet published. 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.

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 for the failure modes, including HTTP 400 with {"code": 9, "message": "user already provisioned with a different SSN"}.

Excluded states

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.
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.
Not yet published. 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.

What can go wrong

Next

Document verification