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/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
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. Run that flow once against staging with a dummy identity to mint a test user, save the returned sharedSecret, and use it as your subsig key for all subsequent staging calls.


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

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.

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

4. 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 is populated with details. Direct the user to supportEmail for manual review.
  • MORTALITY / PEP / OFAC — terminal; the user cannot trade.

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.

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" }
  },
  "gates": {
    "kyc":   { "completed": true,  "description": "KYC verification" },
    "terms": { "completed": false, "description": "Accept current terms" }
  }
}

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

Server-side enforcement. Order and parlay endpoints carry an x-required-permission annotation in the OpenAPI spec (market-order on the four POST/GET /market-orders[/...] operations; parlay on GET /parlays/{parlayId} and POST /parlays/{parlayId}/confirm). 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.


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

DELETE /private/v1/users/USERID

User-scoped. Returns 204 on success.

  • INDIVIDUAL ISVs: returns 409 non_zero_balance if the user's wallet isn't empty. Settle, refund, or withdraw first.
  • STANDING ISVs: deletion is allowed regardless of standing-fund state, since the user row is a tally and the ISV's standing fund is unaffected.

6. A curl walk-through

BASE="https://isv-api.staging.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":"..." }