Before this page you need:
- a working access token — see /connect#authentication,
- the
kyc:writescope 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-idapplies — 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
- ISV
- Introducing broker
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
EveryPOST /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.
decision, status and subStatus are informational, not control flow
Treat all three as telemetry to log, not as branch conditions.
Branch on these instead:
- Is there a
docvobject in the response? If yes, the user has documents to submit. Nothing else matters yet. - Did a terminal webhook arrive?
kyc.approvedandkyc.rejectedare the only authoritative terminal signals. - Is
participantIdnon-empty? On an approved decision it can come back empty while provisioning finishes. Observed gaps run from ~500 ms to 26 minutes.
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:
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.
participantIdcan 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.
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
Authorization: Bearer <access token> and Content-Type: application/json. Do not send x-participant-id — the participant does not exist yet.
Request fields
Requests aresnake_case. Responses are camelCase. See Field naming changes three times in one flow.
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.
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.
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.
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.
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.
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:
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
participantIdon thekyc.approvedwebhook — the authoritative source.GET /v1/kyc/statusafter an approved decision.
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 400with{"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.
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>returnsinvalid userfor the same participant string that works inx-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.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.
HTTP 400 with {"code": 9, "message": "user already provisioned with a different SSN"}.