User Onboarding (users, KYC, terms)
Before a user can submit an order, three things need to happen:
- You create the user record.
- The user accepts the current terms.
- KYC resolves successfully.
These can happen in roughly any order, but trading is gated on all three. This doc walks through each one.
Auth: every route here requires a JWT (see Authentication). The create route uses an ISV-only JWT; everything else is user-scoped, meaning sub must equal the USERID in the URL and you need subsig.
1. The flow at a glance
POST /private/v1/validate-address ─► (optional) pre-check the user's
address against Smarty before you
commit it to POST /users
POST /private/v1/users ─► create user, get UUID + sharedSecret
GET /private/v1/terms ─► fetch current totalVersion
POST /private/v1/users/USERID/terms ─► record acceptance
GET /private/v1/users/USERID/kyc-status ─► poll until SUCCESS
POST /private/v1/users/USERID/kyc-retry ─► resubmit corrected identity fields
if KYC returned FAILURE
GET /private/v1/users/USERID/permissions ─► read permissions + onboarding gates
to drive UI gating
You can accept terms before KYC resolves; you can't trade until both are done. A wallet is automatically created when KYC reaches SUCCESS.
Sandbox tip. To exercise any user-scoped endpoint in sandbox you need a
sharedSecret, and the only way to get one isPOST /users. Sandbox runs against real IDComply, so send one of the deterministic test personas (Scarlett Crowe forSUCCESS, Anita Day forFAILUREand the step-up flow, plus terminals for OFAC/MORTALITY/PEP) — see KYC Testing for the full list, copy-paste-ready payloads, and a scenario-by-scenario test matrix.KYC in sandbox is a real check, not a stub. Sandbox runs against the actual identity-verification vendor, so a fabricated name/SSN/DOB will typically resolve to
FAILURErather thanSUCCESS— it's not simulated per-environment. If you need a test user that reliably passes KYC (to exercise trading, wallets, or parlays end-to-end), either submit real/valid identity information for your test user, or reach out to your ProphetX contact for a known-good sandbox test identity.
2. POST /private/v1/validate-address — pre-check an address (optional)
POST /private/v1/validate-address — pre-check an address (optional)An advisory pre-check that runs the user's address through Smarty before you commit it to POST /users. Nothing is persisted. Use it to catch a bad address up front — before it costs you a KYC FAILURE and burns a retry slot.
ISV-only auth (JWT with sub=""). US addresses only.
Body (ValidateAddressRequest):
{
"addressLine1": "1 Sant Claus Ln",
"addressLine2": null,
"city": "North Pole",
"state": "AK",
"zip": "99705",
"countryCode": "US"
}All fields are required except addressLine2.
Response (200, ValidateAddressResponse):
{
"status": "corrected",
"normalizedAddress": {
"addressLine1": "1 Santa Claus Ln",
"city": "North Pole",
"state": "AK",
"zip": "99705-9901",
"countryCode": "US"
}
}status values:
| Value | Meaning | What to do |
|---|---|---|
verified | The input already matched exactly. | Use the input as-is when calling POST /users. |
corrected | Smarty found a match, but normalized fields — casing, spelling, ZIP+4, etc. | Show the user the normalized address, confirm with them, then submit the normalized version to POST /users. |
missing_secondary | Address is deliverable but Smarty flagged that an apartment/suite (addressLine2) is missing. | Prompt the user for the missing unit number and re-validate before submitting. |
Failure modes:
422 validation_failed— request body failed field validation. Per-field details in theerrorsarray.422 address_not_found— Smarty couldn't find a match at all. The address is not deliverable. Ask the user to correct and retry.
What this endpoint does NOT do. It doesn't create a user, hold a reservation, or communicate anything to the KYC vendor. It's purely an advisory lookup — think of it like an autocomplete verification step you can bolt onto your address entry form.
3. POST /private/v1/users
POST /private/v1/usersCreates a user and returns the things you'll need to operate on them later: a UUID, the initial KYC status (always PENDING), and the per-user shared secret.
You have a choice of two paths for collecting the identity fields. Build a form yourself and pass the fields into this endpoint directly (the rest of this section), or embed ProphetX's phone-first KYC widget — the user provides a mobile number, verifies an SMS OTP, and the widget hands back their pre-filled identity, which you then pass into this endpoint. Same
POST /userscall in both cases; the widget just does the collection step for you. See Embedded UI — KYC modal for the modal-based path.
Body (CreateUserRequest):
| Field | Required | Notes |
|---|---|---|
firstName | yes | Non-empty. Immutable. |
lastName | yes | Non-empty. Immutable. |
middleName | no | |
dateOfBirth | yes | YYYY-MM-DD. Age 19–125 is enforced. Immutable. |
ssnLastDigits | yes | Exactly 4 numeric digits. Immutable. |
addressLine1 | yes | |
addressLine2 | no | Apartment, suite, etc. |
city | yes | |
state | yes | 2-letter USPS state code. |
zip | yes | 5–10 characters. |
countryCode | yes | ISO 3166 alpha-2, e.g. US. |
phoneNumber | no | If provided, 10 numeric digits, unformatted. |
email | no | If provided, valid email format. |
emailVerifiedAt | yes | UTC timestamp. You must have verified the email before this call. |
phoneVerifiedAt | no | UTC timestamp. |
Response (201, CreateUserResponse):
{
"id": "00000000-0000-0000-0000-000000000000",
"kycStatus": "PENDING",
"sharedSecret": "<base64url-32-byte>"
}Save
sharedSecretimmediately and securely — it's not retrievable later. Store it encrypted, indexed by user UUID.
Failure modes:
409 user_already_exists— the identity hash (name + DOB + SSN) collides with another user under this ISV.422with aValidationErrorResponse— one or more fields failed validation.
4. Terms and conditions
The terms bundle is five documents (PRIVACY_POLICY, TERMS_OF_USE, MARKET_PARTICIPANT_AGREEMENT, RISK_DISCLOSURE_STATEMENT, RULEBOOK). Each has its own per-document version, and there's a bundle-level totalVersion that increments whenever any document version changes. The user must accept the current totalVersion.
GET /private/v1/terms — fetch the current bundle
GET /private/v1/terms — fetch the current bundleISV-only. No sub/subsig. Returns:
{
"totalVersion": 7,
"documents": [
{ "documentType": "PRIVACY_POLICY", "version": 3, "url": "https://..." },
{ "documentType": "TERMS_OF_USE", "version": 2, "url": "https://..." },
{ "documentType": "MARKET_PARTICIPANT_AGREEMENT", "version": 1, "url": "https://..." },
{ "documentType": "RISK_DISCLOSURE_STATEMENT", "version": 1, "url": "https://..." },
{ "documentType": "RULEBOOK", "version": 4, "url": "https://..." }
]
}Show the user each url, then capture their acceptance of totalVersion.
GET /private/v1/users/USERID/terms — has this user accepted?
GET /private/v1/users/USERID/terms — has this user accepted?User-scoped. Returns:
{ "accepted": true }POST /private/v1/users/USERID/terms — record acceptance
POST /private/v1/users/USERID/terms — record acceptanceUser-scoped. Body:
{ "totalVersion": 7 }Responses:
204— acceptance recorded.200— user had already accepted this version. Body is{ "accepted": true }.409— submitted version is stale. The body is aStaleTermsResponse:Re-fetch{ "error": "...", "code": "stale_terms", "currentTotalVersion": 8 }/terms, re-prompt the user with the newtotalVersion, and try again.
5. KYC
KYC runs asynchronously after POST /users — that call returns immediately with kycStatus: "PENDING". ProphetX runs the check against an identity-verification provider in the background.
GET /private/v1/users/USERID/kyc-status
GET /private/v1/users/USERID/kyc-statusUser-scoped. Returns:
{
"id": "<userId>",
"kycStatus": "SUCCESS",
"failReason": null,
"supportEmail": "[email protected]"
}Statuses:
PENDING— still in progress; poll again later.SUCCESS— user can trade. A wallet is automatically created at this point (for both STANDING and INDIVIDUAL ISVs).FAILURE—failReason(string) explains what went wrong. Recoverable. Common causes: wrong SSN format, ZIP/state mismatch, transposed name characters. Prompt the user to correct the flagged fields and resubmit viaPOST /users/USERID/kyc-retry(below), or trigger the document-upload flow via Embedded UI — IDPV if the identity fields are correct but automated verification isn't enough. If retries have been exhausted, direct the user tosupportEmailfor manual review.MORTALITY/PEP/OFAC— terminal; the user cannot trade and cannot be retried.failReason(string) is populated with the specific match — display it for support triage only, not to the end user.
failReasonis populated on every non-SUCCESSstatus, not only onFAILURE. Older docs implied it wasFAILURE-only — it's now a plain string on any resolved-but-not-successful KYC result.
A 5–10 second polling interval is plenty. KYC typically resolves in tens of seconds, so tight-looping just adds load without learning anything sooner.
POST /private/v1/users/USERID/kyc-retry
POST /private/v1/users/USERID/kyc-retryUser-scoped. Resubmits KYC for a user whose previous attempt resolved to FAILURE, using a corrected set of identity fields. The submitted fields replace the previously stored values on the user before the new KYC check runs — so this is also the correct way to fix an identity typo (name, DOB, SSN, address) on a user who hasn't passed KYC yet. See the PATCH note in the next section for why this isn't done via PATCH /users/USERID.
Body (RetryKycRequest): Same shape as CreateUserRequest — every field the user was created with. Required fields: firstName, lastName, dateOfBirth, ssnLastDigits, addressLine1, city, state, zip, countryCode. Optional: middleName, addressLine2, phoneNumber, email.
{
"firstName": "Ada",
"lastName": "Lovelace",
"dateOfBirth": "1985-12-10",
"ssnLastDigits": "1234",
"addressLine1": "1 Main St",
"city": "Newark",
"state": "NJ",
"zip": "07102",
"countryCode": "US",
"email": "[email protected]"
}Response (200, RetryKycResponse):
{
"id": "<userId>",
"kycStatus": "PENDING"
}The response flips the user back to PENDING. Resume polling GET /kyc-status for the outcome.
Failure modes:
409 kyc_not_retryable— the user isn't inFAILURE(i.e. they'rePENDING,SUCCESS,MORTALITY,PEP, orOFAC), or the per-ISV retry limit has been exceeded for this user. Both cases surface the same code; checkkyc-statusto disambiguate — aFAILUREthat still returnskyc_not_retryablemeans the retry cap was hit.422 ValidationErrorResponse— one or more fields failed validation. Fix and resubmit.404 user_not_found— no user with that UUID.
When to use
validate-addressfirst. ZIP/state mismatches are a common source of KYCFAILURE. Before callingkyc-retry, run the corrected address throughPOST /private/v1/validate-address(§2) and submit the normalized result — this prevents a retryable failure from consuming a retry slot.
GET /private/v1/users/USERID/permissions
GET /private/v1/users/USERID/permissionsUser-scoped. Returns the user's current permissions (what they can do — e.g. market-order, parlay) and onboarding gates (what they still need to complete — e.g. kyc, terms). Use this to gate UI before the user attempts to submit, so denials are shown up front rather than as 403s mid-flow.
{
"permissions": {
"market-order": { "granted": true, "description": "Can submit market orders" },
"parlay": { "granted": false, "description": "Can submit parlays",
"denyReason": "KYC pending" },
"deposit-aeropay": { "granted": true, "description": "Can deposit via AeroPay" }
},
"gates": {
"kyc": { "completed": true, "description": "KYC verification" },
"terms": { "completed": false, "description": "Accept current terms" },
"not-suspended": { "completed": true, "description": "Account not suspended" }
}
}Field meanings:
permissions— map of permission key →{ granted, description, denyReason? }.granted: falseis accompanied by adenyReasonyou can show the user (e.g. "KYC pending", "Awaiting terms acceptance", "Account suspended").gates— map of gate key →{ completed, description }. A gate becomingcompleted: trueis what causes the corresponding permission to flip togranted: true.
Both maps are open — new keys may appear without an API version bump. Iterate the keys you receive rather than hard-coding an exhaustive switch.
Suspension gate. ProphetX can flip an individual user to suspended — the
not-suspendedgate becomescompleted: false, every action-taking permission (market-order,parlay,deposit-*,withdraw-*, ...) flips togranted: false, and every gated endpoint returns403 permission_deniedwithpending_gates: ["not-suspended"]in the response body. Check this gate before rendering the trading or payment UI rather than letting each call fail individually. A suspension is not a permanent state — reach out to your ProphetX contact to have the underlying issue resolved.
Server-side enforcement. Order, parlay, and payment endpoints carry an x-required-permission annotation in the OpenAPI spec. When the user lacks the required permission the gateway returns 403 permission_denied. See Errors, Limits, and Configuration Reference. The GET /tokens endpoint returns the same Permission and Gate shape on its response — this endpoint is the standalone way to fetch them without minting an embed token.
6. Reading, updating, and deleting users
GET /private/v1/users/USERID
GET /private/v1/users/USERIDUser-scoped. Returns the verified user record — i.e. one that has cleared KYC.
Response (UserResponse):
{
"id": "00000000-0000-0000-0000-000000000000",
"firstName": "Ada",
"lastName": "Lovelace",
"middleName": null,
"dateOfBirth": "1985-12-10",
"addressLine1": "1 Main St",
"addressLine2": null,
"city": "Newark",
"state": "NJ",
"zip": "07102",
"countryCode": "US",
"phoneNumber": "1234567890",
"email": "[email protected]",
"emailVerifiedAt": "2026-05-12T12:00:00Z",
"phoneVerifiedAt": null,
"kycAt": "2026-05-12T12:00:38Z",
"createdAt": "2026-05-12T12:00:00Z",
"updatedAt": null
}Note that ssnLastDigits is not returned here.
Failure modes:
404— the user doesn't exist.409 user_pending_kyc— the user has been created but hasn't been promoted to a verified user yet. They may still bePENDING, or they may have terminally failed (FAILURE/MORTALITY/PEP/OFAC). CallGET /private/v1/users/USERID/kyc-statusto find out which.
PATCH /private/v1/users/USERID
PATCH /private/v1/users/USERIDUser-scoped. You can update any of these fields:
addressLine1,addressLine2,city,state,zip,countryCodephoneNumber,emailemailVerifiedAt,phoneVerifiedAt
Immutable fields (firstName, lastName, dateOfBirth, ssnLastDigits) cannot appear in the body. Including any of them returns 422 with a validation error.
If the identity fields on a KYC-failed user are wrong, don't try to update them here — PATCH is for contact/address corrections on already-verified users. Use
POST /users/USERID/kyc-retry(§5) instead: it replaces the stored identity fields and resubmits KYC in one call. That's the only supported way to correct a name, DOB, or SSN on a user who has resolved toFAILURE.
DELETE /private/v1/users/USERID
DELETE /private/v1/users/USERIDUser-scoped. Returns 204 on success. The user must have reached kycStatus: SUCCESS first — delete is not available on users who never got verified.
409 user_pending_kyc— the user hasn't reachedSUCCESS. Applies toPENDING,FAILURE,MORTALITY,PEP, andOFACusers. The error code says "pending KYC" but the check is really "not verified" — the wording is slightly misleading, but the meaning is: no wallet was ever created, so there's nothing to atomically close down. Contact ProphetX if you need a stranded non-verified user cleaned up.409 non_zero_balance(INDIVIDUAL ISVs) — the user reachedSUCCESS, got a wallet, and the wallet still holds funds. Settle, refund, or withdraw first, then retry.- STANDING ISVs, once the user is verified, can delete regardless of standing-fund state — the user row is a tally and the ISV's standing fund is unaffected.
7. A curl walk-through
BASE="https://isv-api.sandbox.prophetx.dev/private/v1"
# 1. Create user (ISV-only JWT)
curl -X POST "$BASE/users" \
-H "Authorization: Bearer $JWT_ISV" \
-H "Content-Type: application/json" \
-d '{
"firstName":"Ada","lastName":"Lovelace",
"dateOfBirth":"1985-12-10","ssnLastDigits":"1234",
"addressLine1":"1 Main St","city":"Newark","state":"NJ","zip":"07102",
"countryCode":"US","email":"[email protected]",
"emailVerifiedAt":"2026-05-12T12:00:00Z"
}'
# → 201 { "id":"<userId>", "kycStatus":"PENDING", "sharedSecret":"<save-me>" }
# 2. Get current terms, show them to the user
curl "$BASE/terms" -H "Authorization: Bearer $JWT_ISV"
# 3. Record acceptance (user-scoped — JWT has sub=userId + subsig)
curl -X POST "$BASE/users/<userId>/terms" \
-H "Authorization: Bearer $JWT_USER" \
-H "Content-Type: application/json" \
-d '{ "totalVersion": 7 }'
# → 204
# 4. Poll KYC
curl "$BASE/users/<userId>/kyc-status" -H "Authorization: Bearer $JWT_USER"
# → { "kycStatus": "SUCCESS", ... }
# 5. Read the verified user record (once KYC is SUCCESS)
curl "$BASE/users/<userId>" -H "Authorization: Bearer $JWT_USER"
# → 200 { "id":"<userId>", "firstName":"Ada", ..., "kycAt":"..." }Updated 17 days ago
