Error Codes
Every error code the ProphetX SDK can return — what causes it, whether it's retryable, and what your code should do.
Every error from the ProphetX SDK arrives through the onError(code, message) callback on deposit and withdrawal flows, or a dedicated callback/postMessage event for session, geofence, and identity-verification outcomes (onGeofenceBlocked, onKyc*, onOneClick*). This reference covers both.
This page has been corrected against the isv-backend and isv-embed-fe wire contracts. Several codes that used to appear here (
KYC_REQUIRED,TRUSTLY_*,ACTION_NOT_PERMITTED, and aPOST /api/v1/session/validateendpoint) did not correspond to anything in the backend and have been replaced below with the realPERMISSION_DENIED/pending_gatesmechanism (see User prerequisite and permission errors).GEOFENCE_BLOCKEDis real — an earlier correction pass on this page removed it in error; it's back below.useOnboarding/openOnboardingis also gone from the examples: that flow is permanently disabled in the current embed. New users go throughuseOneClickSignup; existing users needing re-verification go throughuseKyc/useIdUpload.
How errors are delivered
| Channel | When | What you receive |
|---|---|---|
onError(code, message) | A deposit, withdrawal, or session operation fails | A machine-readable code and a human-readable message safe to display |
onGeofenceBlocked(stateCode, reason) / PROPHETX_GEOFENCE_BLOCKED | User's location isn't eligible | Real, on every flow except 1-Click Signup — but geo is enforced at token issuance, so this shouldn't fire with a validly-issued token |
PROPHETX_SESSION_EXPIRED | The JWT's exp claim has passed | A standalone event — respond with a fresh token |
The code values are stable strings. The message values are human-readable and may change — never match on them programmatically.
Scope note. The codes below that are prefixed as backend-verified (endpoint paths, gate/permission names,
PERMISSION_DENIED, and the provider-specific payment codes) are confirmed againstisv-backend.SESSION_EXPIRED,SESSION_INVALID,ORIGIN_UNAUTHORIZED,TIMEOUT,NETWORK_ERROR, andUNCAUGHT_ERRORare SDK/transport-level concepts (client-side JWT expiry check, HTTP client timeout, browser network failure, React error boundary) that isv-backend wouldn't be expected to define — they're plausible but not independently verifiable from that repo, so treat their exact behavior as SDK documentation rather than API-contract fact.
Error response shape
Every onError callback receives the same two arguments:
onError: (code: string, message: string) => voidOn the wire, ProphetX's REST APIs (including the embed API) return errors as {"code": "...", "error": "..."} — the SDK's onError callback maps the wire error string to its message parameter. If you're working with the low-level postMessage protocol instead of the SDK, error payloads arrive as:
{
"type": "PROPHETX_ERROR",
"payload": {
"code": "DEPOSIT_FAILED",
"message": "The deposit could not be completed."
}
}Session errors
These errors fire when the embed cannot establish or maintain a valid session. They typically indicate a problem with your token endpoint or key registration — not something the end user can fix.
| Code | HTTP | Retryable | Cause |
|---|---|---|---|
SESSION_EXPIRED | — | Yes | The embed token's exp claim is in the past |
SESSION_INVALID | 401 | No | Malformed JWT, bad signature, wrong issuer, or server-side rejection |
ORIGIN_UNAUTHORIZED | 403 | No | Your page's origin is not in your partner-registered allowlist |
SESSION_EXPIRED
SESSION_EXPIREDEmbed tokens are short-lived (10 minutes by default), so this will happen if a user takes time in a flow.
What to do: Return a fresh token from your onSessionExpired handler. The SDK handles the refresh automatically if you provided this callback. If you're using the low-level postMessage protocol, listen for PROPHETX_SESSION_EXPIRED and respond with PROPHETX_SESSION_REFRESH.
<ProphetXProvider
environment="sandbox"
token={token}
onSessionExpired={async () => {
const res = await fetch("/api/prophetx-token");
const { token } = await res.json();
setToken(token);
return token;
}}
>
<App />
</ProphetXProvider>SESSION_INVALID
SESSION_INVALIDThe token failed validation — either client-side (malformed JWT, missing claims) or server-side (bad signature, unrecognized issuer). GET /embed/v1/token/validate returns a bare 401 with no response body in this case, so the specific cause isn't returned over the wire — investigate from your side.
What to do: Do not retry with the same token. Check your signing implementation:
- Verify you're using the
EdDSAalgorithm with an Ed25519 private key - Confirm the
issclaim matches your ISV ID - Confirm the
subclaim is a valid ProphetX user ID - Ensure the token hasn't been tampered with after signing
ORIGIN_UNAUTHORIZED
ORIGIN_UNAUTHORIZEDThe embed detected that your page's origin isn't in the partner-registered allowlist.
What to do: Register your origin with ProphetX. This is a one-time setup per domain. If you're seeing this in development, make sure your localhost origin (including port) is registered. See Deposits and Withdrawals — Before you begin.
User prerequisite and permission errors
The embed validates the user's session via GET /embed/v1/token/validate, which returns the same gates and permissions objects available on the embed token itself:
{
"expiration": "2026-06-15T16:30:00Z",
"isvId": "80c7cadf-22e4-4752-b821-ef3e2c8f6cb4",
"userId": "4874c17c-d991-496e-b29c-c7bdba87698d",
"gates": {
"kyc": { "completed": true, "description": "Passed KYC" },
"terms": { "completed": true, "description": "Accepted terms and conditions" },
"email-verify": { "completed": true, "description": "Email address verified" },
"phone-verify": { "completed": true, "description": "Phone number verified" },
"not-suspended": { "completed": true, "description": "Account is not suspended" }
},
"permissions": {
"market-order": { "granted": true, "description": "Submit market orders" },
"parlay": { "granted": true, "description": "Submit parlay orders" },
"deposit-aeropay": { "granted": true, "description": "Deposit via AeroPay" },
"withdraw-aeropay": { "granted": true, "description": "Withdraw via AeroPay" },
"deposit-pnm": { "granted": true, "description": "Deposit via PayNearMe" },
"withdraw-pnm": { "granted": true, "description": "Withdraw via PayNearMe" },
"deposit-zerohash": { "granted": false, "denyReason": "Blocked in NY", "description": "Deposit via ZeroHash" },
"withdraw-zerohash": { "granted": false, "denyReason": "Blocked in NY", "description": "Withdraw via ZeroHash" }
}
}There are five gates: kyc, terms, email-verify, phone-verify, not-suspended. Geofencing is handled separately from this gate list — it's its own dedicated onGeofenceBlocked(stateCode, reason) callback / PROPHETX_GEOFENCE_BLOCKED event on every flow except 1-Click Signup, not a pending_gates entry. In the current architecture geo-eligibility is enforced when your backend mints the embed token rather than per-request, so it shouldn't fire in normal operation with a validly-issued token — but wire a handler for it defensively, since the SDK defines and can deliver it.
KYC is not a special case. Unlike a previous version of this page,
gates.kyc.completedis not alwaystruejust because the user has a session token —kycis one of the required gates for every deposit and withdraw permission, exactly liketermsornot-suspended. There is no separate withdrawal-only KYC gate; the mechanism below covers deposits and withdrawals identically.
When any required gate for an action is unmet, the call fails with a single generic code — PERMISSION_DENIED (HTTP 403) — carrying a pending_gates array that names which gates are unsatisfied:
{
"code": "PERMISSION_DENIED",
"permission": "withdraw-aeropay",
"error": "Access denied: insufficient permissions",
"pending_gates": ["kyc", "terms"]
}There is no distinct KYC_REQUIRED, TERMS_NOT_SIGNED, EMAIL_NOT_VERIFIED, or PHONE_NOT_VERIFIED code on the wire — those were speculative names for what is, in reality, one code with a pending_gates list. The SDK's onError callback surfaces this as PERMISSION_DENIED, with message carrying the error string; inspect pending_gates (exposed by the SDK alongside code/message where available) to decide which flow to route the user through:
pending_gates entry | Route the user to |
|---|---|
kyc | useKyc/useIdUpload (document verification) — a session token means the user already exists, so a missing kyc gate here means re-verification after an earlier FAILURE, not new-user signup. See Verified Modal. |
terms | No SDK hook — host /tandc yourself in a raw iframe and drive the postMessage handshake directly. See Terms flow. |
email-verify | No embedded flow today — verification is handled by your registration system or an out-of-band email link |
phone-verify | No embedded flow today — handle via your own OTP UI |
not-suspended | Not user-resolvable — the account is suspended; direct the user to support |
Best practice: check gates and permissions from the validate response (or the token payload) before opening a deposit/withdraw flow, so you can route the user proactively instead of reacting to a PERMISSION_DENIED mid-flow.
import { useIdUpload } from "@prophetx/sdk-react";
function DepositFlow() {
const { open: openDeposit } = useDeposit();
const { open: openIdUpload } = useIdUpload(); // or useKyc — same flow
const handleError = (code, message, pendingGates) => {
if (code === "PERMISSION_DENIED" && pendingGates?.includes("terms")) {
// No useTerms hook — navigate to (or iframe) your own /tandc page.
openTandcIframe({ onAccepted: () => openDeposit({ onError: handleError }) });
} else if (code === "PERMISSION_DENIED" && pendingGates?.includes("kyc")) {
// A session token means this user already exists — this is
// re-verification, not new-user signup.
openIdUpload({ onKycSuccess: () => openDeposit({ onError: handleError }) });
}
};
return <button onClick={() => openDeposit({ onError: handleError })}>Deposit</button>;
}Provider-specific denials. A granted: false permission with a denyReason (e.g. deposit-zerohash blocked in NY) is a separate case from a gate-driven denial — the embed transparently falls back to a permitted provider when one is available, so you generally don't need to intervene unless every provider for that action is denied. Display the denyReason when that happens.
KYC_REQUIRED on withdraw, and a second GEOFENCE_BLOCKED channel
KYC_REQUIRED on withdraw, and a second GEOFENCE_BLOCKED channelUnresolved conflict between our two source repos — verify against the live API before relying on this. The frontend SDK's withdraw flow contains real, active code that reads a
403 { code: "KYC_REQUIRED" }response and surfaces it viaonError("KYC_REQUIRED", message). But the stringKYC_REQUIREDdoes not appear anywhere in the isv-backend repository, including its full commit history — no route, handler, or plugin in that repo emits it. Either isv-backend's withdraw-permission check used to return this and was refactored to the genericPERMISSION_DENIED/pending_gatesmodel without the frontend catching up, or it's emitted by something outside both repos. Don't build against this without confirming which is true today.
If you do see it: onError("KYC_REQUIRED", message) on withdraw only means the user hasn't passed identity verification. It's non-retryable inside the flow — route to useKyc/useIdUpload, then let the user retry the withdrawal once verified.
Separately (and not in conflict — this one's fully confirmed in the frontend source): GEOFENCE_BLOCKED is delivered through two channels, not just the dedicated callback described above:
onGeofenceBlocked(stateCode, reason)/PROPHETX_GEOFENCE_BLOCKED— checked when the flow opens.onError("GEOFENCE_BLOCKED", message)— checked again at submit time, since a user can change location mid-session. Deduplicate on your side if you're listening for both; a block during withdraw fires this and thenonCancelwhen the user closes the resulting screen.
Both channels ultimately depend on the same gate-derivation logic, which — per the frontend's own code comment — doesn't currently apply to embed tokens (geo is enforced when your backend mints the token instead). So in practice neither channel should fire with a validly-issued token, but wire handlers for both defensively.
Deposit and withdrawal provider errors
These errors fire through the onError callback when a deposit or withdrawal fails at the payment-provider stage. AeroPay (deposit + withdraw) and ZeroHash (deposit only) have a shipped ProphetX-hosted embed flow today. PayNearMe is fully supported at the API level (and these codes are real if you're calling its endpoints directly), but has no shipped embed UI yet — a token granting deposit-pnm/withdraw-pnm in the ProphetX-hosted embed currently shows an "isn't available yet" placeholder instead. Trustly integration code exists in the backend but is not wired to any live endpoint at all, so there is no TRUSTLY_* code to handle.
| Code | Provider | Retryable | Cause |
|---|---|---|---|
DEPOSIT_FAILED | Any | Yes | Generic fallback — the deposit API returned an error not covered below |
WITHDRAWAL_FAILED | Any | Yes | Generic fallback — the withdrawal API returned an error not covered below |
amount_mismatch | All | No | The amount for this transactionId doesn't match a prior request — mint a new transactionId and resubmit |
insufficient_funds | All | No | Wallet balance is too low for the requested withdrawal |
aeropay_rejected | AeroPay | Yes | AeroPay rejected the deposit or withdrawal request |
user_unconfirmed | AeroPay | No | The user hasn't completed AeroPay's SMS MFA confirmation yet — see Embedded UI |
link_failed | AeroPay | Yes | Bank-linking via Aerosync failed |
mfa_rejected | AeroPay | Yes | The SMS MFA code was rejected |
no_payment_method | PayNearMe | No | The recipient hasn't added a payout method yet — the flow can't be executed until they do |
not_staged | PayNearMe | No | execute was called before create_pnm_withdrawal staged a transaction |
payment_method_ambiguous | PayNearMe | No | More than one payout method is on file and the choice is ambiguous |
pnm_rejected | PayNearMe | Yes | PayNearMe declined the payment |
invalid_amount | PayNearMe, ZeroHash | No | Amount is not a positive decimal with at most 2 places |
participant_unconfirmed | ZeroHash | No | The user hasn't completed ZeroHash participant onboarding yet |
missing_user_profile | AeroPay, ZeroHash | No | No provider profile exists yet for this user — trigger the provider's onboarding step |
This is a representative list, not exhaustive — see each provider's section in Embedded UI for the full picture, and treat any code not listed here as covered by the DEPOSIT_FAILED / WITHDRAWAL_FAILED fallback.
DEPOSIT_FAILED / WITHDRAWAL_FAILED
DEPOSIT_FAILED / WITHDRAWAL_FAILEDThe backend rejected the request with a code not specifically handled by your integration yet — the message contains the reason.
What to do: Display the message to the user. The embed already shows an error UI with a retry option, so you don't need to close the modal. Log the code and message for debugging.
const { open } = useDeposit();
open({
onError: (code, message) => {
analytics.track("deposit_error", { code, message });
},
});PayNearMe withdrawals: staged, then executed
Unlike AeroPay and ZeroHash, a PayNearMe withdrawal doesn't debit the wallet at creation time — your backend must call execute_pnm_withdraw(transactionId) once the widget reports the recipient picked a payout method (see Embedded UI). A not_staged error means execute was called out of order; no_payment_method means the recipient hasn't finished picking a payout method yet — neither is a hard failure the user needs to retry, they're sequencing signals for your backend.
Runtime errors
| Code | Retryable | Cause |
|---|---|---|
UNCAUGHT_ERROR | Yes | An unexpected runtime error inside the embed |
UNCAUGHT_ERROR
UNCAUGHT_ERRORA catch-all for unhandled exceptions in the embed's React error boundary. This should be rare — it indicates a bug in the embed itself, not a user or integration problem.
What to do: Log the message for debugging and ask the user to retry. If the error persists, contact ProphetX support with the error details.
Network-level errors
These errors come from the SDK's HTTP client layer when requests to the ProphetX API fail at the network level. They appear in the onError callback but originate before any business logic runs.
| Code | Retryable | Cause |
|---|---|---|
TIMEOUT | Yes | The API request exceeded the client's timeout threshold |
NETWORK_ERROR | Yes | The browser couldn't reach the API (DNS failure, offline, CORS block) |
TIMEOUT
TIMEOUTA request to the ProphetX API didn't receive a response within the SDK's client-side timeout.
What to do: The user can retry. If timeouts are frequent, check the user's network connection.
NETWORK_ERROR
NETWORK_ERRORThe browser failed to establish a connection. Common causes: the user is offline, a corporate proxy is blocking the request, or there's a DNS resolution failure.
What to do: Suggest the user check their internet connection and retry. If you're seeing this consistently in development, verify that your CSP (Content Security Policy) headers allow connections to the ProphetX API domain.
Complete error handling example
A single handler that covers all error codes across deposit and withdrawal flows:
function useFlowErrorHandler() {
const { open: openIdUpload } = useIdUpload(); // or useKyc — same flow
return useCallback((code, message, pendingGates) => {
switch (code) {
// Session — refresh or re-authenticate
case "SESSION_EXPIRED":
// Handled by onSessionExpired on the provider — usually no action needed here
break;
case "SESSION_INVALID":
case "ORIGIN_UNAUTHORIZED":
console.error(`Session error [${code}]:`, message);
showContactSupport();
break;
// Gate/permission denial — route based on pending_gates
case "PERMISSION_DENIED":
if (pendingGates?.includes("terms")) {
// No useTerms hook — navigate to (or iframe) your own /tandc page.
openTandcIframe({ onAccepted: () => showRetryPrompt() });
} else if (pendingGates?.includes("kyc")) {
// A session token means this user already exists — this is
// re-verification, not new-user signup.
openIdUpload({ onKycSuccess: () => showRetryPrompt() });
} else if (pendingGates?.includes("email-verify")) {
showEmailVerificationPrompt(message);
} else if (pendingGates?.includes("phone-verify")) {
showPhoneVerificationPrompt(message);
} else {
// Provider-specific denial (denyReason) or account suspension
showActionDenied(message);
}
break;
// Deposits / withdrawals — embed shows retry UI, just log
case "DEPOSIT_FAILED":
case "WITHDRAWAL_FAILED":
analytics.track("payment_error", { code, message });
break;
// Network — suggest retry
case "TIMEOUT":
case "NETWORK_ERROR":
showNetworkError();
break;
// Catch-all
case "UNCAUGHT_ERROR":
default:
console.error(`Unexpected error [${code}]:`, message);
showGenericError();
break;
}
}, [openIdUpload]);
}
// Helper UI functions are partner-defined: showEmailVerificationPrompt(),
// showPhoneVerificationPrompt(), showActionDenied(), etc.Quick reference
Every error code on one page, sorted by category.
| Code | Category | Retryable | Summary |
|---|---|---|---|
SESSION_EXPIRED | Session | Yes | Token expired — refresh it |
SESSION_INVALID | Session | No | Bad JWT — check signing implementation |
ORIGIN_UNAUTHORIZED | Session | No | Origin not registered — contact ProphetX |
PERMISSION_DENIED | Gate/Permission | Varies | A required gate is unmet (see pending_gates) or a provider permission is denied (see denyReason) |
GEOFENCE_BLOCKED | Geofence | No | Two delivery channels: the dedicated onGeofenceBlocked(stateCode, reason) callback (at open) and onError("GEOFENCE_BLOCKED", message) (rechecked at submit). Real in both forms, but shouldn't fire in normal operation — geo is enforced at token issuance. |
KYC_REQUIRED | Withdrawal | No | ⚠️ Real in the frontend SDK's withdraw flow, but unconfirmed against isv-backend (not found anywhere in that repo) — see the caveat in User prerequisite and permission errors before relying on it. |
DEPOSIT_FAILED | Deposit | Yes | Generic deposit fallback — user can retry |
WITHDRAWAL_FAILED | Withdrawal | Yes | Generic withdrawal fallback — user can retry |
Provider codes (amount_mismatch, aeropay_rejected, pnm_rejected, etc.) | Deposit/Withdrawal | Varies | Provider-specific — see Deposit and withdrawal provider errors |
TIMEOUT | Network | Yes | Client-side request timeout |
NETWORK_ERROR | Network | Yes | Can't reach API — check connection |
UNCAUGHT_ERROR | Runtime | Yes | Unexpected embed error — retry or contact support |
Updated 15 days ago
