User Onboarding (users, KYC, terms)

Before a user can place a trade, 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

You can accept terms before KYC resolves; you can't trade until both are done. A wallet is automatically created when KYC reaches SUCCESS.


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


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.
  • SHARED ISVs: deletion is allowed regardless of pot state, since the user row is a tally and the ISV pot is unaffected.

6. A curl walk-through

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