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?

triangle-exclamation

There is no combined "Onboarding" widget. A previous version of this table described one that wraps identity verification, terms acceptance, and POST /users into a single flow. That flow is permanently disabled — the underlying Verified Inc API integration it depended on was never approved for that integration type. What's actually live is two separate, narrower widgets (1-Click Signup and Document Verification, below), and terms acceptance is a third, separate thing you host yourself — see Verified Modal for the full picture.

There are four widgets available — Deposit, Withdraw, 1-Click Signup, Document Verification. 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.
1-Click SignupOptional. Runs a phone-first identity-collection flow (SMS OTP + auto-prefilled PII lookup, via Verified Inc's own hosted modal — not a ProphetX-built screen) for a brand-new user, and hands back a verified identity you redeem with POST /private/v1/users/one-click. It does not include terms acceptance and does not call POST /users itself — your backend does that with the redeemed identity. If you use this, you don't collect name/DOB/SSN/address yourself — the widget does.
Document VerificationOptional. For an existing user whose automated KYC returned FAILURE — a government-ID photo upload reviewed by IDComply, opened in a second browser tab (not an iframe). Not a signup mechanism; the user already exists. See §3.3 below.

For an INDIVIDUAL deployment, the practical minimum is: own onboarding (collect identity fields yourself and call POST /users directly, or use 1-Click Signup) + terms acceptance, 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.

triangle-exclamation

PayNearMe has no ProphetX-hosted embed UI yet. The endpoints below are real and callable, but the payment method list you actually get in the ProphetX-hosted embed is entirely permission-derived — if the token grants deposit-pnm/withdraw-pnm and the user selects it, the embed currently shows an "isn't available yet" placeholder screen instead of a working flow (no API call is made). AeroPay and ZeroHash (deposit-only) are the providers with a shipped embed flow today. If you're calling the PayNearMe endpoints directly from your own custom UI rather than through ProphetX's embed, this doesn't apply to you.

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 approved → mint_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.Deposit: create_pnm_deposit() with an FE-generated idempotency UUID + decimal amount → returns a secureSmartToken → FE calls PNM.init(secureSmartToken, { action: "Pay" }) → outcome arrives asynchronously via the PayNearMe webhook. Withdraw: create_pnm_withdrawal() debits the wallet immediately and stages the transaction in an internal ops-approval queue — PayNearMe isn't contacted until ops approves, at which point the response's secureSmartToken becomes usable → FE calls PNM.init(secureSmartToken, { action: "Disburse" }) so the recipient picks a payout method → your backend must then call execute_pnm_withdraw(transactionId), which looks up the chosen payout method and calls PayNearMe's /make_payment; the wallet debit itself already happened at create time, so execute just completes the payout.

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, /pnm/withdraw/{transactionId}/execute
  • 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 debit the wallet at create time, not at execute time. create_pnm_withdrawal debits the wallet and stages the transaction in an internal ops-approval queue in the same call — PayNearMe is not contacted at all until ops approves the withdrawal. The later steps (the widget-driven execute_pnm_withdraw(transactionId), and PayNearMe's own Authorization callback) find the ledger entry already there and don't debit again. Because the debit happens at create time, a 400 from create_pnm_withdrawal can mean insufficient funds (returned synchronously as insufficient_funds) as well as an invalid request (bad amount, malformed body, etc.) — don't assume a create-time 400 is purely a validation error.

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):

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

code is always LIMIT_EXCEEDED. 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 error and the remaining amount so the user can adjust.

3.2 1-Click Signup — phone-first identity collection for new users

triangle-exclamation

This is not a ProphetX-built modal. A previous version of this section described phone entry, OTP, and PII-confirmation as ProphetX's own screens (/api/v1/kyc/* proxies). That implementation is retired — the account it depended on was never approved for that Verified Inc integration type. The current flow (1-Click Signup) renders Verified Inc's own hosted SDK modal directly; ProphetX doesn't control or expose the internal screens, so this doc can't describe them with certainty. It's also for new users only — an existing user can't be re-verified this way (see §3.3).

The user confirms a phone number by SMS one-time code inside Verified's own modal, reviews the identity Verified already holds for them, and shares it (or declines — not an error, just fall back to your own signup form). The widget returns only an opaque identityUuid — no raw PII (name, DOB, address, SSN) reaches your integration code at any point.

Before opening it, your backend mints a single-use session key:

POST /private/v1/one-click/sessions
Authorization: Bearer <your Ed25519-signed JWT, sub omitted>

  -> 200 { "sessionKey": "...", "environment": "sandbox" }

Pass both fields into the widget. On success, your backend redeems the identity — this is what actually creates the user, not a generic POST /private/v1/users call:

POST /private/v1/users/one-click
{
  "identityUuid": "...",
  "email": "[email protected]",          # Verified returns none — this is yours to supply
  "emailVerifiedAt": "2026-08-25T12:00:00Z",
  "phoneVerifiedAt": "2026-08-25T12:00:00Z"
}

  -> 201 { "id": "...", "kycStatus": "...", "sharedSecret": "..." }
  -> 409 user_already_exists   (that identity already has a ProphetX user)

KYC has not run yet when this returns — it starts once the user is created, exactly like any other user. From there it's the flow documented in User Onboarding: poll GET /kyc-status, offer kyc-retry on FAILURE, or open the document verification modal (next subsection) if the failure needs document evidence.

A session key is single-use. A spent or lapsed key must be re-minted, not reopened — this is the one retryable failure the widget reports; everything else (user declined, no Verified credentials found for that phone number, risk score) is terminal for that attempt and falls back to manual signup.

When to use vs. skip 1-Click Signup:

  • Use it if you don't want to collect PII yourself, and you're happy with Verified's own phone-first UI for new-user signup.
  • 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.

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: completeDone — the user is verified.
kycStatus: FAILURE, idpvSessionStatus: failedDocuments 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: PHOTO_VERIFICATION_PROCESSING, idpvSessionStatus: in_progressIDComply 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": "created"
}
// 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. idpvSessionStatus is one of created, activated, in_progress, complete, failed, expired, archived.

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 its claims: expiration, gates, isvId, permissions, userId. This is a distinct, embed-specific response shape — it does not include a token field, so don't confuse it with the TokenResponse returned by GET /private/v1/tokens.

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?