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.

triangle-exclamation

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 a POST /api/v1/session/validate endpoint) did not correspond to anything in the backend and have been replaced below with the real PERMISSION_DENIED / pending_gates mechanism (see User prerequisite and permission errors). GEOFENCE_BLOCKED is real — an earlier correction pass on this page removed it in error; it's back below. useOnboarding/openOnboarding is also gone from the examples: that flow is permanently disabled in the current embed. New users go through useOneClickSignup; existing users needing re-verification go through useKyc/useIdUpload.

How errors are delivered

ChannelWhenWhat you receive
onError(code, message)A deposit, withdrawal, or session operation failsA machine-readable code and a human-readable message safe to display
onGeofenceBlocked(stateCode, reason) / PROPHETX_GEOFENCE_BLOCKEDUser's location isn't eligibleReal, 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_EXPIREDThe JWT's exp claim has passedA 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 against isv-backend. SESSION_EXPIRED, SESSION_INVALID, ORIGIN_UNAUTHORIZED, TIMEOUT, NETWORK_ERROR, and UNCAUGHT_ERROR are 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) => void

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

CodeHTTPRetryableCause
SESSION_EXPIRED—YesThe embed token's exp claim is in the past
SESSION_INVALID401NoMalformed JWT, bad signature, wrong issuer, or server-side rejection
ORIGIN_UNAUTHORIZED403NoYour page's origin is not in your partner-registered allowlist

SESSION_EXPIRED

Embed 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

The 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 EdDSA algorithm with an Ed25519 private key
  • Confirm the iss claim matches your ISV ID
  • Confirm the sub claim is a valid ProphetX user ID
  • Ensure the token hasn't been tampered with after signing

ORIGIN_UNAUTHORIZED

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

circle-info

KYC is not a special case. Unlike a previous version of this page, gates.kyc.completed is not always true just because the user has a session token — kyc is one of the required gates for every deposit and withdraw permission, exactly like terms or not-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 entryRoute the user to
kycuseKyc/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.
termsNo SDK hook — host /tandc yourself in a raw iframe and drive the postMessage handshake directly. See Terms flow.
email-verifyNo embedded flow today — verification is handled by your registration system or an out-of-band email link
phone-verifyNo embedded flow today — handle via your own OTP UI
not-suspendedNot 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

triangle-exclamation

Unresolved 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 via onError("KYC_REQUIRED", message). But the string KYC_REQUIRED does 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 generic PERMISSION_DENIED/pending_gates model 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:

  1. onGeofenceBlocked(stateCode, reason) / PROPHETX_GEOFENCE_BLOCKED — checked when the flow opens.
  2. 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 then onCancel when 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.

CodeProviderRetryableCause
DEPOSIT_FAILEDAnyYesGeneric fallback — the deposit API returned an error not covered below
WITHDRAWAL_FAILEDAnyYesGeneric fallback — the withdrawal API returned an error not covered below
amount_mismatchAllNoThe amount for this transactionId doesn't match a prior request — mint a new transactionId and resubmit
insufficient_fundsAllNoWallet balance is too low for the requested withdrawal
aeropay_rejectedAeroPayYesAeroPay rejected the deposit or withdrawal request
user_unconfirmedAeroPayNoThe user hasn't completed AeroPay's SMS MFA confirmation yet — see Embedded UI
link_failedAeroPayYesBank-linking via Aerosync failed
mfa_rejectedAeroPayYesThe SMS MFA code was rejected
no_payment_methodPayNearMeNoThe recipient hasn't added a payout method yet — the flow can't be executed until they do
not_stagedPayNearMeNoexecute was called before create_pnm_withdrawal staged a transaction
payment_method_ambiguousPayNearMeNoMore than one payout method is on file and the choice is ambiguous
pnm_rejectedPayNearMeYesPayNearMe declined the payment
invalid_amountPayNearMe, ZeroHashNoAmount is not a positive decimal with at most 2 places
participant_unconfirmedZeroHashNoThe user hasn't completed ZeroHash participant onboarding yet
missing_user_profileAeroPay, ZeroHashNoNo 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

The 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

CodeRetryableCause
UNCAUGHT_ERRORYesAn unexpected runtime error inside the embed

UNCAUGHT_ERROR

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

CodeRetryableCause
TIMEOUTYesThe API request exceeded the client's timeout threshold
NETWORK_ERRORYesThe browser couldn't reach the API (DNS failure, offline, CORS block)

TIMEOUT

A 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

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

CodeCategoryRetryableSummary
SESSION_EXPIREDSessionYesToken expired — refresh it
SESSION_INVALIDSessionNoBad JWT — check signing implementation
ORIGIN_UNAUTHORIZEDSessionNoOrigin not registered — contact ProphetX
PERMISSION_DENIEDGate/PermissionVariesA required gate is unmet (see pending_gates) or a provider permission is denied (see denyReason)
GEOFENCE_BLOCKEDGeofenceNoTwo 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_REQUIREDWithdrawalNo⚠️ 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_FAILEDDepositYesGeneric deposit fallback — user can retry
WITHDRAWAL_FAILEDWithdrawalYesGeneric withdrawal fallback — user can retry
Provider codes (amount_mismatch, aeropay_rejected, pnm_rejected, etc.)Deposit/WithdrawalVariesProvider-specific — see Deposit and withdrawal provider errors
TIMEOUTNetworkYesClient-side request timeout
NETWORK_ERRORNetworkYesCan't reach API — check connection
UNCAUGHT_ERRORRuntimeYesUnexpected embed error — retry or contact support


Did this page help you?