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 is no combined "Onboarding" widget. A previous version of this table described one that wraps identity verification, terms acceptance, and
POST /usersinto 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:
| Widget | INDIVIDUAL ISVs | STANDING ISVs |
|---|---|---|
| Deposit | Required. 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). |
| Withdraw | Required. 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 Signup | Optional. 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 Verification | Optional. 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
GET /private/v1/tokensUser-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:
| Field | Use |
|---|---|
token | Hand to the embedded UI component as its auth credential. Don't log it. |
expiration | Refresh before it expires; embedded components also surface refresh hooks. |
permissions | Map of permission key → { granted, description, denyReason? }. Drive your UI: only render trading controls if permissions.trade.granted. Show denyReason to explain a "no". |
gates | Onboarding 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
/embed/v1/* exposesYou 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.
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-pnmand 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.
| Provider | What it is | Typical 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). |
| PayNearMe | Card 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," checkdeposit-zerohashon 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 nodeposit-wire/withdraw-wirepermission 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_stateprobes AeroPay on every call, so even a user who previously came backconfirmed: truecan suddenly returnAeroPayMFAChallenge(HTTP 202) if AeroPay drops their session (AeroPay error code AP002). Branch on the response type, not on cached state — if the response isAeroPayMFAChallenge, prompt for the SMS code and callconfirm_aeropay_mfa(), then re-issue the state call.
PayNearMe withdrawals debit the wallet at create time, not at execute time.
create_pnm_withdrawaldebits 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-drivenexecute_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, a400fromcreate_pnm_withdrawalcan mean insufficient funds (returned synchronously asinsufficient_funds) as well as an invalid request (bad amount, malformed body, etc.) — don't assume a create-time400is purely a validation error.
Per-transaction limits
AeroPay and PayNearMe enforce per-user, per-provider spending caps. There are three cap kinds:
| Kind | Applies to | What it caps |
|---|---|---|
daily | deposits | Sum of COMPLETED deposits since midnight (provider-local day). |
pending | deposits | Sum of PENDING + AUTHORIZED deposits currently outstanding. Bounds how much a user can have in flight before they've cleared. |
per_tx | withdraws | Maximum 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
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 /usersUI) if you already have KYC infrastructure or need to match a very specific brand experience — you'll be collectingfirstName,lastName,dateOfBirth,ssnLastDigits, address,emailVerifiedAtyourself.
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:
| Method | Path | Purpose |
|---|---|---|
POST | /embed/v1/kyc/idpv/complete | Read 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/start | Starts (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 returns | Meaning | Do next |
|---|---|---|
kycStatus: SUCCESS | User already passed KYC. | Show a "you're verified" page. Don't open the form — that would consume an attempt. |
kycStatus: PENDING | An earlier session is still processing. | Show a "checking your verification" page and poll complete again. |
kycStatus: MORTALITY / PEP / OFAC | Terminal ineligibility. | Show an ineligible page. IDPV cannot rescue any of these. |
kycStatus: FAILURE | Eligible 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: complete | Done — the user is verified. |
kycStatus: FAILURE, idpvSessionStatus: failed | Documents 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_progress | IDComply 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. UsePOST /private/v1/users/USERID/kyc-retrywhen 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
| Method | Path | Purpose |
|---|---|---|
GET | /embed/v1/terms | Same payload as /private/v1/terms. |
GET | /embed/v1/terms/USERID | Whether this user has accepted the current terms. |
POST | /embed/v1/terms/USERID | Record acceptance. |
GET | /embed/v1/wallet | The 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/validate | Validate 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-suspendedgate. A suspended user hitting any deposit/withdraw call gets403 permission_deniedwithpending_gates: ["not-suspended"]. Read the gate up front viaGET /private/v1/users/USERID/permissionsand 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:
- On session start, fetch a token.
- Schedule a refresh just before
expiration. - When refreshing, fetch a new token from
GET /private/v1/tokensand pass it to the embedded component. - 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.
Updated 15 days ago
