Embedded UI (tokens + `/embed/v1`)

ProphetX provides drop-in UI components that handle trading and payment flows inside your frontend. They authenticate to ProphetX directly using a short-lived JWT that your backend mints for each user session, then call /embed/v1/* routes from the user's browser.

Your only responsibility on the backend is to mint those tokens. You don't call /embed/v1 yourself.

Auth on GET /tokens: user-scoped (sub + subsig) — see Authentication.

Which widgets does my integration need?

There are roughly four widgets available — Deposit, Withdraw, Onboarding, KYC. Which ones you embed depends on your fund type and how much of the user flow you want to own:

WidgetINDIVIDUAL ISVsSTANDING ISVs
DepositRequired. The private ISV API exposes no deposit endpoint — the only path money can enter a user's wallet is through this modal (calling /embed/v1/payment/*).Not used for end-user funding (the standing fund is funded out-of-band).
WithdrawRequired. Same reason — there is no POST /withdraw on the private API; the modal is the only path out.Not used for end-user payouts.
OnboardingOptional. Wraps the KYC widget + terms acceptance + POST /users in a single ProphetX-hosted flow — the user walks in with a phone number and walks out as a created user with KYC in PENDING. If you use this, your backend only mints tokens and (optionally) reads status. If you'd rather match your own brand and control the sequencing, skip this and build your own onboarding on top of POST /users and the terms endpoints.
KYCOptional. Runs a phone-first identity-verification flow (SMS OTP + auto-prefilled PII lookup via Verified.Inc) and, on success, hands back a verified identity you use to call POST /users. Also fronts the IDPV document-upload fallback when automated KYC returns FAILURE. If you use this, you don't collect name/DOB/SSN/address yourself — the widget does. See §3.2 below for the modal flow.

For an INDIVIDUAL deployment, the practical minimum is: own onboarding + KYC display, embed Deposit + Withdraw (because there's no API alternative for money movement). Talk through the end-to-end user flow with your ProphetX contact before locking it in — there are a few sequencing edge cases (e.g. when to surface deposit prompts vs. KYC retries) that are easier to align on once rather than discovering after launch.


1. How the pieces fit together

Your frontend                    ProphetX-hosted embedded components
     │                                       │
     │  fetches a token                      │
     ├──► your backend                       │
     │       │                               │
     │       │ GET /private/v1/tokens        │
     │       └──────────────────────────────►│
     │                                       │
     │  token + permissions + gates          │
     │◄──────────────────────────────────────│
     │                                       │
     │  init component with token            │
     ├──► embedded UI                        │
     │                                       │
     │                                       │  /embed/v1/* calls
     │                                       ├──► ProphetX API
     │                                       │
     │                                       │  validates token, returns
     │                                       │◄──── responses

The token is the only ProphetX credential that ever touches the browser. It's short-lived and scoped to a single user session.


2. GET /private/v1/tokens

User-scoped. No body.

Response (TokenResponse):

{
  "token": "eyJhbGciOi...",
  "isvId":  "00000000-0000-0000-0000-000000000000",
  "userId": "00000000-0000-0000-0000-000000000000",
  "expiration": "2026-05-12T12:05:00Z",
  "permissions": {
    "trade":    { "granted": true,  "description": "Can submit orders" },
    "withdraw": { "granted": false, "description": "Can withdraw funds", "denyReason": "KYC pending" }
  },
  "gates": {
    "kyc":   { "completed": true,  "description": "KYC verification" },
    "terms": { "completed": false, "description": "Accept current terms" }
  }
}

What to do with each field:

FieldUse
tokenHand to the embedded UI component as its auth credential. Don't log it.
expirationRefresh before it expires; embedded components also surface refresh hooks.
permissionsMap of permission key → { granted, description, denyReason? }. Drive your UI: only render trading controls if permissions.trade.granted. Show denyReason to explain a "no".
gatesOnboarding gates the user must clear. Use these to drive an onboarding flow that walks them through any completed: false gate.

permissions and gates are maps with arbitrary keys — new ones can appear without an API version bump. Don't hard-code an exhaustive switch; iterate the keys you got.


3. What /embed/v1/* exposes

You don't call these directly, but here's what the embedded UI is doing under the hood so you know what's reachable from a logged-in user's browser session.

3.1 Payment providers

ProphetX exposes per-provider payment endpoints under /embed/v1/payment/{provider}/*. The embedded UI picks the right surface for the provider the user selected; your backend still just mints the token. There is no generic provider-agnostic payment surface — every deposit/withdrawal call is scoped to a specific provider.

ProviderWhat it isTypical flow
AeroPay (via Aerosync)Bank-linked ACH. The Aerosync widget lets the user link a bank once, then AeroPay uses it for future deposits/withdrawals. Gated by an SMS MFA confirmation — see the re-trigger note below.get_aeropay_state() → (SMS confirm if the response is AeroPayMFAChallenge) → list_accounts() or get_aerosync_widget()initiate_deposit() / initiate_withdrawal().
ZeroHash (labeled "Crypto" in the UI)Crypto-to-USD deposit (crypto in, USD credited to the wallet). Requires its own KYC step (participant onboarding) before the first deposit.get_zerohash_state() → (if needs_onboarding, load onboarding token into ZeroHash SDK) → poll until approvedmint_auth_token() → FE initiates the deposit through ZeroHash SDK → record_deposit() (backend logs PENDING; webhook flips to settled).
PayNearMeCard deposits and withdrawals through PayNearMe's hosted widget (PNM.js). Card PII never touches your servers.create_pnm_deposit() (or create_pnm_withdrawal()) with an FE-generated idempotency UUID + decimal amount → returns a secureSmartToken → FE calls PNM.init(secureSmartToken, { action: "Pay" | "Disburse" }) → outcome arrives asynchronously via the PayNearMe webhook (not in the initial response).

Concrete paths (used by the embedded UI, listed here for reference):

  • AeroPay: /embed/v1/payment/aeropay (state), /aeropay/confirm, /aeropay/accounts, /aeropay/widget, /aeropay/widget/link, /aeropay/deposit, /aeropay/withdraw
  • ZeroHash: /embed/v1/payment/zerohash (state), /zerohash/auth-token, /zerohash/deposit
  • PayNearMe: /embed/v1/payment/pnm/deposit, /pnm/withdraw
  • Shared v2 callbacks: /embed/v2/payment/deposit-result, /embed/v2/payment/withdraw-result

Every payment endpoint is user-scoped and requires the corresponding permission on the user's token — deposit-aeropay / withdraw-aeropay, deposit-zerohash, and deposit-pnm / withdraw-pnm. A denied permission surfaces as 403 permission_denied. Check permissions on the token response (or via GET /private/v1/users/USERID/permissions) before rendering that provider's tile.

ZeroHash is shown to end users as "Crypto". The permission slug and the SDK method names still say zerohash — only the display label changed. If a user reports "I can't deposit via Crypto," check deposit-zerohash on their permissions.

Wire Transfer is a UI-only informational option, not an API-backed provider. The embed shows a Wire Transfer tile in the deposit and withdraw flows that renders static instructions (bank details, reference number guidance, minimum amounts, KYC-doc requirements). Selecting it does not call any /embed/v1/payment/* endpoint — the user acts out-of-band by initiating a wire from their own bank. There's no deposit-wire / withdraw-wire permission gating it either; the tile is always rendered. Nothing for your backend to do here beyond knowing it exists.

AeroPay MFA can re-trigger. get_aeropay_state probes AeroPay on every call, so even a user who previously came back confirmed: true can suddenly return AeroPayMFAChallenge (HTTP 202) if AeroPay drops their session (AeroPay error code AP002). Branch on the response type, not on cached state — if the response is AeroPayMFAChallenge, prompt for the SMS code and call confirm_aeropay_mfa(), then re-issue the state call.

PayNearMe withdrawals defer the wallet debit. create_pnm_withdrawal returns immediately with a secureSmartToken; the wallet is not debited at create time. The debit happens in ProphetX's PayNearMe Authorization callback once PayNearMe accepts the push. A 400 from create_pnm_withdrawal means an invalid request (bad amount, malformed body, etc.) — it does not mean insufficient funds. The insufficient-funds check is deferred to the callback.

Per-transaction limits

AeroPay and PayNearMe enforce per-user, per-provider spending caps. There are three cap kinds:

KindApplies toWhat it caps
dailydepositsSum of COMPLETED deposits since midnight (provider-local day).
pendingdepositsSum of PENDING + AUTHORIZED deposits currently outstanding. Bounds how much a user can have in flight before they've cleared.
per_txwithdrawsMaximum size of a single withdrawal request.

Pre-check the caps before the FE builds the amount field:

GET /embed/v1/payment/limits

Response is a provider-keyed map of limits + the calling user's current usage:

{
  "aeropay": {
    "deposit":  { "dailyLimit": "25000.00", "dailyUsed": "8000.00",
                  "pendingLimit": "50000.00", "pendingUsed": "12000.00" },
    "withdraw": { "perTxLimit": "10000.00" }
  },
  "pnm": {
    "deposit":  { "dailyLimit": "5000.00",  "dailyUsed": "0.00",
                  "pendingLimit": "10000.00", "pendingUsed": "0.00" },
    "withdraw": { "perTxLimit": "2500.00" }
  }
}
  • Values are decimal strings, USD.
  • A missing provider block, or a missing sub-field, means UNLIMITED for that (provider, direction, kind) triple.
  • ZeroHash is deliberately absent — its deposit caps are enforced in the ZeroHash admin console, not here, so there's nothing for you to pre-validate against.

When a payment call would exceed a configured cap, the endpoint returns a LimitExceededResponse (4xx):

{
  "kind": "daily",
  "limit": "25000.00",
  "remaining": "17000.00",
  "message": "amount exceeds daily limit of 25000.00 (remaining 17000.00)"
}

kind names which cap tripped (daily / pending / per_tx), limit is the cap in effect, and remaining is how much the user has left before the cap (0 if they're already at or past it). Surface the message and the remaining amount so the user can adjust.

3.2 KYC modal — phone-first identity verification

The embedded KYC widget runs the identity check the ISV would otherwise have to collect field-by-field. The user experience is: phone number → SMS one-time code → confirm the pre-filled identity → done. Under the hood the widget is talking to Verified.Inc (1-click identity lookup) via ProphetX-managed serverless proxies, so credentials and audit logging stay server-side; you don't hold a Verified.Inc key.

What the user sees:

  1. Phone entry. User types their US mobile number.
  2. OTP entry. They receive an SMS with a 6-digit code and enter it. Wrong code → they can retry until attempts are exhausted; resend is available.
  3. PII confirmation. The widget shows the identity Verified.Inc returned — legal name, DOB, address, last-4 SSN, email — for the user to confirm or edit.
  4. Additional inputs (occasional). If Verified.Inc came back but needed extra fields, the widget prompts for those before returning.
  5. Result. On success, the widget returns an identityUuid and (when the user supplied one) an email to your integration code. On pending/failed, the widget shows a terminal screen; you decide what to do next.

Handoff to the ISV backend. Once the widget returns success, your backend calls POST /private/v1/users with the identity fields you got from the widget's PiiConfirmation step. From there it's the flow documented in User Onboarding: poll GET /kyc-status, offer kyc-retry on FAILURE, or open the IDPV modal (next subsection) if the failure needs document evidence.

What NOT to worry about:

  • The Verified.Inc credentials and API — held server-side by ProphetX, never on the browser or your backend.
  • The specific proxy endpoints the widget calls (/api/v1/kyc/* in the current embed deployment) — they're implementation-internal and can change without notice. Consume the widget, not those routes.
  • Rate-limiting the OTP resends — the widget handles attempts + expiry itself.

When to use vs. skip the KYC widget:

  • Use it if you don't want to collect PII yourself, and you're happy with the ProphetX-branded phone-first flow.
  • Skip it (build your own POST /users UI) if you already have KYC infrastructure or need to match a very specific brand experience — you'll be collecting firstName, lastName, dateOfBirth, ssnLastDigits, address, emailVerifiedAt yourself. The Onboarding widget is a bigger wrapper if you want the widget-based path.

3.3 KYC document upload (IDPV)

When a user's automated KYC returns FAILURE and the failure is one where identity fields look right but the vendor needs more evidence (e.g. can't match on demographics alone), the fallback is IDPV — Identity Document Photo Verification. The user uploads a government ID + selfie into IDComply's hosted form; the KYC decision is re-run against that evidence.

You embed the flow via two token-scoped endpoints:

MethodPathPurpose
POST/embed/v1/kyc/idpv/completeRead the caller's current eligibility. Idempotent, never consumes a retry attempt, no request body. Returns kycStatus + idpvSessionStatus. Call this first to disambiguate state (see below).
POST/embed/v1/kyc/idpv/startStarts (or resumes) an IDPV session for the calling user. Returns a hostedFormLink to display. If an active unexpired session already exists, that one comes back as-is without consuming a retry.

Call complete before start. The endpoint is idempotent and doesn't consume an attempt, so reading it up front costs nothing and disambiguates the current state — which matters because start will return a 409 kyc_not_retryable in four different situations with a byte-identical response body. kycStatus from complete is the only way to tell them apart:

complete returnsMeaningDo next
kycStatus: SUCCESSUser already passed KYC.Show a "you're verified" page. Don't open the form — that would consume an attempt.
kycStatus: PENDINGAn earlier session is still processing.Show a "checking your verification" page and poll complete again.
kycStatus: MORTALITY / PEP / OFACTerminal ineligibility.Show an ineligible page. IDPV cannot rescue any of these.
kycStatus: FAILUREEligible to attempt.Call start. A 200 returns a hostedFormLink to open. A 409 means the per-user attempt cap has been exhausted (since FAILURE is the only status that permits start, a refusal at this point can only be the cap).

Then, after the user completes the hosted form and IDComply redirects back:

complete returns (second read)Meaning
kycStatus: SUCCESS, idpvSessionStatus: completedDone — the user is verified.
kycStatus: FAILURE, idpvSessionStatus: rejectedDocuments were rejected (e.g. ID couldn't be read cleanly). User can try again if attempts remain — don't tear down the flow. Loop back to start.
kycStatus: PENDING, idpvSessionStatus: submittedIDComply is still reviewing. Poll complete again shortly.

Request/response shapes:

start accepts an optional redirectUrl in the body. This is where IDComply will send the user after the hosted form completes. Only same-origin URLs or custom app schemes (e.g. myapp://kyc-return) are accepted — arbitrary https:// URLs are rejected to prevent open-redirect via the iframe src. Omit it to use ProphetX's default return page.

// POST /embed/v1/kyc/idpv/start  request (optional body)
{ "redirectUrl": "myapp://kyc-return" }

// 200 response
{
  "token": "8bd55bb94c49257d",
  "openKey": "ce147c7b",
  "hostedFormLink": "https://forms.idcomply.com/...",
  "status": "pending"
}
// POST /embed/v1/kyc/idpv/complete  request (no body)

// 200 response
{
  "kycStatus": "SUCCESS",
  "idpvSessionStatus": "completed"
}

Two-axis status model. IDPV surfaces two independent fields — kycStatus (the overall user state) and idpvSessionStatus (the state of the current document session, if any). They can flip independently, so switch on both rather than collapsing to a single success/fail dimension.

identityUuid is not returned. IDPV escalates an existing user; you already have their UUID from POST /users.

When to use IDPV vs kyc-retry. They're different tools for different failures. Use POST /private/v1/users/USERID/kyc-retry when the identity fields the user submitted were wrong (typo in SSN, mismatched ZIP/state). Use IDPV when the fields were correct but the vendor still couldn't verify — a document + selfie is the escalation. See User Onboarding.

3.4 Terms, wallet, token

MethodPathPurpose
GET/embed/v1/termsSame payload as /private/v1/terms.
GET/embed/v1/terms/USERIDWhether this user has accepted the current terms.
POST/embed/v1/terms/USERIDRecord acceptance.
GET/embed/v1/walletThe calling user's Wallet row. Always user-scoped — the token's sub decides whose wallet is returned, so the component cannot read another user's wallet or the ISV's standing fund. Same shape as /private/v1/wallets.
GET/embed/v1/token/validateValidate an embed token and return the same TokenResponse payload.

The terms routes are a straight passthrough to the private equivalents in User Onboarding (users, KYC, terms), so you can either handle T&Cs on your own backend or let the embedded UI handle them.

The payment routes are the only path for deposits and withdrawals — the private ISV API doesn't expose money-movement endpoints. Your frontend embeds the payment component, and the component calls the appropriate /embed/v1/payment/{provider}/* endpoints itself.

Suspension gate blocks payments. All payment endpoints require the not-suspended gate. A suspended user hitting any deposit/withdraw call gets 403 permission_denied with pending_gates: ["not-suspended"]. Read the gate up front via GET /private/v1/users/USERID/permissions and hide the payment UI rather than letting individual calls fail — see User Onboarding.


4. Token rotation

Tokens are short-lived (the expiration claim). Refresh them before they expire. A common pattern:

  1. On session start, fetch a token.
  2. Schedule a refresh just before expiration.
  3. When refreshing, fetch a new token from GET /private/v1/tokens and pass it to the embedded component.
  4. On the user logging out, drop the token. It can't be revoked from your side, but it will expire shortly.

Don't try to reuse a token across users or sessions — one token, one user, one short window.


5. Curl

BASE="https://isv-api.sandbox.prophetx.dev/private/v1"

# Mint a token for a user
curl "$BASE/tokens" -H "Authorization: Bearer $JWT_USER"

The response goes back to your frontend over your own authenticated session with the user — not directly to the browser as a redirect from this API.


Did this page help you?