User Onboarding (users, KYC, terms)

Before a user can submit an order, three things need to happen:

  1. You create the user record.
  2. The user accepts the current terms.
  3. 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 is POST /users. Sandbox runs against real IDComply, so send one of the deterministic test personas (Scarlett Crowe for SUCCESS, Anita Day for FAILURE and 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 FAILURE rather than SUCCESS — 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)

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:

ValueMeaningWhat to do
verifiedThe input already matched exactly.Use the input as-is when calling POST /users.
correctedSmarty 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_secondaryAddress 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 the errors array.
  • 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

Creates 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 /users call in both cases; the widget just does the collection step for you. See Embedded UI — KYC modal for the modal-based path.

Body (CreateUserRequest):

FieldRequiredNotes
firstNameyesNon-empty. Immutable.
lastNameyesNon-empty. Immutable.
middleNameno
dateOfBirthyesYYYY-MM-DD. Age 19–125 is enforced. Immutable.
ssnLastDigitsyesExactly 4 numeric digits. Immutable.
addressLine1yes
addressLine2noApartment, suite, etc.
cityyes
stateyes2-letter USPS state code.
zipyes5–10 characters.
countryCodeyesISO 3166 alpha-2, e.g. US.
phoneNumbernoIf provided, 10 numeric digits, unformatted.
emailnoIf provided, valid email format.
emailVerifiedAtyesUTC timestamp. You must have verified the email before this call.
phoneVerifiedAtnoUTC timestamp.

Response (201, CreateUserResponse):

{
  "id": "00000000-0000-0000-0000-000000000000",
  "kycStatus": "PENDING",
  "sharedSecret": "<base64url-32-byte>"
}

Save sharedSecret immediately 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.
  • 422 with a ValidationErrorResponse — 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

ISV-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?

User-scoped. Returns:

{ "accepted": true }

POST /private/v1/users/USERID/terms — record acceptance

User-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 a StaleTermsResponse:
    { "error": "...", "code": "stale_terms", "currentTotalVersion": 8 }
    Re-fetch /terms, re-prompt the user with the new totalVersion, 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

User-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).
  • FAILUREfailReason (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 via POST /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 to supportEmail for 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.

failReason is populated on every non-SUCCESS status, not only on FAILURE. Older docs implied it was FAILURE-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

User-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 in FAILURE (i.e. they're PENDING, SUCCESS, MORTALITY, PEP, or OFAC), or the per-ISV retry limit has been exceeded for this user. Both cases surface the same code; check kyc-status to disambiguate — a FAILURE that still returns kyc_not_retryable means 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-address first. ZIP/state mismatches are a common source of KYC FAILURE. Before calling kyc-retry, run the corrected address through POST /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

User-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: false is accompanied by a denyReason you can show the user (e.g. "KYC pending", "Awaiting terms acceptance", "Account suspended").
  • gates — map of gate key → { completed, description }. A gate becoming completed: true is what causes the corresponding permission to flip to granted: 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-suspended gate becomes completed: false, every action-taking permission (market-order, parlay, deposit-*, withdraw-*, ...) flips to granted: false, and every gated endpoint returns 403 permission_denied with pending_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

User-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 be PENDING, or they may have terminally failed (FAILURE / MORTALITY / PEP / OFAC). Call GET /private/v1/users/USERID/kyc-status to find out which.

PATCH /private/v1/users/USERID

User-scoped. You can update any of these fields:

  • addressLine1, addressLine2, city, state, zip, countryCode
  • phoneNumber, email
  • emailVerifiedAt, 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 to FAILURE.

DELETE /private/v1/users/USERID

User-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 reached SUCCESS. Applies to PENDING, FAILURE, MORTALITY, PEP, and OFAC users. 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 reached SUCCESS, 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":"..." }

Did this page help you?