# Apple Pay deposit via the Payment Widget Source: https://developer.uphold.com/developer-guides/apm-transfers/deposit/via-payment-widget/apple-pay Fund a user's account with Apple Pay using the Uphold Payment Widget. The Payment Widget handles Apple Pay selection for deposits via the **Select for Deposit flow**, where users can select Apple Pay as the payment method. After the user confirms an Apple Pay deposit quote, the Payment Widget completes the Apple Pay authorization and creates the transaction via the **Authorize flow**. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview). * The `apple-pay` capability is enabled. * The Payment Widget is [set up in your frontend](/widgets/payment/installation-and-setup). * [Apple Pay's additional setup requirements](/developer-guides/apm-transfers/overview#apple-pay) are met. * The target browser or platform [supports Apple Pay](/developer-guides/apm-transfers/overview#browser-and-platform-compatibility-payment-widget). ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold Usr->>U: Start deposit U->>B: Create widget session B->>A: Create widget session (select-for-deposit) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select Apple Pay P-->>U: complete { via, selection } U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose destination account and amount U->>B: Create quote B->>A: Create quote (APM → account) A-->>B: { quote } B-->>U: { quote } U->>B: Create authorize session B->>A: Create widget session (authorize) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Authorize with Apple Pay sheet P->>A: Create transaction A-->>P: { transaction } P-->>U: complete { transaction, trigger } B-->>Usr: Notify the user ``` *** ## Select deposit method The Payment Widget's **Select for Deposit flow** presents the available payment methods, letting the user select Apple Pay when it's available. It performs **selection only**: it does not create the quote or the transaction. ### Create a widget session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) to start the `select-for-deposit` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-deposit" } ``` ```json theme={null} { "session": { "flow": "select-for-deposit", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` Pass `response.session` to your frontend to initialize the widget. ### Set up the widget ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeDepositWidget = async (session) => { const widget = new PaymentWidget<'select-for-deposit'>(session, { debug: true }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event fires after the user selects Apple Pay. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; // If via is 'apm', you can check the selected method through the property `selection.method`, which will be 'apple-pay' in this case. if (via === 'apm' && selection.method === 'apple-pay') { handleApplePaySelected(); } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; // If via is 'apm', you can check the selected method through the property `selection.method`, which will be 'apple-pay' in this case. if (via === 'apm' && selection.method === 'apple-pay') { handleApplePaySelected(); } teardown(); break; } ``` Once you have the selection, prompt the user to choose a destination account, then create a quote. ### Handle cancellations ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Select destination account deposits can target any account. If the selected account is not in the account's currency, the amount will be converted at settlement using Uphold's prevailing rate. Make sure the destination asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). ### Find an existing account Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one they want to fund. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ### Create a new account If the user has no accounts, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My USD account", "asset": "USD" } ``` ```json theme={null} { "account": { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "0", "available": "0" } } } ``` Once the user selects Apple Pay and their account, proceed to [Create a quote](#create-a-quote). *** ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with Apple Pay as the origin and the user's account as the destination. Specify Apple Pay as the origin with `type: "apm"` with `method: "apple-pay"`. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "apm", "method": "apple-pay" }, "destination": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d" }, "denomination": { "asset": "USD", "amount": "50", "target": "origin" } } ``` A successful response includes the quote details and a `requirements` array. For Apple Pay, it contains `authorize:apple-pay`, which indicates that the user must authorize Apple Pay before the transaction can be created. ```json [expandable] theme={null} { "quote": { "id": "c3e8d2f7-9a41-4b75-b8e3-1d6f4a9c2e57", "origin": { "amount": "50.00", "asset": "USD", "node": { "type": "apm", "method": "apple-pay" }, "rate": "1" }, "destination": { "amount": "50.00", "asset": "USD", "node": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "50.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2025-06-18T01:55:39Z", "requirements": [ "authorize:apple-pay" ] } } ``` *** ## Authorize and create the transaction After the user confirms the Apple Pay quote, hand off to the Payment Widget **Authorize flow**. It presents the Apple Pay sheet, creates the transaction, and polls until a terminal status is reached. Apple Pay authorizes **per transaction** on the user's device — there is no stored authorization to reuse, so the user confirms with Face ID, Touch ID, or their passcode every time. By default, the widget renders a full walkthrough around the authorization; in **headless mode**, it renders only the Apple Pay button so you can embed it directly into your own UI — see [Headless mode](#headless-mode) below. ### Create an authorize session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) with `flow: "authorize"`, the `quoteId` and with the `requirements` array containing `authorize:apple-pay`. For web apps, also include the top-page `domain`. The top-page domain is the domain shown in the browser's URL bar — the very top-level page hosting the widget iframe. For web apps, it must match the domain you registered with Apple Pay for your merchant ID. ```http theme={null} POST /widgets/payment/sessions { "flow": "authorize", "data": { "quoteId": "", "requirements": [ "authorize:apple-pay" ], "domain": "" // For web apps only, required for Apple Pay } } ``` ```json theme={null} { "session": { "flow": "authorize", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL", "data": { "quoteId": "", "requirements": [ "authorize:apple-pay" ], "domain": "" // For web apps only, required for Apple Pay } } } ``` ### Set up the widget Initialize the widget with the session. The widget presents the sheet, collects device data, creates the transaction, and polls until a terminal status is reached. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeAuthorizeWidget = async (session) => { const widget = new PaymentWidget<'authorize'>(session, { debug: true }); widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; console.log('Complete', transaction.status, trigger.reason); widget.unmount(); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```javascript JavaScript [expandable] theme={null} // session is `response.session` from your backend async function initializeAuthorizeWidget(session) { const container = document.getElementById('payment-container'); const sessionOrigin = new URL(session.url).origin; const iframe = document.createElement('iframe'); iframe.src = session.url; iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src'; payment 'src';"); iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; function teardown() { window.removeEventListener('message', onMessage); iframe.remove(); } function onMessage(event) { if (event.origin !== sessionOrigin) return; switch (event.data?.type) { case 'load': iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin); break; case 'complete': { const { transaction, trigger } = event.data.value; console.log('Complete', transaction.status, trigger.reason); teardown(); break; } case 'cancel': console.log('Cancelled'); teardown(); break; case 'error': console.error('Error', event.data.error); teardown(); break; } } window.addEventListener('message', onMessage); container.appendChild(iframe); } ``` Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Headless mode By default, the widget renders a full authorization experience — a short walkthrough, the button, and a processing screen while the transaction is confirmed. In **headless mode**, it renders only the button, with no surrounding UI, so you can embed it directly into your own layout. Your app then owns the surrounding context and the post-authorization experience — progress and success feedback, and a way to cancel. ```javascript Web SDK theme={null} const widget = new PaymentWidget<'authorize'>(session, { debug: true, authorize: { mode: 'headless' } }); ``` ```javascript JavaScript theme={null} iframe.contentWindow.postMessage({ ...session, options: { authorize: { mode: 'headless' } }, type: 'init' }, sessionOrigin); ``` See [`AuthorizeFlowOptions`](/widgets/payment/sdk-reference#authorizeflowoptions) in the SDK reference for the full type definition. ### Handle the complete event The `complete` event does not guarantee success. Always check `transaction.status` and `trigger.reason`. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { transaction, trigger } = event.data.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } teardown(); break; } ``` Failure reasons in `transaction.statusDetails.reason`: | Reason | Description | | ----------------------------------- | ------------------------------------------------ | | `apm-authorization-failed` | The authorization could not be completed | | `card-declined-by-bank` | The card was declined by the issuing bank | | `card-expired` | The card has expired | | `card-permanently-declined-by-bank` | The card was permanently declined | | `card-unauthorized` | The card authorization was not completed | | `card-unsupported` | The card is not supported for this operation | | `insufficient-funds` | The origin account has insufficient funds | | `provider-maximum-limit-exceeded` | The transaction exceeds provider limits | | `velocity` | The transaction was blocked by velocity rules | | `unspecified-error` | The transaction failed for an unspecified reason | ### Handle cancellations The `cancel` event fires when the user dismisses the sheet or navigates back without completing authorization. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors — except `authorize_transaction_failed`, which is retryable **in headless mode**: the widget resets itself back to its ready state, so the button works again if you leave the widget mounted. In default mode there is no in-place retry for this code — the button does not become usable again, so unmount and create a new authorize session if you want the user to try again. `authorize_transaction_failed` is the retryable code for authorization and transaction-creation failures. `event.detail.error.details.reason` narrows down where it happened:
Reason Description
unable-to-create-payment-request The browser could not build the payment request
unable-to-make-payment The device or browser reported it cannot make this payment
unable-to-show-payment-request The sheet could not be shown
invalid-payment-response The sheet returned a response without the expected payment token
unable-to-create-transaction authorization succeeded, but the transaction itself could not be created — see event.detail.error.cause below for the underlying reason
Other failures in this flow — such as a missing or expired quote, or the SDK being unavailable — surface with their own `code` and are not retryable; handle those with a generic fallback. As with any Widget error, the original failure is preserved in [`event.detail.error.cause`](/widgets/payment/sdk-reference#error). When `details.reason` is `unable-to-create-transaction`, `event.detail.error.cause.code` is one of: | Code | Description | | ------------------------- | --------------------------------------------------------- | | `entity_not_found` | The quote was not found or has expired | | `insufficient_balance` | The origin account has insufficient balance | | `operation_not_allowed` | The operation is not permitted | | `user_capability_failure` | The user lacks the required capability for this operation | ```javascript Web SDK theme={null} // Set this to match how you configured the widget — see Headless mode above const isAuthorizeHeadless = false; widget.on('error', (event) => { const { code, cause, details } = event.detail.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed') { if (details.reason === 'unable-to-create-transaction') { // The real reason is nested here, not in the top-level `code` console.error('Create transaction failed:', cause?.code); } if (isAuthorizeHeadless) { // Retryable in headless mode — keep the widget mounted if you want the user to try again return; } // In default mode there is no in-place retry for this code — fall through and unmount } widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget. `isAuthorizeHeadless` is declared once // outside the handler (alongside `sessionOrigin`), matching how you configured the widget. case 'error': { const { code, cause, details } = event.data.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed') { if (details.reason === 'unable-to-create-transaction') { console.error('Create transaction failed:', cause?.code); } if (isAuthorizeHeadless) { // Retryable in headless mode — keep the iframe/WebView mounted so the user can try again break; } // In default mode there is no in-place retry for this code — fall through and teardown } teardown(); // Show a user-friendly error message break; } ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Monitor for settlement transactions remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) *** ## Sample transaction In a successful deposit, the origin is represented as an `apm` node with the method as `{apmMethod}`. The destination is the user's `account`. ```json [expandable] theme={null} { "transaction": { "id": "9b2f4a17-5e3c-4d77-a8e1-9bcdef2c0a42", "origin": { "asset": "USD", "amount": "50.00", "node": { "type": "apm", "method": "apple-pay" } }, "destination": { "asset": "USD", "amount": "50.00", "node": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "status": "completed", "quotedAt": "2025-06-18T00:55:39Z", "createdAt": "2025-06-18T00:56:39Z", "updatedAt": "2025-06-18T00:57:08Z", "denomination": { "asset": "USD", "amount": "50.00", "target": "origin" } } } ``` *** ## Notify the user After the transaction completes, display the transaction details to the user so they can confirm the deposit succeeded. Here's an example:
Apple Pay transaction completed confirmation
*** ## Troubleshooting Having issues rendering Apple Pay? See [Troubleshooting](/widgets/payment/installation-and-setup#troubleshooting) in the Payment Widget installation guide. *** You now support Apple Pay deposits via the Payment Widget. # Google Pay deposit via the Payment Widget Source: https://developer.uphold.com/developer-guides/apm-transfers/deposit/via-payment-widget/google-pay Fund a user's account with Google Pay using the Uphold Payment Widget. The Payment Widget handles Google Pay selection for deposits via the **Select for Deposit flow**, where users can select Google Pay as the payment method. After the user confirms a Google Pay deposit quote, the Payment Widget completes the Google Pay authorization and creates the transaction via the **Authorize flow**. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview). * The `google-pay` capability is enabled. * The Payment Widget is [set up in your frontend](/widgets/payment/installation-and-setup). * [Google Pay's additional setup requirements](/developer-guides/apm-transfers/overview#browser-and-platform-compatibility-payment-widget) are met. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold Usr->>U: Start deposit U->>B: Create widget session B->>A: Create widget session (select-for-deposit) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select Google Pay P-->>U: complete { via, selection } U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose destination account and amount U->>B: Create quote B->>A: Create quote (APM → account) A-->>B: { quote } B-->>U: { quote } U->>B: Create authorize session B->>A: Create widget session (authorize) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Authorize with Google Pay sheet P->>A: Create transaction A-->>P: { transaction (with confirmationUrl if authorization required) } opt confirmationUrl present Usr->>P: Complete authorization challenge end P-->>U: complete { transaction, trigger } B-->>Usr: Notify the user ``` *** ## Select deposit method The Payment Widget's **Select for Deposit flow** presents the available payment methods, letting the user select Google Pay when it's available. It performs **selection only**: it does not create the quote or the transaction. ### Create a widget session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) to start the `select-for-deposit` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-deposit" } ``` ```json theme={null} { "session": { "flow": "select-for-deposit", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` Pass `response.session` to your frontend to initialize the widget. ### Set up the widget ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeDepositWidget = async (session) => { const widget = new PaymentWidget<'select-for-deposit'>(session, { debug: true }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event fires after the user selects Google Pay. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; // If via is 'apm', you can check the selected method through the property `selection.method`, which will be 'google-pay' in this case. if (via === 'apm' && selection.method === 'google-pay') { handleGooglePaySelected(); } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; // If via is 'apm', you can check the selected method through the property `selection.method`, which will be 'google-pay' in this case. if (via === 'apm' && selection.method === 'google-pay') { handleGooglePaySelected(); } teardown(); break; } ``` Once you have the selection, prompt the user to choose a destination account, then create a quote. ### Handle cancellations ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Select destination account deposits can target any account. If the selected account is not in the account's currency, the amount will be converted at settlement using Uphold's prevailing rate. Make sure the destination asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). ### Find an existing account Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one they want to fund. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ### Create a new account If the user has no accounts, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My USD account", "asset": "USD" } ``` ```json theme={null} { "account": { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "0", "available": "0" } } } ``` Once the user selects Google Pay and their account, proceed to [Create a quote](#create-a-quote). *** ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with Google Pay as the origin and the user's account as the destination. Specify Google Pay as the origin with `type: "apm"` with `method: "google-pay"`. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "apm", "method": "google-pay" }, "destination": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d" }, "denomination": { "asset": "USD", "amount": "50", "target": "origin" } } ``` A successful response includes the quote details and a `requirements` array. For Google Pay, it contains `authorize:google-pay`, which indicates that the user must authorize Google Pay before the transaction can be created. ```json [expandable] theme={null} { "quote": { "id": "c3e8d2f7-9a41-4b75-b8e3-1d6f4a9c2e57", "origin": { "amount": "50.00", "asset": "USD", "node": { "type": "apm", "method": "google-pay" }, "rate": "1" }, "destination": { "amount": "50.00", "asset": "USD", "node": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "50.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2025-06-18T01:55:39Z", "requirements": [ "authorize:google-pay" ] } } ``` *** ## Authorize and create the transaction After the user confirms the Google Pay quote, hand off to the Payment Widget **Authorize flow**. It presents the Google Pay sheet, creates the transaction, and polls until a terminal status is reached. Google Pay authorizes **per transaction** — there is no stored authorization to reuse, so the user confirms via the Google Pay sheet every time. By default, the widget renders a full walkthrough around the authorization; in **headless mode**, it renders only the Google Pay button so you can embed it directly into your own UI — see [Headless mode](#headless-mode) below. ### Authorization challenges Some deposits take one more step after the user confirms the Google Pay sheet: the card backing the wallet may also need to be authorized, through a challenge such as 3DS. The card's issuer decides this per transaction, so it isn't known until the transaction is created. The Authorize flow covers this: when a challenge is required, the widget presents it to the user, and only polls for a terminal status once it's resolved. The same integration works whether or not a challenge comes up — there is nothing extra to handle on your side, beyond expecting those deposits to take longer to complete. **Headless mode** is only supported on **Android** devices and cards that support `CRYPTOGRAM_3DS`. Cards requiring 3D Secure (3DS) authentication are not supported in headless mode — see [Headless mode](#headless-mode) below. ### Create an authorize session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) with `flow: "authorize"`, the `quoteId` and with the `requirements` array containing `authorize:google-pay`. ```http theme={null} POST /widgets/payment/sessions { "flow": "authorize", "data": { "quoteId": "", "requirements": [ "authorize:google-pay" ] } } ``` ```json theme={null} { "session": { "flow": "authorize", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL", "data": { "quoteId": "", "requirements": [ "authorize:google-pay" ] } } } ``` ### Set up the widget Initialize the widget with the session. The widget presents the sheet, collects device data, creates the transaction, and polls until a terminal status is reached. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeAuthorizeWidget = async (session) => { const widget = new PaymentWidget<'authorize'>(session, { debug: true }); widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; console.log('Complete', transaction.status, trigger.reason); widget.unmount(); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```javascript JavaScript [expandable] theme={null} // session is `response.session` from your backend async function initializeAuthorizeWidget(session) { const container = document.getElementById('payment-container'); const sessionOrigin = new URL(session.url).origin; const iframe = document.createElement('iframe'); iframe.src = session.url; iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src'; payment 'src';"); iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; function teardown() { window.removeEventListener('message', onMessage); iframe.remove(); } function onMessage(event) { if (event.origin !== sessionOrigin) return; switch (event.data?.type) { case 'load': iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin); break; case 'complete': { const { transaction, trigger } = event.data.value; console.log('Complete', transaction.status, trigger.reason); teardown(); break; } case 'cancel': console.log('Cancelled'); teardown(); break; case 'error': console.error('Error', event.data.error); teardown(); break; } } window.addEventListener('message', onMessage); container.appendChild(iframe); } ``` Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Headless mode By default, the widget renders a full authorization experience — a short walkthrough, the button, and a processing screen while the transaction is confirmed. In **headless mode**, it renders only the button, with no surrounding UI, so you can embed it directly into your own layout. Your app then owns the surrounding context and the post-authorization experience — progress and success feedback, and a way to cancel. ```javascript Web SDK theme={null} const widget = new PaymentWidget<'authorize'>(session, { debug: true, authorize: { mode: 'headless' } }); ``` ```javascript JavaScript theme={null} iframe.contentWindow.postMessage({ ...session, options: { authorize: { mode: 'headless' } }, type: 'init' }, sessionOrigin); ``` See [`AuthorizeFlowOptions`](/widgets/payment/sdk-reference#authorizeflowoptions) in the SDK reference for the full type definition. ### Handle the complete event The `complete` event does not guarantee success. Always check `transaction.status` and `trigger.reason`. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { transaction, trigger } = event.data.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } teardown(); break; } ``` Failure reasons in `transaction.statusDetails.reason`: | Reason | Description | | ----------------------------------- | ------------------------------------------------ | | `apm-authorization-failed` | The authorization could not be completed | | `card-declined-by-bank` | The card was declined by the issuing bank | | `card-expired` | The card has expired | | `card-permanently-declined-by-bank` | The card was permanently declined | | `card-unauthorized` | The card authorization was not completed | | `card-unsupported` | The card is not supported for this operation | | `insufficient-funds` | The origin account has insufficient funds | | `provider-maximum-limit-exceeded` | The transaction exceeds provider limits | | `velocity` | The transaction was blocked by velocity rules | | `unspecified-error` | The transaction failed for an unspecified reason | ### Handle cancellations The `cancel` event fires when the user dismisses the sheet or navigates back without completing authorization. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors — except `authorize_transaction_failed`, which is retryable **in headless mode**: the widget resets itself back to its ready state, so the button works again if you leave the widget mounted. In default mode there is no in-place retry for this code — the button does not become usable again, so unmount and create a new authorize session if you want the user to try again. `authorize_transaction_failed` is the retryable code for authorization and transaction-creation failures. `event.detail.error.details.reason` narrows down where it happened:
Reason Description
unable-to-create-payment-request The browser could not build the payment request
unable-to-make-payment The device or browser reported it cannot make this payment
unable-to-show-payment-request The sheet could not be shown
invalid-payment-response The sheet returned a response without the expected payment token
unable-to-create-transaction authorization succeeded, but the transaction itself could not be created — see event.detail.error.cause below for the underlying reason
Other failures in this flow — such as a missing or expired quote, or the SDK being unavailable — surface with their own `code` and are not retryable; handle those with a generic fallback. As with any Widget error, the original failure is preserved in [`event.detail.error.cause`](/widgets/payment/sdk-reference#error). When `details.reason` is `unable-to-create-transaction`, `event.detail.error.cause.code` is one of: | Code | Description | | ------------------------- | --------------------------------------------------------- | | `entity_not_found` | The quote was not found or has expired | | `insufficient_balance` | The origin account has insufficient balance | | `operation_not_allowed` | The operation is not permitted | | `user_capability_failure` | The user lacks the required capability for this operation | ```javascript Web SDK theme={null} // Set this to match how you configured the widget — see Headless mode above const isAuthorizeHeadless = false; widget.on('error', (event) => { const { code, cause, details } = event.detail.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed') { if (details.reason === 'unable-to-create-transaction') { // The real reason is nested here, not in the top-level `code` console.error('Create transaction failed:', cause?.code); } if (isAuthorizeHeadless) { // Retryable in headless mode — keep the widget mounted if you want the user to try again return; } // In default mode there is no in-place retry for this code — fall through and unmount } widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget. `isAuthorizeHeadless` is declared once // outside the handler (alongside `sessionOrigin`), matching how you configured the widget. case 'error': { const { code, cause, details } = event.data.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed') { if (details.reason === 'unable-to-create-transaction') { console.error('Create transaction failed:', cause?.code); } if (isAuthorizeHeadless) { // Retryable in headless mode — keep the iframe/WebView mounted so the user can try again break; } // In default mode there is no in-place retry for this code — fall through and teardown } teardown(); // Show a user-friendly error message break; } ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Monitor for settlement transactions remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) *** ## Sample transaction In a successful deposit, the origin is represented as an `apm` node with the method as `{apmMethod}`. The destination is the user's `account`. ```json [expandable] theme={null} { "transaction": { "id": "9b2f4a17-5e3c-4d77-a8e1-9bcdef2c0a42", "origin": { "asset": "USD", "amount": "50.00", "node": { "type": "apm", "method": "google-pay" } }, "destination": { "asset": "USD", "amount": "50.00", "node": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "status": "completed", "quotedAt": "2025-06-18T00:55:39Z", "createdAt": "2025-06-18T00:56:39Z", "updatedAt": "2025-06-18T00:57:08Z", "denomination": { "asset": "USD", "amount": "50.00", "target": "origin" } } } ``` *** ## Notify the user After the transaction completes, display the transaction details to the user so they can confirm the deposit succeeded. Here's an example:
Google Pay transaction completed confirmation
*** ## Troubleshooting Having issues rendering Google Pay? See [Troubleshooting](/widgets/payment/installation-and-setup#troubleshooting) in the Payment Widget installation guide. *** You now support Google Pay deposits via the Payment Widget. # PayPal deposit via the Payment Widget Source: https://developer.uphold.com/developer-guides/apm-transfers/deposit/via-payment-widget/paypal Fund a user's account from PayPal using the Uphold Payment Widget. The Payment Widget handles PayPal selection for deposits via the **Select for Deposit flow**, where users can select or unlink a previously linked PayPal account directly in the widget. After the user confirms a PayPal deposit quote, the Payment Widget creates the transaction and completes the PayPal authorization — whether the account is new or already linked — via the **Authorize flow**. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview). * The `paypal` capability is enabled. * The Payment Widget is set up in your frontend. See [Installation and setup](/widgets/payment/installation-and-setup). ## Walkthrough The diagram shows a first-time authorization. For an already-authorized account, skip the sign-in — the widget only collects device data and reuses the stored authorization. ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold Usr->>U: Start deposit U->>B: Create widget session B->>A: Create widget session (select-for-deposit) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select PayPal P-->>U: complete { via, selection } U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose destination account and amount U->>B: Create quote B->>A: Create quote (APM → account) A-->>B: { quote } B-->>U: { quote } U->>B: Create authorize session B->>A: Create widget session (authorize) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Sign in & authorize (first time only) P->>A: Create transaction A-->>P: { transaction } P-->>U: complete { transaction, trigger } B-->>Usr: Notify the user ``` *** ## Select deposit method The Payment Widget's **Select for Deposit flow** presents the available payment methods, letting the user select PayPal when it's available. It performs **selection only**: it does not create the quote or the transaction. ### Create a widget session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) to start the `select-for-deposit` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-deposit" } ``` ```json theme={null} { "session": { "flow": "select-for-deposit", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` Pass `response.session` to your frontend to initialize the widget. ### Set up the widget ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeDepositWidget = async (session) => { const widget = new PaymentWidget<'select-for-deposit'>(session, { debug: true }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event fires after the user selects PayPal. The event payload includes the selected PayPal external account — if it has already been previously authorized. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; // If via is 'apm', you can check the selected method through the property `selection.method`, which will be 'paypal' in this case. if (via === 'apm' && selection.method === 'paypal') { handlePayPalSelected(); } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; // If via is 'apm', you can check the selected method through the property `selection.method`, which will be 'paypal' in this case. if (via === 'apm' && selection.method === 'paypal') { handlePayPalSelected(); } teardown(); break; } ``` Once you have the selection, prompt the user to choose a destination account, then create a quote. ### Handle cancellations ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Select destination account deposits can target any account. If the selected account is not in the account's currency, the amount will be converted at settlement using Uphold's prevailing rate. Make sure the destination asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). ### Find an existing account Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one they want to fund. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ### Create a new account If the user has no accounts, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My USD account", "asset": "USD" } ``` ```json theme={null} { "account": { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "0", "available": "0" } } } ``` Once the user selects PayPal and their account, proceed to [Create a quote](#create-a-quote). *** ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with PayPal as the origin and the user's account as the destination. There are two ways to specify the PayPal origin: * **`apm` shortcut** — use `type: "apm"` with `method: "paypal"`. This is always accepted, whether or not the user already has a linked PayPal account. * **`external-account` reference** — if the user already has a linked PayPal account, you can reference it directly with `type: "external-account"` and its `id` (from [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts)): ```json theme={null} { "origin": { "type": "external-account", "id": "9d3cce5f-a448-f985-b64f-a62930b18eea" } } ``` The example below uses the `apm` shortcut. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "apm", "method": "paypal" }, "destination": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d" }, "denomination": { "asset": "USD", "amount": "50", "target": "origin" } } ``` A successful response includes the quote details and a `requirements` array. If it contains `authorize:paypal`, the user must authorize PayPal before the transaction can be created. ```json [expandable] theme={null} { "quote": { "id": "c3e8d2f7-9a41-4b75-b8e3-1d6f4a9c2e57", "origin": { "amount": "50.00", "asset": "USD", "node": { "type": "apm", "method": "paypal" }, "rate": "1" }, "destination": { "amount": "48.75", "asset": "USD", "node": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "50.00", "target": "origin", "rate": "1" }, "fees": [ { "type": "deposit", "code": "alternative-payment-method-deposit", "asset": "USD", "amount": "1.25", "percentage": "2.50" } ], "expiresAt": "2025-06-18T01:55:39Z", "requirements": [ "authorize:paypal" ] } } ``` *** ## Present the order summary Before creating the transaction, display an order summary of the quote — the amount, fees, and the origin and destination — so the user can review it. Here's an example:
PayPal order summary
*** ## Authorize and create the transaction After the user confirms the PayPal deposit quote, hand off to the Payment Widget **Authorize flow**. It creates the transaction, runs the PayPal authorization, and polls until a terminal status is reached — so the same flow works for both new and already-authorized accounts. ### Create an authorize session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) with `flow: "authorize"`, the `quoteId` and with the `requirements` array containing `authorize:paypal`. ```http theme={null} POST /widgets/payment/sessions { "flow": "authorize", "data": { "quoteId": "", "requirements": [ "authorize:paypal" ] } } ``` ```json theme={null} { "session": { "flow": "authorize", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL", "data": { "quoteId": "", "requirements": [ "authorize:paypal" ] } } } ``` ### Set up the widget Initialize the widget with the session. The widget interacts with PayPal, creates the transaction, and polls until a terminal status is reached. For a **new account** the user signs in to PayPal to authorize; for an **already-authorized account** they do not sign in again — the widget only collects device data and reuses the stored authorization. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeAuthorizeWidget = async (session) => { const widget = new PaymentWidget<'authorize'>(session, { debug: true }); widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; console.log('Complete', transaction.status, trigger.reason); widget.unmount(); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```javascript JavaScript [expandable] theme={null} // session is `response.session` from your backend async function initializeAuthorizeWidget(session) { const container = document.getElementById('payment-container'); const sessionOrigin = new URL(session.url).origin; const iframe = document.createElement('iframe'); iframe.src = session.url; iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src';"); iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; function teardown() { window.removeEventListener('message', onMessage); iframe.remove(); } function onMessage(event) { if (event.origin !== sessionOrigin) return; switch (event.data?.type) { case 'load': iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin); break; case 'complete': { const { transaction, trigger } = event.data.value; console.log('Complete', transaction.status, trigger.reason); teardown(); break; } case 'cancel': console.log('Cancelled'); teardown(); break; case 'error': console.error('Error', event.data.error); teardown(); break; } } window.addEventListener('message', onMessage); container.appendChild(iframe); } ``` Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event does not guarantee success. Always check `transaction.status` and `trigger.reason`. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the account is now authorized and the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { transaction, trigger } = event.data.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the account is now authorized and the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } teardown(); break; } ``` Failure reasons in `transaction.statusDetails.reason`: | Reason | Description | | ---------------------------------- | ------------------------------------------------ | | `apm-authorization-failed` | The PayPal authorization could not be completed | | `apm-payment-method-declined` | PayPal declined the payment method | | `apm-account-holder-data-mismatch` | The account holder details did not match | | `apm-missing-account-holder-data` | Required account holder details were missing | | `insufficient-funds` | The origin account has insufficient funds | | `provider-maximum-limit-exceeded` | The transaction exceeds provider limits | | `velocity` | The transaction was blocked by velocity rules | | `unspecified-error` | The transaction failed for an unspecified reason | ### Handle cancellations The `cancel` event fires when the user navigates back without completing authorization. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors. Most failures in this flow surface with a generic `code: 'error'` and a descriptive `message`. Transaction-creation failures are the exception: they use `code: 'authorize_transaction_failed'` with `event.detail.error.details.reason: 'unable-to-create-transaction'`. As with any Widget error, the original failure is preserved in [`event.detail.error.cause`](/widgets/payment/sdk-reference#error) — for `unable-to-create-transaction`, `event.detail.error.cause.code` is one of: | Code | Description | | ------------------------- | --------------------------------------------------------- | | `entity_not_found` | The quote was not found or has expired | | `insufficient_balance` | The origin account has insufficient balance | | `operation_not_allowed` | The operation is not permitted | | `user_capability_failure` | The user lacks the required capability for this operation | ```javascript Web SDK theme={null} widget.on('error', (event) => { const { code, cause, details } = event.detail.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed' && details?.reason === 'unable-to-create-transaction') { // The real reason is nested here, not in the top-level `code` console.error('Create transaction failed:', cause?.code); } widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': { const { code, cause, details } = event.data.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed' && details?.reason === 'unable-to-create-transaction') { console.error('Create transaction failed:', cause?.code); } teardown(); // Show a user-friendly error message break; } ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Monitor for settlement transactions remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) *** ## Sample transaction In a successful PayPal deposit, the origin is the external account representing the user's PayPal account and the destination is the user's `account`. ```json [expandable] theme={null} { "transaction": { "id": "9b2f4a17-5e3c-4d77-a8e1-9bcdef2c0a42", "origin": { "asset": "USD", "amount": "50.00", "node": { "type": "external-account", "id": "90acb64a-510d-f9d1-b542-d44e6c53eb5d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "destination": { "asset": "USD", "amount": "48.75", "node": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "status": "completed", "quotedAt": "2025-06-18T00:55:39Z", "createdAt": "2025-06-18T00:56:39Z", "updatedAt": "2025-06-18T00:57:08Z", "denomination": { "asset": "USD", "amount": "50.00", "target": "origin" } } } ``` *** ## Notify the user After the transaction completes, display the transaction details to the user so they can confirm the deposit succeeded. It must include the **PayPal logo and the PayPal account email used**. Here's an example:
PayPal transaction completed confirmation
*** You now support PayPal deposits via the Payment Widget. # Apple Pay deposit via the REST API Source: https://developer.uphold.com/developer-guides/apm-transfers/deposit/via-rest-api/apple-pay Fund a user's account from Apple Pay through the Uphold REST API. This guide covers funding a user's account from Apple Pay using the REST API together with the Payment Widget. Every deposit runs through the widget's **Authorize flow**, where the user authorizes with Apple Pay. Your backend only creates the quote and the widget session. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview). * The `apple-pay` capability is enabled. * The Payment Widget is [set up in your frontend](/widgets/payment/installation-and-setup). * [Apple Pay's additional setup requirements](/developer-guides/apm-transfers/overview#apple-pay) are met. * The target browser or platform [supports Apple Pay](/developer-guides/apm-transfers/overview#browser-and-platform-compatibility-payment-widget). ## Walkthrough The diagram shows an Apple Pay transaction authorization. ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold Usr->>U: Start deposit U->>B: Get rails and capabilities B->>A: GET /core/rails?type=apm A-->>B: { rails } B->>A: GET /core/capabilities A-->>B: { capabilities } B-->>U: { rails, capabilities } U->>B: Get accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Select Apple Pay and destination account Usr->>U: Choose amount U->>B: Create quote B->>A: Create quote (APM → account) A-->>B: { quote } B-->>U: { quote } U->>B: Create authorize session B->>A: Create widget session (authorize) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Authorize Apple Pay P->>A: Create transaction A-->>P: { transaction } P-->>U: complete { transaction, trigger } B-->>Usr: Notify the user ``` *** ## Check available rails Call [List rails](/rest-apis/core-api/assets/list-rails) to verify Apple Pay deposit is available. ```http theme={null} GET /core/rails?type=apm ``` ```json theme={null} { "rails": [ { "type": "apm", "network": "apple-pay", "method": "apple-pay", "asset": "USD", "decimals": 2, "features": ["deposit", "withdraw"] } ] } ``` ## Check capabilities Call [List capabilities](/rest-apis/core-api/capabilities/list-user-capabilities) to confirm the user has the `apple-pay` capability enabled with no unmet requirements. ```http theme={null} GET /core/capabilities ``` ```json theme={null} { "capabilities": [ { "code": "apple-pay", "name": "Apple Pay", "enabled": true, "requirements": [], "restrictions": [] } ] } ``` ## Select destination account deposits can target any account. If the selected account is not in the account's currency, the amount will be converted at settlement using Uphold's prevailing rate. Make sure the destination asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). ### Find an existing account Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one they want to fund. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ### Create a new account If the user has no accounts, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My USD account", "asset": "USD" } ``` ```json theme={null} { "account": { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "0", "available": "0" } } } ``` *** ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with Apple Pay as the origin and the user's account as the destination. Specify Apple Pay as the origin with `type: "apm"` with `method: "apple-pay"`. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "apm", "method": "apple-pay" }, "destination": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d" }, "denomination": { "asset": "USD", "amount": "50", "target": "origin" } } ``` A successful response includes the quote details and a `requirements` array. For Apple Pay, it contains `authorize:apple-pay`, which indicates that the user must authorize Apple Pay before the transaction can be created. ```json [expandable] theme={null} { "quote": { "id": "c3e8d2f7-9a41-4b75-b8e3-1d6f4a9c2e57", "origin": { "amount": "50.00", "asset": "USD", "node": { "type": "apm", "method": "apple-pay" }, "rate": "1" }, "destination": { "amount": "48.75", "asset": "USD", "node": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "50.00", "target": "origin", "rate": "1" }, "fees": [ { "type": "deposit", "code": "alternative-payment-method-deposit", "asset": "USD", "amount": "1.25", "percentage": "2.50" } ], "expiresAt": "2025-06-18T01:55:39Z", "requirements": [ "authorize:apple-pay" ] } } ``` *** ## Present the order summary Before creating the transaction, display an order summary of the quote — the amount, fees, and the origin and destination — so the user can review it. Here's an example:
Apple Pay order summary
*** ## Authorize and create the transaction After the user confirms the Apple Pay quote, hand off to the Payment Widget **Authorize flow**. It presents the Apple Pay sheet, creates the transaction, and polls until a terminal status is reached. Apple Pay authorizes **per transaction** on the user's device — there is no stored authorization to reuse, so the user confirms with Face ID, Touch ID, or their passcode every time. By default, the widget renders a full walkthrough around the authorization; in **headless mode**, it renders only the Apple Pay button so you can embed it directly into your own UI — see [Headless mode](#headless-mode) below. ### Create an authorize session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) with `flow: "authorize"`, the `quoteId` and with the `requirements` array containing `authorize:apple-pay`. For web apps, also include the top-page `domain`. The top-page domain is the domain shown in the browser's URL bar — the very top-level page hosting the widget iframe. For web apps, it must match the domain you registered with Apple Pay for your merchant ID. ```http theme={null} POST /widgets/payment/sessions { "flow": "authorize", "data": { "quoteId": "", "requirements": [ "authorize:apple-pay" ], "domain": "" // For web apps only, required for Apple Pay } } ``` ```json theme={null} { "session": { "flow": "authorize", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL", "data": { "quoteId": "", "requirements": [ "authorize:apple-pay" ], "domain": "" // For web apps only, required for Apple Pay } } } ``` ### Set up the widget Initialize the widget with the session. The widget presents the sheet, collects device data, creates the transaction, and polls until a terminal status is reached. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeAuthorizeWidget = async (session) => { const widget = new PaymentWidget<'authorize'>(session, { debug: true }); widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; console.log('Complete', transaction.status, trigger.reason); widget.unmount(); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```javascript JavaScript [expandable] theme={null} // session is `response.session` from your backend async function initializeAuthorizeWidget(session) { const container = document.getElementById('payment-container'); const sessionOrigin = new URL(session.url).origin; const iframe = document.createElement('iframe'); iframe.src = session.url; iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src'; payment 'src';"); iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; function teardown() { window.removeEventListener('message', onMessage); iframe.remove(); } function onMessage(event) { if (event.origin !== sessionOrigin) return; switch (event.data?.type) { case 'load': iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin); break; case 'complete': { const { transaction, trigger } = event.data.value; console.log('Complete', transaction.status, trigger.reason); teardown(); break; } case 'cancel': console.log('Cancelled'); teardown(); break; case 'error': console.error('Error', event.data.error); teardown(); break; } } window.addEventListener('message', onMessage); container.appendChild(iframe); } ``` Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Headless mode By default, the widget renders a full authorization experience — a short walkthrough, the button, and a processing screen while the transaction is confirmed. In **headless mode**, it renders only the button, with no surrounding UI, so you can embed it directly into your own layout. Your app then owns the surrounding context and the post-authorization experience — progress and success feedback, and a way to cancel. ```javascript Web SDK theme={null} const widget = new PaymentWidget<'authorize'>(session, { debug: true, authorize: { mode: 'headless' } }); ``` ```javascript JavaScript theme={null} iframe.contentWindow.postMessage({ ...session, options: { authorize: { mode: 'headless' } }, type: 'init' }, sessionOrigin); ``` See [`AuthorizeFlowOptions`](/widgets/payment/sdk-reference#authorizeflowoptions) in the SDK reference for the full type definition. ### Handle the complete event The `complete` event does not guarantee success. Always check `transaction.status` and `trigger.reason`. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { transaction, trigger } = event.data.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } teardown(); break; } ``` Failure reasons in `transaction.statusDetails.reason`: | Reason | Description | | ----------------------------------- | ------------------------------------------------ | | `apm-authorization-failed` | The authorization could not be completed | | `card-declined-by-bank` | The card was declined by the issuing bank | | `card-expired` | The card has expired | | `card-permanently-declined-by-bank` | The card was permanently declined | | `card-unauthorized` | The card authorization was not completed | | `card-unsupported` | The card is not supported for this operation | | `insufficient-funds` | The origin account has insufficient funds | | `provider-maximum-limit-exceeded` | The transaction exceeds provider limits | | `velocity` | The transaction was blocked by velocity rules | | `unspecified-error` | The transaction failed for an unspecified reason | ### Handle cancellations The `cancel` event fires when the user dismisses the sheet or navigates back without completing authorization. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors — except `authorize_transaction_failed`, which is retryable **in headless mode**: the widget resets itself back to its ready state, so the button works again if you leave the widget mounted. In default mode there is no in-place retry for this code — the button does not become usable again, so unmount and create a new authorize session if you want the user to try again. `authorize_transaction_failed` is the retryable code for authorization and transaction-creation failures. `event.detail.error.details.reason` narrows down where it happened:
Reason Description
unable-to-create-payment-request The browser could not build the payment request
unable-to-make-payment The device or browser reported it cannot make this payment
unable-to-show-payment-request The sheet could not be shown
invalid-payment-response The sheet returned a response without the expected payment token
unable-to-create-transaction authorization succeeded, but the transaction itself could not be created — see event.detail.error.cause below for the underlying reason
Other failures in this flow — such as a missing or expired quote, or the SDK being unavailable — surface with their own `code` and are not retryable; handle those with a generic fallback. As with any Widget error, the original failure is preserved in [`event.detail.error.cause`](/widgets/payment/sdk-reference#error). When `details.reason` is `unable-to-create-transaction`, `event.detail.error.cause.code` is one of: | Code | Description | | ------------------------- | --------------------------------------------------------- | | `entity_not_found` | The quote was not found or has expired | | `insufficient_balance` | The origin account has insufficient balance | | `operation_not_allowed` | The operation is not permitted | | `user_capability_failure` | The user lacks the required capability for this operation | ```javascript Web SDK theme={null} // Set this to match how you configured the widget — see Headless mode above const isAuthorizeHeadless = false; widget.on('error', (event) => { const { code, cause, details } = event.detail.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed') { if (details.reason === 'unable-to-create-transaction') { // The real reason is nested here, not in the top-level `code` console.error('Create transaction failed:', cause?.code); } if (isAuthorizeHeadless) { // Retryable in headless mode — keep the widget mounted if you want the user to try again return; } // In default mode there is no in-place retry for this code — fall through and unmount } widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget. `isAuthorizeHeadless` is declared once // outside the handler (alongside `sessionOrigin`), matching how you configured the widget. case 'error': { const { code, cause, details } = event.data.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed') { if (details.reason === 'unable-to-create-transaction') { console.error('Create transaction failed:', cause?.code); } if (isAuthorizeHeadless) { // Retryable in headless mode — keep the iframe/WebView mounted so the user can try again break; } // In default mode there is no in-place retry for this code — fall through and teardown } teardown(); // Show a user-friendly error message break; } ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Monitor for settlement transactions remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) *** ## Sample transaction In a successful deposit, the origin is represented as an `apm` node with the method as `{apmMethod}`. The destination is the user's `account`. ```json [expandable] theme={null} { "transaction": { "id": "9b2f4a17-5e3c-4d77-a8e1-9bcdef2c0a42", "origin": { "asset": "USD", "amount": "50.00", "node": { "type": "apm", "method": "apple-pay" } }, "destination": { "asset": "USD", "amount": "50.00", "node": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "status": "completed", "quotedAt": "2025-06-18T00:55:39Z", "createdAt": "2025-06-18T00:56:39Z", "updatedAt": "2025-06-18T00:57:08Z", "denomination": { "asset": "USD", "amount": "50.00", "target": "origin" } } } ``` *** ## Notify the user After the transaction completes, display the transaction details to the user so they can confirm the deposit succeeded. Here's an example:
Apple Pay transaction completed confirmation
## Troubleshooting Having issues rendering Apple Pay? See [Troubleshooting](/widgets/payment/installation-and-setup#troubleshooting) in the Payment Widget installation guide. *** You now support Apple Pay deposits via the REST API together with the Payment Widget. # Google Pay deposit via the REST API Source: https://developer.uphold.com/developer-guides/apm-transfers/deposit/via-rest-api/google-pay Fund a user's account from Google Pay through the Uphold REST API. This guide covers funding a user's account from Google Pay using the REST API together with the Payment Widget. Every deposit runs through the widget's **Authorize flow**, where the user authorizes with Google Pay. Your backend only creates the quote and the widget session. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview). * The `google-pay` capability is enabled. * The Payment Widget is [set up in your frontend](/widgets/payment/installation-and-setup). * [Google Pay's additional setup requirements](/developer-guides/apm-transfers/overview#browser-and-platform-compatibility-payment-widget) are met. ## Walkthrough The diagram shows a Google Pay transaction authorization. ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold Usr->>U: Start deposit U->>B: Get rails and capabilities B->>A: GET /core/rails?type=apm A-->>B: { rails } B->>A: GET /core/capabilities A-->>B: { capabilities } B-->>U: { rails, capabilities } U->>B: Get accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Select Google Pay and destination account Usr->>U: Choose amount U->>B: Create quote B->>A: Create quote (APM → account) A-->>B: { quote } B-->>U: { quote } U->>B: Create authorize session B->>A: Create widget session (authorize) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Authorize Google Pay P->>A: Create transaction A-->>P: { transaction (with confirmationUrl if authorization required) } opt confirmationUrl present Usr->>P: Complete authorization challenge end P-->>U: complete { transaction, trigger } B-->>Usr: Notify the user ``` *** ## Check available rails Call [List rails](/rest-apis/core-api/assets/list-rails) to verify Google Pay deposit is available. ```http theme={null} GET /core/rails?type=apm ``` ```json theme={null} { "rails": [ { "type": "apm", "network": "google-pay", "method": "google-pay", "asset": "USD", "decimals": 2, "features": ["deposit"] } ] } ``` ## Check capabilities Call [List capabilities](/rest-apis/core-api/capabilities/list-user-capabilities) to confirm the user has the `google-pay` capability enabled with no unmet requirements. ```http theme={null} GET /core/capabilities ``` ```json theme={null} { "capabilities": [ { "code": "google-pay", "name": "Google Pay", "enabled": true, "requirements": [], "restrictions": [] } ] } ``` ## Select destination account deposits can target any account. If the selected account is not in the account's currency, the amount will be converted at settlement using Uphold's prevailing rate. Make sure the destination asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). ### Find an existing account Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one they want to fund. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ### Create a new account If the user has no accounts, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My USD account", "asset": "USD" } ``` ```json theme={null} { "account": { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "0", "available": "0" } } } ``` *** ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with Google Pay as the origin and the user's account as the destination. Specify Google Pay as the origin with `type: "apm"` with `method: "google-pay"`. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "apm", "method": "google-pay" }, "destination": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d" }, "denomination": { "asset": "USD", "amount": "50", "target": "origin" } } ``` A successful response includes the quote details and a `requirements` array. For Google Pay, it contains `authorize:google-pay`, which indicates that the user must authorize Google Pay before the transaction can be created. ```json [expandable] theme={null} { "quote": { "id": "c3e8d2f7-9a41-4b75-b8e3-1d6f4a9c2e57", "origin": { "amount": "50.00", "asset": "USD", "node": { "type": "apm", "method": "google-pay" }, "rate": "1" }, "destination": { "amount": "48.75", "asset": "USD", "node": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "50.00", "target": "origin", "rate": "1" }, "fees": [ { "type": "deposit", "code": "alternative-payment-method-deposit", "asset": "USD", "amount": "1.25", "percentage": "2.50" } ], "expiresAt": "2025-06-18T01:55:39Z", "requirements": [ "authorize:google-pay" ] } } ``` *** ## Present the order summary Before creating the transaction, display an order summary of the quote — the amount, fees, and the origin and destination — so the user can review it. Here's an example:
Google Pay order summary
*** ## Authorize and create the transaction After the user confirms the Google Pay quote, hand off to the Payment Widget **Authorize flow**. It presents the Google Pay sheet, creates the transaction, and polls until a terminal status is reached. Google Pay authorizes **per transaction** — there is no stored authorization to reuse, so the user confirms via the Google Pay sheet every time. By default, the widget renders a full walkthrough around the authorization; in **headless mode**, it renders only the Google Pay button so you can embed it directly into your own UI — see [Headless mode](#headless-mode) below. ### Authorization challenges Some deposits take one more step after the user confirms the Google Pay sheet: the card backing the wallet may also need to be authorized, through a challenge such as 3DS. The card's issuer decides this per transaction, so it isn't known until the transaction is created. The Authorize flow covers this: when a challenge is required, the widget presents it to the user, and only polls for a terminal status once it's resolved. The same integration works whether or not a challenge comes up — there is nothing extra to handle on your side, beyond expecting those deposits to take longer to complete. **Headless mode** is only supported on **Android** devices and cards that support `CRYPTOGRAM_3DS`. Cards requiring 3D Secure (3DS) authentication are not supported in headless mode — see [Headless mode](#headless-mode) below. ### Create an authorize session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) with `flow: "authorize"`, the `quoteId` and with the `requirements` array containing `authorize:google-pay`. ```http theme={null} POST /widgets/payment/sessions { "flow": "authorize", "data": { "quoteId": "", "requirements": [ "authorize:google-pay" ] } } ``` ```json theme={null} { "session": { "flow": "authorize", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL", "data": { "quoteId": "", "requirements": [ "authorize:google-pay" ] } } } ``` ### Set up the widget Initialize the widget with the session. The widget presents the sheet, collects device data, creates the transaction, and polls until a terminal status is reached. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeAuthorizeWidget = async (session) => { const widget = new PaymentWidget<'authorize'>(session, { debug: true }); widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; console.log('Complete', transaction.status, trigger.reason); widget.unmount(); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```javascript JavaScript [expandable] theme={null} // session is `response.session` from your backend async function initializeAuthorizeWidget(session) { const container = document.getElementById('payment-container'); const sessionOrigin = new URL(session.url).origin; const iframe = document.createElement('iframe'); iframe.src = session.url; iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src'; payment 'src';"); iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; function teardown() { window.removeEventListener('message', onMessage); iframe.remove(); } function onMessage(event) { if (event.origin !== sessionOrigin) return; switch (event.data?.type) { case 'load': iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin); break; case 'complete': { const { transaction, trigger } = event.data.value; console.log('Complete', transaction.status, trigger.reason); teardown(); break; } case 'cancel': console.log('Cancelled'); teardown(); break; case 'error': console.error('Error', event.data.error); teardown(); break; } } window.addEventListener('message', onMessage); container.appendChild(iframe); } ``` Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Headless mode By default, the widget renders a full authorization experience — a short walkthrough, the button, and a processing screen while the transaction is confirmed. In **headless mode**, it renders only the button, with no surrounding UI, so you can embed it directly into your own layout. Your app then owns the surrounding context and the post-authorization experience — progress and success feedback, and a way to cancel. ```javascript Web SDK theme={null} const widget = new PaymentWidget<'authorize'>(session, { debug: true, authorize: { mode: 'headless' } }); ``` ```javascript JavaScript theme={null} iframe.contentWindow.postMessage({ ...session, options: { authorize: { mode: 'headless' } }, type: 'init' }, sessionOrigin); ``` See [`AuthorizeFlowOptions`](/widgets/payment/sdk-reference#authorizeflowoptions) in the SDK reference for the full type definition. ### Handle the complete event The `complete` event does not guarantee success. Always check `transaction.status` and `trigger.reason`. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { transaction, trigger } = event.data.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } teardown(); break; } ``` Failure reasons in `transaction.statusDetails.reason`: | Reason | Description | | ----------------------------------- | ------------------------------------------------ | | `apm-authorization-failed` | The authorization could not be completed | | `card-declined-by-bank` | The card was declined by the issuing bank | | `card-expired` | The card has expired | | `card-permanently-declined-by-bank` | The card was permanently declined | | `card-unauthorized` | The card authorization was not completed | | `card-unsupported` | The card is not supported for this operation | | `insufficient-funds` | The origin account has insufficient funds | | `provider-maximum-limit-exceeded` | The transaction exceeds provider limits | | `velocity` | The transaction was blocked by velocity rules | | `unspecified-error` | The transaction failed for an unspecified reason | ### Handle cancellations The `cancel` event fires when the user dismisses the sheet or navigates back without completing authorization. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors — except `authorize_transaction_failed`, which is retryable **in headless mode**: the widget resets itself back to its ready state, so the button works again if you leave the widget mounted. In default mode there is no in-place retry for this code — the button does not become usable again, so unmount and create a new authorize session if you want the user to try again. `authorize_transaction_failed` is the retryable code for authorization and transaction-creation failures. `event.detail.error.details.reason` narrows down where it happened:
Reason Description
unable-to-create-payment-request The browser could not build the payment request
unable-to-make-payment The device or browser reported it cannot make this payment
unable-to-show-payment-request The sheet could not be shown
invalid-payment-response The sheet returned a response without the expected payment token
unable-to-create-transaction authorization succeeded, but the transaction itself could not be created — see event.detail.error.cause below for the underlying reason
Other failures in this flow — such as a missing or expired quote, or the SDK being unavailable — surface with their own `code` and are not retryable; handle those with a generic fallback. As with any Widget error, the original failure is preserved in [`event.detail.error.cause`](/widgets/payment/sdk-reference#error). When `details.reason` is `unable-to-create-transaction`, `event.detail.error.cause.code` is one of: | Code | Description | | ------------------------- | --------------------------------------------------------- | | `entity_not_found` | The quote was not found or has expired | | `insufficient_balance` | The origin account has insufficient balance | | `operation_not_allowed` | The operation is not permitted | | `user_capability_failure` | The user lacks the required capability for this operation | ```javascript Web SDK theme={null} // Set this to match how you configured the widget — see Headless mode above const isAuthorizeHeadless = false; widget.on('error', (event) => { const { code, cause, details } = event.detail.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed') { if (details.reason === 'unable-to-create-transaction') { // The real reason is nested here, not in the top-level `code` console.error('Create transaction failed:', cause?.code); } if (isAuthorizeHeadless) { // Retryable in headless mode — keep the widget mounted if you want the user to try again return; } // In default mode there is no in-place retry for this code — fall through and unmount } widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget. `isAuthorizeHeadless` is declared once // outside the handler (alongside `sessionOrigin`), matching how you configured the widget. case 'error': { const { code, cause, details } = event.data.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed') { if (details.reason === 'unable-to-create-transaction') { console.error('Create transaction failed:', cause?.code); } if (isAuthorizeHeadless) { // Retryable in headless mode — keep the iframe/WebView mounted so the user can try again break; } // In default mode there is no in-place retry for this code — fall through and teardown } teardown(); // Show a user-friendly error message break; } ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Monitor for settlement transactions remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) *** ## Sample transaction In a successful deposit, the origin is represented as an `apm` node with the method as `{apmMethod}`. The destination is the user's `account`. ```json [expandable] theme={null} { "transaction": { "id": "9b2f4a17-5e3c-4d77-a8e1-9bcdef2c0a42", "origin": { "asset": "USD", "amount": "50.00", "node": { "type": "apm", "method": "google-pay" } }, "destination": { "asset": "USD", "amount": "50.00", "node": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "status": "completed", "quotedAt": "2025-06-18T00:55:39Z", "createdAt": "2025-06-18T00:56:39Z", "updatedAt": "2025-06-18T00:57:08Z", "denomination": { "asset": "USD", "amount": "50.00", "target": "origin" } } } ``` *** ## Notify the user After the transaction completes, display the transaction details to the user so they can confirm the deposit succeeded. Here's an example:
Google Pay transaction completed confirmation
## Troubleshooting Having issues rendering Google Pay? See [Troubleshooting](/widgets/payment/installation-and-setup#troubleshooting) in the Payment Widget installation guide. *** You now support Google Pay deposits via the REST API together with the Payment Widget. # PayPal deposit via the REST API Source: https://developer.uphold.com/developer-guides/apm-transfers/deposit/via-rest-api/paypal Fund a user's account from PayPal through the Uphold REST API. This guide covers funding a user's account from PayPal using the REST API together with the Payment Widget. It handles both cases: **authorizing a new account** for the first time, and **depositing from an account the user already authorized**. Both run through the widget's **Authorize flow** — your backend only creates the quote and the widget session. Authorization always runs through the Payment Widget — for both new and already-authorized accounts. The widget collects Braintree **device data** and submits it with the transaction, which PayPal uses for fraud and risk analysis. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview). * The `paypal` capability is enabled. * The Payment Widget is set up in your frontend. See [Installation and setup](/widgets/payment/installation-and-setup). ## Walkthrough The diagram shows a first-time authorization. For an already-authorized account, skip the sign-in — the widget only collects device data and reuses the stored authorization. ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold Usr->>U: Start deposit U->>B: Get rails and capabilities B->>A: GET /core/rails?type=apm A-->>B: { rails } B->>A: GET /core/capabilities A-->>B: { capabilities } B-->>U: { rails, capabilities } U->>B: Get accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Select PayPal and destination account Usr->>U: Choose amount U->>B: Create quote B->>A: Create quote (APM → account) A-->>B: { quote } B-->>U: { quote } U->>B: Create authorize session B->>A: Create widget session (authorize) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Sign in & authorize (first time only) P->>A: Create transaction A-->>P: { transaction } P-->>U: complete { transaction, trigger } B-->>Usr: Notify the user ``` *** ## Check available rails Call [List rails](/rest-apis/core-api/assets/list-rails) to verify PayPal deposit is available. ```http theme={null} GET /core/rails?type=apm ``` ```json theme={null} { "rails": [ { "type": "apm", "network": "paypal", "method": "paypal", "asset": "USD", "decimals": 2, "features": ["deposit", "withdraw"] } ] } ``` ## Check capabilities Call [List capabilities](/rest-apis/core-api/capabilities/list-user-capabilities) to confirm the user has the `paypal` and `deposits` capabilities enabled with no unmet requirements. ```http theme={null} GET /core/capabilities ``` ```json theme={null} { "capabilities": [ { "code": "paypal", "name": "PayPal", "enabled": true, "requirements": [], "restrictions": [] } ] } ``` ## Select destination account deposits can target any account. If the selected account is not in the account's currency, the amount will be converted at settlement using Uphold's prevailing rate. Make sure the destination asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). ### Find an existing account Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one they want to fund. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ### Create a new account If the user has no accounts, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My USD account", "asset": "USD" } ``` ```json theme={null} { "account": { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "0", "available": "0" } } } ``` *** ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with PayPal as the origin and the user's account as the destination. There are two ways to specify the PayPal origin: * **`apm` shortcut** — use `type: "apm"` with `method: "paypal"`. This is always accepted, whether or not the user already has a linked PayPal account. * **`external-account` reference** — if the user already has a linked PayPal account, you can reference it directly with `type: "external-account"` and its `id` (from [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts)): ```json theme={null} { "origin": { "type": "external-account", "id": "9d3cce5f-a448-f985-b64f-a62930b18eea" } } ``` The example below uses the `apm` shortcut. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "apm", "method": "paypal" }, "destination": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d" }, "denomination": { "asset": "USD", "amount": "50", "target": "origin" } } ``` A successful response includes the quote details and a `requirements` array. If it contains `authorize:paypal`, the user must authorize PayPal before the transaction can be created. ```json [expandable] theme={null} { "quote": { "id": "c3e8d2f7-9a41-4b75-b8e3-1d6f4a9c2e57", "origin": { "amount": "50.00", "asset": "USD", "node": { "type": "apm", "method": "paypal" }, "rate": "1" }, "destination": { "amount": "48.75", "asset": "USD", "node": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "50.00", "target": "origin", "rate": "1" }, "fees": [ { "type": "deposit", "code": "alternative-payment-method-deposit", "asset": "USD", "amount": "1.25", "percentage": "2.50" } ], "expiresAt": "2025-06-18T01:55:39Z", "requirements": [ "authorize:paypal" ] } } ``` *** ## Present the order summary Before creating the transaction, display an order summary of the quote — the amount, fees, and the origin and destination — so the user can review it. Here's an example:
PayPal order summary
*** ## Authorize and create the transaction After the user confirms the PayPal deposit quote, hand off to the Payment Widget **Authorize flow**. It creates the transaction, runs the PayPal authorization, and polls until a terminal status is reached — so the same flow works for both new and already-authorized accounts. ### Create an authorize session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) with `flow: "authorize"`, the `quoteId` and with the `requirements` array containing `authorize:paypal`. ```http theme={null} POST /widgets/payment/sessions { "flow": "authorize", "data": { "quoteId": "", "requirements": [ "authorize:paypal" ] } } ``` ```json theme={null} { "session": { "flow": "authorize", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL", "data": { "quoteId": "", "requirements": [ "authorize:paypal" ] } } } ``` ### Set up the widget Initialize the widget with the session. The widget interacts with PayPal, creates the transaction, and polls until a terminal status is reached. For a **new account** the user signs in to PayPal to authorize; for an **already-authorized account** they do not sign in again — the widget only collects device data and reuses the stored authorization. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeAuthorizeWidget = async (session) => { const widget = new PaymentWidget<'authorize'>(session, { debug: true }); widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; console.log('Complete', transaction.status, trigger.reason); widget.unmount(); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```javascript JavaScript [expandable] theme={null} // session is `response.session` from your backend async function initializeAuthorizeWidget(session) { const container = document.getElementById('payment-container'); const sessionOrigin = new URL(session.url).origin; const iframe = document.createElement('iframe'); iframe.src = session.url; iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src';"); iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; function teardown() { window.removeEventListener('message', onMessage); iframe.remove(); } function onMessage(event) { if (event.origin !== sessionOrigin) return; switch (event.data?.type) { case 'load': iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin); break; case 'complete': { const { transaction, trigger } = event.data.value; console.log('Complete', transaction.status, trigger.reason); teardown(); break; } case 'cancel': console.log('Cancelled'); teardown(); break; case 'error': console.error('Error', event.data.error); teardown(); break; } } window.addEventListener('message', onMessage); container.appendChild(iframe); } ``` Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event does not guarantee success. Always check `transaction.status` and `trigger.reason`. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the account is now authorized and the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { transaction, trigger } = event.data.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the account is now authorized and the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } teardown(); break; } ``` Failure reasons in `transaction.statusDetails.reason`: | Reason | Description | | ---------------------------------- | ------------------------------------------------ | | `apm-authorization-failed` | The PayPal authorization could not be completed | | `apm-payment-method-declined` | PayPal declined the payment method | | `apm-account-holder-data-mismatch` | The account holder details did not match | | `apm-missing-account-holder-data` | Required account holder details were missing | | `insufficient-funds` | The origin account has insufficient funds | | `provider-maximum-limit-exceeded` | The transaction exceeds provider limits | | `velocity` | The transaction was blocked by velocity rules | | `unspecified-error` | The transaction failed for an unspecified reason | ### Handle cancellations The `cancel` event fires when the user navigates back without completing authorization. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors. Most failures in this flow surface with a generic `code: 'error'` and a descriptive `message`. Transaction-creation failures are the exception: they use `code: 'authorize_transaction_failed'` with `event.detail.error.details.reason: 'unable-to-create-transaction'`. As with any Widget error, the original failure is preserved in [`event.detail.error.cause`](/widgets/payment/sdk-reference#error) — for `unable-to-create-transaction`, `event.detail.error.cause.code` is one of: | Code | Description | | ------------------------- | --------------------------------------------------------- | | `entity_not_found` | The quote was not found or has expired | | `insufficient_balance` | The origin account has insufficient balance | | `operation_not_allowed` | The operation is not permitted | | `user_capability_failure` | The user lacks the required capability for this operation | ```javascript Web SDK theme={null} widget.on('error', (event) => { const { code, cause, details } = event.detail.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed' && details?.reason === 'unable-to-create-transaction') { // The real reason is nested here, not in the top-level `code` console.error('Create transaction failed:', cause?.code); } widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': { const { code, cause, details } = event.data.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed' && details?.reason === 'unable-to-create-transaction') { console.error('Create transaction failed:', cause?.code); } teardown(); // Show a user-friendly error message break; } ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Monitor for settlement transactions remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) *** ## Sample transaction In a successful PayPal deposit, the origin is the external account representing the user's PayPal account and the destination is the user's `account`. ```json [expandable] theme={null} { "transaction": { "id": "9b2f4a17-5e3c-4d77-a8e1-9bcdef2c0a42", "origin": { "asset": "USD", "amount": "50.00", "node": { "type": "external-account", "id": "90acb64a-510d-f9d1-b542-d44e6c53eb5d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "destination": { "asset": "USD", "amount": "48.75", "node": { "type": "account", "id": "71e9fd4b-dfcd-4643-a5b0-51fd33e50a8d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "status": "completed", "quotedAt": "2025-06-18T00:55:39Z", "createdAt": "2025-06-18T00:56:39Z", "updatedAt": "2025-06-18T00:57:08Z", "denomination": { "asset": "USD", "amount": "50.00", "target": "origin" } } } ``` *** ## Notify the user After the transaction completes, display the transaction details to the user so they can confirm the deposit succeeded. It must include the **PayPal logo and the PayPal account email used**. Here's an example:
PayPal transaction completed confirmation
*** You now support PayPal deposits via the REST API together with the Payment Widget. # APM transfer integration overview Source: https://developer.uphold.com/developer-guides/apm-transfers/overview Let users fund their accounts and payouts using Alternative Payment Methods (APM). ## Available methods The available payment methods are Apple Pay, Google Pay and PayPal. All are available in the UK and US. Apple Pay and Google Pay support **Individual** accounts, while PayPal supports both **Individual** and **Business** accounts. Support for Business accounts on Apple Pay, Google Pay and other methods will be added soon. ### Coverage | Method | Region | Currency | | ------------------------------------- | ------- | --------- | | **Apple Pay** | UK / US | GBP / USD | | **Google Pay** | UK / US | GBP / USD | | **PayPal** | UK / US | GBP / USD | ### Supported features | Method | Deposit | Withdrawal | | ------------------------------------- | --------------------- | ------------------------------------ | | **Apple Pay** | Supported | Supported | | **Google Pay** | Supported | Not applicable | | **PayPal** | Supported | Supported | ### Browser and platform compatibility (Payment Widget) Some APMs require authorization through the [Payment Widget](/widgets/payment/introduction) regardless of integration path, whether [via REST API](/developer-guides/apm-transfers/deposit/via-rest-api) or [via the widget directly](/developer-guides/apm-transfers/deposit/via-payment-widget). | APM | Web browsers | iOS WebView | Android WebView | | ------------------------------------- | ----------------------------- | --------------- | --------------- | | **Apple Pay** | Safari, Chrome¹, Edge¹ | Yes | No | | **Google Pay** | Safari, Chrome, Edge | Yes | Yes | | **PayPal** | Safari, Chrome, Firefox, Edge | Yes | Yes | ¹Partial support — Apple Pay renders a QR code instead of the payment sheet; the user must scan it with an iOS device to complete the payment there. ## Apple Pay Apple Pay requires a couple of extra things to be set up: * **Domain registration** — For web apps, your top-level domain must be registered with Apple Pay before going live (this is not necessary for sandbox if you use an Apple Tester Account as explained [here](/rest-apis/core-api/accounts/test-helpers/fund-sandbox-accounts#setting-up-apple-pay)). Contact your account manager if you haven't done so already, and note that: * Your domain must not already be registered with a different Apple Merchant ID in the Apple Developer Portal. * You must verify ownership of your domain by hosting a domain verification file, which we provide. * **Native apps** — integrating with the Payment Widget [without the SDK](/widgets/payment/installation-and-setup#setup-with-javascript) works out of the box. If you use [the Web SDK](/widgets/payment/installation-and-setup#choose-an-integration-approach) instead, the WebView's HTML page must be served from a real HTTPS origin rather than bundled locally. ## Key concepts * **Transfers are quote-based** — deposits and withdrawals both require the user to confirm a quote before the transaction is created, whether funds are being collected from the payment method or paid out to it. * **APM authorization** — when the quote response includes a `authorize:` requirement, the specified method needs to be authorized. Use the Payment Widget to manage the authorization flow. * **Authorized external accounts** — some APMs support account linking. After the first deposit or withdrawal, an external account is created and reused automatically for subsequent transactions. Other APMs don't support linking, so the transaction node is always the APM identified by its method, with no persistent external account created. ## Testing in Sandbox Each APM has its own setup for testing in Sandbox. See [Testing APMs](/rest-apis/core-api/accounts/test-helpers/fund-sandbox-accounts#testing-apms) for testing steps and recommended actions to take. ## Start building Fund a user's account programmatically. Let users deposit funds with a low-code, embeddable widget. Create quotes and submit payouts programmatically. Let users withdraw funds with a low-code, embeddable widget. # Apple Pay withdrawal via the Payment Widget Source: https://developer.uphold.com/developer-guides/apm-transfers/withdrawal/via-payment-widget/apple-pay Pay out from a user's account to Apple Pay using the Uphold Payment Widget. The Payment Widget handles Apple Pay selection for withdrawals via the **Select for Withdrawal flow**, where users can select Apple Pay directly in the widget. After the user confirms an Apple Pay withdrawal quote, the Payment Widget creates the transaction and completes the Apple Pay authorization via the **Authorize flow**. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview). * The `apple-pay-withdrawals` capability is enabled. * A **funded account** to debit the funds from. * The Payment Widget is [set up in your frontend](/widgets/payment/installation-and-setup). * [Apple Pay's additional setup requirements](/developer-guides/apm-transfers/overview#apple-pay) are met. * The target browser or platform [supports Apple Pay](/developer-guides/apm-transfers/overview#browser-and-platform-compatibility-payment-widget). ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold Usr->>U: Start withdrawal U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose source account U->>B: Create widget session B->>A: Create widget session (select-for-withdrawal) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select Apple Pay P-->>U: complete { via, selection } Usr->>U: Choose amount U->>B: Create quote B->>A: Create quote (account → APM) A-->>B: { quote } B-->>U: { quote } U->>B: Create authorize session B->>A: Create widget session (authorize) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Authorize with Apple Pay sheet P->>A: Create transaction A-->>P: { transaction } P-->>U: complete { transaction, trigger } B-->>Usr: Notify the user ``` *** ## Select source account withdrawals can be sourced from any account. If the selected account is not in the account's currency, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one to withdraw from. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` *** ## Select withdrawal method The Payment Widget's **Select for Withdrawal flow** presents the available payment methods, letting the user select Apple Pay when it's available. It performs **selection only**: it does not create the quote or the transaction. ### Create a widget session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) to start the `select-for-withdrawal` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-withdrawal" } ``` ```json theme={null} { "session": { "flow": "select-for-withdrawal", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` Pass `response.session` to your frontend to initialize the widget. ### Set up the widget ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeWithdrawalWidget = async (session) => { const widget = new PaymentWidget<'select-for-withdrawal'>(session, { debug: true }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event fires after the user selects Apple Pay. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; // If via is 'apm', you can check the selected method through the property `selection.method`, which will be 'apple-pay' in this case. if (via === 'apm' && selection.method === 'apple-pay') { handleApplePaySelected(); } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; // If via is 'apm', you can check the selected method through the property `selection.method`, which will be 'apple-pay' in this case. if (via === 'apm' && selection.method === 'apple-pay') { handleApplePaySelected(); } teardown(); break; } ``` Once you have the selection, proceed to [Create a quote](#create-a-quote). ### Handle cancellations ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with the user's account as the origin and Apple Pay as the destination. Specify Apple Pay as the destination with `type: "apm"` with `method: "apple-pay"`. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "b8618cd1-ccb0-4a72-985f-3bb0d268dabd" }, "destination": { "type": "apm", "method": "apple-pay" }, "denomination": { "asset": "USD", "amount": "15", "target": "origin" } } ``` A successful response includes the quote details and a `requirements` array. For Apple Pay, it contains `authorize:apple-pay`, which indicates that the user must authorize Apple Pay before the transaction can be created. ```json [expandable] theme={null} { "quote": { "id": "c3e8d2f7-9a41-4b75-b8e3-1d6f4a9c2e57", "origin": { "amount": "15.00", "asset": "USD", "node": { "type": "account", "id": "b8618cd1-ccb0-4a72-985f-3bb0d268dabd", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" }, "rate": "1" }, "destination": { "amount": "15.00", "asset": "USD", "node": { "type": "apm", "method": "apple-pay" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "15.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2025-06-18T01:55:39Z", "requirements": [ "authorize:apple-pay" ] } } ``` *** ## Authorize and create the transaction After the user confirms the Apple Pay quote, hand off to the Payment Widget **Authorize flow**. It presents the Apple Pay sheet, creates the transaction, and polls until a terminal status is reached. Apple Pay authorizes **per transaction** on the user's device — there is no stored authorization to reuse, so the user confirms with Face ID, Touch ID, or their passcode every time. By default, the widget renders a full walkthrough around the authorization; in **headless mode**, it renders only the Apple Pay button so you can embed it directly into your own UI — see [Headless mode](#headless-mode) below. ### Create an authorize session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) with `flow: "authorize"`, the `quoteId` and with the `requirements` array containing `authorize:apple-pay`. For web apps, also include the top-page `domain`. The top-page domain is the domain shown in the browser's URL bar — the very top-level page hosting the widget iframe. For web apps, it must match the domain you registered with Apple Pay for your merchant ID. ```http theme={null} POST /widgets/payment/sessions { "flow": "authorize", "data": { "quoteId": "", "requirements": [ "authorize:apple-pay" ], "domain": "" // For web apps only, required for Apple Pay } } ``` ```json theme={null} { "session": { "flow": "authorize", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL", "data": { "quoteId": "", "requirements": [ "authorize:apple-pay" ], "domain": "" // For web apps only, required for Apple Pay } } } ``` ### Set up the widget Initialize the widget with the session. The widget presents the sheet, collects device data, creates the transaction, and polls until a terminal status is reached. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeAuthorizeWidget = async (session) => { const widget = new PaymentWidget<'authorize'>(session, { debug: true }); widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; console.log('Complete', transaction.status, trigger.reason); widget.unmount(); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```javascript JavaScript [expandable] theme={null} // session is `response.session` from your backend async function initializeAuthorizeWidget(session) { const container = document.getElementById('payment-container'); const sessionOrigin = new URL(session.url).origin; const iframe = document.createElement('iframe'); iframe.src = session.url; iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src'; payment 'src';"); iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; function teardown() { window.removeEventListener('message', onMessage); iframe.remove(); } function onMessage(event) { if (event.origin !== sessionOrigin) return; switch (event.data?.type) { case 'load': iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin); break; case 'complete': { const { transaction, trigger } = event.data.value; console.log('Complete', transaction.status, trigger.reason); teardown(); break; } case 'cancel': console.log('Cancelled'); teardown(); break; case 'error': console.error('Error', event.data.error); teardown(); break; } } window.addEventListener('message', onMessage); container.appendChild(iframe); } ``` Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Headless mode By default, the widget renders a full authorization experience — a short walkthrough, the button, and a processing screen while the transaction is confirmed. In **headless mode**, it renders only the button, with no surrounding UI, so you can embed it directly into your own layout. Your app then owns the surrounding context and the post-authorization experience — progress and success feedback, and a way to cancel. ```javascript Web SDK theme={null} const widget = new PaymentWidget<'authorize'>(session, { debug: true, authorize: { mode: 'headless' } }); ``` ```javascript JavaScript theme={null} iframe.contentWindow.postMessage({ ...session, options: { authorize: { mode: 'headless' } }, type: 'init' }, sessionOrigin); ``` See [`AuthorizeFlowOptions`](/widgets/payment/sdk-reference#authorizeflowoptions) in the SDK reference for the full type definition. ### Handle the complete event The `complete` event does not guarantee success. Always check `transaction.status` and `trigger.reason`. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { transaction, trigger } = event.data.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } teardown(); break; } ``` Failure reasons in `transaction.statusDetails.reason`: | Reason | Description | | ----------------------------------- | ------------------------------------------------ | | `apm-authorization-failed` | The authorization could not be completed | | `card-declined-by-bank` | The card was declined by the issuing bank | | `card-expired` | The card has expired | | `card-permanently-declined-by-bank` | The card was permanently declined | | `card-unauthorized` | The card authorization was not completed | | `card-unsupported` | The card is not supported for this operation | | `insufficient-funds` | The origin account has insufficient funds | | `provider-maximum-limit-exceeded` | The transaction exceeds provider limits | | `velocity` | The transaction was blocked by velocity rules | | `unspecified-error` | The transaction failed for an unspecified reason | ### Handle cancellations The `cancel` event fires when the user dismisses the sheet or navigates back without completing authorization. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors — except `authorize_transaction_failed`, which is retryable **in headless mode**: the widget resets itself back to its ready state, so the button works again if you leave the widget mounted. In default mode there is no in-place retry for this code — the button does not become usable again, so unmount and create a new authorize session if you want the user to try again. `authorize_transaction_failed` is the retryable code for authorization and transaction-creation failures. `event.detail.error.details.reason` narrows down where it happened:
Reason Description
unable-to-create-payment-request The browser could not build the payment request
unable-to-make-payment The device or browser reported it cannot make this payment
unable-to-show-payment-request The sheet could not be shown
invalid-payment-response The sheet returned a response without the expected payment token
unable-to-create-transaction authorization succeeded, but the transaction itself could not be created — see event.detail.error.cause below for the underlying reason
Other failures in this flow — such as a missing or expired quote, or the SDK being unavailable — surface with their own `code` and are not retryable; handle those with a generic fallback. As with any Widget error, the original failure is preserved in [`event.detail.error.cause`](/widgets/payment/sdk-reference#error). When `details.reason` is `unable-to-create-transaction`, `event.detail.error.cause.code` is one of: | Code | Description | | ------------------------- | --------------------------------------------------------- | | `entity_not_found` | The quote was not found or has expired | | `insufficient_balance` | The origin account has insufficient balance | | `operation_not_allowed` | The operation is not permitted | | `user_capability_failure` | The user lacks the required capability for this operation | ```javascript Web SDK theme={null} // Set this to match how you configured the widget — see Headless mode above const isAuthorizeHeadless = false; widget.on('error', (event) => { const { code, cause, details } = event.detail.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed') { if (details.reason === 'unable-to-create-transaction') { // The real reason is nested here, not in the top-level `code` console.error('Create transaction failed:', cause?.code); } if (isAuthorizeHeadless) { // Retryable in headless mode — keep the widget mounted if you want the user to try again return; } // In default mode there is no in-place retry for this code — fall through and unmount } widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget. `isAuthorizeHeadless` is declared once // outside the handler (alongside `sessionOrigin`), matching how you configured the widget. case 'error': { const { code, cause, details } = event.data.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed') { if (details.reason === 'unable-to-create-transaction') { console.error('Create transaction failed:', cause?.code); } if (isAuthorizeHeadless) { // Retryable in headless mode — keep the iframe/WebView mounted so the user can try again break; } // In default mode there is no in-place retry for this code — fall through and teardown } teardown(); // Show a user-friendly error message break; } ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Monitor for settlement transactions remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) *** ## Sample transaction In a successful withdrawal, the origin is the user's `account` and the destination is an `apm` node with the method as `{apmMethod}`. The transaction status is initially `processing` and updates to `completed` once the transfer settles. ```json [expandable] theme={null} { "transaction": { "id": "9b2f4a17-5e3c-4d77-a8e1-9bcdef2c0a42", "origin": { "asset": "USD", "amount": "15.00", "node": { "type": "account", "id": "b8618cd1-ccb0-4a72-985f-3bb0d268dabd", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "destination": { "asset": "USD", "amount": "15.00", "node": { "type": "apm", "method": "apple-pay" } }, "status": "completed", "quotedAt": "2025-06-18T00:55:39Z", "createdAt": "2025-06-18T00:56:39Z", "updatedAt": "2025-06-18T00:57:08Z", "denomination": { "asset": "USD", "amount": "15.00", "target": "origin" } } } ``` *** ## Notify the user After the transaction completes, display the transaction details to the user so they can confirm the withdrawal succeeded. Here's an example:
Apple Pay transaction completed confirmation
*** ## Troubleshooting Having issues rendering Apple Pay? See [Troubleshooting](/widgets/payment/installation-and-setup#troubleshooting) in the Payment Widget installation guide. *** You now support Apple Pay withdrawals via the Payment Widget. # PayPal withdrawal via the Payment Widget Source: https://developer.uphold.com/developer-guides/apm-transfers/withdrawal/via-payment-widget/paypal Pay out from a user's account to PayPal using the Uphold Payment Widget. The Payment Widget handles PayPal selection for withdrawals via the **Select for Withdrawal flow**, where users can select or unlink a previously linked PayPal account directly in the widget. After the user confirms a PayPal withdrawal quote, the Payment Widget creates the transaction and completes the PayPal authorization — whether the account is new or already linked — via the **Authorize flow**. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview). * The `paypal-withdrawals` capability is enabled. * A **funded account** to debit the funds from. * The Payment Widget is set up in your frontend. See [Installation and setup](/widgets/payment/installation-and-setup). ## Walkthrough The diagram shows a first-time authorization. For an already-authorized account, skip the sign-in — the widget only collects device data and reuses the stored authorization. ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold Usr->>U: Start withdrawal U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose source account U->>B: Create widget session B->>A: Create widget session (select-for-withdrawal) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select PayPal P-->>U: complete { via, selection } Usr->>U: Choose amount U->>B: Create quote B->>A: Create quote (account → APM) A-->>B: { quote } B-->>U: { quote } U->>B: Create authorize session B->>A: Create widget session (authorize) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Sign in & authorize (first time only) P->>A: Create transaction A-->>P: { transaction } P-->>U: complete { transaction, trigger } B-->>Usr: Notify the user ``` *** ## Select source account withdrawals can be sourced from any account. If the selected account is not in the account's currency, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one to withdraw from. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` *** ## Select withdrawal method The Payment Widget's **Select for Withdrawal flow** presents the available payment methods, letting the user select PayPal when it's available. It performs **selection only**: it does not create the quote or the transaction. ### Create a widget session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) to start the `select-for-withdrawal` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-withdrawal" } ``` ```json theme={null} { "session": { "flow": "select-for-withdrawal", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` Pass `response.session` to your frontend to initialize the widget. ### Set up the widget ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeWithdrawalWidget = async (session) => { const widget = new PaymentWidget<'select-for-withdrawal'>(session, { debug: true }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event fires after the user selects PayPal. The event payload includes the selected PayPal external account — if it has already been previously authorized. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; // If via is 'apm', you can check the selected method through the property `selection.method`, which will be 'paypal' in this case. if (via === 'apm' && selection.method === 'paypal') { handlePayPalSelected(); } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; // If via is 'apm', you can check the selected method through the property `selection.method`, which will be 'paypal' in this case. if (via === 'apm' && selection.method === 'paypal') { handlePayPalSelected(); } teardown(); break; } ``` Once you have the selection, proceed to [Create a quote](#create-a-quote). ### Handle cancellations ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with the user's account as the origin and PayPal as the destination. There are two ways to specify the PayPal destination: * **`apm` shortcut** — use `type: "apm"` with `method: "paypal"`. This is always accepted, whether or not the user already has a linked PayPal account. * **`external-account` reference** — if the user already has a linked PayPal account, you can reference it directly with `type: "external-account"` and its `id` (from [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts)): ```json theme={null} { "destination": { "type": "external-account", "id": "9d3cce5f-a448-f985-b64f-a62930b18eea" } } ``` The example below uses the `apm` shortcut. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "b8618cd1-ccb0-4a72-985f-3bb0d268dabd" }, "destination": { "type": "apm", "method": "paypal" }, "denomination": { "asset": "USD", "amount": "15", "target": "origin" } } ``` A successful response includes the quote details and a `requirements` array. If it contains `authorize:paypal`, the user must authorize PayPal before the transaction can be created. ```json [expandable] theme={null} { "quote": { "id": "c3e8d2f7-9a41-4b75-b8e3-1d6f4a9c2e57", "origin": { "amount": "15.00", "asset": "USD", "node": { "type": "account", "id": "b8618cd1-ccb0-4a72-985f-3bb0d268dabd", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" }, "rate": "1" }, "destination": { "amount": "14.00", "asset": "USD", "node": { "type": "apm", "method": "paypal" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "15.00", "target": "origin", "rate": "1" }, "fees": [ { "type": "withdrawal", "code": "alternative-payment-method-withdrawals", "asset": "USD", "amount": "1.00", "percentage": "1.75" } ], "expiresAt": "2025-06-18T01:55:39Z", "requirements": [ "authorize:paypal" ] } } ``` *** ## Present the order summary Before creating the transaction, display an order summary of the quote — the amount, fees, and the origin and destination — so the user can review it. Here's an example:
PayPal order summary
*** ## Authorize and create the transaction After the user confirms the PayPal deposit quote, hand off to the Payment Widget **Authorize flow**. It creates the transaction, runs the PayPal authorization, and polls until a terminal status is reached — so the same flow works for both new and already-authorized accounts. ### Create an authorize session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) with `flow: "authorize"`, the `quoteId` and with the `requirements` array containing `authorize:paypal`. ```http theme={null} POST /widgets/payment/sessions { "flow": "authorize", "data": { "quoteId": "", "requirements": [ "authorize:paypal" ] } } ``` ```json theme={null} { "session": { "flow": "authorize", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL", "data": { "quoteId": "", "requirements": [ "authorize:paypal" ] } } } ``` ### Set up the widget Initialize the widget with the session. The widget interacts with PayPal, creates the transaction, and polls until a terminal status is reached. For a **new account** the user signs in to PayPal to authorize; for an **already-authorized account** they do not sign in again — the widget only collects device data and reuses the stored authorization. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeAuthorizeWidget = async (session) => { const widget = new PaymentWidget<'authorize'>(session, { debug: true }); widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; console.log('Complete', transaction.status, trigger.reason); widget.unmount(); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```javascript JavaScript [expandable] theme={null} // session is `response.session` from your backend async function initializeAuthorizeWidget(session) { const container = document.getElementById('payment-container'); const sessionOrigin = new URL(session.url).origin; const iframe = document.createElement('iframe'); iframe.src = session.url; iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src';"); iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; function teardown() { window.removeEventListener('message', onMessage); iframe.remove(); } function onMessage(event) { if (event.origin !== sessionOrigin) return; switch (event.data?.type) { case 'load': iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin); break; case 'complete': { const { transaction, trigger } = event.data.value; console.log('Complete', transaction.status, trigger.reason); teardown(); break; } case 'cancel': console.log('Cancelled'); teardown(); break; case 'error': console.error('Error', event.data.error); teardown(); break; } } window.addEventListener('message', onMessage); container.appendChild(iframe); } ``` Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event does not guarantee success. Always check `transaction.status` and `trigger.reason`. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the account is now authorized and the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { transaction, trigger } = event.data.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the account is now authorized and the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } teardown(); break; } ``` Failure reasons in `transaction.statusDetails.reason`: | Reason | Description | | ---------------------------------- | ------------------------------------------------ | | `apm-authorization-failed` | The PayPal authorization could not be completed | | `apm-payment-method-declined` | PayPal declined the payment method | | `apm-account-holder-data-mismatch` | The account holder details did not match | | `apm-missing-account-holder-data` | Required account holder details were missing | | `insufficient-funds` | The origin account has insufficient funds | | `provider-maximum-limit-exceeded` | The transaction exceeds provider limits | | `velocity` | The transaction was blocked by velocity rules | | `unspecified-error` | The transaction failed for an unspecified reason | ### Handle cancellations The `cancel` event fires when the user navigates back without completing authorization. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors. Most failures in this flow surface with a generic `code: 'error'` and a descriptive `message`. Transaction-creation failures are the exception: they use `code: 'authorize_transaction_failed'` with `event.detail.error.details.reason: 'unable-to-create-transaction'`. As with any Widget error, the original failure is preserved in [`event.detail.error.cause`](/widgets/payment/sdk-reference#error) — for `unable-to-create-transaction`, `event.detail.error.cause.code` is one of: | Code | Description | | ------------------------- | --------------------------------------------------------- | | `entity_not_found` | The quote was not found or has expired | | `insufficient_balance` | The origin account has insufficient balance | | `operation_not_allowed` | The operation is not permitted | | `user_capability_failure` | The user lacks the required capability for this operation | ```javascript Web SDK theme={null} widget.on('error', (event) => { const { code, cause, details } = event.detail.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed' && details?.reason === 'unable-to-create-transaction') { // The real reason is nested here, not in the top-level `code` console.error('Create transaction failed:', cause?.code); } widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': { const { code, cause, details } = event.data.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed' && details?.reason === 'unable-to-create-transaction') { console.error('Create transaction failed:', cause?.code); } teardown(); // Show a user-friendly error message break; } ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Monitor for settlement transactions remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) *** ## Sample transaction In a successful PayPal withdrawal, the origin is the user's `account` and the destination is the external account representing the user's PayPal account. ```json [expandable] theme={null} { "transaction": { "id": "9b2f4a17-5e3c-4d77-a8e1-9bcdef2c0a42", "origin": { "asset": "USD", "amount": "15.00", "node": { "type": "account", "id": "b8618cd1-ccb0-4a72-985f-3bb0d268dabd", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "destination": { "asset": "USD", "amount": "14.00", "node": { "type": "external-account", "id": "90acb64a-510d-f9d1-b542-d44e6c53eb5d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "status": "completed", "quotedAt": "2025-06-18T00:55:39Z", "createdAt": "2025-06-18T00:56:39Z", "updatedAt": "2025-06-18T00:57:08Z", "denomination": { "asset": "USD", "amount": "15.00", "target": "origin" } } } ``` *** ## Notify the user After the transaction completes, display the transaction details to the user so they can confirm the withdrawal succeeded. It must include the **PayPal logo and the PayPal account email used**. Here's an example:
PayPal transaction completed confirmation
*** You now support PayPal withdrawals via the Payment Widget. # Apple Pay withdrawal via the REST API Source: https://developer.uphold.com/developer-guides/apm-transfers/withdrawal/via-rest-api/apple-pay Pay out from a user's account to Apple Pay through the Uphold REST API. This guide covers paying out from a user's account to Apple Pay using the REST API together with the Payment Widget. Every withdrawal runs through the widget's **Authorize flow**, where the user authorizes with Apple Pay. Your backend only creates the quote and the widget session. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview). * The `apple-pay-withdrawals` capability is enabled. * A **funded account** to debit the funds from. * The Payment Widget is [set up in your frontend](/widgets/payment/installation-and-setup). * [Apple Pay's additional setup requirements](/developer-guides/apm-transfers/overview#apple-pay) are met. * The target browser or platform [supports Apple Pay](/developer-guides/apm-transfers/overview#browser-and-platform-compatibility-payment-widget). ## Walkthrough The diagram shows an Apple Pay transaction authorization. ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold Usr->>U: Start withdrawal U->>B: Get rails and capabilities B->>A: GET /core/rails?type=apm A-->>B: { rails } B->>A: GET /core/capabilities A-->>B: { capabilities } B-->>U: { rails, capabilities } U->>B: Get accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Select Apple Pay and origin account Usr->>U: Choose amount U->>B: Create quote B->>A: Create quote (account → APM) A-->>B: { quote } B-->>U: { quote } U->>B: Create authorize session B->>A: Create widget session (authorize) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Authorize Apple Pay P->>A: Create transaction A-->>P: { transaction } P-->>U: complete { transaction, trigger } B-->>Usr: Notify the user ``` *** ## Check available rails Call [List rails](/rest-apis/core-api/assets/list-rails) to verify Apple Pay withdrawal is available. ```http theme={null} GET /core/rails?type=apm ``` ```json theme={null} { "rails": [ { "type": "apm", "network": "apple-pay", "method": "apple-pay", "asset": "USD", "decimals": 2, "features": ["deposit", "withdraw"] } ] } ``` ## Check capabilities Call [List capabilities](/rest-apis/core-api/capabilities/list-user-capabilities) to confirm the user has the `apple-pay-withdrawals` capability enabled with no unmet requirements. ```http theme={null} GET /core/capabilities ``` ```json theme={null} { "capabilities": [ { "code": "apple-pay-withdrawals", "name": "Withdraw to apple pay", "enabled": true, "requirements": [], "restrictions": [] } ] } ``` ## Select source account withdrawals can be sourced from any account. If the selected account is not in the account's currency, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one to withdraw from. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` *** ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with the user's account as the origin and Apple Pay as the destination. Specify Apple Pay as the destination with `type: "apm"` with `method: "apple-pay"`. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "b8618cd1-ccb0-4a72-985f-3bb0d268dabd" }, "destination": { "type": "apm", "method": "apple-pay" }, "denomination": { "asset": "USD", "amount": "15", "target": "origin" } } ``` A successful response includes the quote details and a `requirements` array. For Apple Pay, it contains `authorize:apple-pay`, which indicates that the user must authorize Apple Pay before the transaction can be created. ```json [expandable] theme={null} { "quote": { "id": "c3e8d2f7-9a41-4b75-b8e3-1d6f4a9c2e57", "origin": { "amount": "15.00", "asset": "USD", "node": { "type": "account", "id": "b8618cd1-ccb0-4a72-985f-3bb0d268dabd", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" }, "rate": "1" }, "destination": { "amount": "14.00", "asset": "USD", "node": { "type": "apm", "method": "apple-pay" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "15.00", "target": "origin", "rate": "1" }, "fees": [ { "type": "withdrawal", "code": "alternative-payment-method-withdrawals", "asset": "USD", "amount": "1.00", "percentage": "1.75" } ], "expiresAt": "2025-06-18T01:55:39Z", "requirements": [ "authorize:apple-pay" ] } } ``` *** ## Present the order summary Before creating the transaction, display an order summary of the quote — the amount, fees, and the origin and destination — so the user can review it. Here's an example:
Apple Pay order summary
*** ## Authorize and create the transaction After the user confirms the Apple Pay quote, hand off to the Payment Widget **Authorize flow**. It presents the Apple Pay sheet, creates the transaction, and polls until a terminal status is reached. Apple Pay authorizes **per transaction** on the user's device — there is no stored authorization to reuse, so the user confirms with Face ID, Touch ID, or their passcode every time. By default, the widget renders a full walkthrough around the authorization; in **headless mode**, it renders only the Apple Pay button so you can embed it directly into your own UI — see [Headless mode](#headless-mode) below. ### Create an authorize session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) with `flow: "authorize"`, the `quoteId` and with the `requirements` array containing `authorize:apple-pay`. For web apps, also include the top-page `domain`. The top-page domain is the domain shown in the browser's URL bar — the very top-level page hosting the widget iframe. For web apps, it must match the domain you registered with Apple Pay for your merchant ID. ```http theme={null} POST /widgets/payment/sessions { "flow": "authorize", "data": { "quoteId": "", "requirements": [ "authorize:apple-pay" ], "domain": "" // For web apps only, required for Apple Pay } } ``` ```json theme={null} { "session": { "flow": "authorize", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL", "data": { "quoteId": "", "requirements": [ "authorize:apple-pay" ], "domain": "" // For web apps only, required for Apple Pay } } } ``` ### Set up the widget Initialize the widget with the session. The widget presents the sheet, collects device data, creates the transaction, and polls until a terminal status is reached. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeAuthorizeWidget = async (session) => { const widget = new PaymentWidget<'authorize'>(session, { debug: true }); widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; console.log('Complete', transaction.status, trigger.reason); widget.unmount(); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```javascript JavaScript [expandable] theme={null} // session is `response.session` from your backend async function initializeAuthorizeWidget(session) { const container = document.getElementById('payment-container'); const sessionOrigin = new URL(session.url).origin; const iframe = document.createElement('iframe'); iframe.src = session.url; iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src'; payment 'src';"); iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; function teardown() { window.removeEventListener('message', onMessage); iframe.remove(); } function onMessage(event) { if (event.origin !== sessionOrigin) return; switch (event.data?.type) { case 'load': iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin); break; case 'complete': { const { transaction, trigger } = event.data.value; console.log('Complete', transaction.status, trigger.reason); teardown(); break; } case 'cancel': console.log('Cancelled'); teardown(); break; case 'error': console.error('Error', event.data.error); teardown(); break; } } window.addEventListener('message', onMessage); container.appendChild(iframe); } ``` Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Headless mode By default, the widget renders a full authorization experience — a short walkthrough, the button, and a processing screen while the transaction is confirmed. In **headless mode**, it renders only the button, with no surrounding UI, so you can embed it directly into your own layout. Your app then owns the surrounding context and the post-authorization experience — progress and success feedback, and a way to cancel. ```javascript Web SDK theme={null} const widget = new PaymentWidget<'authorize'>(session, { debug: true, authorize: { mode: 'headless' } }); ``` ```javascript JavaScript theme={null} iframe.contentWindow.postMessage({ ...session, options: { authorize: { mode: 'headless' } }, type: 'init' }, sessionOrigin); ``` See [`AuthorizeFlowOptions`](/widgets/payment/sdk-reference#authorizeflowoptions) in the SDK reference for the full type definition. ### Handle the complete event The `complete` event does not guarantee success. Always check `transaction.status` and `trigger.reason`. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { transaction, trigger } = event.data.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } teardown(); break; } ``` Failure reasons in `transaction.statusDetails.reason`: | Reason | Description | | ----------------------------------- | ------------------------------------------------ | | `apm-authorization-failed` | The authorization could not be completed | | `card-declined-by-bank` | The card was declined by the issuing bank | | `card-expired` | The card has expired | | `card-permanently-declined-by-bank` | The card was permanently declined | | `card-unauthorized` | The card authorization was not completed | | `card-unsupported` | The card is not supported for this operation | | `insufficient-funds` | The origin account has insufficient funds | | `provider-maximum-limit-exceeded` | The transaction exceeds provider limits | | `velocity` | The transaction was blocked by velocity rules | | `unspecified-error` | The transaction failed for an unspecified reason | ### Handle cancellations The `cancel` event fires when the user dismisses the sheet or navigates back without completing authorization. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors — except `authorize_transaction_failed`, which is retryable **in headless mode**: the widget resets itself back to its ready state, so the button works again if you leave the widget mounted. In default mode there is no in-place retry for this code — the button does not become usable again, so unmount and create a new authorize session if you want the user to try again. `authorize_transaction_failed` is the retryable code for authorization and transaction-creation failures. `event.detail.error.details.reason` narrows down where it happened:
Reason Description
unable-to-create-payment-request The browser could not build the payment request
unable-to-make-payment The device or browser reported it cannot make this payment
unable-to-show-payment-request The sheet could not be shown
invalid-payment-response The sheet returned a response without the expected payment token
unable-to-create-transaction authorization succeeded, but the transaction itself could not be created — see event.detail.error.cause below for the underlying reason
Other failures in this flow — such as a missing or expired quote, or the SDK being unavailable — surface with their own `code` and are not retryable; handle those with a generic fallback. As with any Widget error, the original failure is preserved in [`event.detail.error.cause`](/widgets/payment/sdk-reference#error). When `details.reason` is `unable-to-create-transaction`, `event.detail.error.cause.code` is one of: | Code | Description | | ------------------------- | --------------------------------------------------------- | | `entity_not_found` | The quote was not found or has expired | | `insufficient_balance` | The origin account has insufficient balance | | `operation_not_allowed` | The operation is not permitted | | `user_capability_failure` | The user lacks the required capability for this operation | ```javascript Web SDK theme={null} // Set this to match how you configured the widget — see Headless mode above const isAuthorizeHeadless = false; widget.on('error', (event) => { const { code, cause, details } = event.detail.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed') { if (details.reason === 'unable-to-create-transaction') { // The real reason is nested here, not in the top-level `code` console.error('Create transaction failed:', cause?.code); } if (isAuthorizeHeadless) { // Retryable in headless mode — keep the widget mounted if you want the user to try again return; } // In default mode there is no in-place retry for this code — fall through and unmount } widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget. `isAuthorizeHeadless` is declared once // outside the handler (alongside `sessionOrigin`), matching how you configured the widget. case 'error': { const { code, cause, details } = event.data.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed') { if (details.reason === 'unable-to-create-transaction') { console.error('Create transaction failed:', cause?.code); } if (isAuthorizeHeadless) { // Retryable in headless mode — keep the iframe/WebView mounted so the user can try again break; } // In default mode there is no in-place retry for this code — fall through and teardown } teardown(); // Show a user-friendly error message break; } ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Monitor for settlement transactions remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) *** ## Sample transaction In a successful withdrawal, the origin is the user's `account` and the destination is an `apm` node with the method as `{apmMethod}`. The transaction status is initially `processing` and updates to `completed` once the transfer settles. ```json [expandable] theme={null} { "transaction": { "id": "9b2f4a17-5e3c-4d77-a8e1-9bcdef2c0a42", "origin": { "asset": "USD", "amount": "15.00", "node": { "type": "account", "id": "b8618cd1-ccb0-4a72-985f-3bb0d268dabd", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "destination": { "asset": "USD", "amount": "15.00", "node": { "type": "apm", "method": "apple-pay" } }, "status": "completed", "quotedAt": "2025-06-18T00:55:39Z", "createdAt": "2025-06-18T00:56:39Z", "updatedAt": "2025-06-18T00:57:08Z", "denomination": { "asset": "USD", "amount": "15.00", "target": "origin" } } } ``` *** ## Notify the user After the transaction completes, display the transaction details to the user so they can confirm the withdrawal succeeded. Here's an example:
Apple Pay transaction completed confirmation
*** ## Troubleshooting Having issues rendering Apple Pay? See [Troubleshooting](/widgets/payment/installation-and-setup#troubleshooting) in the Payment Widget installation guide. *** You now support Apple Pay withdrawals via the REST API together with the Payment Widget. # PayPal withdrawal via the REST API Source: https://developer.uphold.com/developer-guides/apm-transfers/withdrawal/via-rest-api/paypal Pay out from a user's account to PayPal through the Uphold REST API. This guide covers paying out from a user's account to PayPal using the REST API together with the Payment Widget. It handles both cases: **authorizing a new account** for the first time, and **withdrawing to an account the user already authorized**. Both run through the widget's **Authorize flow** — your backend only creates the quote and the widget session. Authorization always runs through the Payment Widget — for both new and already-authorized accounts. The widget collects Braintree **device data** and submits it with the transaction, which PayPal uses for fraud and risk analysis. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview). * The `paypal-withdrawals` capability is enabled. * A **funded account** to debit the funds from. * The Payment Widget is set up in your frontend. See [Installation and setup](/widgets/payment/installation-and-setup). ## Walkthrough The diagram shows a first-time authorization. For an already-authorized account, skip the sign-in — the widget only collects device data and reuses the stored authorization. ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold Usr->>U: Start withdrawal U->>B: Get rails and capabilities B->>A: GET /core/rails?type=apm A-->>B: { rails } B->>A: GET /core/capabilities A-->>B: { capabilities } B-->>U: { rails, capabilities } U->>B: Get accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Select PayPal and origin account Usr->>U: Choose amount U->>B: Create quote B->>A: Create quote (account → APM) A-->>B: { quote } B-->>U: { quote } U->>B: Create authorize session B->>A: Create widget session (authorize) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Sign in & authorize (first time only) P->>A: Create transaction A-->>P: { transaction } P-->>U: complete { transaction, trigger } B-->>Usr: Notify the user ``` *** ## Check available rails Call [List rails](/rest-apis/core-api/assets/list-rails) to verify PayPal withdrawal is available. ```http theme={null} GET /core/rails?type=apm ``` ```json theme={null} { "rails": [ { "type": "apm", "network": "paypal", "method": "paypal", "asset": "USD", "decimals": 2, "features": ["deposit", "withdraw"] } ] } ``` ## Check capabilities Call [List capabilities](/rest-apis/core-api/capabilities/list-user-capabilities) to confirm the user has the `paypal-withdrawals` capability enabled with no unmet requirements. ```http theme={null} GET /core/capabilities ``` ```json theme={null} { "capabilities": [ { "code": "paypal-withdrawals", "name": "PayPal", "enabled": true, "requirements": [], "restrictions": [] } ] } ``` ## Select source account withdrawals can be sourced from any account. If the selected account is not in the account's currency, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one to withdraw from. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` *** ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with the user's account as the origin and PayPal as the destination. There are two ways to specify the PayPal destination: * **`apm` shortcut** — use `type: "apm"` with `method: "paypal"`. This is always accepted, whether or not the user already has a linked PayPal account. * **`external-account` reference** — if the user already has a linked PayPal account, you can reference it directly with `type: "external-account"` and its `id` (from [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts)): ```json theme={null} { "destination": { "type": "external-account", "id": "9d3cce5f-a448-f985-b64f-a62930b18eea" } } ``` The example below uses the `apm` shortcut. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "b8618cd1-ccb0-4a72-985f-3bb0d268dabd" }, "destination": { "type": "apm", "method": "paypal" }, "denomination": { "asset": "USD", "amount": "15", "target": "origin" } } ``` A successful response includes the quote details and a `requirements` array. If it contains `authorize:paypal`, the user must authorize PayPal before the transaction can be created. ```json [expandable] theme={null} { "quote": { "id": "c3e8d2f7-9a41-4b75-b8e3-1d6f4a9c2e57", "origin": { "amount": "15.00", "asset": "USD", "node": { "type": "account", "id": "b8618cd1-ccb0-4a72-985f-3bb0d268dabd", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" }, "rate": "1" }, "destination": { "amount": "14.00", "asset": "USD", "node": { "type": "apm", "method": "paypal" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "15.00", "target": "origin", "rate": "1" }, "fees": [ { "type": "withdrawal", "code": "alternative-payment-method-withdrawals", "asset": "USD", "amount": "1.00", "percentage": "1.75" } ], "expiresAt": "2025-06-18T01:55:39Z", "requirements": [ "authorize:paypal" ] } } ``` *** ## Present the order summary Before creating the transaction, display an order summary of the quote — the amount, fees, and the origin and destination — so the user can review it. Here's an example:
PayPal order summary
*** ## Authorize and create the transaction After the user confirms the PayPal deposit quote, hand off to the Payment Widget **Authorize flow**. It creates the transaction, runs the PayPal authorization, and polls until a terminal status is reached — so the same flow works for both new and already-authorized accounts. ### Create an authorize session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) with `flow: "authorize"`, the `quoteId` and with the `requirements` array containing `authorize:paypal`. ```http theme={null} POST /widgets/payment/sessions { "flow": "authorize", "data": { "quoteId": "", "requirements": [ "authorize:paypal" ] } } ``` ```json theme={null} { "session": { "flow": "authorize", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL", "data": { "quoteId": "", "requirements": [ "authorize:paypal" ] } } } ``` ### Set up the widget Initialize the widget with the session. The widget interacts with PayPal, creates the transaction, and polls until a terminal status is reached. For a **new account** the user signs in to PayPal to authorize; for an **already-authorized account** they do not sign in again — the widget only collects device data and reuses the stored authorization. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeAuthorizeWidget = async (session) => { const widget = new PaymentWidget<'authorize'>(session, { debug: true }); widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; console.log('Complete', transaction.status, trigger.reason); widget.unmount(); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```javascript JavaScript [expandable] theme={null} // session is `response.session` from your backend async function initializeAuthorizeWidget(session) { const container = document.getElementById('payment-container'); const sessionOrigin = new URL(session.url).origin; const iframe = document.createElement('iframe'); iframe.src = session.url; iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src';"); iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; function teardown() { window.removeEventListener('message', onMessage); iframe.remove(); } function onMessage(event) { if (event.origin !== sessionOrigin) return; switch (event.data?.type) { case 'load': iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin); break; case 'complete': { const { transaction, trigger } = event.data.value; console.log('Complete', transaction.status, trigger.reason); teardown(); break; } case 'cancel': console.log('Cancelled'); teardown(); break; case 'error': console.error('Error', event.data.error); teardown(); break; } } window.addEventListener('message', onMessage); container.appendChild(iframe); } ``` Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event does not guarantee success. Always check `transaction.status` and `trigger.reason`. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the account is now authorized and the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { transaction, trigger } = event.data.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — the account is now authorized and the transfer settled } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } teardown(); break; } ``` Failure reasons in `transaction.statusDetails.reason`: | Reason | Description | | ---------------------------------- | ------------------------------------------------ | | `apm-authorization-failed` | The PayPal authorization could not be completed | | `apm-payment-method-declined` | PayPal declined the payment method | | `apm-account-holder-data-mismatch` | The account holder details did not match | | `apm-missing-account-holder-data` | Required account holder details were missing | | `insufficient-funds` | The origin account has insufficient funds | | `provider-maximum-limit-exceeded` | The transaction exceeds provider limits | | `velocity` | The transaction was blocked by velocity rules | | `unspecified-error` | The transaction failed for an unspecified reason | ### Handle cancellations The `cancel` event fires when the user navigates back without completing authorization. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors. Most failures in this flow surface with a generic `code: 'error'` and a descriptive `message`. Transaction-creation failures are the exception: they use `code: 'authorize_transaction_failed'` with `event.detail.error.details.reason: 'unable-to-create-transaction'`. As with any Widget error, the original failure is preserved in [`event.detail.error.cause`](/widgets/payment/sdk-reference#error) — for `unable-to-create-transaction`, `event.detail.error.cause.code` is one of: | Code | Description | | ------------------------- | --------------------------------------------------------- | | `entity_not_found` | The quote was not found or has expired | | `insufficient_balance` | The origin account has insufficient balance | | `operation_not_allowed` | The operation is not permitted | | `user_capability_failure` | The user lacks the required capability for this operation | ```javascript Web SDK theme={null} widget.on('error', (event) => { const { code, cause, details } = event.detail.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed' && details?.reason === 'unable-to-create-transaction') { // The real reason is nested here, not in the top-level `code` console.error('Create transaction failed:', cause?.code); } widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': { const { code, cause, details } = event.data.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed' && details?.reason === 'unable-to-create-transaction') { console.error('Create transaction failed:', cause?.code); } teardown(); // Show a user-friendly error message break; } ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Monitor for settlement transactions remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) *** ## Sample transaction In a successful PayPal withdrawal, the origin is the user's `account` and the destination is the external account representing the user's PayPal account. ```json [expandable] theme={null} { "transaction": { "id": "9b2f4a17-5e3c-4d77-a8e1-9bcdef2c0a42", "origin": { "asset": "USD", "amount": "15.00", "node": { "type": "account", "id": "b8618cd1-ccb0-4a72-985f-3bb0d268dabd", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "destination": { "asset": "USD", "amount": "14.00", "node": { "type": "external-account", "id": "90acb64a-510d-f9d1-b542-d44e6c53eb5d", "ownerId": "48e40cb2-6c34-44ce-b2f1-6adac459bb37" } }, "status": "completed", "quotedAt": "2025-06-18T00:55:39Z", "createdAt": "2025-06-18T00:56:39Z", "updatedAt": "2025-06-18T00:57:08Z", "denomination": { "asset": "USD", "amount": "15.00", "target": "origin" } } } ``` *** ## Notify the user After the transaction completes, display the transaction details to the user so they can confirm the withdrawal succeeded. It must include the **PayPal logo and the PayPal account email used**. Here's an example:
PayPal transaction completed confirmation
*** You now support PayPal withdrawals via the REST API together with the Payment Widget. # ACH, Fednow/RTP & Wire bank deposit via the Payment Widget Source: https://developer.uphold.com/developer-guides/bank-transfers/deposit/via-payment-widget/ach Accept ACH, Fednow/RTP & Wire bank deposits with the Uphold Payment Widget: create a session, display transfer instructions to the user, and monitor for incoming funds. The Payment Widget handles deposit method selection across all supported US networks — ACH, Wire, FedNow, and RTP — and displays the necessary transfer instructions to the user. Your backend only needs to create the session and monitor for the incoming transfer. No per-network handling is required. The Payment Widget does not create any transaction. Monitoring and processing the incoming transfer must be handled by your backend via webhooks or polling. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. * The Payment Widget is set up in your frontend. See [Installation and setup](/widgets/payment/installation-and-setup). ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold participant F as Bank Usr->>U: Request deposit instructions U->>B: Create widget session B->>A: Create widget session (select-for-deposit) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select deposit method P-->>Usr: Display bank transfer instructions Usr->>F: Initiate bank transfer F-->>A: Incoming transfer received A-->>B: webhook: transaction.created (processing) F-->>A: Settlement confirmed A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Select deposit method The widget lets the user select a deposit method and view transfer instructions that work across all supported US networks — ACH, Wire, FedNow, and RTP. ### Create a widget session Call the [Create widget session](/rest-apis/widgets-api/payment/create-session) endpoint to create a session for the `select-for-deposit` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-deposit" } ``` A successful response contains a `session` object. Pass `response.session` to your frontend to initialize the widget. ```json theme={null} { "session": { "flow": "select-for-deposit", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` ### Set up the widget Initialize the widget for the `select-for-deposit` flow using the session data returned from the API. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeDepositWidget = async (session) => { // Initialize the widget const widget = new PaymentWidget<'select-for-deposit'>(session, { debug: true }); // Set up event handlers widget.on('ready', () => { console.log('Ready'); }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); // Mount the widget widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event fires when the user selects a deposit method and completes the flow. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; if (via === 'deposit-method') { const { depositMethod, account } = selection; handleBankDeposit(depositMethod, account); } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; if (via === 'deposit-method') { const { depositMethod, account } = selection; handleBankDeposit(depositMethod, account); } teardown(); break; } ``` The event payload contains two primary properties: * `via` — is set to `deposit-method` when the user selects a deposit method and completes the flow. * `selection` — contains the `account` that will receive the funds and the `depositMethod` configuration with transfer instructions. The widget presents these details directly to the user, so you don't need to display them separately. Across supported US rails, the transfer instruction fields such as routing and account details are shared, while `depositMethod.details.network` indicates the rail selected for this deposit method. No per-network handling is required. In the API, `"fednow"` represents both the FedNow and RTP networks. The optional `secondaryNetworks` field lists additional supported rails for the same account and transfer instructions, and it's only present when more than one network is supported. ```json theme={null} { "type": "bank", "status": "ok", "details": { "network": "ach", "asset": "USD", "routingNumber": "021000021", "accountNumber": "123456789", "beneficiary": "John Doe", "bankName": "Example Bank", "bankAddress": { "line1": "456 Bank Avenue", "line2": "Fort Lee, NJ 07024" }, "secondaryNetworks": [ "fednow", "wire" ] } } ``` ### Handle cancellations The `cancel` event fires when the user closes the widget without selecting a deposit method. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Redirect back or show a cancellation message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Redirect back or show a cancellation message break; ``` ### Handle errors The `error` event fires when an error occurs during the deposit method selection process. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. ## Monitor for the incoming transfer The widget presents the deposit instructions to the user but does not monitor for the incoming transfer. Your application must do this via webhooks or polling. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → bank transfer received, pending posting * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds settled * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Sample transaction The sample below shows an ACH deposit. Transactions for other supported networks have the same structure — only `origin.node.network` differs: `"ach"`, `"fednow"` (FedNow and RTP), or `"wire"`. In a successful ACH bank deposit, the origin is represented as a `bank-address` node. The destination is the user's default USD account. ```json [expandable] theme={null} { "transaction": { "id": "a2b3c4d5-e6f7-8a9b-c0d1-e2f3a4b5c6d7", "origin": { "asset": "USD", "amount": "500.00", "node": { "type": "bank-address", "network": "ach" } }, "destination": { "asset": "USD", "amount": "500.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "status": "completed", "quotedAt": "2025-06-15T14:00:00Z", "createdAt": "2025-06-15T14:10:00Z", "updatedAt": "2025-06-15T14:30:00Z", "denomination": { "asset": "USD", "amount": "500.00", "target": "origin" } } } ``` ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support ACH, Wire, RTP, and FedNow push bank deposits via the Payment Widget. # FPS bank deposit via the Payment Widget Source: https://developer.uphold.com/developer-guides/bank-transfers/deposit/via-payment-widget/fps Accept GBP Faster Payments (FPS) deposits with the Uphold Payment Widget. Create a session, display bank instructions, and monitor for incoming funds. The Payment Widget handles FPS deposit method selection and displays the necessary transfer instructions to the user. Your backend only needs to create the session and monitor for the incoming transfer. The Payment Widget does not create any transaction. Monitoring and processing the incoming transfer must be handled by your backend via webhooks or polling. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. * The Payment Widget is set up in your frontend. See [Installation and setup](/widgets/payment/installation-and-setup). ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold participant F as Bank Usr->>U: Request deposit instructions U->>B: Create widget session B->>A: Create widget session (select-for-deposit) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select deposit method P-->>Usr: Display bank transfer instructions Usr->>F: Initiate bank transfer F-->>A: Incoming transfer received A->>A: Link origin as external account A-->>B: webhook: transaction.created (processing) F-->>A: Settlement confirmed A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Select deposit method The widget lets the user select an FPS deposit method and view the transfer instructions. ### Create a widget session Call the [Create widget session](/rest-apis/widgets-api/payment/create-session) endpoint to create a session for the `select-for-deposit` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-deposit" } ``` A successful response contains a `session` object. Pass `response.session` to your frontend to initialize the widget. ```json theme={null} { "session": { "flow": "select-for-deposit", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` ### Set up the widget Initialize the widget for the `select-for-deposit` flow using the session data returned from the API. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeDepositWidget = async (session) => { // Initialize the widget const widget = new PaymentWidget<'select-for-deposit'>(session, { debug: true }); // Set up event handlers widget.on('ready', () => { console.log('Ready'); }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); // Mount the widget widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event fires when the user selects a deposit method and completes the flow. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; if (via === 'deposit-method') { const { depositMethod, account } = selection; handleBankDeposit(depositMethod, account); } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; if (via === 'deposit-method') { const { depositMethod, account } = selection; handleBankDeposit(depositMethod, account); } teardown(); break; } ``` The event payload contains two primary properties: * `via` — is set to `deposit-method` when the user selects a deposit method and completes the flow. * `selection` — contains the `account` that will receive the funds and the `depositMethod` configuration with transfer instructions. The widget presents these details directly to the user, so you don't need to display them separately. The FPS `depositMethod.details` contains: ```json theme={null} { "type": "bank", "status": "ok", "details": { "network": "fps", "asset": "GBP", "sortCode": "123456", "accountNumber": "12345678", "reference": "UH12345678", "beneficiary": "John Doe", "bankName": "Example Bank", "bankAddress": { "line1": "123 Bank Street", "line2": "London, United Kingdom" } } } ``` ### Handle cancellations The `cancel` event fires when the user closes the widget without selecting a deposit method. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Redirect back or show a cancellation message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Redirect back or show a cancellation message break; ``` ### Handle errors The `error` event fires when an error occurs during the deposit method selection process. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. ## Monitor for the incoming transfer The widget presents the deposit instructions to the user but does not monitor for the incoming transfer. Your application must do this via webhooks or polling. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → bank transfer received, pending posting * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds settled * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Sample transaction In a successful FPS deposit, the origin is represented as an `external-account` node. This is because the origin bank details are automatically registered as an external account. The destination is the account selected when the deposit instructions were generated, or the user's default GBP account if the transfer was sent without a reference, or the selected account doesn't exist anymore. ```json [expandable] theme={null} { "transaction": { "id": "b1bbbc0f-dae2-4e94-9e6d-4b9d5a1f3c1f", "origin": { "asset": "GBP", "amount": "250.00", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "GBP", "amount": "250.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "status": "completed", "quotedAt": "2025-01-10T11:02:39Z", "createdAt": "2025-01-10T11:12:39Z", "updatedAt": "2025-01-10T11:13:08Z", "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } } ``` The new external account can be then retrieved with [Get external account](/rest-apis/core-api/external-accounts/get-external-account) to show the sender's bank details alongside the transaction, or to withdraw funds to the same account in the future. ```json [expandable] theme={null} { "externalAccount": { "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "bank", "status": "ok", "label": "GBP Bank Account", "asset": "GBP", "network": "fps", "features": [ "withdraw" ], "details": { "accountNumber": "12345678", "sortCode": "123456" }, "createdAt": "2025-01-10T11:12:39Z", "updatedAt": "2025-01-10T11:13:08Z" } } ``` ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support FPS bank transfer deposits via the Payment Widget. # ACH bank deposit via the REST API Source: https://developer.uphold.com/developer-guides/bank-transfers/deposit/via-rest-api/ach Support ACH bank deposits in USD using the Uphold REST API: configure the deposit method, share routing details, and monitor for incoming funds. This guide walks you through the steps to support ACH bank deposits using the REST API — from generating deposit instructions to monitoring for the incoming transfer. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold participant F as Bank Usr->>U: Request deposit instructions U->>B: Set up deposit method B->>A: Set up account deposit method A-->>B: { depositMethod } B-->>U: { depositMethod } U-->>Usr: Display bank instructions Usr->>F: Initiate bank transfer F-->>A: Incoming transfer received A-->>B: webhook: transaction.created (processing) F-->>A: Settlement confirmed A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Check available rails Call [List Rails](/rest-apis/core-api/assets/list-rails) to verify ACH supports the `deposit` feature before proceeding. ```http theme={null} GET /core/rails?type=bank&network=ach&asset=USD ``` A successful response includes the rail details. The `constraints` field indicates that US rail deposits can only be credited to the user's default USD account, regardless of the network used. ```json theme={null} { "rails": [ { "type": "bank", "network": "ach", "method": "bank-transfer", "asset": "USD", "decimals": 2, "features": [ "deposit", "withdraw" ], "constraints": [ { "rule": "allowed-deposit-accounts", "allowed": "default-only" } ] } ] } ``` ## Select destination account USD bank deposits can only be credited to the user's default USD account. ### Find the default account Call [List default accounts](/rest-apis/core-api/accounts/list-default-accounts) to retrieve it. ```http theme={null} GET /core/accounts/defaults?asset=USD ``` ```json theme={null} { "accounts": [ { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "ownerId": "1e32fca3-23f7-40ed-bc1b-de10c790182d", "label": "My USD account", "asset": "USD", "balance": { "total": "100.00", "available": "100.00" } } ] } ``` ### Create a new account If the user has no default USD account, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My USD account", "asset": "USD" } ``` ```json theme={null} { "account": { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "ownerId": "1e32fca3-23f7-40ed-bc1b-de10c790182d", "label": "My USD account", "asset": "USD", "balance": { "total": "0", "available": "0" } } } ``` ## Generate deposit method For US bank rails, the same account and routing number are shared across all networks (ACH, FedNow / RTP, and Wire), so the same deposit method is generated and can be used for any of these networks. Call [Set up account deposit method](/rest-apis/core-api/accounts/set-up-account-deposit-method) with the target account id and the desired network. For subsequent calls, use [Get account deposit method](/rest-apis/core-api/accounts/get-account-deposit-method) instead. ```http theme={null} PUT /core/accounts/{accountId}/deposit-method?type=bank&network=ach&asset=USD ``` Use `network=fednow` or `network=wire` to generate instructions branded for those networks instead. A successful response includes the bank details for the user to initiate the transfer. ```json theme={null} { "depositMethod": { "type": "bank", "status": "ok", "details": { "network": "ach", "asset": "USD", "routingNumber": "021000021", "accountNumber": "123456789", "accountType": "checking", "beneficiary": "John Doe", "bankName": "Cross River Bank", "bankAddress": { "line1": "2 Riverview Dr", "line2": "Fort Lee, NJ 07024" }, "secondaryNetworks": [ "fednow", "wire" ] } } } ``` The deposit method may initially return `status: processing` while the details are being prepared. Call [Get account deposit method](/rest-apis/core-api/accounts/get-account-deposit-method) to make sure the deposit method is ready (`status: ok`) before displaying instructions to the user. Provide the **routing number**, **account number** and **account type**. Include the **beneficiary name**, **bank name**, and **bank address** to help the user confirm the legitimacy of the instructions. No reference is provided because all deposits are credited to the default USD account. ## Monitor for the incoming transfer Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → bank transfer received, pending posting * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds settled * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Sample transaction In a successful ACH bank deposit, the origin is represented as a `bank-address` node. The destination is the user's default USD account. ```json [expandable] theme={null} { "transaction": { "id": "a2b3c4d5-e6f7-8a9b-c0d1-e2f3a4b5c6d7", "origin": { "asset": "USD", "amount": "500.00", "node": { "type": "bank-address", "network": "ach" } }, "destination": { "asset": "USD", "amount": "500.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "status": "completed", "quotedAt": "2025-06-15T14:00:00Z", "createdAt": "2025-06-15T14:10:00Z", "updatedAt": "2025-06-15T14:30:00Z", "denomination": { "asset": "USD", "amount": "500.00", "target": "origin" } } } ``` ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support ACH bank transfer deposits via the REST API. # FedNow / RTP bank deposit via the REST API Source: https://developer.uphold.com/developer-guides/bank-transfers/deposit/via-rest-api/fednow Send near-instant USD withdrawals via FedNow and RTP using the Uphold REST API: create an external bank account, generate a quote, submit the payout, and monitor settlement. This guide walks you through the steps to support FedNow / RTP bank deposits using the REST API. FedNow / RTP uses the same bank account and routing number as ACH — the deposit method setup is identical. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold participant F as Bank Usr->>U: Request deposit instructions U->>B: Set up deposit method B->>A: Set up account deposit method A-->>B: { depositMethod } B-->>U: { depositMethod } U-->>Usr: Display bank instructions Usr->>F: Initiate FedNow / RTP transfer F-->>A: Incoming transfer received A-->>B: Transaction created (processing) F-->>A: Settlement confirmed A-->>B: Transaction completed/failed B-->>Usr: Notify the user ``` *** ## Check available rails Call [List Rails](/rest-apis/core-api/assets/list-rails) to verify FedNow supports the `deposit` feature before proceeding. ```http theme={null} GET /core/rails?type=bank&network=fednow&asset=USD ``` A successful response includes the rail details. The `constraints` field indicates deposits are credited to the user's default USD account. ```json theme={null} { "rails": [ { "type": "bank", "network": "fednow", "method": "bank-transfer", "asset": "USD", "decimals": 2, "features": [ "deposit" ], "constraints": [ { "rule": "allowed-deposit-accounts", "allowed": "default-only" } ] } ] } ``` ## Select destination account USD bank deposits can only be credited to the user's default USD account. ### Find the default account Call [List default accounts](/rest-apis/core-api/accounts/list-default-accounts) to retrieve it. ```http theme={null} GET /core/accounts/defaults?asset=USD ``` ```json theme={null} { "accounts": [ { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "ownerId": "1e32fca3-23f7-40ed-bc1b-de10c790182d", "label": "My USD account", "asset": "USD", "balance": { "total": "100.00", "available": "100.00" } } ] } ``` ### Create a new account If the user has no default USD account, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My USD account", "asset": "USD" } ``` ```json theme={null} { "account": { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "ownerId": "1e32fca3-23f7-40ed-bc1b-de10c790182d", "label": "My USD account", "asset": "USD", "balance": { "total": "0", "available": "0" } } } ``` ## Generate deposit method For US bank rails, the same account and routing number are shared across all networks (ACH, FedNow / RTP, and Wire), so the same deposit method is generated and can be used for any of these networks. Call [Set up account deposit method](/rest-apis/core-api/accounts/set-up-account-deposit-method) with the target account id and the desired network. For subsequent calls, use [Get account deposit method](/rest-apis/core-api/accounts/get-account-deposit-method) instead. ```http theme={null} PUT /core/accounts/{accountId}/deposit-method?type=bank&network=fednow&asset=USD ``` A successful response includes the bank details. These are the same routing and account numbers as ACH — the sender uses them to initiate a FedNow / RTP push. ```json theme={null} { "depositMethod": { "type": "bank", "status": "ok", "details": { "network": "fednow", "asset": "USD", "routingNumber": "021000021", "accountNumber": "123456789", "accountType": "checking", "beneficiary": "John Doe", "bankName": "Cross River Bank", "bankAddress": { "line1": "2 Riverview Dr", "line2": "Fort Lee, NJ 07024" } } } } ``` The deposit method may initially return `status: processing` while the details are being prepared. Call [Get account deposit method](/rest-apis/core-api/accounts/get-account-deposit-method) to confirm it is ready (`status: ok`) before displaying instructions to the user. Provide the **routing number**, **account number** and **account type**. Include the **beneficiary name**, **bank name**, and **bank address** to help the user confirm the legitimacy of the instructions. ## Monitor for the incoming transfer Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → bank transfer received, pending posting * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds settled * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Sample transaction In a successful FedNow / RTP bank deposit, the origin is represented as a `bank-address` node. The destination is the user's default USD account. ```json [expandable] theme={null} { "transaction": { "id": "a2b3c4d5-e6f7-8a9b-c0d1-e2f3a4b5c6d7", "origin": { "asset": "USD", "amount": "500.00", "node": { "type": "bank-address", "network": "fednow" } }, "destination": { "asset": "USD", "amount": "500.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "status": "completed", "quotedAt": "2025-06-15T14:00:00Z", "createdAt": "2025-06-15T14:10:00Z", "updatedAt": "2025-06-15T14:30:00Z", "denomination": { "asset": "USD", "amount": "500.00", "target": "origin" } } } ``` ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support FedNow / RTP bank deposits via the REST API. # FPS bank deposit via the REST API Source: https://developer.uphold.com/developer-guides/bank-transfers/deposit/via-rest-api/fps Generate GBP Faster Payments (FPS) deposit instructions and monitor incoming transfers using the Uphold REST API, with automatic origin account linking after first deposit. This guide walks you through the steps to support FPS bank deposits using the REST API — from generating deposit instructions to monitoring for the incoming transfer. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold participant F as Bank Usr->>U: Choose account to fund U->>B: Set up deposit method B->>A: Set up account deposit method A-->>B: { depositMethod } B-->>U: { depositMethod } U-->>Usr: Display bank instructions Usr->>F: Initiate bank transfer F-->>A: Incoming transfer received A->>A: Link origin as external account A-->>B: webhook: transaction.created (processing) F-->>A: Settlement confirmed A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Check available rails Call [List Rails](/rest-apis/core-api/assets/list-rails) to verify FPS supports the `deposit` feature before proceeding. ```http theme={null} GET /core/rails?type=bank&network=fps&asset=GBP ``` A successful response includes the rail details and its features. ```json theme={null} { "rails": [ { "type": "bank", "network": "fps", "method": "bank-transfer", "asset": "GBP", "decimals": 2, "features": [ "deposit", "withdraw" ] } ] } ``` ## Select destination account FPS push bank deposits can target any account. If the selected account is not in GBP, the deposited amount will be converted to the destination account's currency at the time of settlement. Make sure the destination asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). ### Find an existing account Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one they want to fund. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My GBP account", "asset": "GBP", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ### Create a new account If the user has no accounts, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My GBP account", "asset": "GBP" } ``` ```json theme={null} { "account": { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My GBP account", "asset": "GBP", "balance": { "total": "0", "available": "0" } } } ``` ## Terms and disclosures Before generating deposit instructions, you must collect the user's acceptance of the applicable terms of service and display the required regulatory disclaimer. ### Accept terms of service The user must accept the `unique-account-number-viban` terms of service before FPS deposit instructions can be generated. Display the following copy to the user and prompt them to continue: > By selecting continue you agree to the LHV's [Principles of Processing Customer Data](https://www.lhv.ee/en/principles-of-processing-customer-data) and the [Personal Deposit Accounts Terms and Conditions](https://uphold.com/en-gb/legal/unique-account-number-terms-of-use). Once the user confirms, call [Accept terms of service](/rest-apis/core-api/terms-of-service/accept-terms-of-service) to register their acceptance. ```http theme={null} POST /core/terms-of-service/unique-account-number-viban/accept ``` ### EMD disclaimer This disclaimer is required to comply with FCA regulations. Display it to users based in GB, the first time they link a credit/debit card or generate bank deposit details — it only needs to be shown once: > Uphold Europe Limited is an EMD Agent of Optimus Cards UK Limited (FRN: 902034). All received funds are held in a designated safekeeping account with a regulated bank and kept separate from Uphold's own assets. These funds are not protected by the UK Financial Services Compensation Scheme. ## Generate deposit method Call [Set up account deposit method](/rest-apis/core-api/accounts/set-up-account-deposit-method) with the target account id and the desired network. For subsequent calls, use [Get account deposit method](/rest-apis/core-api/accounts/get-account-deposit-method) instead. ```http theme={null} PUT /core/accounts/{accountId}/deposit-method?type=bank&network=fps&asset=GBP ``` A successful response includes the necessary bank details and reference for the user to initiate the transfer. ```json theme={null} { "depositMethod": { "type": "bank", "status": "ok", "details": { "network": "fps", "asset": "GBP", "sortCode": "123456", "accountNumber": "12345678", "reference": "UH12345678", "beneficiary": "John Doe", "bankName": "Example Bank", "bankAddress": { "line1": "123 Bank Street", "line2": "London, United Kingdom" } } } } ``` The deposit method may initially return `status: processing` while the details are being prepared. Call [Get account deposit method](/rest-apis/core-api/accounts/get-account-deposit-method) to make sure the deposit method is ready (`status: ok`) before displaying instructions to the user. Provide the **sort code**, **account number**, and **reference** to the user. Include the **beneficiary name**, **bank name**, and **bank address** to help the user confirm the legitimacy of the instructions. Deposits sent without the reference are still credited, but funds will be deposited into the user's default GBP account. ## Monitor for the incoming transfer Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → bank transfer received, pending posting * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds settled * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Sample transaction In a successful FPS deposit, the origin is represented as an `external-account` node. This is because the origin bank details are automatically registered as an external account. The destination is the account selected when the deposit instructions were generated, or the user's default GBP account if the transfer was sent without a reference, or the selected account doesn't exist anymore. ```json [expandable] theme={null} { "transaction": { "id": "b1bbbc0f-dae2-4e94-9e6d-4b9d5a1f3c1f", "origin": { "asset": "GBP", "amount": "250.00", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "GBP", "amount": "250.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "status": "completed", "quotedAt": "2025-01-10T11:02:39Z", "createdAt": "2025-01-10T11:12:39Z", "updatedAt": "2025-01-10T11:13:08Z", "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } } ``` The new external account can be then retrieved with [Get external account](/rest-apis/core-api/external-accounts/get-external-account) to show the sender's bank details alongside the transaction, or to withdraw funds to the same account in the future. ```json [expandable] theme={null} { "externalAccount": { "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "bank", "status": "ok", "label": "GBP Bank Account", "asset": "GBP", "network": "fps", "features": [ "withdraw" ], "details": { "accountNumber": "12345678", "sortCode": "123456" }, "createdAt": "2025-01-10T11:12:39Z", "updatedAt": "2025-01-10T11:13:08Z" } } ``` ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support FPS bank transfer deposits via the REST API. # SEPA bank deposit via the REST API Source: https://developer.uphold.com/developer-guides/bank-transfers/deposit/via-rest-api/sepa Generate EUR SEPA deposit instructions and monitor incoming transfers using the Uphold REST API, with automatic origin account linking after first deposit. This guide walks you through the steps to support SEPA bank deposits using the REST API — from generating deposit instructions to monitoring for the incoming transfer. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold participant F as Bank Usr->>U: Choose account to fund U->>B: Set up deposit method B->>A: Set up account deposit method A-->>B: { depositMethod } B-->>U: { depositMethod } U-->>Usr: Display bank instructions Usr->>F: Initiate bank transfer F-->>A: Incoming transfer received A->>A: Link origin as external account A-->>B: webhook: transaction.created (processing) F-->>A: Settlement confirmed A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Check available rails Call [List Rails](/rest-apis/core-api/assets/list-rails) to verify SEPA supports the `deposit` feature before proceeding. ```http theme={null} GET /core/rails?type=bank&network=sepa&asset=EUR ``` A successful response includes the rail details and its features. ```json theme={null} { "rails": [ { "type": "bank", "network": "sepa", "method": "bank-transfer", "asset": "EUR", "decimals": 2, "features": [ "deposit", "withdraw" ] } ] } ``` ## Select destination account SEPA deposits can target any account. If the selected account is not in EUR, the deposited amount will be converted to the destination account's currency at the time of settlement. Make sure the destination asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). ### Find an existing account Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one they want to fund. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My EUR account", "asset": "EUR", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ### Create a new account If the user has no accounts, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My EUR account", "asset": "EUR" } ``` ```json theme={null} { "account": { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My EUR account", "asset": "EUR", "balance": { "total": "0", "available": "0" } } } ``` ## Terms and disclosures Before generating deposit instructions, you must collect the user's acceptance of the applicable terms of service and display the required regulatory disclaimer. ### Accept terms of service The user must accept the `unique-account-number-viban` terms of service before SEPA deposit instructions can be generated. Display the following copy to the user and prompt them to continue: > By selecting continue you agree to the LHV's [Principles of Processing Customer Data](https://www.lhv.ee/en/principles-of-processing-customer-data) and the [Personal Deposit Accounts Terms and Conditions](https://uphold.com/en-gb/legal/unique-account-number-terms-of-use). Once the user confirms, call [Accept terms of service](/rest-apis/core-api/terms-of-service/accept-terms-of-service) to register their acceptance. ```http theme={null} POST /core/terms-of-service/unique-account-number-viban/accept ``` ### EMD disclaimer This disclaimer is required to comply with FCA regulations. Display it to users based in GB, the first time they link a credit/debit card or generate bank deposit details — it only needs to be shown once: > Uphold Europe Limited is an EMD Agent of Optimus Cards UK Limited (FRN: 902034). All received funds are held in a designated safekeeping account with a regulated bank and kept separate from Uphold's own assets. These funds are not protected by the UK Financial Services Compensation Scheme. ## Generate deposit method Call [Set up account deposit method](/rest-apis/core-api/accounts/set-up-account-deposit-method) with the target account id and the desired network. For subsequent calls, use [Get account deposit method](/rest-apis/core-api/accounts/get-account-deposit-method) instead. ```http theme={null} PUT /core/accounts/{accountId}/deposit-method?type=bank&network=sepa&asset=EUR ``` A successful response includes the necessary bank details and reference for the user to initiate the transfer. ```json theme={null} { "depositMethod": { "type": "bank", "status": "ok", "details": { "network": "sepa", "asset": "EUR", "beneficiary": "John Doe", "bankName": "Example Bank", "bankAddress": { "line1": "123 Bank Street", "line2": "Vienna, Austria" }, "bic": "EXAAAT2K", "iban": "AT487954841229809844", "reference": "UH12345678" } } } ``` The deposit method may initially return `status: processing` while the details are being prepared. Call [Get account deposit method](/rest-apis/core-api/accounts/get-account-deposit-method) to make sure the deposit method is ready (`status: ok`) before displaying instructions to the user. Provide the **IBAN**, **BIC**, and **reference** to the user. Include the **beneficiary name**, **bank name**, and **bank address** to help the user confirm the legitimacy of the instructions. Deposits sent without the reference are still credited, but funds will be deposited into the user's default EUR account. ## Monitor for the incoming transfer Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → bank transfer received, pending posting * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds settled * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Sample transaction In a successful SEPA deposit, the origin is represented as an `external-account` node. This is because the origin bank details are automatically registered as an external account. The destination is the account selected when the deposit instructions were generated, or the user's default EUR account if the transfer was sent without a reference, or the selected account doesn't exist anymore. ```json [expandable] theme={null} { "transaction": { "id": "b1bbbc0f-dae2-4e94-9e6d-4b9d5a1f3c1f", "origin": { "asset": "EUR", "amount": "250.00", "node": { "type": "external-account", "id": "cc8e8abc-0f95-4b9f-8490-2569a67bd80e", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "EUR", "amount": "250.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "status": "completed", "quotedAt": "2025-01-10T11:02:39Z", "createdAt": "2025-01-10T11:12:39Z", "updatedAt": "2025-01-10T11:13:08Z", "denomination": { "asset": "EUR", "amount": "250.00", "target": "origin" } } } ``` The new external account can be then retrieved with [Get external account](/rest-apis/core-api/external-accounts/get-external-account) to show the sender's bank details alongside the transaction, or to withdraw funds to the same account in the future. ```json [expandable] theme={null} { "externalAccount": { "id": "cc8e8abc-0f95-4b9f-8490-2569a67bd80e", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "bank", "status": "ok", "label": "EUR Bank Account", "asset": "EUR", "network": "sepa", "features": [ "withdraw" ], "details": { "iban": "AT487954841229809844", "bic": "EXAAAT2K" }, "createdAt": "2025-01-10T11:12:39Z", "updatedAt": "2025-01-10T11:13:08Z" } } ``` ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support SEPA bank transfer deposits via the REST API. # Wire bank deposit via the REST API Source: https://developer.uphold.com/developer-guides/bank-transfers/deposit/via-rest-api/wire Support USD wire transfer deposits using the Uphold REST API. Wire shares routing and account details with ACH and FedNow for one deposit method setup. This guide walks you through the steps to support Wire bank deposits using the REST API. Wire uses the same bank account and routing number as ACH — the deposit method setup is identical. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold participant F as Bank Usr->>U: Request deposit instructions U->>B: Set up deposit method B->>A: Set up account deposit method A-->>B: { depositMethod } B-->>U: { depositMethod } U-->>Usr: Display bank instructions Usr->>F: Initiate wire transfer F-->>A: Incoming transfer received A-->>B: webhook: transaction.created (processing) F-->>A: Settlement confirmed A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Check available rails Call [List Rails](/rest-apis/core-api/assets/list-rails) to verify Wire supports the `deposit` feature before proceeding. ```http theme={null} GET /core/rails?type=bank&network=wire&asset=USD ``` A successful response includes the rail details. The `constraints` field indicates deposits are credited to the user's default USD account. ```json theme={null} { "rails": [ { "type": "bank", "network": "wire", "method": "bank-transfer", "asset": "USD", "decimals": 2, "features": [ "deposit" ], "constraints": [ { "rule": "allowed-deposit-accounts", "allowed": "default-only" } ] } ] } ``` ## Select destination account USD bank deposits can only be credited to the user's default USD account. ### Find the default account Call [List default accounts](/rest-apis/core-api/accounts/list-default-accounts) to retrieve it. ```http theme={null} GET /core/accounts/defaults?asset=USD ``` ```json theme={null} { "accounts": [ { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "ownerId": "1e32fca3-23f7-40ed-bc1b-de10c790182d", "label": "My USD account", "asset": "USD", "balance": { "total": "100.00", "available": "100.00" } } ] } ``` ### Create a new account If the user has no default USD account, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My USD account", "asset": "USD" } ``` ```json theme={null} { "account": { "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "ownerId": "1e32fca3-23f7-40ed-bc1b-de10c790182d", "label": "My USD account", "asset": "USD", "balance": { "total": "0", "available": "0" } } } ``` ## Generate deposit method For US bank rails, the same account and routing number are shared across all networks (ACH, FedNow / RTP, and Wire), so the same deposit method is generated and can be used for any of these networks. Call [Set up account deposit method](/rest-apis/core-api/accounts/set-up-account-deposit-method) with the target account id and the desired network. For subsequent calls, use [Get account deposit method](/rest-apis/core-api/accounts/get-account-deposit-method) instead. ```http theme={null} PUT /core/accounts/{accountId}/deposit-method?type=bank&network=wire&asset=USD ``` A successful response includes the bank details. These are the same routing and account numbers as ACH — the sender uses them to initiate a wire transfer. ```json theme={null} { "depositMethod": { "type": "bank", "status": "ok", "details": { "network": "wire", "asset": "USD", "routingNumber": "021000021", "accountNumber": "123456789", "accountType": "checking", "beneficiary": "John Doe", "bankName": "Cross River Bank", "bankAddress": { "line1": "2 Riverview Dr", "line2": "Fort Lee, NJ 07024" } } } } ``` The deposit method may initially return `status: processing` while the details are being prepared. Call [Get account deposit method](/rest-apis/core-api/accounts/get-account-deposit-method) to confirm it is ready (`status: ok`) before displaying instructions to the user. Provide the **routing number**, **account number** and **account type**. Include the **beneficiary name**, **bank name**, and **bank address** to help the user confirm the legitimacy of the instructions. ## Monitor for the incoming transfer Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → bank transfer received, pending posting * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds settled * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Sample transaction In a successful Wire bank deposit, the origin is represented as a `bank-address` node. The destination is the user's default USD account. ```json [expandable] theme={null} { "transaction": { "id": "a2b3c4d5-e6f7-8a9b-c0d1-e2f3a4b5c6d7", "origin": { "asset": "USD", "amount": "500.00", "node": { "type": "bank-address", "network": "wire" } }, "destination": { "asset": "USD", "amount": "500.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "status": "completed", "quotedAt": "2025-06-15T14:00:00Z", "createdAt": "2025-06-15T14:10:00Z", "updatedAt": "2025-06-15T14:30:00Z", "denomination": { "asset": "USD", "amount": "500.00", "target": "origin" } } } ``` ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support Wire bank deposits via the REST API. # Bank transfer integration overview (FPS, SEPA, ACH, Wire) Source: https://developer.uphold.com/developer-guides/bank-transfers/overview Connect to local banking rails across the UK, EU, and US — FPS, SEPA, ACH, FedNow/RTP, and Wire — with deposit and withdrawal coverage and quote flows. Connect to local banking rails across the UK, EU, and US. ## Available networks Five banking rails are available across the UK, EU, and US. Settlement speed and features vary by network. All networks support **Individual** accounts; availability for **Business** accounts depends on the specific rail and your contract. ### Coverage | Network | Region | Currency | Settlement | | ---------------- | ------- | -------- | ------------------------------ | | **FPS** | UK / EU | GBP | Near-instant (24/7) | | **SEPA** | UK / EU | EUR | Near-instant or 1 business day | | **ACH** | US | USD | 1–3 business days | | **FedNow / RTP** | US | USD | Near-instant (24/7) | | **Wire** | US | USD | Same or next day | ### Supported features | Network | Deposit | Withdrawal | | ---------------- | --------------------- | ------------------------ | | **FPS** | Supported | Supported | | **SEPA** | Supported | Supported | | **ACH** | Supported | Supported | | **FedNow / RTP** | Supported | Supported | | **Wire** | Supported | Not supported | ## Key concepts * **Push deposits are external transactions** — only push deposits are supported. The sender initiates the transfer from their bank, and the platform creates the transaction automatically when funds are detected. No quote is required. * **Withdrawals are quote-based** — the user must confirm a quote before the transaction is created and the payout is submitted. * **Origin linking** — for [FPS](/developer-guides/bank-transfers/deposit/via-rest-api/fps) and [SEPA](/developer-guides/bank-transfers/deposit/via-rest-api/sepa), the origin bank account is automatically linked as an [external account](/rest-apis/core-api/external-accounts/introduction) after the first deposit. For [ACH](/developer-guides/bank-transfers/withdrawal/via-rest-api/ach), users must explicitly add one. ## Network specifics | Network | ToS required | Deposit target | Notes | | ---------------- | ----------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------ | | **FPS** | `unique-account-number-viban` | Any account | — | | **SEPA** | `unique-account-number-viban` | Any account | — | | **ACH** | None | Default USD account | Shares routing details with FedNow / RTP and Wire | | **FedNow / RTP** | None | Default USD account | Not all banks supported; shares routing details with ACH and Wire; withdrawal incurs an additional fee | | **Wire** | None | Default USD account | Shares routing details with ACH and FedNow / RTP | ## Testing in sandbox Use the [Simulate bank deposit](/rest-apis/core-api/accounts/test-helpers/simulate-bank-deposit) test helper to simulate an incoming deposit in sandbox for any supported network (FPS, SEPA, ACH, FedNow / RTP, Wire). Call it with the deposit method details returned by the [Setup Account Deposit Method](/rest-apis/core-api/accounts/set-up-account-deposit-method) endpoint and specify the `network`, and the platform will create a completed transaction as if the user had sent the transfer from their bank. ## Start building Set up deposit instructions and handle incoming funds programmatically. Let users deposit funds with a low-code, embeddable widget. Create quotes and submit payouts to your users' bank accounts. Let users withdraw funds with a low-code, embeddable widget. # ACH bank withdrawal via the Payment Widget Source: https://developer.uphold.com/developer-guides/bank-transfers/withdrawal/via-payment-widget/ach Send ACH bank withdrawals with the Uphold Payment Widget for account selection, then create the quote and transaction via the REST API to complete payout. The Payment Widget handles ACH bank account selection and creation. Your backend creates the session, then continues with the REST API to create a quote and transaction once the user has a destination bank account. The Payment Widget does not create any transaction. Your backend must create the quote and transaction via the REST API after the user selects their bank account. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. * The Payment Widget is set up in your frontend. See [Installation and setup](/widgets/payment/installation-and-setup). ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold participant F as Bank Usr->>U: Start bank withdrawal U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose source account U->>B: Create widget session B->>A: Create widget session (select-for-withdrawal) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select bank account P-->>U: complete { via: "external-account", selection } U->>B: Request quote B->>A: Create quote A-->>B: { quote } B-->>U: { quote } Usr->>U: Confirm quote U->>B: Create transaction B->>A: Create transaction A-->>B: { transaction } A-->>B: webhook: transaction.created (processing) A->>F: Submit bank transfer F-->>A: Settlement confirmed A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Select source account ACH withdrawals can be sourced from any account. If the selected account is not in USD, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one to withdraw from. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` *** ## Select a bank account The widget lets the user select an existing ACH bank account or add a new one by providing their routing and account number. ### Create a widget session Call the [Create widget session](/rest-apis/widgets-api/payment/create-session) endpoint to create a session for the `select-for-withdrawal` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-withdrawal" } ``` A successful response contains a `session` object. Pass `response.session` to your frontend to initialize the widget. ```json theme={null} { "session": { "flow": "select-for-withdrawal", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` ### Set up the widget Initialize the widget for the `select-for-withdrawal` flow using the session data returned from the API. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeWithdrawalWidget = async (session) => { // Initialize the widget const widget = new PaymentWidget<'select-for-withdrawal'>(session, { debug: true }); // Set up event handlers widget.on('ready', () => { console.log('Ready'); }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); // Mount the widget widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event When the user completes the selection, the `complete` event fires with `via: "external-account"` and a `selection` containing the chosen external account. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; if (via === 'external-account' && selection.type === 'bank') { // selection is the external account the user selected or just added handleBankWithdrawal(selection); } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; if (via === 'external-account' && selection.type === 'bank') { // selection is the external account the user selected or just added handleBankWithdrawal(selection); } teardown(); break; } ``` The event payload: * `via` — set to `external-account` when the user selects or adds a bank account. * `selection` — an [external account](/rest-apis/core-api/external-accounts/introduction) object with the selected bank details. * `selection.network` — the network to use for the withdrawal (`"ach"` or `"fednow"` if `secondaryNetworks` includes it). Pass this value in the quote request. ```json theme={null} { "via": "external-account", "selection": { "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "type": "bank", "network": "ach", "label": "My USD Checking Account", "secondaryNetworks": ["fednow"] } } ``` ### Handle cancellations The `cancel` event fires when the user closes the widget without selecting a bank account. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Redirect back or show a cancellation message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Redirect back or show a cancellation message break; ``` ### Handle errors The `error` event fires when an error occurs during the bank account selection process. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. ## Create a quote Create a quote with [Create quote](/rest-apis/core-api/transactions/create-quote). Pass `network: "ach"` on the destination node to route the transfer via ACH, or leave it out to route via the external account's default network. If the external account has `secondaryNetworks`, the user can choose which one to use for the transfer. The quote must be created with the selected network to ensure accurate fees and expiration time. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "destination": { "type": "external-account", "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "network": "ach" }, "denomination": { "asset": "USD", "amount": "500.00", "target": "origin" } } ``` A successful response returns a `quote` object with fees and expiration. ```json [expandable] theme={null} { "quote": { "id": "734111d9-ace0-5b3c-bb4e-7b7b55b8a7b1", "origin": { "amount": "500.00", "asset": "USD", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "destination": { "amount": "500.00", "asset": "USD", "node": { "type": "external-account", "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "network": "ach" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "500.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2024-07-24T15:22:39Z" } } ``` Quotes typically **expire** quickly. Prompt for user confirmation within the expiry window and requote if needed. ## Confirm and create transaction After the user confirms the withdrawal, call [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the quote ID to execute the transfer. ```http theme={null} POST /core/transactions { "quoteId": "734111d9-ace0-5b3c-bb4e-7b7b55b8a7b1" } ``` In a successful ACH withdrawal, the origin is the user's `account` and the destination is the `external-account` representing the user's bank. The transaction status is initially `processing` and updates to `completed` once the transfer settles. ```json [expandable] theme={null} { "transaction": { "id": "e4f5a6b7-2c3d-4e6f-a09b-8c7d6e5f4a3c", "origin": { "asset": "USD", "amount": "500.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "USD", "amount": "500.00", "node": { "type": "external-account", "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "network": "ach" } }, "status": "processing", "quotedAt": "2025-01-10T14:22:15Z", "createdAt": "2025-01-10T14:22:45Z", "updatedAt": "2025-01-10T14:22:45Z", "denomination": { "asset": "USD", "amount": "500.00", "target": "origin" } } } ``` ## Monitor for settlement The widget does not monitor for settlement. Your application must do this via webhooks or polling. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → payout submitted to the bank network * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds delivered to the user's bank * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support ACH bank transfer withdrawals via the Payment Widget. # FPS bank withdrawal via the Payment Widget Source: https://developer.uphold.com/developer-guides/bank-transfers/withdrawal/via-payment-widget/fps Send GBP Faster Payments (FPS) withdrawals with the Uphold Payment Widget for bank account selection, then create the quote and transaction via REST API. The Payment Widget handles FPS bank account selection. Your backend creates the session, then continues with the REST API to create a quote and transaction once the user selects their destination bank account. The Payment Widget does not create any transaction. Your backend must create the quote and transaction via the REST API after the user selects their bank account. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. * The Payment Widget is set up in your frontend. See [Installation and setup](/widgets/payment/installation-and-setup). ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold participant F as Bank Usr->>U: Start bank withdrawal U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose source account U->>B: Create widget session B->>A: Create widget session (select-for-withdrawal) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select bank account P-->>U: complete { via: "external-account", selection } U->>B: Request quote B->>A: Create quote A-->>B: { quote } B-->>U: { quote } Usr->>U: Confirm quote U->>B: Create transaction B->>A: Create transaction A-->>B: { transaction } A-->>B: webhook: transaction.created (processing) A->>F: Submit bank transfer F-->>A: Settlement confirmed A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Select source account FPS withdrawals can be sourced from any account. If the selected account is not in GBP, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one to withdraw from. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My GBP account", "asset": "GBP", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` *** ## Select a bank account The widget lets the user select an existing FPS-linked bank account. ### Create a widget session Call the [Create widget session](/rest-apis/widgets-api/payment/create-session) endpoint to create a session for the `select-for-withdrawal` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-withdrawal" } ``` A successful response contains a `session` object. Pass `response.session` to your frontend to initialize the widget. ```json theme={null} { "session": { "flow": "select-for-withdrawal", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` ### Set up the widget Initialize the widget for the `select-for-withdrawal` flow using the session data returned from the API. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeWithdrawalWidget = async (session) => { // Initialize the widget const widget = new PaymentWidget<'select-for-withdrawal'>(session, { debug: true }); // Set up event handlers widget.on('ready', () => { console.log('Ready'); }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); // Mount the widget widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event fires when the user selects a bank account and completes the flow. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; if (via === 'external-account' && selection.type === 'bank') { // selection is the external account the user selected handleBankWithdrawal(selection); } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; if (via === 'external-account' && selection.type === 'bank') { // selection is the external account the user selected handleBankWithdrawal(selection); } teardown(); break; } ``` The event payload contains two primary properties: * `via` — set to `external-account` when the user selects a saved bank account. * `selection` — an [external account](/rest-apis/core-api/external-accounts/introduction) object with the selected bank details. ```json theme={null} { "via": "external-account", "selection": { "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "type": "bank", "network": "fps", "label": "My GBP Account" } } ``` ### Handle cancellations The `cancel` event fires when the user closes the widget without selecting a bank account. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Redirect back or show a cancellation message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Redirect back or show a cancellation message break; ``` ### Handle errors The `error` event fires when an error occurs during the bank account selection process. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. ## Create a quote To initiate the withdrawal, call [Create quote](/rest-apis/core-api/transactions/create-quote) with the origin as the user's account and the destination as the selected FPS external account. Specify the amount and asset for the withdrawal. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "destination": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68" }, "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } ``` A successful response returns a `quote` object with details about the withdrawal, including fees and expiration. ```json [expandable] theme={null} { "quote": { "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "origin": { "amount": "250.00", "asset": "GBP", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "destination": { "amount": "250.00", "asset": "GBP", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2024-07-24T15:22:39Z" } } ``` Quotes typically **expire** quickly. Prompt for user confirmation within the expiry window and requote if needed. ## Confirm and create transaction After the user confirms the withdrawal, call [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the quote ID to execute the transfer. ```http theme={null} POST /core/transactions { "quoteId": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0" } ``` In a successful FPS withdrawal, the origin is the user's `account` and the destination is the `external-account` representing the user's bank. The transaction status is initially `processing` and updates to `completed` once the transfer settles. ```json [expandable] theme={null} { "transaction": { "id": "d3e4f5a6-1b2c-4d5e-9f8a-7b6c5d4e3f2a", "origin": { "asset": "GBP", "amount": "250.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "GBP", "amount": "250.00", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "network": "fps" } }, "status": "processing", "quotedAt": "2025-01-10T14:22:15Z", "createdAt": "2025-01-10T14:22:45Z", "updatedAt": "2025-01-10T14:22:45Z", "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } } ``` ## Monitor for settlement The widget does not monitor for settlement. Your application must do this via webhooks or polling. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → payout submitted to the bank network * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds delivered to the user's bank * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support FPS bank transfer withdrawals via the Payment Widget. # ACH bank withdrawal via the REST API Source: https://developer.uphold.com/developer-guides/bank-transfers/withdrawal/via-rest-api/ach Send USD ACH bank withdrawals with the Uphold REST API: create an external bank account, generate a quote, submit the payout, and monitor settlement. This guide walks you through the steps to support ACH bank withdrawals using the REST API — from creating an external account to monitoring for settlement. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold participant F as Bank Usr->>U: Start withdrawal U->>B: Find bank account B->>A: GET /core/external-accounts A-->>B: { externalAccounts } B-->>U: { externalAccounts } Usr->>U: Select or link bank account opt No existing bank account U->>B: Link bank account B->>A: Create external account A-->>B: { externalAccount } end Usr->>U: Choose source account and amount U->>B: Request quote B->>A: Create quote (account -> external account) A-->>B: { quote } B-->>U: { quote } Usr->>U: Confirm quote U->>B: Create transaction B->>A: Create transaction A-->>B: { transaction } A-->>B: webhook: transaction.created (processing) A->>F: Submit bank transfer F-->>A: Settlement confirmed A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Check available rails Before creating a quote, verify the ACH rail is available for withdrawals. Call [List Rails](/rest-apis/core-api/assets/list-rails) for `USD` to confirm the `ach` network has the `withdraw` feature. ```http theme={null} GET /core/rails?type=bank&network=ach&asset=USD ``` ```json theme={null} { "rails": [ { "type": "bank", "network": "ach", "method": "bank-transfer", "asset": "USD", "decimals": 2, "features": [ "deposit", "withdraw" ] } ] } ``` ## Select source account ACH withdrawals can be sourced from any account. If the selected account is not in USD, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one to withdraw from. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ## Select a bank account ACH bank accounts are not linked automatically. Users must provide their routing and account number to add one. Select an existing linked account or add a new one. ### Find an existing bank account Call [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts) and filter for accounts with `type: "bank"`. If no USD bank account exists, proceed to link one. ```http theme={null} GET /core/external-accounts ``` Make sure the selected account has `status: "ok"` and `"withdraw"` in `features`. ```json theme={null} { "externalAccounts": [ { "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "type": "bank", "status": "ok", "network": "ach", "label": "My USD Checking Account", "features": [ "withdraw" ], "secondaryNetworks": ["fednow"] } ] } ``` ### Link a new bank account Create a USD bank account by calling [Create external account](/rest-apis/core-api/external-accounts/create-external-account) with the user's bank details. ```http theme={null} POST /core/external-accounts { "type": "bank", "asset": "USD", "network": "ach", "label": "My USD Checking Account", "details": { "routingNumber": "121000248", "accountNumber": "9876543210", "accountType": "checking", "address": { "country": "US", "subdivision": "US-CA", "city": "San Francisco", "line1": "123 Main Street", "postalCode": "94102" } } } ``` ```json theme={null} { "externalAccount": { "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "type": "bank", "status": "ok", "network": "ach", "label": "My USD Checking Account", "features": [ "withdraw" ], "secondaryNetworks": ["fednow"] } } ``` The `secondaryNetworks` field is present only when the destination bank supports FedNow. If it appears, you may use `"fednow"` as the `network` in the quote to settle funds faster. ## Create a quote Create a quote with [Create quote](/rest-apis/core-api/transactions/create-quote). Pass `network: "ach"` on the destination node to route the transfer via ACH, or leave it out to route via the external account's default network. If the external account has `secondaryNetworks`, the user can choose which one to use for the transfer. The quote must be created with the selected network to ensure accurate fees and expiration time. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "destination": { "type": "external-account", "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "network": "ach" }, "denomination": { "asset": "USD", "amount": "500.00", "target": "origin" } } ``` A successful response returns a `quote` object with fees and expiration. ```json [expandable] theme={null} { "quote": { "id": "734111d9-ace0-5b3c-bb4e-7b7b55b8a7b1", "origin": { "amount": "500.00", "asset": "USD", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "destination": { "amount": "500.00", "asset": "USD", "node": { "type": "external-account", "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "network": "ach" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "500.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2024-07-24T15:22:39Z" } } ``` Quotes typically **expire** quickly. Prompt for user confirmation within the expiry window and requote if needed. ## Confirm and create transaction After the user confirms the withdrawal, call [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the quote ID to execute the transfer. ```http theme={null} POST /core/transactions { "quoteId": "734111d9-ace0-5b3c-bb4e-7b7b55b8a7b1" } ``` In a successful ACH withdrawal, the origin is the user's `account` and the destination is the `external-account` representing the user's bank. The transaction status is initially `processing` and updates to `completed` once the transfer settles. ```json [expandable] theme={null} { "transaction": { "id": "e4f5a6b7-2c3d-4e6f-a09b-8c7d6e5f4a3c", "origin": { "asset": "USD", "amount": "500.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "USD", "amount": "500.00", "node": { "type": "external-account", "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "network": "ach" } }, "status": "processing", "quotedAt": "2025-01-10T14:22:15Z", "createdAt": "2025-01-10T14:22:45Z", "updatedAt": "2025-01-10T14:22:45Z", "denomination": { "asset": "USD", "amount": "500.00", "target": "origin" } } } ``` ## Monitor for settlement Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → payout submitted to the bank network * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds delivered to the user's bank * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support ACH bank transfer withdrawals via the REST API. # FedNow / RTP bank withdrawal via the REST API Source: https://developer.uphold.com/developer-guides/bank-transfers/withdrawal/via-rest-api/fednow Send near-instant USD withdrawals via FedNow and RTP using the Uphold REST API, including verifying the destination bank's support for these rails. This guide walks you through the steps to support FedNow / RTP withdrawals using the REST API — from verifying the destination bank supports FedNow / RTP to monitoring for settlement. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. FedNow withdrawals incur an additional fee. Make sure to present the quote to the user for confirmation before creating the transaction. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold participant F as Bank Usr->>U: Start withdrawal U->>B: Find bank account B->>A: GET /core/external-accounts A-->>B: { externalAccounts } B-->>U: { externalAccounts } Usr->>U: Select bank account opt No eligible bank account U->>B: Link bank account B->>A: Create external account A-->>B: { externalAccount } end U->>B: Request quote B->>A: Create quote (network: fednow) A-->>B: { quote } B-->>U: { quote } Usr->>U: Confirm quote U->>B: Create transaction B->>A: Create transaction A-->>B: { transaction } A-->>B: webhook: transaction.created (processing) A->>F: Submit FedNow transfer F-->>A: Settlement confirmed A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Check available rails Call [List Rails](/rest-apis/core-api/assets/list-rails) to confirm the FedNow rail has the `withdraw` feature enabled. ```http theme={null} GET /core/rails?type=bank&network=fednow&asset=USD ``` ```json theme={null} { "rails": [ { "type": "bank", "network": "fednow", "method": "bank-transfer", "asset": "USD", "decimals": 2, "features": [ "deposit", "withdraw" ] } ] } ``` ## Select source account FedNow withdrawals can be sourced from any account. If the selected account is not in USD, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one to withdraw from. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My USD account", "asset": "USD", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ## Select a bank account The destination bank account must support FedNow or RTP, indicated by `"fednow"` in `secondaryNetworks`. Select an existing eligible account or add a new one. ### Find an existing bank account Call [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts) and filter for accounts with `type: "bank"`. If no eligible account exists, proceed to link one. ```http theme={null} GET /core/external-accounts ``` Make sure the selected account has `status: "ok"`, `"withdraw"` in `features`, and `"fednow"` in `secondaryNetworks`. ```json theme={null} { "externalAccounts": [ { "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "type": "bank", "status": "ok", "network": "ach", "label": "My USD Checking Account", "features": [ "withdraw" ], "secondaryNetworks": ["fednow"] } ] } ``` ### Link a new bank account If no eligible account exists, add one by calling [Create external account](/rest-apis/core-api/external-accounts/create-external-account) with the user's bank details. The `secondaryNetworks` field in the response will confirm whether the bank supports FedNow or RTP. ```http theme={null} POST /core/external-accounts { "type": "bank", "asset": "USD", "network": "ach", "label": "My USD Checking Account", "details": { "routingNumber": "121000248", "accountNumber": "9876543210", "accountType": "checking", "address": { "country": "US", "subdivision": "US-CA", "city": "San Francisco", "line1": "123 Main Street", "postalCode": "94102" } } } ``` ```json theme={null} { "externalAccount": { "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "type": "bank", "status": "ok", "network": "ach", "label": "My USD Checking Account", "features": [ "withdraw" ], "secondaryNetworks": ["fednow"] } } ``` If `secondaryNetworks` does not include `"fednow"`, the destination bank does not support FedNow or RTP. Use ACH instead. ## Create a quote Create a quote with [Create quote](/rest-apis/core-api/transactions/create-quote). Pass `network: "fednow"` on the destination node to route the transfer via FedNow. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "destination": { "type": "external-account", "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "network": "fednow" }, "denomination": { "asset": "USD", "amount": "500.00", "target": "origin" } } ``` The response includes the fees for the FedNow transfer. Present the full quote to the user before proceeding. ```json [expandable] theme={null} { "quote": { "id": "734111d9-ace0-5b3c-bb4e-7b7b55b8a7b1", "origin": { "amount": "500.00", "asset": "USD", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "destination": { "amount": "500.00", "asset": "USD", "node": { "type": "external-account", "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "network": "fednow" }, "rate": "1" }, "denomination": { "asset": "USD", "amount": "500.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2024-07-24T15:22:39Z" } } ``` Quotes typically **expire** quickly. Prompt for user confirmation within the expiry window and requote if needed. ## Confirm and create transaction After the user confirms the withdrawal, call [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the quote ID to execute the transfer. ```http theme={null} POST /core/transactions { "quoteId": "734111d9-ace0-5b3c-bb4e-7b7b55b8a7b1" } ``` In a successful FedNow / RTP withdrawal, the origin is the user's `account` and the destination is the `external-account` representing the user's bank. The transaction status is initially `processing` and updates to `completed` once the transfer settles. ```json [expandable] theme={null} { "transaction": { "id": "e4f5a6b7-2c3d-4e6f-a09b-8c7d6e5f4a3c", "origin": { "asset": "USD", "amount": "500.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "USD", "amount": "500.00", "node": { "type": "external-account", "id": "bb7f7fab-9e84-5a8e-9389-1458f56ac79", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "network": "fednow" } }, "status": "processing", "quotedAt": "2025-01-10T14:22:15Z", "createdAt": "2025-01-10T14:22:45Z", "updatedAt": "2025-01-10T14:22:45Z", "denomination": { "asset": "USD", "amount": "500.00", "target": "origin" } } } ``` ## Monitor for settlement Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → payout submitted to the bank network * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds delivered to the user's bank * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support FedNow / RTP withdrawals via the REST API. # FPS bank withdrawal via the REST API Source: https://developer.uphold.com/developer-guides/bank-transfers/withdrawal/via-rest-api/fps Send GBP Faster Payments (FPS) withdrawals using the Uphold REST API: list linked external accounts, generate and confirm a quote, and monitor for settlement. This guide walks you through the steps to support FPS bank withdrawals using the REST API — from listing external accounts to monitoring for settlement. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold participant F as Bank Usr->>U: Start withdrawal U->>B: Find bank account B->>A: GET /core/external-accounts A-->>B: { externalAccounts } B-->>U: { externalAccounts } Usr->>U: Select bank account Usr->>U: Choose source account and amount U->>B: Request quote B->>A: Create quote (account -> external account) A-->>B: { quote } B-->>U: { quote } Usr->>U: Confirm quote U->>B: Create transaction B->>A: Create transaction A-->>B: { transaction } A-->>B: webhook: transaction.created (processing) A->>F: Submit bank transfer F-->>A: Settlement confirmed A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Check available rails Before creating a quote, verify the FPS rail is available for withdrawals. Call [List Rails](/rest-apis/core-api/assets/list-rails) for `GBP` to confirm the `fps` network has the `withdraw` feature. ```http theme={null} GET /core/rails?type=bank&network=fps&asset=GBP ``` The rail always exists in the system, but the `withdraw` feature is only present if it's enabled for the user. ```json theme={null} { "rails": [ { "type": "bank", "network": "fps", "method": "bank-transfer", "asset": "GBP", "decimals": 2, "features": [ "deposit", "withdraw" ] } ] } ``` ## Select source account FPS withdrawals can be sourced from any account. If the selected account is not in GBP, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one to withdraw from. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My GBP account", "asset": "GBP", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ## Select a bank account FPS bank accounts are registered automatically as external accounts when a user makes their first FPS deposit — they cannot be added manually. If the user has no FPS-linked accounts yet, direct them to complete an [FPS deposit](/developer-guides/bank-transfers/deposit/via-rest-api/fps) first. Call [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts) and filter for `bank` type and `fps` network to retrieve the user's linked bank accounts available for withdrawal. ```http theme={null} GET /core/external-accounts ``` Present the accounts to the user and make sure the selected one has `status: "ok"` and `"withdraw"` in `features`. ```json theme={null} { "externalAccounts": [ { "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "type": "bank", "status": "ok", "network": "fps", "label": "My GBP Account", "features": [ "withdraw" ] } ] } ``` ## Create a quote To initiate the withdrawal, call [Create quote](/rest-apis/core-api/transactions/create-quote) with the origin as the user's account and the destination as the selected FPS external account. Specify the amount and asset for the withdrawal. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "destination": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68" }, "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } ``` A successful response returns a `quote` object with details about the withdrawal, including fees and expiration. ```json [expandable] theme={null} { "quote": { "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "origin": { "amount": "250.00", "asset": "GBP", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "destination": { "amount": "250.00", "asset": "GBP", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2024-07-24T15:22:39Z" } } ``` Quotes typically **expire** quickly. Prompt for user confirmation within the expiry window and requote if needed. ## Confirm and create transaction After the user confirms the withdrawal, call [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the quote ID to execute the transfer. ```http theme={null} POST /core/transactions { "quoteId": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0" } ``` In a successful FPS withdrawal, the origin is the user's `account` and the destination is the `external-account` representing the user's bank. The transaction status is initially `processing` and updates to `completed` once the transfer settles. ```json [expandable] theme={null} { "transaction": { "id": "d3e4f5a6-1b2c-4d5e-9f8a-7b6c5d4e3f2a", "origin": { "asset": "GBP", "amount": "250.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "GBP", "amount": "250.00", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "network": "fps" } }, "status": "processing", "quotedAt": "2025-01-10T14:22:15Z", "createdAt": "2025-01-10T14:22:45Z", "updatedAt": "2025-01-10T14:22:45Z", "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } } ``` ## Monitor for settlement Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → payout submitted to the bank network * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds delivered to the user's bank * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support FPS bank transfer withdrawals via the REST API. # SEPA bank withdrawal via the REST API Source: https://developer.uphold.com/developer-guides/bank-transfers/withdrawal/via-rest-api/sepa Send EUR SEPA bank withdrawals using the Uphold REST API: list linked external accounts, generate and confirm a quote, and monitor settlement. This guide walks you through the steps to support SEPA bank withdrawals using the REST API — from listing external accounts to monitoring for settlement. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold participant F as Bank Usr->>U: Start withdrawal U->>B: Find bank account B->>A: GET /core/external-accounts A-->>B: { externalAccounts } B-->>U: { externalAccounts } Usr->>U: Select bank account Usr->>U: Choose source account and amount U->>B: Request quote B->>A: Create quote (account -> external account) A-->>B: { quote } B-->>U: { quote } Usr->>U: Confirm quote U->>B: Create transaction B->>A: Create transaction A-->>B: { transaction } A-->>B: webhook: transaction.created (processing) A->>F: Submit bank transfer F-->>A: Settlement confirmed A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Check available rails Before creating a quote, verify the SEPA rail is available for withdrawals. Call [List Rails](/rest-apis/core-api/assets/list-rails) for `EUR` to confirm the `sepa` network has the `withdraw` feature. ```http theme={null} GET /core/rails?type=bank&network=sepa&asset=EUR ``` The rail always exists in the system, but the `withdraw` feature is only present if it's enabled for the user. ```json theme={null} { "rails": [ { "type": "bank", "network": "sepa", "method": "bank-transfer", "asset": "EUR", "decimals": 2, "features": [ "deposit", "withdraw" ] } ] } ``` ## Select source account SEPA withdrawals can be sourced from any account. If the selected account is not in EUR, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one to withdraw from. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My EUR account", "asset": "EUR", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ## Select a bank account SEPA bank accounts are registered automatically as external accounts when a user makes their first SEPA deposit — they cannot be added manually. If the user has no SEPA-linked accounts yet, direct them to complete a [SEPA deposit](/developer-guides/bank-transfers/deposit/via-rest-api/sepa) first. Call [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts) and filter for accounts with `type: "bank"`. ```http theme={null} GET /core/external-accounts ``` Make sure the selected account has `status: "ok"` and `"withdraw"` in `features`. ```json theme={null} { "externalAccounts": [ { "id": "cc8e8abc-0f95-4b9f-8490-2569a67bd80e", "type": "bank", "status": "ok", "network": "sepa", "label": "My EUR Account", "features": [ "withdraw" ], "details": { "iban": "AT487954841229809844", "bic": "EXAAAT2K" } } ] } ``` ## Create a quote To initiate the withdrawal, call [Create quote](/rest-apis/core-api/transactions/create-quote) with the origin as the user's account and the destination as the selected SEPA external account. Specify the amount and asset for the withdrawal. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "destination": { "type": "external-account", "id": "cc8e8abc-0f95-4b9f-8490-2569a67bd80e" }, "denomination": { "asset": "EUR", "amount": "250.00", "target": "origin" } } ``` A successful response returns a `quote` object with details about the withdrawal, including fees and expiration. ```json [expandable] theme={null} { "quote": { "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "origin": { "amount": "250.00", "asset": "EUR", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "destination": { "amount": "250.00", "asset": "EUR", "node": { "type": "external-account", "id": "cc8e8abc-0f95-4b9f-8490-2569a67bd80e", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "denomination": { "asset": "EUR", "amount": "250.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2024-07-24T15:22:39Z" } } ``` Quotes typically **expire** quickly. Prompt for user confirmation within the expiry window and requote if needed. ## Confirm and create transaction After the user confirms the withdrawal, call [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the quote ID to execute the transfer. ```http theme={null} POST /core/transactions { "quoteId": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0" } ``` In a successful SEPA withdrawal, the origin is the user's `account` and the destination is the `external-account` representing the user's bank. The transaction status is initially `processing` and updates to `completed` once the transfer settles. ```json [expandable] theme={null} { "transaction": { "id": "d3e4f5a6-1b2c-4d5e-9f8a-7b6c5d4e3f2a", "origin": { "asset": "EUR", "amount": "250.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "EUR", "amount": "250.00", "node": { "type": "external-account", "id": "cc8e8abc-0f95-4b9f-8490-2569a67bd80e", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "network": "sepa" } }, "status": "processing", "quotedAt": "2025-01-10T14:22:15Z", "createdAt": "2025-01-10T14:22:45Z", "updatedAt": "2025-01-10T14:22:45Z", "denomination": { "asset": "EUR", "amount": "250.00", "target": "origin" } } } ``` ## Monitor for settlement Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → payout submitted to the bank network * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → funds delivered to the user's bank * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support SEPA bank transfer withdrawals via the REST API. # Interactive buy walkthrough Source: https://developer.uphold.com/developer-guides/buy-and-sell/buy/interactive-buy-flow Navigate through a card → crypto buy flow. Click each step to see the matching API call and visual representation side by side.
```bash cURL theme={null} curl -X GET "https://api.enterprise.uphold.com/core/assets" \ -H "Authorization: Bearer " ``` ```js JavaScript theme={null} const response = await fetch("https://api.enterprise.uphold.com/core/assets", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.request( "GET", "https://api.enterprise.uphold.com/core/assets", headers={ "Authorization": "Bearer ", } ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/assets")) .header("Authorization", "Bearer ") .method("GET", HttpRequest.BodyPublishers.noBody()) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "assets": [ { "code": "BTC", "name": "Bitcoin", "type": "crypto", "decimals": 8, "features": ["buy", "deposit", "sell", "transfer", "withdraw"] }, { "code": "ETH", "name": "Ethereum", "type": "crypto", "decimals": 18, "features": ["buy", "deposit", "sell", "transfer", "withdraw"] }, { "code": "USDC", "name": "USD Coin", "type": "crypto", "decimals": 6, "features": ["buy", "deposit", "sell", "transfer", "withdraw"] } ] } ``` The asset the user selects (`BTC` in this walkthrough) becomes the `destination.asset` of the buy. If the user does not yet have an account for that asset, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding.
```bash cURL theme={null} curl -X GET "https://api.enterprise.uphold.com/core/external-accounts" \ -H "Authorization: Bearer " ``` ```js JavaScript theme={null} const response = await fetch("https://api.enterprise.uphold.com/core/external-accounts", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.request( "GET", "https://api.enterprise.uphold.com/core/external-accounts", headers={ "Authorization": "Bearer ", } ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/external-accounts")) .header("Authorization", "Bearer ") .method("GET", HttpRequest.BodyPublishers.noBody()) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "externalAccounts": [ { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "card", "status": "ok", "label": "My Visa Card", "asset": "USD", "network": "visa", "features": ["deposit", "withdraw"], "details": { "type": "debit", "last4Digits": "4242", "expiryDate": { "month": 12, "year": 2028 } } }, { "id": "c4a2e8b1-5d6f-4a3b-9e7c-8f1d2a3b4c5d", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "bank", "status": "ok", "label": "Checking ••3344", "asset": "USD", "network": "ach", "features": ["deposit", "withdraw"] } ] } ```
```bash cURL theme={null} curl -X POST "https://api.enterprise.uphold.com/core/transactions/quote" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "origin": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7" }, "destination": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "denomination": { "asset": "USD", "amount": "300.00", "target": "origin" } }' ``` ```js JavaScript theme={null} const response = await fetch("https://api.enterprise.uphold.com/core/transactions/quote", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ origin: { type: "external-account", id: "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7" }, destination: { type: "account", id: "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, denomination: { asset: "USD", amount: "300.00", target: "origin" }, }), }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.request( "POST", "https://api.enterprise.uphold.com/core/transactions/quote", headers={ "Authorization": "Bearer ", "Content-Type": "application/json", }, json={ "origin": {"type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7"}, "destination": {"type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8"}, "denomination": {"asset": "USD", "amount": "300.00", "target": "origin"}, } ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/transactions/quote")) .header("Authorization", "Bearer ") .header("Content-Type", "application/json") .method("POST", HttpRequest.BodyPublishers.ofString(""" { "origin": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7" }, "destination": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "denomination": { "asset": "USD", "amount": "300.00", "target": "origin" } }""")) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "quote": { "id": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5", "origin": { "amount": "300.00", "asset": "USD", "node": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7" } }, "destination": { "amount": "300.00", "asset": "USD", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" } }, "fees": [], "expiresAt": "2026-06-08T12:00:30Z" } } ``` **Step 2 — Trade quote (fiat account → BTC):** Create the trade quote in the same flow so the user sees the full preview — the card charge and the BTC they will receive — before confirming. ```json Request theme={null} { "origin": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "destination": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" }, "denomination": { "asset": "USD", "amount": "300.00", "target": "origin" } } ``` ```json Response theme={null} { "quote": { "id": "c3d4e5f6-a7b8-4c9d-0e1f-a2b3c4d5e6f7", "origin": { "amount": "300.00", "asset": "USD", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" } }, "destination": { "amount": "0.00465", "asset": "BTC", "rate": "0.0000155", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" } }, "fees": [], "expiresAt": "2026-06-08T12:01:30Z" } } ```
```bash cURL theme={null} curl -X POST "https://api.enterprise.uphold.com/core/transactions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "quoteId": "c3d4e5f6-a7b8-4c9d-0e1f-a2b3c4d5e6f7" }' ``` ```js JavaScript theme={null} const response = await fetch("https://api.enterprise.uphold.com/core/transactions", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ quoteId: "c3d4e5f6-a7b8-4c9d-0e1f-a2b3c4d5e6f7", }), }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.request( "POST", "https://api.enterprise.uphold.com/core/transactions", headers={ "Authorization": "Bearer ", "Content-Type": "application/json", }, json={ "quoteId": "c3d4e5f6-a7b8-4c9d-0e1f-a2b3c4d5e6f7", } ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/transactions")) .header("Authorization", "Bearer ") .header("Content-Type", "application/json") .method("POST", HttpRequest.BodyPublishers.ofString(""" { "quoteId": "c3d4e5f6-a7b8-4c9d-0e1f-a2b3c4d5e6f7" }""")) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "transaction": { "id": "d4e5f6a7-b8c9-4d0e-1f2a-b3c4d5e6f7a8", "status": "processing", "origin": { "amount": "300.00", "asset": "USD", "node": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7" } }, "destination": { "amount": "0.00465", "asset": "BTC", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" } }, "fees": [], "quotedAt": "2026-06-08T12:00:00Z", "createdAt": "2026-06-08T12:00:05Z", "updatedAt": "2026-06-08T12:00:05Z" } } ```
**Webhook event:** `core.transaction.status-changed` ```json Webhook payload theme={null} { "id": "a5b6c7d8-e9f0-4a1b-2c3d-e4f5a6b7c8d9", "type": "core.transaction.status-changed", "createdAt": "2026-06-08T12:01:45Z", "data": { "transaction": { "id": "f6a7b8c9-d0e1-4f2a-3b4c-d5e6f7a8b9c0", "status": "completed", "origin": { "asset": "USD", "amount": "300.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" } }, "destination": { "asset": "BTC", "amount": "0.00465", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" } }, "denomination": { "asset": "USD", "amount": "300.00", "rate": "1.00", "target": "origin" }, "fees": [], "quotedAt": "2026-06-08T12:01:00Z", "createdAt": "2026-06-08T12:01:05Z", "updatedAt": "2026-06-08T12:01:45Z" } } } ``` Show the user a final confirmation — `0.00465 BTC` is now credited to their Uphold account. Display both the amount spent and the crypto received.
*** For the full prose walkthrough — prerequisites, error table, and denomination logic — see the [Buy via the REST API](/developer-guides/buy-and-sell/buy/via-rest-api) guide. # Buy via the REST API Source: https://developer.uphold.com/developer-guides/buy-and-sell/buy/via-rest-api Let users buy crypto with a fiat payment method — card or bank — using the Uphold REST API. A buy moves value into the user's Uphold crypto account, funded from a fiat external account (card or bank). ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview). * **Capabilities** — card deposits require the `cards` capability and the fiat → crypto leg requires `trades` (UK retail users have a 24-hour post-onboarding cooldown before trading). Bank deposit capability varies by rail — see the per-rail guides. * The integration uses Uphold's rails. Partner-owned rails (omnibus) follow a different flow not covered here. ## Walkthrough Prefer to click through the flow? See the [interactive buy walkthrough](/developer-guides/buy-and-sell/buy/interactive-buy-flow). *** ## Select a crypto to buy Call [List assets](/rest-apis/core-api/assets/list-assets) to retrieve the assets available on the platform. Filter to assets that include `buy` in their `features` array to show only purchasable assets. ```http theme={null} GET /core/assets ``` ```json [expandable] theme={null} { "assets": [ { "code": "BTC", "name": "Bitcoin", "type": "crypto", "symbol": "₿", "decimals": 8, "features": [ "buy", "deposit", "sell", "transfer", "withdraw" ] }, { "code": "ETH", "name": "Ethereum", "type": "crypto", "symbol": "Ξ", "decimals": 18, "features": [ "buy", "deposit", "sell", "transfer", "withdraw" ] } ] } ``` The chosen asset (`BTC` in this example) becomes the `destination.asset` for the buy. Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to find the user's existing Uphold accounts for that asset. ```http theme={null} GET /core/accounts ``` If the user does not yet have an account for the chosen asset, create one with [Create account](/rest-apis/core-api/accounts/create-account). ```http theme={null} POST /core/accounts { "label": "My BTC Account", "asset": "BTC" } ``` ## Define the source The source determines how the buy is funded. Choose the path that matches the user's intent: ### Fiat external account The user funds the buy from a linked card or bank account. #### Check available rails Call [List Rails](/rest-apis/core-api/assets/list-rails) to verify the deposit rail you plan to use is available. **Card:** ```http theme={null} GET /core/rails?type=card ``` ```json theme={null} { "rails": [ { "type": "card", "network": "visa", "method": "debit-card", "asset": "USD", "decimals": 2, "features": [ "deposit", "withdraw" ] } ] } ``` **Bank (example: SEPA):** ```http theme={null} GET /core/rails?type=bank&network=sepa&asset=EUR ``` ```json theme={null} { "rails": [ { "type": "bank", "network": "sepa", "method": "bank-transfer", "asset": "EUR", "decimals": 2, "features": [ "deposit", "withdraw" ] } ] } ``` #### Link or select the external account Let the user link a new external account or select an existing one. **Card:** Call [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts) to fetch the user's saved cards. ```http theme={null} GET /core/external-accounts ``` Make sure the selected card has `status: "ok"` and in `features`. ```json theme={null} { "externalAccounts": [ { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "card", "status": "ok", "label": "My Visa Card", "asset": "GBP", "network": "visa", "features": [ "deposit", "withdraw" ], "details": { "type": "debit", "last4Digits": "5119", "expiryDate": { "month": 12, "year": 2028 }, "octSupport": "supported" } } ] } ``` If the user wants to use a card not yet on file, call [Create external account](/rest-apis/core-api/external-accounts/create-external-account) with the card details. ```http theme={null} POST /core/external-accounts { "type": "card", "label": "My Visa Card", "number": "4921817844445119", "securityCode": "123", "expiryDate": { "month": 12, "year": 2028 } } ``` The response initially returns `status: "processing"` while the card is validated. ```json theme={null} { "externalAccount": { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "type": "card", "status": "processing", "label": "My Visa Card" } } ``` Once validated, the status transitions to `ok` and the full details are available. ```json theme={null} { "externalAccount": { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "card", "status": "ok", "label": "My Visa Card", "asset": "GBP", "network": "visa", "features": [ "deposit", "withdraw" ], "details": { "type": "debit", "last4Digits": "5119", "expiryDate": { "month": 12, "year": 2028 }, "octSupport": "supported" } } } ``` Monitor the external account status via [Get external account](/rest-apis/core-api/external-accounts/get-external-account) or the `external-account.status-changed` webhook until `status` is `ok` before proceeding. ### EMD disclaimer This disclaimer is required to comply with FCA regulations. Display it to users based in GB, the first time they link a credit/debit card or generate bank deposit details — it only needs to be shown once: > Uphold Europe Limited is an EMD Agent of Optimus Cards UK Limited (FRN: 902034). All received funds are held in a designated safekeeping account with a regulated bank and kept separate from Uphold's own assets. These funds are not protected by the UK Financial Services Compensation Scheme. **Bank:** For bank accounts, the linking flow varies by rail. See the per-rail guides: * [SEPA](/developer-guides/bank-transfers/deposit/via-rest-api/sepa) * [ACH](/developer-guides/bank-transfers/deposit/via-rest-api/ach) * [FPS](/developer-guides/bank-transfers/deposit/via-rest-api/fps) * [FedNow](/developer-guides/bank-transfers/deposit/via-rest-api/fednow) * [Wire](/developer-guides/bank-transfers/deposit/via-rest-api/wire) Once the user has a linked bank external account, retrieve it with [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts). ```http theme={null} GET /core/external-accounts ``` ```json [expandable] theme={null} { "externalAccounts": [ { "id": "c4a2e8b1-5d6f-4a3b-9e7c-8f1d2a3b4c5d", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604", "type": "bank", "status": "ok", "label": "My SEPA Bank Account", "asset": "EUR", "network": "sepa", "features": [ "withdraw" ], "details": { "bic": "EXAAAT2K", "iban": "AT48444441229809844" }, "createdAt": "2025-03-10T09:03:45.321Z", "updatedAt": "2025-03-10T09:03:45.321Z" } ] } ``` ## Transaction preview Show the user a preview of the buy before committing. The preview is a quote — a price-locked offer valid until `expiresAt`. The amount the user specifies is `denomination.amount`; set `denomination.asset` to indicate whether the amount is expressed in the source or destination asset. Quotes expire quickly. Capture user confirmation before `expiresAt` and re-quote if the user hesitates. ### Fiat external account A fiat buy runs in two sequenced legs: a fiat deposit into the user's Uphold fiat account, followed by a fiat-to-crypto trade. Create a quote for each leg. **Deposit quote — card:** ```http theme={null} POST /core/transactions/quote { "origin": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68" }, "destination": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "denomination": { "asset": "USD", "amount": "300.00", "target": "origin" } } ``` ```json [expandable] theme={null} { "quote": { "id": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5", "origin": { "amount": "300.00", "asset": "USD", "rate": "1", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "300.00", "asset": "USD", "rate": "1", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "USD", "amount": "300.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2026-06-01T12:00:30Z" } } ``` **Deposit quote — bank (example: SEPA):** ```http theme={null} POST /core/transactions/quote { "origin": { "type": "external-account", "id": "b7f3c2d9-4e1a-4b8f-9c2e-5d6a7b8c9d0e" }, "destination": { "type": "account", "id": "c11608ff-739d-4f38-bf91-f2d51c3b9c19" }, "denomination": { "asset": "EUR", "amount": "300.00", "target": "origin" } } ``` ```json [expandable] theme={null} { "quote": { "id": "b2c3d4e5-f6a7-4b8c-9d0e-f1a2b3c4d5e6", "origin": { "amount": "300.00", "asset": "EUR", "rate": "1", "node": { "type": "external-account", "id": "b7f3c2d9-4e1a-4b8f-9c2e-5d6a7b8c9d0e", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "300.00", "asset": "EUR", "rate": "1", "node": { "type": "account", "id": "c11608ff-739d-4f38-bf91-f2d51c3b9c19", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "EUR", "amount": "300.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2026-06-01T12:00:30Z" } } ``` **Trade quote (fiat → crypto):** After presenting the deposit quote, also create the trade quote so the user can see the full buy preview — the deposit amount and the crypto amount they will receive — before confirming. For **bank deposits**, this trade quote is for preview only. Bank deposits settle asynchronously (hours to days), so this quote will have expired long before the deposit lands. Re-quote at the time you confirm the trade leg, after the deposit `status: "completed"` webhook fires. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "destination": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" }, "denomination": { "asset": "USD", "amount": "300.00", "target": "origin" } } ``` ```json [expandable] theme={null} { "quote": { "id": "c3d4e5f6-a7b8-4c9d-0e1f-a2b3c4d5e6f7", "origin": { "amount": "300.00", "asset": "USD", "rate": "1", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "0.003", "asset": "BTC", "rate": "0.00001", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "USD", "amount": "300.00", "target": "origin", "rate": "0.00001" }, "fees": [], "expiresAt": "2026-06-01T12:01:30Z" } } ``` ## Confirm quote Commit the buy by calling [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the quote ID. ### Fiat external account Confirm the deposit leg first, then the trade leg after the deposit settles. **Deposit — card (requires 3DS):** Always include the `authorize:3ds` requirement — the card issuer may require 3DS authorization. You may embed stateful data in the `returnUrl` query parameters to resume the flow after redirect. Include `X-Uphold-User-Ip` and `X-Uphold-User-Agent` headers — they are used by the platform for 3DS risk evaluation. ```http theme={null} POST /core/transactions X-Uphold-User-Ip: X-Uphold-User-Agent: { "quoteId": "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5", "params": { "requirements": [ { "name": "authorize:3ds", "returnUrl": "https://example.com/return" } ] } } ``` ```json [expandable] theme={null} { "transaction": { "id": "d4e5f6a7-b8c9-4d0e-1f2a-b3c4d5e6f7a8", "status": "processing", "origin": { "amount": "300.00", "asset": "USD", "rate": "1", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604", "confirmationUrl": "https://authentication-devices.sandbox.checkout.com/sessions-interceptor/sid_zrih63pnk6ietkpuerqiyd5zga" } }, "destination": { "amount": "300.00", "asset": "USD", "rate": "1", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "USD", "amount": "300.00", "target": "origin", "rate": "1" }, "fees": [], "quotedAt": "2026-06-01T12:00:00Z", "createdAt": "2026-06-01T12:00:05Z", "updatedAt": "2026-06-01T12:00:05Z" } } ``` If `origin.node.confirmationUrl` is present, redirect the user to that URL to complete the 3DS challenge. After completion, the issuer redirects the user back to your `returnUrl`. Wait for the `core.transaction.status-changed` webhook with `status: "completed"` before proceeding to the trade leg. **Deposit — bank:** Bank deposits do not require a `returnUrl` or 3DS. ```http theme={null} POST /core/transactions { "quoteId": "b2c3d4e5-f6a7-4b8c-9d0e-f1a2b3c4d5e6" } ``` ```json [expandable] theme={null} { "transaction": { "id": "e5f6a7b8-c9d0-4e1f-2a3b-c4d5e6f7a8b9", "status": "processing", "origin": { "amount": "300.00", "asset": "EUR", "rate": "1", "node": { "type": "external-account", "id": "b7f3c2d9-4e1a-4b8f-9c2e-5d6a7b8c9d0e", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "300.00", "asset": "EUR", "rate": "1", "node": { "type": "account", "id": "c11608ff-739d-4f38-bf91-f2d51c3b9c19", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "EUR", "amount": "300.00", "target": "origin", "rate": "1" }, "fees": [], "quotedAt": "2026-06-01T12:00:00Z", "createdAt": "2026-06-01T12:00:05Z", "updatedAt": "2026-06-01T12:00:05Z" } } ``` Bank deposits settle asynchronously — instantly for FPS / FedNow, hours to days for ACH / SEPA / wire. See the per-rail bank-transfer deposit guides for timing details. Wait for the `core.transaction.status-changed` webhook with `status: "completed"` before proceeding to the trade leg. **Trade (fiat → crypto):** Once the deposit settles, confirm the trade quote. ```http theme={null} POST /core/transactions { "quoteId": "c3d4e5f6-a7b8-4c9d-0e1f-a2b3c4d5e6f7" } ``` ```json [expandable] theme={null} { "transaction": { "id": "f6a7b8c9-d0e1-4f2a-3b4c-d5e6f7a8b9c0", "status": "processing", "origin": { "amount": "300.00", "asset": "USD", "rate": "1", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "0.003", "asset": "BTC", "rate": "0.00001", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "USD", "amount": "300.00", "target": "origin", "rate": "0.00001" }, "fees": [], "quotedAt": "2026-06-01T12:01:00Z", "createdAt": "2026-06-01T12:01:05Z", "updatedAt": "2026-06-01T12:01:05Z" } } ``` Wait for the `core.transaction.status-changed` webhook with `status: "completed"` before notifying the user. ## Monitor for settlement Monitor each transaction in the chain by listening for `core.transaction.status-changed` webhooks or polling. Each leg fires its own event — wait for `status: "completed"` on one leg before submitting the next. ### Via webhooks Subscribe to `core.transaction.status-changed`. The event fires on every status transition; filter on `data.transaction.status` to detect terminal states: * `completed` — transaction settled successfully * `failed` — transaction failed; inspect `data.transaction.errors` for details ```json theme={null} { "id": "f4a5b6c7-d8e9-4f0a-1b2c-d3e4f5a6b7c8", "type": "core.transaction.status-changed", "createdAt": "2026-06-01T12:01:00Z", "data": { "transaction": { "id": "d4e5f6a7-b8c9-4d0e-1f2a-b3c4d5e6f7a8", "status": "completed" } } } ``` ### Via polling `GET /core/transactions/{transactionId}` returns the current state. Use polling as a fallback when webhook delivery cannot be guaranteed. Terminal statuses are `completed` and `failed`. ```http theme={null} GET /core/transactions/a0b1c2d3-e4f5-4a6b-7c8d-e9f0a1b2c3d4 ``` ```json theme={null} { "transaction": { "id": "a0b1c2d3-e4f5-4a6b-7c8d-e9f0a1b2c3d4", "status": "completed" } } ``` Prefer webhooks over polling in production. See [Webhooks](/rest-apis/webhooks) for setup instructions. ## Notify the user Display an in-app confirmation when each `core.transaction.status-changed` webhook fires with `status: "completed"`. Show a final confirmation once the last leg settles — the crypto is now credited to the user's Uphold account. You now support crypto buy via the REST API. ## Sample transactions A buy via a fiat external account produces two chained transactions — a fiat deposit and a fiat-to-crypto trade. Each follows the same lifecycle and fires its own `core.transaction.status-changed` webhook on completion. ### Card deposit ```json [expandable] theme={null} { "transaction": { "id": "d4e5f6a7-b8c9-4d0e-1f2a-b3c4d5e6f7a8", "status": "completed", "origin": { "amount": "300.00", "asset": "USD", "rate": "1", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "300.00", "asset": "USD", "rate": "1", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "USD", "amount": "300.00", "target": "origin", "rate": "1" }, "fees": [], "quotedAt": "2026-06-01T12:00:00Z", "createdAt": "2026-06-01T12:00:05Z", "updatedAt": "2026-06-01T12:01:00Z" } } ``` ### Bank deposit (SEPA) ```json [expandable] theme={null} { "transaction": { "id": "e5f6a7b8-c9d0-4e1f-2a3b-c4d5e6f7a8b9", "status": "completed", "origin": { "amount": "300.00", "asset": "EUR", "rate": "1", "node": { "type": "external-account", "id": "b7f3c2d9-4e1a-4b8f-9c2e-5d6a7b8c9d0e", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "300.00", "asset": "EUR", "rate": "1", "node": { "type": "account", "id": "c11608ff-739d-4f38-bf91-f2d51c3b9c19", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "EUR", "amount": "300.00", "target": "origin", "rate": "1" }, "fees": [], "quotedAt": "2026-06-01T12:00:00Z", "createdAt": "2026-06-01T12:00:05Z", "updatedAt": "2026-06-01T12:01:00Z" } } ``` ### Fiat-to-crypto trade ```json [expandable] theme={null} { "transaction": { "id": "f6a7b8c9-d0e1-4f2a-3b4c-d5e6f7a8b9c0", "status": "completed", "origin": { "amount": "300.00", "asset": "USD", "rate": "1", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "0.003", "asset": "BTC", "rate": "0.00001", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "USD", "amount": "300.00", "target": "origin", "rate": "0.00001" }, "fees": [], "quotedAt": "2026-06-01T12:01:00Z", "createdAt": "2026-06-01T12:01:05Z", "updatedAt": "2026-06-01T12:01:30Z" } } ``` ## Failure handling | Leg | Step | Failure | Funds | | -------------- | ----------- | --------------------------------------------------- | ---------------------------------------------------------------- | | Deposit (card) | Quote | Card declined or 3DS failed (charge never captured) | Nothing moved; no fees charged | | Deposit (card) | Transaction | Quote expired before commit | No transaction created; create a new quote | | Deposit (card) | Transaction | Card captured but later reversed by issuer | Fiat balance reduced by reversal; Uphold absorbs processing fees | | Deposit (card) | Transaction | `cards` capability not enabled | No transaction; prompt KYC | | Deposit (bank) | Transaction | Insufficient funds or mandate not authorised | No deposit received; surface error to user | | Deposit (bank) | Transaction | Settlement timeout (rail-dependent) | No deposit received; retry or contact support | If the deposit leg completes but the fiat → crypto conversion fails, the user's fiat funds remain in their Uphold fiat account. Detect this via the `core.transaction.status-changed` webhook and either retry the conversion or surface the fiat balance to the user. The `core.transaction.status-changed` webhook fires with `status: "failed"` for each failed transaction. # Buy and sell overview Source: https://developer.uphold.com/developer-guides/buy-and-sell/overview Let users buy crypto from a fiat payment method and sell crypto back to a fiat destination — card and bank, or APMs — in a single transaction. **Buy** moves fiat into crypto in the user's Uphold account, sourced from a linked fiat external account (card or bank). In one transaction Uphold both takes the deposit and converts it to crypto — no need to run a separate deposit and trade, each with its own quote and commit. **Sell** moves value out of the user's Uphold crypto account to a linked fiat external account — a card (via OCT) or a bank account — converting crypto to fiat and paying out. ## Key concepts * **`cards` capability** — buying with a card deposit requires the user to have the `cards` capability enabled. * **`trades` capability** — any buy or sell that involves converting one asset to another requires the `trades` capability. UK retail users face a 24-hour post-onboarding cooldown before trading is available. * **`card-withdrawals` capability** — selling to a card via OCT requires the `card-withdrawals` capability. * **3DS authorization** — card deposits may require user authorization via a 3DS challenge. When they do, the transaction response includes a `confirmationUrl` — redirect the user there to complete it. * **`octSupport`** — selling to a card requires the destination external account to have `octSupport: "supported"`. Verify this when linking the payout card. ## Supported methods | Direction | Source / destination | Notes | | --------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Buy | Card | May require 3DS authorization | | Buy | Bank account | SEPA / FPS / ACH / FedNow / wire — see [bank-transfer deposit guides](/developer-guides/bank-transfers/deposit/via-rest-api) for rail-specific setup | | Buy | APMs | Apple Pay / Google Pay / PayPal — see [apm-transfer deposit guides](/developer-guides/apm-transfers/deposit/via-rest-api) for rail-specific setup | | Sell | Card (OCT) | Requires `octSupport: "supported"` on the external account | | Sell | Bank account | SEPA / FPS / ACH / FedNow — see [bank-transfer withdrawal guides](/developer-guides/bank-transfers/withdrawal/via-rest-api) for rail-specific setup | | Sell | APMs | Apple Pay / PayPal — see [apm-transfer withdrawal guides](/developer-guides/apm-transfers/withdrawal/via-rest-api) for rail-specific setup | ## Testing in sandbox Use [test card numbers](/rest-apis/test-helpers) to simulate card deposits and payouts in the sandbox. Crypto deposits can be triggered via the [simulate crypto deposit](/rest-apis/core-api/accounts/test-helpers/simulate-crypto-deposit) endpoint. ## Start building Buy crypto for users from a fiat payment method — card and bank — in a single transaction. Sell crypto and pay out to a fiat destination — card and bank — in a single transaction. # Interactive sell walkthrough Source: https://developer.uphold.com/developer-guides/buy-and-sell/sell/interactive-sell-flow Navigate through a crypto → fiat sell flow. Click each step to see the matching API call and visual representation side by side.
```bash cURL theme={null} curl -X GET "https://api.enterprise.uphold.com/core/accounts" \ -H "Authorization: Bearer " ``` ```js JavaScript theme={null} const response = await fetch("https://api.enterprise.uphold.com/core/accounts", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.request( "GET", "https://api.enterprise.uphold.com/core/accounts", headers={ "Authorization": "Bearer ", } ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/accounts")) .header("Authorization", "Bearer ") .method("GET", HttpRequest.BodyPublishers.noBody()) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "accounts": [ { "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f", "asset": "BTC", "balance": "0.1", "status": "ok" }, { "id": "9b3d2c1a-7f4e-4d2a-8c5b-1e6f3d2c4b7a", "asset": "ETH", "balance": "0.25", "status": "ok" }, { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "asset": "USD", "balance": "0.00", "status": "ok" } ] } ``` The account the user selects (`f8c2d1e4` / BTC in this walkthrough) becomes the `origin` of the sell.
```bash cURL theme={null} curl -G "https://api.enterprise.uphold.com/core/external-accounts" \ -H "Authorization: Bearer " \ --data-urlencode "feature=withdraw" ``` ```js JavaScript theme={null} const params = new URLSearchParams({ feature: 'withdraw' }); const response = await fetch( `https://api.enterprise.uphold.com/core/external-accounts?${params}`, { headers: { Authorization: 'Bearer ' } } ); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.get( "https://api.enterprise.uphold.com/core/external-accounts", headers={"Authorization": "Bearer "}, params={"feature": "withdraw"}, ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/external-accounts?feature=withdraw")) .header("Authorization", "Bearer ") .GET() .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "externalAccounts": [ { "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604", "type": "card", "status": "ok", "label": "Personal card", "asset": "USD", "network": "visa", "features": ["withdraw"], "details": { "last4Digits": "4242", "expiryDate": { "month": 12, "year": 2030 }, "octSupport": "supported" } }, { "id": "bb7f7fgb-9e84-5b8d-9389-1458g560ce79", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604", "type": "bank", "status": "ok", "label": "Checking ••3344", "asset": "USD", "features": ["withdraw"] } ] } ```
```bash cURL theme={null} curl -X POST "https://api.enterprise.uphold.com/core/transactions/quote" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "origin": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" }, "destination": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68" }, "denomination": { "asset": "USD", "amount": "490.00", "target": "destination" } }' ``` ```js JavaScript theme={null} const response = await fetch("https://api.enterprise.uphold.com/core/transactions/quote", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ origin: { type: "account", id: "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" }, destination: { type: "external-account", id: "aa6e6efa-8d73-497c-8278-0347f459bd68" }, denomination: { asset: "USD", amount: "490.00", target: "destination" }, }), }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.request( "POST", "https://api.enterprise.uphold.com/core/transactions/quote", headers={ "Authorization": "Bearer ", "Content-Type": "application/json", }, json={ "origin": {"type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f"}, "destination": {"type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68"}, "denomination": {"asset": "USD", "amount": "490.00", "target": "destination"}, } ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/transactions/quote")) .header("Authorization", "Bearer ") .header("Content-Type", "application/json") .method("POST", HttpRequest.BodyPublishers.ofString(""" { "origin": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" }, "destination": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68" }, "denomination": { "asset": "USD", "amount": "490.00", "target": "destination" } }""")) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "quote": { "id": "a7b8c9d0-e1f2-4a3b-4c5d-e6f7a8b9c0d1", "origin": { "amount": "0.0049", "asset": "BTC", "rate": "100000", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" } }, "destination": { "amount": "490.00", "asset": "USD", "rate": "1", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68" } }, "denomination": { "asset": "USD", "amount": "490.00", "target": "destination", "rate": "1" }, "fees": [], "expiresAt": "2026-06-08T12:00:30Z" } } ```
```bash cURL theme={null} curl -X POST "https://api.enterprise.uphold.com/core/transactions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "quoteId": "a7b8c9d0-e1f2-4a3b-4c5d-e6f7a8b9c0d1" }' ``` ```js JavaScript theme={null} const response = await fetch("https://api.enterprise.uphold.com/core/transactions", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ quoteId: "a7b8c9d0-e1f2-4a3b-4c5d-e6f7a8b9c0d1", }), }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.request( "POST", "https://api.enterprise.uphold.com/core/transactions", headers={ "Authorization": "Bearer ", "Content-Type": "application/json", }, json={ "quoteId": "a7b8c9d0-e1f2-4a3b-4c5d-e6f7a8b9c0d1", } ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/transactions")) .header("Authorization", "Bearer ") .header("Content-Type", "application/json") .method("POST", HttpRequest.BodyPublishers.ofString(""" { "quoteId": "a7b8c9d0-e1f2-4a3b-4c5d-e6f7a8b9c0d1" }""")) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "transaction": { "id": "c9d0e1f2-a3b4-4c5d-6e7f-a8b9c0d1e2f3", "status": "processing", "origin": { "amount": "0.0049", "asset": "BTC", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" } }, "destination": { "amount": "490.00", "asset": "USD", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68" } }, "fees": [], "quotedAt": "2026-06-08T12:00:00Z", "createdAt": "2026-06-08T12:00:05Z", "updatedAt": "2026-06-08T12:00:05Z" } } ```
**Webhook event:** `core.transaction.status-changed` ```json Webhook payload theme={null} { "id": "e3f4a5b6-c7d8-4e9f-0a1b-c2d3e4f5a6b7", "type": "core.transaction.status-changed", "createdAt": "2026-06-08T12:00:30Z", "data": { "transaction": { "id": "c9d0e1f2-a3b4-4c5d-6e7f-a8b9c0d1e2f3", "status": "completed", "origin": { "amount": "0.0049", "asset": "BTC", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" } }, "destination": { "amount": "490.00", "asset": "USD", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68" } }, "fees": [], "quotedAt": "2026-06-08T12:00:00Z", "createdAt": "2026-06-08T12:00:05Z", "updatedAt": "2026-06-08T12:00:30Z" } } } ``` Show the user a final confirmation — `$490.00 USD` has been credited to their card.
*** For the full prose walkthrough — prerequisites, error table, and `octSupport` polling — see the [Sell via the REST API](/developer-guides/buy-and-sell/sell/via-rest-api) guide. # Sell via the REST API Source: https://developer.uphold.com/developer-guides/buy-and-sell/sell/via-rest-api Let users sell crypto to a fiat payment method — card via OCT or bank — using the Uphold REST API. A sell moves value out of the user's Uphold crypto account to a fiat external account (card via OCT or bank). ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview). * The user has an internal Uphold crypto account with sufficient balance. * **Capabilities** — card payouts require the `card-withdrawals` capability and a destination card with `octSupport: "supported"`; the crypto → fiat leg of a bank sell requires `trades`. Bank withdrawal capability varies by rail — see the per-rail guides. * The integration uses Uphold's rails. Partner-owned rails (omnibus) follow a different flow not covered here. ## Walkthrough Prefer to click through the flow? See the [interactive sell walkthrough](/developer-guides/buy-and-sell/sell/interactive-sell-flow). *** ## Select a crypto to sell Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's Uphold accounts. Present crypto accounts with a non-zero balance as sellable. ```http theme={null} GET /core/accounts ``` ```json [expandable] theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "asset": "USD", "balance": "0.00", "status": "ok" }, { "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f", "asset": "BTC", "balance": "0.1", "status": "ok" }, { "id": "c11608ff-739d-4f38-bf91-f2d51c3b9c19", "asset": "EUR", "balance": "0.00", "status": "ok" } ] } ``` The chosen account (`f8c2d1e4` / BTC in this example) is the `origin` for the sell. ## Define the destination The destination determines where the sell proceeds go. Choose the path that matches the user's intent: ### Fiat external account The user receives the proceeds in a linked card or bank account. Apple Pay and Google Pay are not supported as sell destinations — the platforms do not expose a payout API. Card OCT is the only card-rail payout supported. #### Check available rails Call [List Rails](/rest-apis/core-api/assets/list-rails) to verify the payout rail you plan to use is available. The `withdraw` feature must be present for the rail to be usable as a sell destination. **Card:** ```http theme={null} GET /core/rails?type=card ``` ```json theme={null} { "rails": [ { "type": "card", "network": "visa", "method": "debit-card", "asset": "USD", "decimals": 2, "features": ["deposit", "withdraw"] } ] } ``` **Bank (example: SEPA):** ```http theme={null} GET /core/rails?type=bank&network=sepa&asset=EUR ``` ```json theme={null} { "rails": [ { "type": "bank", "network": "sepa", "method": "bank-transfer", "asset": "EUR", "decimals": 2, "features": ["deposit", "withdraw"] } ] } ``` #### Link or select the external account Let the user link a new external account or select an existing one. **Card:** Call [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts) to fetch the user's saved cards. ```http theme={null} GET /core/external-accounts ``` Make sure the selected card has `status: "ok"` and in `features`. ```json theme={null} { "externalAccounts": [ { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "card", "status": "ok", "label": "My Visa Card", "asset": "GBP", "network": "visa", "features": [ "deposit", "withdraw" ], "details": { "type": "debit", "last4Digits": "5119", "expiryDate": { "month": 12, "year": 2028 }, "octSupport": "supported" } } ] } ``` If the user wants to use a card not yet on file, call [Create external account](/rest-apis/core-api/external-accounts/create-external-account) with the card details. ```http theme={null} POST /core/external-accounts { "type": "card", "label": "My Visa Card", "number": "4921817844445119", "securityCode": "123", "expiryDate": { "month": 12, "year": 2028 } } ``` The response initially returns `status: "processing"` while the card is validated. ```json theme={null} { "externalAccount": { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "type": "card", "status": "processing", "label": "My Visa Card" } } ``` Once validated, the status transitions to `ok` and the full details are available. ```json theme={null} { "externalAccount": { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "card", "status": "ok", "label": "My Visa Card", "asset": "GBP", "network": "visa", "features": [ "deposit", "withdraw" ], "details": { "type": "debit", "last4Digits": "5119", "expiryDate": { "month": 12, "year": 2028 }, "octSupport": "supported" } } } ``` Monitor the external account status via [Get external account](/rest-apis/core-api/external-accounts/get-external-account) or the `external-account.status-changed` webhook until `status` is `ok` before proceeding. ### EMD disclaimer This disclaimer is required to comply with FCA regulations. Display it to users based in GB, the first time they link a credit/debit card or generate bank deposit details — it only needs to be shown once: > Uphold Europe Limited is an EMD Agent of Optimus Cards UK Limited (FRN: 902034). All received funds are held in a designated safekeeping account with a regulated bank and kept separate from Uphold's own assets. These funds are not protected by the UK Financial Services Compensation Scheme. A freshly linked card returns `"octSupport": "unknown"` while Uphold verifies OCT eligibility. Poll `GET /core/external-accounts/{id}` until `details.octSupport` is `"supported"` before using the card as a sell destination. **Bank:** Bank account linking varies by rail. See the per-rail guides: * [SEPA](/developer-guides/bank-transfers/withdrawal/via-rest-api/sepa) * [ACH](/developer-guides/bank-transfers/withdrawal/via-rest-api/ach) * [FPS](/developer-guides/bank-transfers/withdrawal/via-rest-api/fps) * [FedNow](/developer-guides/bank-transfers/withdrawal/via-rest-api/fednow) Once the user has a linked bank external account, retrieve it with [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts). Filter for `type: "bank"`, `status: "ok"`, and `"withdraw"` in `features`. ```http theme={null} GET /core/external-accounts ``` ```json [expandable] theme={null} { "externalAccounts": [ { "id": "cc8e8abc-0f95-4b9f-8490-2569a67bd80e", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604", "type": "bank", "status": "ok", "label": "My SEPA Bank Account", "asset": "EUR", "network": "sepa", "features": ["withdraw"], "details": { "bic": "EXAAAT2K", "iban": "AT48444441229809844" }, "createdAt": "2025-03-10T09:03:45.321Z", "updatedAt": "2025-03-10T09:03:45.321Z" } ] } ``` Also ensure the user has a fiat Uphold account in the bank's currency to receive the trade proceeds. If one does not exist, create it with [Create account](/rest-apis/core-api/accounts/create-account). ```http theme={null} POST /core/accounts { "label": "My EUR Account", "asset": "EUR" } ``` ## Transaction preview Show the user a preview of the sell before committing. The preview is a quote — a price-locked offer valid until `expiresAt`. The amount the user specifies is `denomination.amount`; set `denomination.asset` to indicate whether the amount is expressed in the source or destination asset. Quotes expire quickly. Capture user confirmation before `expiresAt` and re-quote if the user hesitates. ### Fiat external account **Card (OCT):** A card sell settles in a single transaction — the crypto account is debited, converted at the locked rate, and fiat is paid out to the card via OCT. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" }, "destination": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68" }, "denomination": { "asset": "USD", "amount": "490.00", "target": "destination" } } ``` ```json [expandable] theme={null} { "quote": { "id": "a7b8c9d0-e1f2-4a3b-4c5d-e6f7a8b9c0d1", "origin": { "amount": "0.0049", "asset": "BTC", "rate": "0.00001", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "490.00", "asset": "USD", "rate": "1", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "USD", "amount": "490.00", "target": "destination", "rate": "0.00001" }, "fees": [], "expiresAt": "2026-06-01T13:00:30Z" } } ``` **Bank:** A bank sell runs in two sequenced legs: a crypto-to-fiat trade lands fiat in the user's Uphold fiat account, followed by a fiat withdrawal to the linked bank account. Create a quote for each leg. **Trade quote (crypto → fiat):** ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f" }, "destination": { "type": "account", "id": "c11608ff-739d-4f38-bf91-f2d51c3b9c19" }, "denomination": { "asset": "EUR", "amount": "250.00", "target": "destination" } } ``` ```json [expandable] theme={null} { "quote": { "id": "b8c9d0e1-f2a3-4b4c-5d6e-f7a8b9c0d1e2", "origin": { "amount": "0.005", "asset": "BTC", "rate": "0.00002", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "250.00", "asset": "EUR", "rate": "1", "node": { "type": "account", "id": "c11608ff-739d-4f38-bf91-f2d51c3b9c19", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "EUR", "amount": "250.00", "target": "destination", "rate": "0.00002" }, "fees": [], "expiresAt": "2026-06-01T13:05:30Z" } } ``` **Withdrawal quote (fiat → bank):** After presenting the trade quote, also create the withdrawal quote so the user sees the full sell preview before confirming. The example below uses SEPA (EUR). For rail-specific quote shapes, see the per-rail bank-transfer withdrawal guides. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "c11608ff-739d-4f38-bf91-f2d51c3b9c19" }, "destination": { "type": "external-account", "id": "cc8e8abc-0f95-4b9f-8490-2569a67bd80e" }, "denomination": { "asset": "EUR", "amount": "250.00", "target": "origin" } } ``` ```json [expandable] theme={null} { "quote": { "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "origin": { "amount": "250.00", "asset": "EUR", "rate": "1", "node": { "type": "account", "id": "c11608ff-739d-4f38-bf91-f2d51c3b9c19", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "250.00", "asset": "EUR", "rate": "1", "node": { "type": "external-account", "id": "cc8e8abc-0f95-4b9f-8490-2569a67bd80e", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "EUR", "amount": "250.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2026-06-01T13:06:30Z" } } ``` ## Confirm quote Commit the sell by calling [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the quote ID. ### Fiat external account **Card (OCT):** No `returnUrl` or 3DS is needed — card sell transactions do not require user card authorization. ```http theme={null} POST /core/transactions { "quoteId": "a7b8c9d0-e1f2-4a3b-4c5d-e6f7a8b9c0d1" } ``` ```json [expandable] theme={null} { "transaction": { "id": "c9d0e1f2-a3b4-4c5d-6e7f-a8b9c0d1e2f3", "status": "processing", "origin": { "amount": "0.0049", "asset": "BTC", "rate": "0.00001", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "490.00", "asset": "USD", "rate": "1", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "USD", "amount": "490.00", "target": "destination", "rate": "0.00001" }, "fees": [], "quotedAt": "2026-06-01T13:00:00Z", "createdAt": "2026-06-01T13:00:05Z", "updatedAt": "2026-06-01T13:00:05Z" } } ``` Wait for the `core.transaction.status-changed` webhook with `status: "completed"` before notifying the user. **Bank — trade leg (crypto → fiat):** ```http theme={null} POST /core/transactions { "quoteId": "b8c9d0e1-f2a3-4b4c-5d6e-f7a8b9c0d1e2" } ``` ```json [expandable] theme={null} { "transaction": { "id": "d0e1f2a3-b4c5-4d6e-7f8a-b9c0d1e2f3a4", "status": "processing", "origin": { "amount": "0.005", "asset": "BTC", "rate": "0.00002", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "250.00", "asset": "EUR", "rate": "1", "node": { "type": "account", "id": "c11608ff-739d-4f38-bf91-f2d51c3b9c19", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "EUR", "amount": "250.00", "target": "destination", "rate": "0.00002" }, "fees": [], "quotedAt": "2026-06-01T13:05:00Z", "createdAt": "2026-06-01T13:05:05Z", "updatedAt": "2026-06-01T13:05:05Z" } } ``` UK retail users may encounter a `409 user_capability_failure` with `restrictions: ["financial-promotion-cooldown-running"]` for 24 hours after onboarding. See the [trade guide](/developer-guides/trade-and-send/trade/via-rest-api) for cooldown details. Wait for the `core.transaction.status-changed` webhook with `status: "completed"` before proceeding to the withdrawal leg. **Bank — withdrawal leg (fiat → bank):** Once the trade settles and fiat lands in the user's Uphold fiat account, confirm the withdrawal quote. ```http theme={null} POST /core/transactions { "quoteId": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0" } ``` ```json [expandable] theme={null} { "transaction": { "id": "d3e4f5a6-1b2c-4d5e-9f8a-7b6c5d4e3f2a", "status": "processing", "origin": { "amount": "250.00", "asset": "EUR", "rate": "1", "node": { "type": "account", "id": "c11608ff-739d-4f38-bf91-f2d51c3b9c19", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "250.00", "asset": "EUR", "rate": "1", "node": { "type": "external-account", "id": "cc8e8abc-0f95-4b9f-8490-2569a67bd80e", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "EUR", "amount": "250.00", "target": "origin", "rate": "1" }, "fees": [], "quotedAt": "2026-06-01T13:06:00Z", "createdAt": "2026-06-01T13:06:05Z", "updatedAt": "2026-06-01T13:06:05Z" } } ``` Bank withdrawals settle asynchronously — instantly for FPS / FedNow, hours to days for ACH / SEPA. See the per-rail bank-transfer withdrawal guides for timing details. Wait for the `core.transaction.status-changed` webhook with `status: "completed"` before notifying the user. ## Monitor for settlement Monitor each transaction in the chain by listening for `core.transaction.status-changed` webhooks or polling. Each leg fires its own event — wait for `status: "completed"` on one leg before submitting the next. ### Via webhooks Subscribe to `core.transaction.status-changed`. The event fires on every status transition; filter on `data.transaction.status` to detect terminal states: * `completed` — transaction settled successfully * `failed` — transaction failed; inspect `data.transaction.errors` for details ```json theme={null} { "id": "f4a5b6c7-d8e9-4f0a-1b2c-d3e4f5a6b7c8", "type": "core.transaction.status-changed", "createdAt": "2026-06-01T12:01:00Z", "data": { "transaction": { "id": "d4e5f6a7-b8c9-4d0e-1f2a-b3c4d5e6f7a8", "status": "completed" } } } ``` ### Via polling `GET /core/transactions/{transactionId}` returns the current state. Use polling as a fallback when webhook delivery cannot be guaranteed. Terminal statuses are `completed` and `failed`. ```http theme={null} GET /core/transactions/a0b1c2d3-e4f5-4a6b-7c8d-e9f0a1b2c3d4 ``` ```json theme={null} { "transaction": { "id": "a0b1c2d3-e4f5-4a6b-7c8d-e9f0a1b2c3d4", "status": "completed" } } ``` Prefer webhooks over polling in production. See [Webhooks](/rest-apis/webhooks) for setup instructions. ## Notify the user Display a confirmation when the final `core.transaction.status-changed` webhook fires with `status: "completed"`. For card payouts, include the payout amount and currency so the user knows the card credit to expect. For bank withdrawals, confirm the amount and destination account once the withdrawal settles. You now support crypto sell via the REST API. ## Sample transaction A sell to a card debits the user's internal crypto account, converts at the locked quote rate, and pays out fiat to the linked card via OCT. The transaction fires a single `core.transaction.status-changed` webhook on completion. ```json [expandable] theme={null} { "transaction": { "id": "c9d0e1f2-a3b4-4c5d-6e7f-a8b9c0d1e2f3", "status": "completed", "origin": { "amount": "0.0049", "asset": "BTC", "rate": "0.00001", "node": { "type": "account", "id": "f8c2d1e4-3a7b-4c9d-8e5f-2b1a6d3c7e8f", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "destination": { "amount": "490.00", "asset": "USD", "rate": "1", "node": { "type": "external-account", "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "cd21b26d-35d2-408a-9201-b8fdbef7a604" } }, "denomination": { "asset": "USD", "amount": "490.00", "target": "destination", "rate": "0.00001" }, "fees": [], "quotedAt": "2026-06-01T13:00:00Z", "createdAt": "2026-06-01T13:00:05Z", "updatedAt": "2026-06-01T13:00:30Z" } } ``` ## Failure handling | Failure | What happens to funds | Action | | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | | Quote expires before user confirms | Quote terminates; no debit from crypto account | Re-quote and present to user | | Insufficient crypto balance | Transaction rejected at creation; no debit | Surface error to user; they must top up | | `card-withdrawals` capability missing or restricted | Quote creation succeeds but returns non-empty `requirements`; transaction creation blocked until resolved | Prompt user to complete the outstanding KYC step, then re-quote | | Destination card fails `octSupport` check | Transaction fails before any debit | Re-verify `octSupport` on the card external account; relink if needed | | Card OCT declined by issuer at payout | Crypto is automatically credited back to the user's internal Uphold account | `core.transaction.status-changed` fires with `status: "failed"`; notify the user | | Rate slippage — quote expired between quote and transaction | Transaction creation returns a quote-expired error; no debit | Re-quote and retry | Every terminal state — `completed` or `failed` — fires a `core.transaction.status-changed` webhook. Listen for all three to keep your UI in sync. If the card OCT is declined after the crypto has been debited, the crypto is returned to the user's internal account — not to an external wallet. Make sure your UI communicates this clearly. # Card deposit via the Payment Widget Source: https://developer.uphold.com/developer-guides/card-transfers/deposit/via-payment-widget Accept card deposits with the Uphold Payment Widget for card selection and 3DS authorization. The Payment Widget handles card linking and selection for deposits via the **Select for Deposit flow**, where users can add a new card or pick an existing one directly in the widget. After the user confirms a card deposit quote, the Payment Widget creates the transaction and handles any 3DS challenge the issuer requires via the **Authorize flow**. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. * The Payment Widget is set up in your frontend. See [Installation and setup](/widgets/payment/installation-and-setup). ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold Usr->>U: Start card deposit U->>B: Create widget session B->>A: Create widget session (select-for-deposit) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select card account P-->>U: complete { via, selection } U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose destination account and amount U->>B: Request quote B->>A: Create quote (card → account) A-->>B: { quote } B-->>U: { quote } Usr->>U: Confirm U->>B: Create widget session B->>A: Create widget session (authorize) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget P->>A: Create transaction A-->>P: { transaction (with confirmationUrl if authorization required) } A-->>B: webhook: transaction.created (processing) opt confirmationUrl present Usr->>P: Complete authorization challenge end P-->>U: complete { transaction, trigger } A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Link or select a card account The widget session lets the user link a new card or select an existing one. ### Create a widget session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) to start the `select-for-deposit` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-deposit" } ``` ```json theme={null} { "session": { "flow": "select-for-deposit", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` Pass `response.session` to your frontend to initialize the widget. ### Set up the widget ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeDepositWidget = async (session) => { const widget = new PaymentWidget<'select-for-deposit'>(session, { debug: true }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event fires after the user selects a card. The event payload includes the selected card external account. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; if (via === 'external-account') { // selection is the selected card external account // use selection.id as the origin in the quote handleCardSelected(selection); } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; if (via === 'external-account') { // selection is the selected card external account // use selection.id as the origin in the quote handleCardSelected(selection); } teardown(); break; } ``` Once you have the selected card, prompt the user to select a destination account, then create a quote. ### Handle cancellations ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors. Card-specific errors (duplicate card, country mismatch, card limits) are handled by the widget internally. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Select destination account Card deposits can target any account. If the selected account is not in the card's currency, the amount will be converted at settlement using Uphold's prevailing rate. Make sure the destination asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). ### Find an existing account Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one they want to fund. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My GBP account", "asset": "GBP", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ### Create a new account If the user has no accounts, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My GBP account", "asset": "GBP" } ``` ```json theme={null} { "account": { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My GBP account", "asset": "GBP", "balance": { "total": "0", "available": "0" } } } ``` *** ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with the selected card external account as origin and the destination account. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7" }, "destination": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } ``` ```json [expandable] theme={null} { "quote": { "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "origin": { "amount": "250.00", "asset": "GBP", "node": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "destination": { "amount": "250.00", "asset": "GBP", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2024-07-24T15:22:39Z" } } ``` Quotes typically **expire** quickly. Prompt for user confirmation within the expiry window and requote if needed. ## Authorize and create the transaction After the user confirms the card deposit quote, hand off to the Payment Widget Authorize flow. This flow creates the transaction, completes any 3DS challenge the issuer requires, and polls until a terminal status is reached — so the same flow works whether or not authorization is needed. ### Create an authorize session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) with `flow: "authorize"` and the `quoteId`. ```http theme={null} POST /widgets/payment/sessions { "flow": "authorize", "data": { "quoteId": "" } } ``` ```json theme={null} { "session": { "flow": "authorize", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` ### Set up the widget Initialize the widget with the session. The widget creates the transaction, handles the 3DS redirect, and polls until a terminal status is reached. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeAuthorizeWidget = async (session) => { const widget = new PaymentWidget<'authorize'>(session, { debug: true }); widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; console.log('Complete', transaction.status, trigger.reason); widget.unmount(); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```javascript javascript [expandable] theme={null} // session is `response.session` from your backend async function initializeAuthorizeWidget(session) { const container = document.getElementById('payment-container'); const sessionOrigin = new URL(session.url).origin; const iframe = document.createElement('iframe'); iframe.src = session.url; iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src';"); iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; function teardown() { window.removeEventListener('message', onMessage); iframe.remove(); } function onMessage(event) { if (event.origin !== sessionOrigin) return; switch (event.data?.type) { case 'load': iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin); break; case 'complete': { const { transaction, trigger } = event.data.value; console.log('Complete', transaction.status, trigger.reason); teardown(); break; } case 'cancel': console.log('Cancelled'); teardown(); break; case 'error': console.error('Error', event.data.error); teardown(); break; } } window.addEventListener('message', onMessage); container.appendChild(iframe); } ``` Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event does not guarantee success. Always check `transaction.status` and `trigger.reason`. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } widget.unmount(); }); ``` ```javascript javascript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { transaction, trigger } = event.data.value; if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success } else if (transaction.status === 'failed') { // Map transaction.statusDetails.reason to a user-facing message } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } teardown(); break; } ``` Failure reasons in `transaction.statusDetails.reason`: | Reason | Description | | ----------------------------------- | --------------------------------------------- | | `card-declined-by-bank` | The card was declined by the issuing bank | | `card-expired` | The card has expired | | `card-permanently-declined-by-bank` | The card was permanently declined | | `card-unauthorized` | The card authorization was not completed | | `card-unsupported` | The card is not supported for this operation | | `insufficient-funds` | The origin account has insufficient funds | | `provider-maximum-limit-exceeded` | The transaction exceeds provider limits | | `velocity` | The transaction was blocked by velocity rules | ### Handle cancellations The `cancel` event fires when the user navigates back without completing authorization. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript javascript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors. Card-specific errors (duplicate card, country mismatch, card limits) are handled by the widget internally. Most failures in this flow surface with a generic `code: 'error'` and a descriptive `message`. Transaction-creation failures are the exception: they use `code: 'authorize_transaction_failed'` with `event.detail.error.details.reason: 'unable-to-create-transaction'`. As with any Widget error, the original failure is preserved in [`event.detail.error.cause`](/widgets/payment/sdk-reference#error) — for `unable-to-create-transaction`, `event.detail.error.cause.code` is one of: | Code | Description | | ------------------------- | ----------------------------------------------------------------------------- | | `entity_not_found` | The quote was not found or has expired | | `insufficient_balance` | The origin account has insufficient balance | | `operation_not_allowed` | The operation is not permitted (e.g. duplicate withdrawal, card unauthorized) | | `user_capability_failure` | The user lacks the required capability for this operation | ```javascript Web SDK theme={null} widget.on('error', (event) => { const { code, cause, details } = event.detail.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed' && details?.reason === 'unable-to-create-transaction') { // The real reason is nested here, not in the top-level `code` console.error('Create transaction failed:', cause?.code); } widget.unmount(); // Show a user-friendly error message }); ``` ```javascript javascript theme={null} // Inside the onMessage switch from Set up the widget case 'error': { const { code, cause, details } = event.data.error; console.error('Widget error:', code, details, cause); if (code === 'authorize_transaction_failed' && details?.reason === 'unable-to-create-transaction') { console.error('Create transaction failed:', cause?.code); } teardown(); // Show a user-friendly error message break; } ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. ### Complete implementation example ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeAuthorizeWidget = async (session) => { const widget = new PaymentWidget<'authorize'>(session, { debug: true }); widget.on('complete', (event) => { const { transaction, trigger } = event.detail.value; handleAuthorizeComplete(transaction, trigger); widget.unmount(); }); widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); widget.on('error', (event) => { const { code, cause, details } = event.detail.error; handleAuthorizeError(code, details, cause); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; const handleAuthorizeComplete = (transaction, trigger) => { if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — transaction is settled } else if (transaction.status === 'failed') { handleTransactionFailure(transaction.statusDetails.reason); } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } }; const handleTransactionFailure = (reason) => { switch (reason) { case 'card-declined-by-bank': case 'card-permanently-declined-by-bank': // Prompt the user to try a different card break; case 'card-expired': // Prompt the user to update their card break; case 'card-unauthorized': // 3DS authentication was not completed break; case 'card-unsupported': // Card type is not supported for this operation break; case 'insufficient-funds': // User does not have enough funds break; case 'provider-maximum-limit-exceeded': case 'velocity': // Transaction blocked by limits — inform the user break; default: // Unhandled reason — show a generic error message break; } }; const handleAuthorizeError = (code, details, cause) => { // For 'authorize_transaction_failed', the real reason is nested in `cause.code`, // not in the top-level `code` const effectiveCode = code === 'authorize_transaction_failed' && details?.reason === 'unable-to-create-transaction' ? cause?.code : code; switch (effectiveCode) { case 'entity_not_found': // Quote expired — prompt user to start over break; case 'insufficient_balance': // Origin account has insufficient balance break; case 'operation_not_allowed': // Operation not permitted (e.g. duplicate withdrawal, card unauthorized) break; case 'user_capability_failure': // User lacks the required capability break; default: // Unexpected error — show a generic error message break; } }; ``` ```javascript javascript [expandable] theme={null} // session is `response.session` from your backend async function initializeAuthorizeWidget(session) { const container = document.getElementById('payment-container'); const sessionOrigin = new URL(session.url).origin; const iframe = document.createElement('iframe'); iframe.src = session.url; iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src';"); iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; function teardown() { window.removeEventListener('message', onMessage); iframe.remove(); } function onMessage(event) { if (event.origin !== sessionOrigin) return; switch (event.data?.type) { case 'load': iframe.contentWindow.postMessage({ ...session, options: {}, type: 'init' }, sessionOrigin); break; case 'complete': { const { transaction, trigger } = event.data.value; handleAuthorizeComplete(transaction, trigger); teardown(); break; } case 'cancel': teardown(); // Return the user to the previous screen break; case 'error': { const { code, cause, details } = event.data.error; handleAuthorizeError(code, details, cause); teardown(); break; } } } window.addEventListener('message', onMessage); container.appendChild(iframe); } const handleAuthorizeComplete = (transaction, trigger) => { if (trigger.reason === 'transaction-status-changed') { if (transaction.status === 'completed') { // Show success — transaction is settled } else if (transaction.status === 'failed') { handleTransactionFailure(transaction.statusDetails.reason); } } else if (trigger.reason === 'max-retries-reached') { // Widget stopped polling — continue monitoring via webhooks or polling } }; const handleTransactionFailure = (reason) => { switch (reason) { case 'card-declined-by-bank': case 'card-permanently-declined-by-bank': // Prompt the user to try a different card break; case 'card-expired': // Prompt the user to update their card break; case 'card-unauthorized': // 3DS authentication was not completed break; case 'card-unsupported': // Card type is not supported for this operation break; case 'insufficient-funds': // User does not have enough funds break; case 'provider-maximum-limit-exceeded': case 'velocity': // Transaction blocked by limits — inform the user break; default: // Unhandled reason — show a generic error message break; } }; const handleAuthorizeError = (code, details, cause) => { // For 'authorize_transaction_failed', the real reason is nested in `cause.code`, // not in the top-level `code` const effectiveCode = code === 'authorize_transaction_failed' && details?.reason === 'unable-to-create-transaction' ? cause?.code : code; switch (effectiveCode) { case 'entity_not_found': // Quote expired — prompt user to start over break; case 'insufficient_balance': // Origin account has insufficient balance break; case 'operation_not_allowed': // Operation not permitted (e.g. duplicate withdrawal, card unauthorized) break; case 'user_capability_failure': // User lacks the required capability break; default: // Unexpected error — show a generic error message break; } }; ``` In a successful card deposit, the origin is the `external-account` representing the card and the destination is the user's `account`. ```json [expandable] theme={null} { "transaction": { "id": "f5a6b7c8-3d4e-4f7a-b00c-9d8e7f6a5b4c", "origin": { "asset": "GBP", "amount": "250.00", "node": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "GBP", "amount": "250.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "status": "completed", "quotedAt": "2025-01-10T11:02:39Z", "createdAt": "2025-01-10T11:12:39Z", "updatedAt": "2025-01-10T11:13:08Z", "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } } ``` *** ## Monitor for settlement Card deposit transactions may remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support card deposits via the Payment Widget. # Card deposit via the REST API Source: https://developer.uphold.com/developer-guides/card-transfers/deposit/via-rest-api Support card deposits using the Uphold REST API: link a card as an external account, create a quote, handle 3DS, and create the deposit transaction. This guide walks you through supporting card deposits using the REST API. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold Usr->>U: Start card deposit U->>B: List card accounts B->>A: GET /core/external-accounts A-->>B: { externalAccounts } B-->>U: { externalAccounts } opt No existing card account Usr->>U: Add card details U->>B: Link card account B->>A: Create card external account A-->>B: { externalAccount } B-->>U: { externalAccount } end Usr->>U: Choose card account alt First time (EMD disclaimer) U->>Usr: Display EMD disclaimer Usr->>U: Acknowledge end U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose destination account and amount U->>B: Request quote B->>A: Create quote (card → account) A-->>B: { quote } B-->>U: { quote } Usr->>U: Confirm U->>B: Create transaction (with returnUrl) B->>A: Create transaction A-->>B: { transaction (with confirmationUrl if authorization required) } B-->>U: { transaction } A-->>B: webhook: transaction.created (processing) alt confirmationUrl present (authorization required) U->>Usr: Redirect to confirmationUrl Usr->>A: Complete authorization challenge A->>Usr: Redirect to returnUrl end A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Check available rails Call [List Rails](/rest-apis/core-api/assets/list-rails) to verify card deposit is available. ```http theme={null} GET /core/rails?type=card ``` ```json theme={null} { "rails": [ { "type": "card", "network": "visa", "method": "debit-card", "asset": "GBP", "decimals": 2, "features": [ "deposit", "withdraw" ] } ] } ``` ## Link or select a card account Let the user link a new card or select an existing one. ### Find an existing card account Call [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts) to fetch the user's saved cards. ```http theme={null} GET /core/external-accounts ``` Make sure the selected card has `status: "ok"` and in `features`. ```json theme={null} { "externalAccounts": [ { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "card", "status": "ok", "label": "My Visa Card", "asset": "GBP", "network": "visa", "features": [ "deposit", "withdraw" ], "details": { "type": "debit", "last4Digits": "5119", "expiryDate": { "month": 12, "year": 2028 }, "octSupport": "supported" } } ] } ``` ### Link a new card account If the user wants to use a card not yet on file, call [Create external account](/rest-apis/core-api/external-accounts/create-external-account) with the card details. ```http theme={null} POST /core/external-accounts { "type": "card", "label": "My Visa Card", "number": "4921817844445119", "securityCode": "123", "expiryDate": { "month": 12, "year": 2028 } } ``` The response initially returns `status: "processing"` while the card is validated. ```json theme={null} { "externalAccount": { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "type": "card", "status": "processing", "label": "My Visa Card" } } ``` Once validated, the status transitions to `ok` and the full details are available. ```json theme={null} { "externalAccount": { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "card", "status": "ok", "label": "My Visa Card", "asset": "GBP", "network": "visa", "features": [ "deposit", "withdraw" ], "details": { "type": "debit", "last4Digits": "5119", "expiryDate": { "month": 12, "year": 2028 }, "octSupport": "supported" } } } ``` Monitor the external account status via [Get external account](/rest-apis/core-api/external-accounts/get-external-account) or the `external-account.status-changed` webhook until `status` is `ok` before proceeding. ### EMD disclaimer This disclaimer is required to comply with FCA regulations. Display it to users based in GB, the first time they link a credit/debit card or generate bank deposit details — it only needs to be shown once: > Uphold Europe Limited is an EMD Agent of Optimus Cards UK Limited (FRN: 902034). All received funds are held in a designated safekeeping account with a regulated bank and kept separate from Uphold's own assets. These funds are not protected by the UK Financial Services Compensation Scheme. ## Select destination account Card deposits can target any account. If the selected account is not in the card's currency, the amount will be converted at settlement using Uphold's prevailing rate. Make sure the destination asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). ### Find an existing account Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one they want to fund. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My GBP account", "asset": "GBP", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ### Create a new account If the user has no accounts, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My GBP account", "asset": "GBP" } ``` ```json theme={null} { "account": { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My GBP account", "asset": "GBP", "balance": { "total": "0", "available": "0" } } } ``` ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with the card external account as origin and the destination account. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7" }, "destination": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } ``` ```json [expandable] theme={null} { "quote": { "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "origin": { "amount": "250.00", "asset": "GBP", "node": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "destination": { "amount": "250.00", "asset": "GBP", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2024-07-24T15:22:39Z" } } ``` Quotes typically **expire** quickly. Prompt for user confirmation within the expiry window and requote if needed. ## Confirm and create transaction Once the user confirms, call [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the quote ID. Always include the `authorize:3ds` requirement for card-origin transactions — whether the card requires authorization is decided per transaction by the issuer and isn't known until the transaction response. You may include stateful data in query parameters of the `returnUrl`, such as the `quoteId`, so you can preserve context and resume the transaction flow upon return. ```http theme={null} POST /core/transactions { "quoteId": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "params": { "requirements": [ { "name": "authorize:3ds", "returnUrl": "https://example.com/redirect?quoteId=623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0" } ] } } ``` If authorization is required, the response will include a `confirmationUrl` on the origin node. Redirect the user to that URL to complete the challenge. After completion, the user will be redirected back to your `returnUrl`. ```json theme={null} { "transaction": { "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "status": "processing", "origin": { "node": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "confirmationUrl": "https://authentication-devices.sandbox.checkout.com/sessions-interceptor/sid_...", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } } } } ``` In a successful card deposit, the origin is the `external-account` representing the card and the destination is the user's `account`. ```json [expandable] theme={null} { "transaction": { "id": "f5a6b7c8-3d4e-4f7a-b00c-9d8e7f6a5b4c", "origin": { "asset": "GBP", "amount": "250.00", "node": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "GBP", "amount": "250.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "status": "completed", "quotedAt": "2025-01-10T11:02:39Z", "createdAt": "2025-01-10T11:12:39Z", "updatedAt": "2025-01-10T11:13:08Z", "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } } ``` ## Monitor for settlement Card deposit transactions may remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support card deposits via the REST API. # Card transfer integration overview (debit and credit cards) Source: https://developer.uphold.com/developer-guides/card-transfers/overview Let users fund accounts and cash out with debit or credit cards in EUR, GBP, or USD. Covers card linking, 3DS authorization, and card limits. Allow users to fund or cash out with their debit/credit cards, globally, in EUR, GBP, or USD. ## Key concepts * **External account lifecycle** — when a card is added, its status starts as `processing` and transitions to `ok` once validated, or `failed` if the card is invalid or declined. If using the Payment Widget, card-linking specific errors (duplicates, country mismatch, card limits) are handled internally. * **3DS authorization** — when the transaction response includes a `confirmationUrl` on the card node, 3DS needs to be fulfilled. Either handle the redirect in your app or use the Payment Widget to manage the authorization flow. * **Card limits** — users may be subject to active card limits and unique card limits. These are enforced as capability restrictions. ## Testing in sandbox Use [Test cards](/rest-apis/core-api/accounts/test-helpers/fund-sandbox-accounts#test-cards) to simulate card deposits and withdrawals in the Sandbox environment. The table lists card numbers by network, type, and country, along with reserved amounts that trigger specific error responses. ## Start building Set up card deposits and handle 3DS authentication programmatically. Let users add their card and deposit funds with a low-code, embeddable widget. Create quotes and submit payouts to your users' cards. Let users withdraw funds to their card with a low-code, embeddable widget. # Card withdrawal via the Payment Widget Source: https://developer.uphold.com/developer-guides/card-transfers/withdrawal/via-payment-widget Send card withdrawals with the Uphold Payment Widget for card selection, then create the quote and transaction directly via the REST API to complete payout. The Payment Widget handles card selection for withdrawals via the **Select for Withdrawal flow**. Your backend then creates the quote and the transaction directly via the REST API. The Payment Widget handles card selection only. Your backend must create the transaction via the REST API. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. * The Payment Widget is set up in your frontend. See [Installation and setup](/widgets/payment/installation-and-setup). ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold Usr->>U: Start card withdrawal U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose source account U->>B: Create widget session B->>A: Create widget session (select-for-withdrawal) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select card account P-->>U: complete { via, selection } Usr->>U: Choose amount U->>B: Request quote B->>A: Create quote (account → card) A-->>B: { quote } B-->>U: { quote } Usr->>U: Confirm U->>B: Create transaction B->>A: Create transaction A-->>B: { transaction } A-->>B: webhook: transaction.created (processing) A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Select source account Card withdrawals can be sourced from any account. If the selected account is not in the card's currency, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one to withdraw from. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My GBP account", "asset": "GBP", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` *** ## Link or select a card account The widget session lets the user link a new card or select an existing one. ### Create a widget session Call [Create widget session](/rest-apis/widgets-api/payment/create-session) to start the `select-for-withdrawal` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-withdrawal" } ``` ```json theme={null} { "session": { "flow": "select-for-withdrawal", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` Pass `response.session` to your frontend to initialize the widget. ### Set up the widget ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeWithdrawalWidget = async (session) => { const widget = new PaymentWidget<'select-for-withdrawal'>(session, { debug: true }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event fires after the user selects a card. The event payload includes the selected card external account. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; if (via === 'external-account') { // selection is the selected card external account // use selection.id as the destination in the quote handleCardSelected(selection); } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; if (via === 'external-account') { // selection is the selected card external account // use selection.id as the destination in the quote handleCardSelected(selection); } teardown(); break; } ``` Once you have the selected card, prompt the user to select a source account, then create a quote. ### Handle cancellations ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Return the user to the previous screen break; ``` ### Handle errors The `error` event fires for critical unrecoverable errors. Card-specific errors (duplicate card, country mismatch, card limits) are handled by the widget internally. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` The Payment Widget handles most errors internally. For unrecoverable errors, the widget fires an `error` event. It is the host application's responsibility to handle these events, present an error message to the user, and unmount the widget. *** ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with the origin account and the selected card external account as destination. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "destination": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7" }, "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } ``` A successful response includes the quote details. Present the quote to the user for confirmation before proceeding. ```json [expandable] theme={null} { "quote": { "id": "a91f3c72-1e4b-4c8a-b3e9-9f2d8e4b7c1a", "origin": { "amount": "250.00", "asset": "GBP", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "destination": { "amount": "250.00", "asset": "GBP", "node": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2024-07-24T15:22:39Z" } } ``` Quotes typically **expire** quickly. Prompt for user confirmation within the expiry window and requote if needed. *** ## Confirm and create transaction Once the user confirms, call [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the quote ID. ```http theme={null} POST /core/transactions { "quoteId": "a91f3c72-1e4b-4c8a-b3e9-9f2d8e4b7c1a" } ``` In a successful card withdrawal, the origin is the user's `account` and the destination is the `external-account` representing the card. The transaction status is initially `processing` and updates to `completed` once the transfer settles. ```json [expandable] theme={null} { "transaction": { "id": "a1b2c3d4-5e6f-4a8b-9c0d-1e2f3a4b5c6d", "origin": { "asset": "GBP", "amount": "250.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "GBP", "amount": "250.00", "node": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "status": "completed", "quotedAt": "2025-01-10T14:22:15Z", "createdAt": "2025-01-10T14:22:45Z", "updatedAt": "2025-01-10T14:22:45Z", "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } } ``` *** ## Monitor for settlement Card withdrawal transactions may remain in `processing` while the payment settles. Monitor until the transaction reaches a terminal state. * **Webhook events** (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) — `status: processing` → transaction created, pending settlement * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) — `status: completed` → funds settled; `status: failed` → irrecoverable error * **Polling** (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support card withdrawals via the Payment Widget. # Card withdrawal via the REST API Source: https://developer.uphold.com/developer-guides/card-transfers/withdrawal/via-rest-api Send card withdrawals with the Uphold REST API: link or list a card external account, create a quote, and submit the withdrawal transaction to the user. This guide walks you through supporting card withdrawals using the REST API. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. * A **funded account** to debit the funds from. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold Usr->>U: Start card withdrawal U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose source account U->>B: List card accounts B->>A: GET /core/external-accounts A-->>B: { externalAccounts } B-->>U: { externalAccounts } opt No existing card account Usr->>U: Add card details U->>B: Link card account B->>A: Create card external account A-->>B: { externalAccount } end Usr->>U: Choose card and amount alt First time (EMD disclaimer) U->>Usr: Display EMD disclaimer Usr->>U: Acknowledge end U->>B: Request quote B->>A: Create quote (account → card) A-->>B: { quote } B-->>U: { quote } Usr->>U: Confirm U->>B: Create transaction B->>A: Create transaction A-->>B: { transaction } A-->>B: webhook: transaction.created (processing) A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Check available rails Call [List Rails](/rest-apis/core-api/assets/list-rails) to verify card withdrawal is available. ```http theme={null} GET /core/rails?type=card ``` ```json theme={null} { "rails": [ { "type": "card", "network": "visa", "method": "debit-card", "asset": "GBP", "decimals": 2, "features": [ "deposit", "withdraw" ] } ] } ``` ## Select source account Card withdrawals can be sourced from any account. If the selected account is not in the card's currency, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one to withdraw from. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My GBP account", "asset": "GBP", "balance": { "total": "500.00", "available": "500.00" } } ] } ``` ## Link or select a card account Let the user link a new card or select an existing one. ### Find an existing card account Call [List external accounts](/rest-apis/core-api/external-accounts/list-external-accounts) to fetch the user's saved cards. ```http theme={null} GET /core/external-accounts ``` Make sure the selected card has `status: "ok"` and in `features`. ```json theme={null} { "externalAccounts": [ { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "card", "status": "ok", "label": "My Visa Card", "asset": "GBP", "network": "visa", "features": [ "deposit", "withdraw" ], "details": { "type": "debit", "last4Digits": "5119", "expiryDate": { "month": 12, "year": 2028 }, "octSupport": "supported" } } ] } ``` ### Link a new card account If the user wants to use a card not yet on file, call [Create external account](/rest-apis/core-api/external-accounts/create-external-account) with the card details. ```http theme={null} POST /core/external-accounts { "type": "card", "label": "My Visa Card", "number": "4921817844445119", "securityCode": "123", "expiryDate": { "month": 12, "year": 2028 } } ``` The response initially returns `status: "processing"` while the card is validated. ```json theme={null} { "externalAccount": { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "type": "card", "status": "processing", "label": "My Visa Card" } } ``` Once validated, the status transitions to `ok` and the full details are available. ```json theme={null} { "externalAccount": { "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "type": "card", "status": "ok", "label": "My Visa Card", "asset": "GBP", "network": "visa", "features": [ "deposit", "withdraw" ], "details": { "type": "debit", "last4Digits": "5119", "expiryDate": { "month": 12, "year": 2028 }, "octSupport": "supported" } } } ``` Monitor the external account status via [Get external account](/rest-apis/core-api/external-accounts/get-external-account) or the `external-account.status-changed` webhook until `status` is `ok` before proceeding. ### EMD disclaimer This disclaimer is required to comply with FCA regulations. Display it to users based in GB, the first time they link a credit/debit card or generate bank deposit details — it only needs to be shown once: > Uphold Europe Limited is an EMD Agent of Optimus Cards UK Limited (FRN: 902034). All received funds are held in a designated safekeeping account with a regulated bank and kept separate from Uphold's own assets. These funds are not protected by the UK Financial Services Compensation Scheme. ## Create a quote Call [Create quote](/rest-apis/core-api/transactions/create-quote) with the origin account and the card external account as destination. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "destination": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7" }, "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } ``` ```json [expandable] theme={null} { "quote": { "id": "a91f3c72-1e4b-4c8a-b3e9-9f2d8e4b7c1a", "origin": { "amount": "250.00", "asset": "GBP", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "destination": { "amount": "250.00", "asset": "GBP", "node": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" }, "rate": "1" }, "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin", "rate": "1" }, "fees": [], "expiresAt": "2024-07-24T15:22:39Z" } } ``` Quotes typically **expire** quickly. Prompt for user confirmation within the expiry window and requote if needed. ## Confirm and create transaction Once the user confirms, call [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the quote ID. ```http theme={null} POST /core/transactions { "quoteId": "a91f3c72-1e4b-4c8a-b3e9-9f2d8e4b7c1a" } ``` In a successful card withdrawal, the origin is the user's `account` and the destination is the `external-account` representing the card. The transaction status is initially `processing` and updates to `completed` once the transfer settles. ```json [expandable] theme={null} { "transaction": { "id": "a1b2c3d4-5e6f-4a8b-9c0d-1e2f3a4b5c6d", "origin": { "asset": "GBP", "amount": "250.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "asset": "GBP", "amount": "250.00", "node": { "type": "external-account", "id": "7d5928c5-8ac4-4b0d-8b45-f332ba6a9de7", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "status": "completed", "quotedAt": "2025-01-10T14:22:15Z", "createdAt": "2025-01-10T14:22:45Z", "updatedAt": "2025-01-10T14:22:45Z", "denomination": { "asset": "GBP", "amount": "250.00", "target": "origin" } } } ``` ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support card withdrawals via the REST API. # Crypto deposit via the Payment Widget Source: https://developer.uphold.com/developer-guides/crypto-transfers/deposit/via-payment-widget Accept crypto deposits with the Uphold Payment Widget for network selection and address display, then monitor incoming transactions on your backend. The Payment Widget handles crypto network selection and displays the deposit address to the user. Your backend only needs to create the session and monitor for the incoming transfer. The Payment Widget does not create any transaction. Monitoring and processing the incoming transfer must be handled by your backend via webhooks or polling. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. * The Payment Widget is set up in your frontend. See [Installation and setup](/widgets/payment/installation-and-setup). ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant W as Travel Rule Widget participant A as Uphold participant C as Blockchain Network Usr->>U: Request deposit instructions U->>B: Create widget session B->>A: Create widget session (select-for-deposit) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select deposit method P-->>Usr: Display crypto deposit instructions Usr->>C: Send crypto transfer C-->>A: Incoming transfer received A-->>B: webhook: transaction.created (processing) C-->>A: Confirmations reached alt on-hold (pending RFI) A-->>B: webhook: transaction.status-changed (on-hold) B->>A: GET /core/requests-for-information A-->>B: { requestsForInformation } B->>A: POST /widgets/travel-rule/sessions A-->>B: { session } B-->>U: { session } U->>W: Initialize widget Usr->>W: Submit Travel Rule information W-->>U: complete { travelRule } U->>B: { travelRule } B->>A: PATCH /core/requests-for-information/{rfiId} A-->>B: RFI resolved end A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Select deposit method The widget lets the user select a crypto network and view the deposit address. ### Create a widget session Call the [Create widget session](/rest-apis/widgets-api/payment/create-session) endpoint to create a session for the `select-for-deposit` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-deposit" } ``` A successful response contains a `session` object. Pass `response.session` to your frontend to initialize the widget. ```json theme={null} { "session": { "flow": "select-for-deposit", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` ### Set up the widget Initialize the widget for the `select-for-deposit` flow using the session data returned from the API. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeDepositWidget = async (session) => { const widget = new PaymentWidget<'select-for-deposit'>(session, { debug: true }); widget.on('ready', () => { console.log('Ready'); }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The `complete` event fires when the user selects a deposit method and the widget displays the deposit address. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; if (via === 'deposit-method') { const { depositMethod, account } = selection; if (depositMethod.type === 'crypto') { handleCryptoDeposit(depositMethod, account); } } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; if (via === 'deposit-method') { const { depositMethod, account } = selection; if (depositMethod.type === 'crypto') { handleCryptoDeposit(depositMethod, account); } } teardown(); break; } ``` The event payload: * `via` — set to `deposit-method` when the user completes the crypto network selection. * `selection.account` — the account that will receive the deposit. * `selection.depositMethod` — the deposit method with crypto transfer instructions. The widget presents the deposit address and reference directly to the user. The `depositMethod.details` contains: ```json theme={null} { "type": "crypto", "status": "ok", "details": { "network": "xrp-ledger", "asset": "XRP", "address": "rfBtmHiLwwWH5maH2PT78GxubrSydRF9aY", "reference": "3457810109" } } ``` ### Handle cancellations The `cancel` event fires when the user closes the widget without completing the selection. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Redirect back or show a cancellation message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Redirect back or show a cancellation message break; ``` ### Handle errors The `error` event fires when an error occurs during the flow. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` ## Monitor for the incoming transfer The widget presents the deposit instructions to the user but does not monitor for the incoming transfer. Your application must do this via webhooks or polling. Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → detected on-chain but not yet confirmed * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → necessary confirmations reached * `status: on-hold` → transaction checks paused (e.g., pending RFIs) * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Sample transaction In a successful crypto deposit, the origin is represented as a `crypto-address` node reflecting the sender's on-chain address. The destination is the account that was set up to receive the deposit. ```json [expandable] theme={null} { "transaction": { "id": "223c24c5-76c6-4553-91bc-5af519441f03", "origin": { "asset": "BTC", "amount": "0.00121023", "rate": "1.00", "node": { "type": "crypto-address", "network": "bitcoin", "address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", "execution": { "mode": "onchain", "transactionHash": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b" } } }, "destination": { "asset": "BTC", "amount": "0.00121023", "rate": "1.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "fees": [], "status": "completed", "quotedAt": "2024-07-24T15:02:39Z", "createdAt": "2024-07-24T15:22:39Z", "updatedAt": "2024-07-24T15:33:08Z", "denomination": { "asset": "BTC", "amount": "0.00121023", "rate": "1.00", "target": "origin" } } } ``` ### Execution modes Crypto deposits are processed using different execution modes depending on the environment and configuration: * **On-chain execution**: The transaction is processed directly on the blockchain network. The `origin.node.execution.mode` will be `onchain` and include a `transactionHash` as shown in the example above. * **Off-chain execution**: When both the sender and recipient are Uphold users, the transaction is processed internally within Uphold's infrastructure. This eliminates network fees and is faster than on-chain processing. The `origin.node.execution.mode` will be `offchain`. The `execution` object for these transactions includes additional properties: `accountOwnerId` (the sender user ID) and `accountId` (the sender account ID). * **Simulated execution (Test Helpers)**: Used in development environments for testing purposes ([Simulate crypto deposit](/rest-apis/core-api/accounts/test-helpers/simulate-crypto-deposit)). The transaction appears as processed but does not affect actual blockchain state (user balances will be affected though). The `origin.node.execution.mode` will be `simulated`. ## Handle on-hold transactions If the crypto deposit is placed `on-hold` with reason `pending-requests-for-information`, resolve the pending RFIs before the deposit can complete. For the full step-by-step implementation, see the [Travel Rule deposit flow](/developer-guides/travel-rule/deposit) guide. ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support crypto deposits via the Payment Widget. # Crypto deposit via the REST API Source: https://developer.uphold.com/developer-guides/crypto-transfers/deposit/via-rest-api Support crypto deposits with the Uphold REST API: configure the deposit method, generate a network address, and monitor for incoming on-chain transfers. This guide walks you through supporting crypto deposits using the REST API. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. Some networks require **destination tags/memos** (e.g., XRP, XLM). If a `reference` is returned with the `address`, you can check its type by calling [Get network](/rest-apis/core-api/assets/get-network) endpoint. Educate the user on the importance of providing this information when making the deposit, as it is essential for crediting their account correctly. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold participant C as Blockchain Network Usr->>U: Request deposit instructions U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose destination account U->>B: Generate deposit method B->>A: PUT /core/accounts/{accountId}/deposit-method A-->>B: { depositMethod } B-->>U: { depositMethod } U-->>Usr: Display deposit instructions Usr->>C: Send crypto transfer C-->>A: Detect inbound tx to address A-->>B: webhook: transaction.created (processing) C-->>A: Confirmations reached A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` ## Check available rails Call [List rails](/rest-apis/core-api/assets/list-rails) to confirm the asset and network the user wants to deposit from, and verify the `deposit` feature is enabled. ```http theme={null} GET /core/rails?type=crypto&asset=BTC ``` A successful response lists all rails for the asset. Each rail includes a `features` array — only proceed with networks that include `"deposit"`. For assets supported by multiple networks, require an explicit network selection from the user. ```json theme={null} { "rails": [ { "type": "crypto", "network": "bitcoin", "method": "crypto-transaction", "asset": "BTC", "decimals": 8, "features": [ "deposit", "withdraw" ] }, { "type": "crypto", "network": "lightning", "method": "crypto-transaction", "asset": "BTC", "decimals": 8, "features": [] } ] } ``` ## Select destination account Crypto deposits can target any account. If the selected account is not in the deposited asset, the amount will be converted at settlement using Uphold's prevailing rate. Make sure the destination asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). ### Find an existing account Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them pick the one they want to fund. ```http theme={null} GET /core/accounts ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My BTC account", "asset": "BTC", "balance": { "total": "0.05", "available": "0.05" } } ] } ``` ### Create a new account If the user has no accounts, create one with [Create account](/rest-apis/core-api/accounts/create-account) before proceeding. ```http theme={null} POST /core/accounts { "label": "My BTC account", "asset": "BTC" } ``` ```json theme={null} { "account": { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My BTC account", "asset": "BTC", "balance": { "total": "0", "available": "0" } } } ``` ## Generate deposit method Call [Set up account deposit method](/rest-apis/core-api/accounts/set-up-account-deposit-method) with the target account id, asset, and network. ```http theme={null} PUT /core/accounts/{accountId}/deposit-method?type=crypto&asset=BTC&network=bitcoin ``` A successful response includes the `address` and, if applicable, a `reference` (e.g., destination tag, memo). ```json theme={null} { "depositMethod": { "type": "crypto", "status": "ok", "details": { "network": "bitcoin", "asset": "BTC", "address": "tb1qgu0gacn9pqpnvlqclvdwyz4gfgxz8pptfz4emt" } } } ``` The deposit method may initially return `status: processing` while the address is being prepared. Call [Get account deposit method](/rest-apis/core-api/accounts/get-account-deposit-method) to confirm it is ready (`status: ok`) before displaying instructions to the user. Render the address clearly and, if a `reference` is present, display it prominently and treat it as required input. We suggest also displaying a QR code to reduce input errors. ## Monitor for the incoming transfer Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → detected on-chain but not yet confirmed * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → necessary confirmations reached * `status: on-hold` → transaction checks paused (e.g., pending RFIs) * `status: failed` → irrecoverable error * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Sample transaction In a successful crypto deposit, the origin is represented as a `crypto-address` node reflecting the sender's on-chain address. The destination is the account that was set up to receive the deposit. ```json [expandable] theme={null} { "transaction": { "id": "223c24c5-76c6-4553-91bc-5af519441f03", "origin": { "asset": "BTC", "amount": "0.00121023", "rate": "1.00", "node": { "type": "crypto-address", "network": "bitcoin", "address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", "execution": { "mode": "onchain", "transactionHash": "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b" } } }, "destination": { "asset": "BTC", "amount": "0.00121023", "rate": "1.00", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "fees": [], "status": "completed", "quotedAt": "2024-07-24T15:02:39Z", "createdAt": "2024-07-24T15:22:39Z", "updatedAt": "2024-07-24T15:33:08Z", "denomination": { "asset": "BTC", "amount": "0.00121023", "rate": "1.00", "target": "origin" } } } ``` ### Execution modes Crypto deposits are processed using different execution modes depending on the environment and configuration: * **On-chain execution**: The transaction is processed directly on the blockchain network. The `origin.node.execution.mode` will be `onchain` and include a `transactionHash` as shown in the example above. * **Off-chain execution**: When both the sender and recipient are Uphold users, the transaction is processed internally within Uphold's infrastructure. This eliminates network fees and is faster than on-chain processing. The `origin.node.execution.mode` will be `offchain`. The `execution` object for these transactions includes additional properties: `accountOwnerId` (the sender user ID) and `accountId` (the sender account ID). * **Simulated execution (Test Helpers)**: Used in development environments for testing purposes ([Simulate crypto deposit](/rest-apis/core-api/accounts/test-helpers/simulate-crypto-deposit)). The transaction appears as processed but does not affect actual blockchain state (user balances will be affected though). The `origin.node.execution.mode` will be `simulated`. ## Handle on-hold transactions If the crypto deposit is placed `on-hold` with reason `pending-requests-for-information`, resolve the pending RFIs before the deposit can complete. For the full step-by-step implementation, see the [Travel Rule deposit flow](/developer-guides/travel-rule/deposit) guide. ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support crypto deposits with the Enterprise API Suite. # Crypto transfer integration overview (50+ blockchains) Source: https://developer.uphold.com/developer-guides/crypto-transfers/overview Build native crypto on/off-ramps across 50+ blockchain networks, with on-chain, off-chain, and simulated execution modes for deposits and withdrawals. Power native crypto on/off-ramp experiences across 50+ blockchain networks. ## Key concepts * **Deposits** are detected automatically when incoming funds arrive at the generated deposit address. No quote is required. * **Withdrawals** are quote-based — the user must confirm a quote before the transaction is created and broadcast. * **Execution modes** — transactions can be processed on-chain, off-chain (between Uphold users), or in simulated mode (sandbox only). * **Transaction rules** — some quotes may include requirements (e.g., Travel Rule) that must be resolved before creating the transaction. On-hold transactions may require pending RFIs to be resolved. ## Start building Generate a deposit address and monitor for incoming on-chain transfers. Let users select a crypto network and receive deposit instructions with a low-code, embeddable widget. Create and broadcast a crypto withdrawal to an external address. Let users select a network and enter their address with a low-code, embeddable widget. # Crypto withdrawal via the Payment Widget Source: https://developer.uphold.com/developer-guides/crypto-transfers/withdrawal/via-payment-widget Send crypto withdrawals with the Uphold Payment Widget for asset, network and address collection, then create the quote and transaction via the REST API. The Payment Widget handles crypto asset, network and address collection for withdrawals. Your backend creates the session, then continues with the REST API to create a quote and transaction once the user has provided the destination details. The Payment Widget does not create any transaction. Transaction creation must be handled by your backend via the REST API. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. * The Payment Widget is set up in your frontend. See [Installation and setup](/widgets/payment/installation-and-setup). ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant P as Payment Widget participant A as Uphold participant N as Blockchain Network Usr->>U: Start crypto withdrawal U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose source account U->>B: Create widget session B->>A: Create widget session (select-for-withdrawal) A-->>B: { session } B-->>U: { session } U->>P: Initialize widget Usr->>P: Select asset, network and enter address P-->>U: complete { via: "crypto-network", selection } U->>B: Request quote B->>A: Create quote A-->>B: { quote } B-->>U: { quote } Usr->>U: Confirm quote U->>B: Create transaction B->>A: Create transaction A-->>B: { transaction } A-->>B: webhook: transaction.created (processing) A->>N: Broadcast withdrawal N-->>A: Confirmations reached A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` *** ## Select source account Crypto withdrawals can be sourced from any account. If the selected account is not in the withdrawal asset, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them choose one with sufficient balance for the withdrawal. ```http theme={null} GET /core/accounts?currency=BTC ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My BTC account", "asset": "BTC", "balance": { "total": "0.05", "available": "0.05" } } ] } ``` *** ## Set a crypto destination The widget lets the user select a crypto asset, choose a network and enter the destination address. ### Create a widget session Call the [Create widget session](/rest-apis/widgets-api/payment/create-session) endpoint to create a session for the `select-for-withdrawal` flow. ```http theme={null} POST /widgets/payment/sessions { "flow": "select-for-withdrawal" } ``` A successful response contains a `session` object. Pass `response.session` to your frontend to initialize the widget. ```json theme={null} { "session": { "flow": "select-for-withdrawal", "url": "https://payment.enterprise.uphold.com/", "token": "GEbRxBN...edjnXbL" } } ``` ### Set up the widget Initialize the widget for the `select-for-withdrawal` flow using the session data returned from the API. ```javascript Web SDK [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; const initializeWithdrawalWidget = async (session) => { const widget = new PaymentWidget<'select-for-withdrawal'>(session, { debug: true }); widget.on('ready', () => { console.log('Ready'); }); widget.on('complete', (event) => { console.log('Complete', JSON.stringify(event.detail.value)); }); widget.on('cancel', () => { console.log('Cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Error', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('payment-container')); }; ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/payment/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/payment/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event The widget lets the user select a crypto asset, choose a network and enter a destination address. If the network requires a destination tag or memo, the widget prompts for it and warns the user if it's missing. When the user completes the selection, the `complete` event fires with `via: "crypto-network"`. ```javascript Web SDK theme={null} widget.on('complete', (event) => { const { via, selection } = event.detail.value; if (via === 'crypto-network') { handleCryptoWithdrawal(selection); } widget.unmount(); }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': { const { via, selection } = event.data.value; if (via === 'crypto-network') { handleCryptoWithdrawal(selection); } teardown(); break; } ``` The event payload: * `via` — set to `crypto-network` when the user provides a crypto withdrawal address. * `selection.asset` — the selected crypto asset code (e.g. `BTC`, `XRP`). * `selection.network` — the selected blockchain network (e.g. `bitcoin`, `xrp-ledger`). * `selection.address` — the destination wallet address. * `selection.reference` — the destination tag or memo, if required by the network. ```json theme={null} { "via": "crypto-network", "selection": { "asset": "XRP", "network": "xrp-ledger", "address": "rPjTZfLP3Qxwwd2xvXSALJzEFmmf7bEYgh", "reference": "12345678" } } ``` ### Handle cancellations The `cancel` event fires when the user closes the widget without completing the selection. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Redirect back or show a cancellation message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Redirect back or show a cancellation message break; ``` ### Handle errors The `error` event fires when an error occurs during the flow. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` ## Create a quote Use the `selection` data from the widget to create a quote via the [Create quote](/rest-apis/core-api/transactions/create-quote) endpoint. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "destination": { "type": "crypto-address", "asset": "XRP", "network": "xrp-ledger", "address": "rPjTZfLP3Qxwwd2xvXSALJzEFmmf7bEYgh", "reference": "12345678" }, "denomination": { "asset": "XRP", "amount": "10.00", "target": "origin" } } ``` Quotes typically **expire** quickly. Prompt for user confirmation within the expiry window and requote if needed. ## Handle quote requirements When the quote is returned, check the `requirements` array. If non-empty, resolve each requirement before creating the transaction. ```json theme={null} { "quote": { "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "requirements": [ "travel-rule" ], "expiresAt": "2024-07-24T15:22:39Z" } } ``` ### Travel Rule If the `requirements` array contains `travel-rule`, you must collect the required originator and beneficiary information before creating the transaction. For the full step-by-step implementation, see the [Travel Rule withdrawal](/developer-guides/travel-rule/withdrawal) guide. ## Create a transaction Once the user confirms the quote, create the transaction using the [Create transaction](/rest-apis/core-api/transactions/create-transaction) endpoint. If the Travel Rule widget emitted a `complete` event, include the `travelRule` data in `params`. If Travel Rule was required, the original quote may have expired while the user was completing the form. If so, create a new quote before proceeding — the Travel Rule data remains valid and will automatically apply to the new quote. ```http theme={null} POST /core/transactions { "quoteId": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "params": { "travelRule": { // Travel Rule data from widget complete event — omit if not required } } } ``` In a successful crypto withdrawal, the origin is the source account and the destination is a `crypto-address` node reflecting the recipient's on-chain address. ```json [expandable] theme={null} { "transaction": { "id": "223c24c5-76c6-4553-91bc-5af519441f03", "origin": { "amount": "0.00121023", "asset": "BTC", "rate": "0.00002629253259492961", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "amount": "0.00121023", "asset": "BTC", "rate": "1", "node": { "type": "crypto-address", "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "network": "bitcoin", "execution": { "mode": "onchain" } } }, "fees": [], "status": "processing", "quotedAt": "2024-07-24T15:02:39Z", "createdAt": "2024-07-24T15:22:39Z", "updatedAt": "2024-07-24T15:22:39Z", "denomination": { "amount": "100.00", "asset": "GBP", "target": "origin", "rate": "0.00001210225938333485" } } } ``` ### Execution modes Crypto withdrawals support different execution modes depending on the destination and environment: * **On-chain execution**: The transaction is processed directly on the blockchain network. The `destination.node.execution.mode` will be `onchain` and include blockchain-specific details such as transaction hashes upon completion. * **Off-chain execution**: When both the sender and recipient are Uphold users, the transaction is processed internally within Uphold's infrastructure. This eliminates network fees and is faster than onchain processing. The `destination.node.execution.mode` will be `offchain`. For offchain transactions, the `execution` object includes additional properties: `accountOwnerId` (the recipient user ID) and `accountId` (the recipient account ID). * **Simulated execution**: Used in development environments for testing purposes. The transaction appears processed but does not affect actual blockchain state (user balances will be affected though). The `destination.node.execution.mode` will be `simulated`. ## Monitor for settlement Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → initiated but not yet broadcast * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → broadcast and confirmed * `status: on-hold` → transaction checks paused (e.g., pending RFIs) * `status: failed` → transaction failed, check `statusDetails` for more info * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support crypto withdrawals via the Payment Widget. # Crypto withdrawal via the REST API Source: https://developer.uphold.com/developer-guides/crypto-transfers/withdrawal/via-rest-api Send crypto withdrawals with the Uphold REST API: collect destination details, create a quote, handle requirements, and monitor for settlement. This guide walks you through supporting crypto withdrawals using the REST API. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. If the destination network requires an **additional reference** (e.g., destination tag, memo), strongly encourage your users to provide it. Missing references often lead to loss of funds or lengthy recovery. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant A as Uphold participant N as Blockchain Network Usr->>U: Start crypto withdrawal U->>B: List accounts B->>A: GET /core/accounts A-->>B: { accounts } B-->>U: { accounts } Usr->>U: Choose source account and amount U->>B: Request quote B->>A: Create quote A-->>B: { quote } B-->>U: { quote } Usr->>U: Confirm quote U->>B: Create transaction B->>A: Create transaction A-->>B: { transaction } A-->>B: webhook: transaction.created (processing) A->>N: Broadcast withdrawal N-->>A: Confirmations reached A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user ``` ## Check available rails Call [List rails](/rest-apis/core-api/assets/list-rails) to confirm the asset and network the user wants to withdraw to, and verify the `withdraw` feature is enabled. ```http theme={null} GET /core/rails?type=crypto&asset=BTC ``` A successful response lists all rails for the asset. Each rail includes a `features` array — only proceed with networks that include `"withdraw"`. For assets supported by multiple networks, require an explicit network selection from the user. Use the network's `reference` field to determine whether to show a destination tag or memo input in your UI. ```json theme={null} { "rails": [ { "type": "crypto", "network": "bitcoin", "method": "crypto-transaction", "asset": "BTC", "decimals": 8, "features": [ "deposit", "withdraw" ] }, { "type": "crypto", "network": "lightning", "method": "crypto-transaction", "asset": "BTC", "decimals": 8, "features": [] } ] } ``` ## Select source account Crypto withdrawals can be sourced from any account. If the selected account is not in the withdrawal asset, the balance will be converted at the time of the transaction using Uphold's prevailing rate. Make sure the origin asset has the necessary [features enabled](/rest-apis/core-api/assets/introduction#features-and-deposits-/-withdrawals). Call [List accounts](/rest-apis/core-api/accounts/list-accounts) to retrieve the user's accounts and let them choose one with sufficient balance for the withdrawal. ```http theme={null} GET /core/accounts?currency=BTC ``` ```json theme={null} { "accounts": [ { "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a", "label": "My BTC account", "asset": "BTC", "balance": { "total": "0.05", "available": "0.05" } } ] } ``` ## Create a quote Create a quote for the withdrawal using the [Create quote](/rest-apis/core-api/transactions/create-quote) endpoint. ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8" }, "destination": { "type": "crypto-address", "asset": "BTC", "network": "bitcoin", "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" }, "denomination": { "asset": "GBP", "amount": "100.00", "target": "origin" } } ``` A successful response returns a `quote` object with details about the withdrawal, including fees and expiration. ```json [expandable] theme={null} { "quote": { "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "origin": { "amount": "0.00121023", "asset": "BTC", "rate": "0.00002629253259492961", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "amount": "0.00121023", "asset": "BTC", "rate": "1", "node": { "type": "crypto-address", "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "network": "bitcoin", "execution": { "mode": "onchain" } } }, "denomination": { "amount": "100.00", "asset": "GBP", "target": "origin", "rate": "0.00001210225938333485" }, "fees": [], "expiresAt": "2024-07-24T15:22:39Z" } } ``` Quotes typically **expire** quickly. Prompt for user confirmation within the expiry window and regenerate if needed. ## Handle quote requirements When the quote is returned, check the `requirements` array. If non-empty, resolve each requirement before creating the transaction. ```json theme={null} { "quote": { "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "requirements": [ "travel-rule" ], "expiresAt": "2024-07-24T15:22:39Z" } } ``` ### Travel Rule If the `requirements` array contains `travel-rule`, you must collect the required originator and beneficiary information before creating the transaction. For the full step-by-step implementation, see the [Travel Rule withdrawal](/developer-guides/travel-rule/withdrawal) guide. ## Create a transaction Once the user confirms the quote, create the transaction using the [Create transaction](/rest-apis/core-api/transactions/create-transaction) endpoint. ```http theme={null} POST /core/transactions { "quoteId": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0" } ``` If the quote required Travel Rule, include the data collected by the widget under `params.travelRule`. See the [Travel Rule withdrawal flow](/developer-guides/travel-rule/withdrawal) guide. In a successful crypto withdrawal, the origin is the source account and the destination is a `crypto-address` node reflecting the recipient's on-chain address. ```json [expandable] theme={null} { "transaction": { "id": "223c24c5-76c6-4553-91bc-5af519441f03", "origin": { "amount": "0.00121023", "asset": "BTC", "rate": "0.00002629253259492961", "node": { "type": "account", "id": "a00507fe-628c-4f27-ae81-e1c40b2a8fb8", "ownerId": "e4ce04dc-67b7-4e9f-af91-482cb6f9fc4a" } }, "destination": { "amount": "0.00121023", "asset": "BTC", "rate": "1", "node": { "type": "crypto-address", "address": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", "network": "bitcoin", "execution": { "mode": "onchain" } } }, "fees": [], "status": "processing", "quotedAt": "2024-07-24T15:02:39Z", "createdAt": "2024-07-24T15:22:39Z", "updatedAt": "2024-07-24T15:22:39Z", "denomination": { "amount": "100.00", "asset": "GBP", "target": "origin", "rate": "0.00001210225938333485" } } } ``` ### Execution modes Crypto withdrawals support different execution modes depending on the destination and environment: * **On-chain execution**: The transaction is processed directly on the blockchain network. The `destination.node.execution.mode` will be `onchain` and include blockchain-specific details such as transaction hashes upon completion. * **Off-chain execution**: When both the sender and recipient are Uphold users, the transaction is processed internally within Uphold's infrastructure. This eliminates network fees and is faster than onchain processing. The `destination.node.execution.mode` will be `offchain`. For offchain transactions, the `execution` object includes additional properties: `accountOwnerId` (the recipient user ID) and `accountId` (the recipient account ID). * **Simulated execution**: Used in development environments for testing purposes. The transaction appears processed but does not affect actual blockchain state (user balances will be affected though). The `destination.node.execution.mode` will be `simulated`. ## Monitor for settlement Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.transaction.created](/rest-apis/core-api/transactions/webhooks/transaction-created) * `status: processing` → initiated but not yet broadcast * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: completed` → broadcast and confirmed * `status: on-hold` → transaction checks paused (e.g., pending RFIs) * `status: failed` → transaction failed, check `statusDetails` for more info * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) ## Notify the user Display an in-app confirmation when the transaction is `completed`, and send an email if applicable. You now support crypto withdrawals with the Enterprise API Suite. # Developer guides for onboarding and money movement Source: https://developer.uphold.com/developer-guides/overview Step-by-step guides for the most common Uphold integrations: user onboarding, plus bank, card, and crypto deposits and withdrawals via API or Widget. The guides in this section walk through the full lifecycle of the most common integration scenarios — from bringing new users onto the platform to moving funds across crypto networks, banks, and cards. ## User onboarding Bring retail users onto the platform — verified, compliant, and ready to transact. Businesses Preview } icon="briefcase" href="/developer-guides/user-onboarding/business/overview"> Onboard business clients with the KYB and compliance workflows they need. ## Money movement Connect to local banking rails across the UK, EU, and US. Let users fund and cash out instantly with the card already in their wallet. Let users buy crypto with fiat, and sell crypto for fiat. Power native crypto on/off-ramp experiences across 50+ blockchain networks. Trade assets between accounts or send funds between users — both via the quote → commit flow. Retrieve monthly portfolio snapshots and transaction history, and generate compliance reports for your users. * [Fetching the data](/developer-guides/statements/fetching-data) * [Preparing data](/developer-guides/statements/preparing-data) * [Generating the report](/developer-guides/statements/generating-report) # Introduction to dynamic forms Source: https://developer.uphold.com/developer-guides/resources/dynamic-forms/introduction Dynamic forms are server-driven JSON Schema and UI Schema definitions that adapt data collection (KYC, compliance) without you shipping app code changes. Dynamic forms are server-driven forms that enable flexible and adaptive data collection. They allow the Enterprise API Suite to evolve form requirements — such as adding new fields for regulatory compliance — without requiring you to update your application code. ## What are dynamic forms? Dynamic forms are defined by API responses rather than hardcoded in your application. They consist of two parts: * **JSON Schema**: Defines the data structure and validation rules * **UI Schema**: Defines the layout, controls, and conditional behavior This approach is powered by [JSON Forms](https://jsonforms.io/), an open standard for describing forms in a platform-agnostic way. ## Why dynamic forms? Forms automatically adapt to changing compliance requirements without app updates. Questions are revealed based on previous answers, creating a guided experience. Standardized form definitions ensure consistent behavior across platforms. Your integration remains compliant as form requirements are updated. ## How dynamic forms work When an API response includes a dynamic form, you receive a `schema` and a `uiSchema`. Your application uses these to render the form dynamically. ### Progressive disclosure Forms support **progressive disclosure** — the ability to reveal additional fields based on previous answers. When you submit partial data, the API may return an updated `schema` and `uiSchema` with new questions influenced by your submission. This creates a conversational flow where users only see relevant questions, improving the experience and data quality. ### Prefilled forms Some forms can be rendered with previously submitted data, allowing users to review and update their information. In these cases, you receive the `schema` and `uiSchema` alongside the existing data, which you can use to prefill the form. ## Where you can find dynamic forms Several KYC processes use dynamic forms to collect user information. The specific fields required may vary based on the user's country of residence. | Process | Description | | --------------------------------------------------------------------------------------------- | --------------------------------------------------------- | | [Customer due diligence](/rest-apis/core-api/kyc/update-customer-due-diligence) | Financial information and risk assessment | | [Tax details](/rest-apis/core-api/kyc/update-tax-details) | Tax residency and identification numbers | | [Crypto risk assessment](/rest-apis/core-api/kyc/update-crypto-risk-assessment) | Knowledge assessment for crypto risks (GB residents only) | | [Self-categorization statement](/rest-apis/core-api/kyc/update-self-categorization-statement) | Investor profile classification (GB residents only) | For more details on KYC processes, see the [KYC Introduction](/rest-apis/core-api/kyc/introduction). ## Implementation approaches You can implement dynamic form rendering using an official JSON Forms library or by building your own custom renderer. See [Rendering](./rendering) for detailed guidance on both approaches. ## Next steps Learn about JSON Schema types and validation. Explore layouts, controls, and rules. # Render dynamic forms in your application Source: https://developer.uphold.com/developer-guides/resources/dynamic-forms/rendering Render Uphold dynamic forms in your app using the JSON Schema, UI Schema, and submission handlers, with examples for React, JSON Forms, and validation. This page provides implementation guidance for rendering dynamic forms in your application. ## Getting started To render a dynamic form, you need: 1. A **schema** — the JSON Schema defining data types and validation 2. A **uiSchema** — the UI Schema defining layout and controls 3. A **data** — (optional) existing data to prefill the form 4. A **renderer** — either a JSON Forms library or your own implementation ## Using JSON Forms libraries The fastest way to get started is using an official JSON Forms renderer: * [React](https://jsonforms.io/docs/integrations/react/) * [Angular](https://jsonforms.io/docs/integrations/angular) * [Vue](https://jsonforms.io/docs/integrations/vue) These libraries handle schema interpretation, validation, and rendering out of the box. ### Schema-level hints The JSON Schema includes a `format` keyword that indicates how a field should be rendered. Use it to select the appropriate input component: | Format | Implementation | | ---------------- | -------------------------- | | `format: "date"` | Render a date picker input | ### Custom renderers for Enterprise API Suite The UI Schema extends JSON Forms with custom `options` that require additional rendering logic: | Option | Implementation | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data.source` | Populate options from any data source (API, database, etc.) based on the source identifier. The Enterprise API Suite provides endpoints for available sources. | | `data.exclude` | Filter the data source based on the specified restriction | | `format: "postal-code"` | Render a postal code input with country-aware formatting | | `rules` | Apply client-side validation rules (e.g., age thresholds) | | `dependsOn` | Re-fetch or update the control when a dependency value changes | ## Building a custom renderer If you need full control over the user experience, you can build your own form renderer. Your implementation must: 1. **Parse the schema** — Extract property definitions, types, and validation rules 2. **Parse the uiSchema** — Build the layout tree from elements 3. **Render controls** — Map schema types to appropriate input components 4. **Apply rules** — Evaluate conditions and show/hide/enable/disable elements 5. **Validate input** — Enforce schema constraints before submission 6. **Handle progressive disclosure** — Re-render when the API returns updated schemas ## Handling progressive disclosure When implementing progressive disclosure: 1. Render the form from `schema`, `uiSchema`, and any existing `data` 2. Collect user input for the current step (Category) 3. Submit the answers to the API 4. Compare the new response with the previous one: * If there's a new root property in the schema, or a new Category in the uiSchema, re-render the form to show the new questions * If the schema and uiSchema are unchanged, the form is complete ## Examples: React custom renderers The examples below show how to implement custom renderers using [JSON Forms for React](https://jsonforms.io/docs/integrations/react/) and Material UI. Each renderer follows the same pattern: a **tester** that matches specific UI Schema options, and a **control component** that renders the appropriate input. ### Country renderer (`data.source: "countries"`) Fetches the country list and applies `data.exclude` filters. In this example, the data is fetched from [List countries](/rest-apis/core-api/countries/list-countries) endpoint. ```tsx expandable theme={null} import { withJsonFormsControlProps } from '@jsonforms/react'; import { rankWith } from '@jsonforms/core'; import { Autocomplete, TextField } from '@mui/material'; import { useEffect, useState } from 'react'; // Custom renderer that fetches data for the data.source option const DataSourceControl = (props) => { const { data, path, handleChange, uischema, label } = props; const [options, setOptions] = useState([]); const { data: dataOptions } = uischema.options || {}; useEffect(() => { if (dataOptions?.source === 'countries') { // Fetch countries and apply exclude filters fetch('https://api.enterprise.uphold.com/core/countries', { headers: { 'Authorization': `Bearer ${accessToken}` // Your OAuth2 access token } }) .then((res) => res.json()) .then((countries) => { // Apply exclude filter if specified const filtered = dataOptions.exclude?.restrictions ? countries.filter((country) => !dataOptions.exclude.restrictions.some((r) => country.restrictions?.some((cr) => cr.scope === r.scope) ) ) : countries; setOptions(filtered.map((c) => ({ value: c.code, label: c.name }))); }); } }, [dataOptions]); return ( o.value === data) || null} onChange={(_, selected) => handleChange(path, selected?.value)} getOptionLabel={(option) => option.label} renderInput={(params) => } /> ); }; // Tester: use this renderer when data.source is present const dataSourceTester = rankWith(10, (uischema) => uischema.options?.data?.source ? true : false ); // Export for use with JsonForms export const dataSourceRenderer = { tester: dataSourceTester, renderer: withJsonFormsControlProps(DataSourceControl), }; ``` This example demonstrates: * Detecting the `data.source` option with a custom tester * Fetching options from the API and applying `data.exclude` filters * Using Material UI's Autocomplete for consistent styling * Passing existing `data` to prefill the form ### Subdivision renderer (`data.source: "subdivisions"` + `dependsOn`) Fetches subdivisions based on the selected country. Uses `dependsOn` to read the current country value and re-fetch when it changes. In this example, the data is fetched from [Get country](/rest-apis/core-api/countries/get-country) endpoint. ```tsx expandable theme={null} import { withJsonFormsControlProps } from '@jsonforms/react'; import { rankWith, Resolve, toDataPath } from '@jsonforms/core'; import { useJsonForms } from '@jsonforms/react'; import { Autocomplete, TextField } from '@mui/material'; import { useEffect, useRef, useState } from 'react'; // Custom renderer that fetches subdivisions based on the selected country const SubdivisionControl = (props) => { const { data, path, handleChange, uischema, label } = props; const { core } = useJsonForms(); const [options, setOptions] = useState([]); const isFirstRender = useRef(true); const { data: dataOptions } = uischema.options || {}; // Read the country value from the dependency declared in dependsOn const dependsOn = uischema.options?.dependsOn; const countryScope = dependsOn?.find(({ name }) => name === 'country')?.scope; const country = countryScope ? Resolve.data(core?.data, toDataPath(countryScope)) : undefined; useEffect(() => { if (dataOptions?.source === 'subdivisions' && country) { // Clear the subdivision value when the country changes (except on initial render) if (!isFirstRender.current) { handleChange(path, undefined); } isFirstRender.current = false; // Fetch subdivisions from GET /countries/{country} fetch(`https://api.enterprise.uphold.com/core/countries/${country}`, { headers: { 'Authorization': `Bearer ${accessToken}` // Your OAuth2 access token } }) .then((res) => res.json()) .then((data) => { const subdivisions = data.country?.subdivisions ?? []; setOptions(subdivisions.map((s) => ({ value: s.code, label: s.name }))); }); } else { setOptions([]); } }, [dataOptions, country]); return ( o.value === data) || null} onChange={(_, selected) => handleChange(path, selected?.value)} getOptionLabel={(option) => option.label} disabled={!country} renderInput={(params) => } /> ); }; // Tester: use this renderer when data.source is "subdivisions" const subdivisionTester = rankWith(10, (uischema) => uischema.options?.data?.source === 'subdivisions' ); // Export for use with JsonForms export const subdivisionRenderer = { tester: subdivisionTester, renderer: withJsonFormsControlProps(SubdivisionControl), }; ``` This example demonstrates: * Reading dependency values from `dependsOn` using `Resolve.data` and `toDataPath` * Re-fetching subdivisions from [Get country](/rest-apis/core-api/countries/get-country) when the country changes * Clearing the selected value when the dependency changes (except on initial render) * Disabling the control until a country is selected ### Date renderer (`format: "date"` + `rules`) Renders a date picker and converts `rules` (e.g., `difference-greater-than-or-equal-to-threshold`, `difference-less-than-or-equal-to-threshold`) into `min`/`max` date constraints. The `format: "date"` is defined in the JSON Schema; this renderer adds support for the `rules` option from the UI Schema. ```tsx expandable theme={null} import { withJsonFormsControlProps } from '@jsonforms/react'; import { rankWith } from '@jsonforms/core'; import { TextField } from '@mui/material'; // Custom renderer that handles the format: "date" schema with validation rules const DateControl = (props) => { const { data, path, handleChange, uischema, label } = props; const rules = uischema.options?.rules ?? []; // Your own function that converts rules into date picker constraints. // This must be implemented by the partner (e.g., evaluating threshold rules against the current date). const { min, max } = RulesEvaluator.datepicker.evaluate(rules); return ( handleChange(path, e.target.value || undefined)} /> ); }; // Tester: use this renderer when schema.format is "date" const dateTester = rankWith(10, (schema) => schema?.format === 'date' ); // Export for use with JsonForms export const dateRenderer = { tester: dateTester, renderer: withJsonFormsControlProps(DateControl), }; ``` This example demonstrates: * Detecting `format: "date"` in the JSON Schema with a custom tester * Delegating rule evaluation to a partner-implemented function * Applying `min`/`max` constraints to the native date input ### Postal code renderer (`format: "postal-code"` + `dependsOn`) Renders a text input for postal codes. Uses `dependsOn` to read the current country and subdivision values, then applies country-specific validation patterns. ```tsx expandable theme={null} import { withJsonFormsControlProps } from '@jsonforms/react'; import { rankWith, Resolve, toDataPath } from '@jsonforms/core'; import { useJsonForms } from '@jsonforms/react'; import { Autocomplete, TextField } from '@mui/material'; import { useEffect, useState } from 'react'; // Custom renderer that validates postal codes based on the selected country const PostalCodeControl = (props) => { const { data, path, handleChange, uischema, label } = props; const { core } = useJsonForms(); const [pattern, setPattern] = useState(null); // Read dependency values declared in dependsOn const dependsOn = uischema.options?.dependsOn; const countryScope = dependsOn?.find(({ name }) => name === 'country')?.scope; const subdivisionScope = dependsOn?.find(({ name }) => name === 'subdivision')?.scope; const country = countryScope ? Resolve.data(core?.data, toDataPath(countryScope)) : undefined; const subdivision = subdivisionScope ? Resolve.data(core?.data, toDataPath(subdivisionScope)) : undefined; useEffect(() => { if (uischema.options?.format === 'postal-code' && country) { // Your own service/function that returns a regex pattern for the given country. // This must be implemented by the partner (e.g., from a local mapping or an API). PostalCodeService.getPattern(country, subdivision).then(setPattern); } else { setPattern(null); } }, [uischema.options?.format, country, subdivision]); const validationError = data && pattern && !new RegExp(pattern).test(data) ? 'Invalid postal code format' : undefined; return ( handleChange(path, e.target.value)} /> ); }; // Tester: use this renderer when options.format is "postal-code" const postalCodeTester = rankWith(10, (uischema) => uischema.options?.format === 'postal-code' ); // Export for use with JsonForms export const postalCodeRenderer = { tester: postalCodeTester, renderer: withJsonFormsControlProps(PostalCodeControl), }; ``` This example demonstrates: * Reading multiple dependency values (`country`, `subdivision`) from `dependsOn` * Delegating pattern resolution to a partner-implemented service * Applying regex validation against the resolved pattern * Disabling the control until a country is selected ### Registering all custom renderers Register all custom renderers with the `JsonForms` component: ```tsx expandable theme={null} import { JsonForms } from '@jsonforms/react'; import { materialRenderers, materialCells } from '@jsonforms/material-renderers'; import { dataSourceRenderer } from './DataSourceControl'; import { subdivisionRenderer } from './SubdivisionControl'; import { dateRenderer } from './DateControl'; import { postalCodeRenderer } from './PostalCodeControl'; const customRenderers = [ dataSourceRenderer, subdivisionRenderer, dateRenderer, postalCodeRenderer, ...materialRenderers, ]; const DynamicForm = ({ schema, uiSchema, data }) => ( ); ``` ## See it in action To see dynamic forms in practice, explore the KYC processes that use them: Learn how dynamic forms power KYC data collection. # Dynamic forms JSON Schema reference Source: https://developer.uphold.com/developer-guides/resources/dynamic-forms/schema Reference for the JSON Schema that defines dynamic form data structure, types, and validation rules. Covers properties, required fields, and constraints. The JSON Schema defines the data structure, types, and validation rules for dynamic forms. It follows the [JSON Schema](https://json-schema.org/) specification. ## Structure A schema defines an `object` with `properties`. Each property specifies its type and validation constraints: ```json theme={null} { "type": "object", "properties": { "firstName": { "type": "string", "minLength": 1, "maxLength": 100 }, "age": { "type": "number" } }, "required": ["firstName"] } ``` The `required` array lists properties that must be provided when submitting the form. ## Steps Root-level properties in the schema represent **steps** — logical groupings of related questions. Each step is an object containing the fields the user needs to complete. A form with one step: ```json theme={null} { "type": "object", "properties": { "personalInfo": { "type": "object", "properties": { "firstName": { "type": "string" }, "lastName": { "type": "string" } } } } } ``` A form with two steps: ```json theme={null} { "type": "object", "properties": { "personalInfo": { "type": "object", "properties": { "firstName": { "type": "string" }, "lastName": { "type": "string" } } }, "contactInfo": { "type": "object", "properties": { "email": { "type": "string" }, "phone": { "type": "string" } } } } } ``` ### Progressive disclosure Steps enable **progressive disclosure** — as the user completes one step and submits their answers, the API may return an updated schema with new steps revealed. This means the schema grows dynamically based on previous answers, allowing you to guide users through a multi-step flow without showing all questions upfront. ## Types ### String Text values, typically rendered as text inputs. ```json theme={null} { "type": "string" } ``` ### Number Numeric values, typically rendered as number inputs. ```json theme={null} { "type": "number" } ``` ### Boolean True/false values, typically rendered as checkboxes or toggles. ```json theme={null} { "type": "boolean" } ``` ### Array Lists of items, typically rendered as multi-selects or repeatable sections. ```json theme={null} { "type": "array", "items": { "type": "object", "properties": { "country": { "type": "string" }, "taxId": { "type": "string" } } }, "minItems": 1, "maxItems": 5 } ``` ### Object Nested structures with their own properties. ```json theme={null} { "type": "object", "properties": { "street": { "type": "string" }, "city": { "type": "string" }, "postalCode": { "type": "string" } } } ``` ## Formats The `format` keyword provides semantic meaning to string values. ### Date Date values, typically rendered as date pickers. ```json theme={null} { "type": "string", "format": "date" } ``` Custom display formats (e.g., `postal-code`) are defined in the UI Schema via [`options.format`](/developer-guides/resources/dynamic-forms/ui-schema#format), not in the JSON Schema. ## Validation keywords ### String validation | Keyword | Description | | ----------- | --------------------------------------- | | `minLength` | Minimum number of characters | | `maxLength` | Maximum number of characters | | `pattern` | Regular expression the value must match | ```json theme={null} { "type": "string", "pattern": "^[A-Z]{2}[0-9]{9}$", "minLength": 11, "maxLength": 11 } ``` ### Array validation | Keyword | Description | | ------------- | ------------------------------------------------- | | `minItems` | Minimum number of items | | `maxItems` | Maximum number of items | | `uniqueItems` | When `true`, all items must be unique | | `contains` | At least one item must match the specified schema | ```json theme={null} { "type": "array", "minItems": 1, "maxItems": 10, "uniqueItems": true } ``` ### Enumeration and constants #### enum Restricts a property to a predefined list of allowed values. ```json theme={null} { "type": "string", "enum": ["employed", "self-employed", "retired", "student", "unemployed"] } ``` #### const Restricts a property to a single fixed value. ```json theme={null} { "type": "string", "const": "agreed" } ``` #### oneOf Allows a value to match one of several schemas. Often used for conditional validation based on a previous answer. ```json theme={null} { "oneOf": [ { "properties": { "employmentStatus": { "const": "employed" }, "employerName": { "type": "string" } }, "required": ["employerName"] }, { "properties": { "employmentStatus": { "const": "self-employed" }, "businessName": { "type": "string" } }, "required": ["businessName"] }, { "properties": { "employmentStatus": { "enum": ["retired", "student", "unemployed"] } } } ] } ``` In this example, users who select "employed" must provide an employer name, those who select "self-employed" must provide a business name, and other statuses require no additional information. ## Next steps Learn how to render forms with layouts, controls, and rules. Implementation guidance and best practices. # Dynamic forms UI Schema reference Source: https://developer.uphold.com/developer-guides/resources/dynamic-forms/ui-schema Reference for the UI Schema that defines dynamic form layouts, controls, conditional visibility, and custom options based on the JSON Forms specification. The UI Schema defines how to render a dynamic form — the layout structure, controls, conditional visibility rules, and custom options. It follows the [JSON Forms UI Schema](https://jsonforms.io/docs/uischema) specification. ## Structure A UI Schema is a tree of **elements**. Each element has a `type` that determines how it behaves: * **Layouts** — Container elements that organize other elements * **Controls** — Elements that render input fields bound to schema properties ```json theme={null} { "type": "VerticalLayout", "elements": [ { "type": "Control", "scope": "#/properties/firstName", "label": "First Name" }, { "type": "Control", "scope": "#/properties/lastName", "label": "Last Name" } ] } ``` ## Layouts Layouts organize controls and other layouts into a visual structure. ### VerticalLayout Arranges elements vertically, one below the other. ```json theme={null} { "type": "VerticalLayout", "elements": [ { "type": "Control", "scope": "#/properties/firstName" }, { "type": "Control", "scope": "#/properties/lastName" } ] } ``` ### HorizontalLayout Arranges elements horizontally, side by side. ```json theme={null} { "type": "HorizontalLayout", "elements": [ { "type": "Control", "scope": "#/properties/firstName" }, { "type": "Control", "scope": "#/properties/lastName" } ] } ``` ### Group Groups related controls together with an optional label. ```json theme={null} { "type": "Group", "label": "Personal Information", "elements": [ { "type": "Control", "scope": "#/properties/firstName" }, { "type": "Control", "scope": "#/properties/lastName" } ] } ``` ### Categorization Organizes content into tabs or wizard-style steps. A `Categorization` contains multiple `Category` elements, each representing a step in the form. ```json theme={null} { "type": "Categorization", "elements": [ { "type": "Category", "label": "Personal Info", "elements": [ { "type": "Control", "scope": "#/properties/personalInfo/properties/firstName" }, { "type": "Control", "scope": "#/properties/personalInfo/properties/lastName" } ] }, { "type": "Category", "label": "Contact Info", "elements": [ { "type": "Control", "scope": "#/properties/contactInfo/properties/email" }, { "type": "Control", "scope": "#/properties/contactInfo/properties/phone" } ] } ] } ``` Categories correspond to **steps** in the schema. Each `Category` typically maps to a root-level schema property, enabling progressive disclosure as users complete each step. ### ListWithDetail Renders an array as a master-detail view — a list of items on one side and a detail form for the selected item. ```json theme={null} { "type": "ListWithDetail", "scope": "#/properties/addresses", "options": { "detail": { "type": "VerticalLayout", "elements": [ { "type": "Control", "scope": "#/properties/street" }, { "type": "Control", "scope": "#/properties/city" } ] } } } ``` ## Controls Controls render input fields and bind them to schema properties. ### Control The `Control` element renders an input based on the schema type at the specified `scope`. ```json theme={null} { "type": "Control", "scope": "#/properties/email", "label": "Email Address" } ``` | Property | Description | | --------- | ------------------------------------------------------------------------- | | `scope` | JSON Pointer to the schema property (e.g., `#/properties/email`) | | `label` | Display label for the control (optional — derived from schema if omitted) | | `options` | Custom options to modify control behavior | The renderer automatically selects the appropriate input type based on the schema: | Schema Type | Rendered As | | ------------------------------------------ | -------------------- | | `string` | Text input | | `string` + `format: "date"` (schema) | Date picker | | `string` + `options.format: "postal-code"` | Postal code input | | `string` + `enum` | Dropdown/select | | `number` | Number input | | `boolean` | Checkbox or toggle | | `array` | List with add/remove | ## Rules Rules control the visibility and enabled state of elements based on data conditions. ```json theme={null} { "type": "Control", "scope": "#/properties/employerName", "rule": { "effect": "SHOW", "condition": { "scope": "#/properties/employmentStatus", "schema": { "const": "employed" } } } } ``` ### Effects | Effect | Description | | --------- | ------------------------------------------------------------ | | `SHOW` | Show the element when condition is true, hide otherwise | | `HIDE` | Hide the element when condition is true, show otherwise | | `ENABLE` | Enable the element when condition is true, disable otherwise | | `DISABLE` | Disable the element when condition is true, enable otherwise | ### Conditions A condition specifies a `scope` (the property to evaluate) and a `schema` that the value must match. ```json theme={null} { "condition": { "scope": "#/properties/country", "schema": { "enum": ["US", "CA"] } } } ``` Common condition patterns: | Pattern | Schema | | ------------------ | ----------------------------- | | Equals a value | `{ "const": "value" }` | | One of many values | `{ "enum": ["a", "b", "c"] }` | | Not empty | `{ "minLength": 1 }` | ## Options The `options` property on controls allows you to customize behavior. These are standard [JSON Forms options](https://jsonforms.io/docs/uischema/controls#options). ### Array options Options for array controls: | Option | Description | | ------------------ | ---------------------------------------------------- | | `showSortButtons` | Shows buttons to reorder items | | `elementLabelProp` | Property to use as the label for each item in a list | | `disableAdd` | Prevents adding new items (renderer-specific) | | `disableRemove` | Prevents removing items (renderer-specific) | ```json theme={null} { "type": "Control", "scope": "#/properties/documents", "options": { "elementLabelProp": "name", "showSortButtons": true } } ``` ### readonly Renders the control as read-only. ```json theme={null} { "type": "Control", "scope": "#/properties/accountId", "options": { "readonly": true } } ``` ### detail For `ListWithDetail` layouts, specifies the UI Schema for the detail view. ```json theme={null} { "type": "ListWithDetail", "scope": "#/properties/items", "options": { "detail": { "type": "VerticalLayout", "elements": [...] } } } ``` ## Custom options The Enterprise API Suite extends JSON Forms with custom options for specific use cases. ### data Specifies a data source for populating select options dynamically. The `data` object defines where to fetch options from and how to filter them. ```json theme={null} { "type": "Control", "scope": "#/properties/country", "options": { "data": { "source": "countries", "exclude": { "restrictions": [ { "scope": "citizenship" } ] } } } } ``` | Property | Description | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data.source` | The data source to fetch options from.
Available sources: `"countries"` ([List countries](/rest-apis/core-api/countries/list-countries)) and `"subdivisions"` ([Get country](/rest-apis/core-api/countries/get-country), returns subdivisions for a given country). | | `data.exclude` | Optional filter to exclude values from the data source | | `data.exclude.restrictions` | Array of restrictions to filter values from the data source | | `data.exclude.restrictions[].scope` | The restriction scope (e.g., `citizenship`, `residence`, `geolocation`, `phone`) | ### format Specifies custom display formats for controls that are not covered by the standard JSON Schema `format` keyword. Standard formats like `date` are defined in the [JSON Schema](/developer-guides/resources/dynamic-forms/schema#formats) and handled natively by renderers. ```json theme={null} { "type": "Control", "scope": "#/properties/address/properties/postalCode", "options": { "format": "postal-code" } } ``` | Format | Description | | ------------- | ---------------------------------------------------------------------------------------------------------- | | `postal-code` | Renders an input optimized for postal codes, with formatting and validation rules that may vary by country | ### rules (validation) Defines client-side validation rules on a control. These rules allow you to enforce constraints that go beyond standard JSON Schema validation, such as age thresholds relative to the current date. These validation rules are defined in `options.rules` on individual controls and are distinct from the [visibility rules](#rules) (`rule` with `effect` and `condition`) that control element visibility. ```json theme={null} { "type": "Control", "scope": "#/properties/birthdate", "options": { "rules": [ { "rule": "difference-greater-than-or-equal-to-threshold", "threshold": { "limit": 18, "unit": "years" } }, { "rule": "difference-less-than-or-equal-to-threshold", "threshold": { "limit": 100, "unit": "years" } } ] } } ``` In this example, the date must be at least 18 years ago and at most 100 years ago, effectively restricting the input to valid adult birthday dates. | Property | Description | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `rules[].rule` | The validation rule type. Available rules: `difference-greater-than-or-equal-to-threshold`, `difference-less-than-or-equal-to-threshold`. | | `rules[].threshold.limit` | The numeric limit for the threshold | | `rules[].threshold.unit` | The unit for the threshold (e.g., `"years"`) | ### dependsOn Declares that a control depends on the value of one or more other controls. When a dependency value changes, the dependent control should update its available options or validation accordingly. ```json theme={null} { "type": "Control", "scope": "#/properties/address/properties/subdivision", "options": { "data": { "source": "subdivisions" }, "dependsOn": [ { "name": "country", "scope": "#/properties/address/properties/country" } ] } } ``` In this example, the subdivision field depends on the selected country — when the country changes, the list of available subdivisions is updated. | Property | Description | | ------------------- | ------------------------------------------------------ | | `dependsOn` | Array of field dependencies | | `dependsOn[].name` | The logical name of the dependency (e.g., `"country"`) | | `dependsOn[].scope` | JSON Pointer to the dependency's schema property | Dependencies can also be chained. For example, a postal code field can depend on both country and subdivision: ```json theme={null} { "type": "Control", "scope": "#/properties/address/properties/postalCode", "options": { "format": "postal-code", "dependsOn": [ { "name": "country", "scope": "#/properties/address/properties/country" }, { "name": "subdivision", "scope": "#/properties/address/properties/subdivision" } ] } } ``` ## Next steps Implementation guidance and best practices. # Fetching data Source: https://developer.uphold.com/developer-guides/statements/fetching-data This guide covers how to set a period and denomination, fetch portfolio holdings and transactions, and pull it all together into a multi-month report. ## Prerequisites * The user is onboarded and verified (KYC is complete). ## Set the period All statements are scoped to a specific period, defined by three required query parameters: * `period` — the period type. Currently only `one-month` is supported. * `year` — the calendar year (e.g. `2025`). * `month` — the calendar month as a number from `1` to `12`. The `period` object in the response reflects the exact UTC start and end timestamps of the requested period: ```json theme={null} { "period": { "from": "2025-03-01T00:00:00.000Z", "to": "2025-03-31T23:59:59.999Z" } } ``` Valid periods range from the month the user's account was created to the last fully completed calendar month. Requests outside this range, or with an unsupported denomination asset, return a `409` error. A valid period with no user activity still returns a successful response — holdings and transactions will have no entries. ## Set the denomination The `denomination` query parameter accepts a single asset code (e.g. `denomination=GBP`). If not provided, `USD` is used by default. Exchange rates use USD as the base pair. When the denomination is not USD, the response also includes a `USD-{denomination}` rate to complete the conversion. Supported assets include some of the major fiat currencies and BTC. If you need a particular asset added to this list, please reach out to your Account Manager. For a more detailed explanation of the denomination concept, check the [Core Concepts](/rest-apis/core-api/concepts#denomination) page. ## Fetch portfolio statements Call [Get portfolio statement](/rest-apis/core-api/statements/get-portfolio-statement) to retrieve the user's holdings at the end of the requested period, along with exchange rates in the requested denomination. Rates are a period-end snapshot. ```http theme={null} GET /core/statements/portfolio?period=one-month&year=2025&month=3&denomination=GBP ``` Holdings are returned per asset. Assets already held in the denomination currency (GBP in this case) have no rate entry — their value is their amount directly. ```json theme={null} { "statement": { "holdings": [ { "asset": "BTC", "total": "0.02" }, { "asset": "ETH", "total": "0.5" }, { "asset": "GBP", "total": "250.00" } ], "period": { "from": "2025-03-01T00:00:00.000Z", "to": "2025-03-31T23:59:59.999Z" }, "rates": { "BTC-USD": "68106.26", "ETH-USD": "2094.45", "USD-GBP": "0.75629" } } } ``` ## Fetch transaction statements Call [Get transactions statement](/rest-apis/core-api/statements/get-transactions-statement) to retrieve a paginated list of completed transactions during the period, each with exchange rates captured at the time they completed. ```http theme={null} GET /core/statements/transactions?period=one-month&year=2025&month=3&denomination=GBP&page=1&perPage=50 ``` Each entry contains the transaction details and a `rates` object with exchange rates captured at the time that transaction completed. ```json theme={null} { "statement": { "period": { "from": "2025-03-01T00:00:00.000Z", "to": "2025-03-31T23:59:59.999Z" }, "transactions": [ { "rates": { "BTC-USD": "68106.26", "USD-GBP": "0.75629" }, "transaction": { "id": "8daa6dbd-21a0-4305-93c3-bd1d04bc575c", "status": "completed", "completedAt": "2025-03-23T12:06:55.116Z", "denomination": { "amount": "100", "asset": "GBP", "target": "origin" }, "origin": { "amount": "100", "asset": "GBP" }, "destination": { "amount": "0.00186036", "asset": "BTC", "rate": "0.00001869711794070723" }, "fees": [ { "amount": "0.5", "asset": "GBP", "type": "deposit" } ] } } ] }, "pagination": { "first": "https://api.enterprise.uphold.com/core/statements/transactions?period=one-month&year=2025&month=3&denomination=GBP&page=1&perPage=50", "next": "https://api.enterprise.uphold.com/core/statements/transactions?period=one-month&year=2025&month=3&denomination=GBP&page=2&perPage=50" } } ``` ### Handling pagination The response includes a `pagination` object with `first` and `next` URL links. `next` points to the next page and is absent on the last page. For a full overview of how pagination works across the API, see [Pagination in responses](/rest-apis/pagination#pagination-in-responses). To retrieve all transactions, request each page in sequence and follow `next` until it's absent. The following helper collects all pages upfront: ```javascript theme={null} async function fetchAllTransactions({ token, userId, year, month, denomination }) { const transactions = []; let page = 1; const perPage = 50; while (true) { const params = new URLSearchParams({ period: 'one-month', year, month, denomination, page, perPage }); const response = await fetch( `https://api.enterprise.uphold.com/core/statements/transactions?${params}`, { headers: { Authorization: `Bearer ${token}`, 'X-On-Behalf-Of': userId } } ); if (!response.ok) { throw new Error(`Failed to fetch transactions: ${response.status}`); } const { statement, pagination } = await response.json(); transactions.push(...statement.transactions); if (!pagination.next) break; page++; } return transactions; } ``` ## Build a quarterly statement To generate a quarterly report, use the helpers above: fetch the portfolio snapshot at the end of the last month of the quarter and aggregate transactions across all three months. ```javascript theme={null} const QUARTER_MONTHS = { Q1: [1, 2, 3], Q2: [4, 5, 6], Q3: [7, 8, 9], Q4: [10, 11, 12] }; async function fetchQuarterlyData({ token, userId, year, quarter, denomination }) { const months = QUARTER_MONTHS[quarter]; const { statement: portfolio } = await fetchPortfolioStatement({ token, userId, year, month: months[2], denomination }); const allTransactions = []; for (const month of months) { const monthTransactions = await fetchAllTransactions({ token, userId, year, month, denomination }); allTransactions.push(...monthTransactions); } return { portfolio, transactions: allTransactions }; } ``` ## Next steps Compute denominated values for holdings and transactions and build the report payload. Build a PDF from the processed data and run the generation pipeline in a background worker. # Generating report Source: https://developer.uphold.com/developer-guides/statements/generating-report This guide covers how to build a document template from processed data and run the generation pipeline in a background worker to ensure end users aren't blocked waiting for the result. ## Choose a generation strategy Choose the method that best fits your infrastructure and layout requirements: * **Render via headless browser** — build an HTML/CSS template and use a browser engine to render it to PDF. Best when you want full control over layout and styling via HTML/CSS. * **Construct programmatically** — use a PDF library like [PDFKit](https://pdfkit.org) to build the document step-by-step in code. Highly performant and ideal for simple, repetitive layouts without the overhead of a browser. * **Use an external service** — send your structured data to a third-party API that handles rendering and hosting, offloading document assembly entirely. The example below uses headless browser rendering with Puppeteer: an HTML template is built from the processed data, and Puppeteer renders it to a PDF. By the end of this step, you will have a PDF buffer ready to store and deliver. ### Render with a headless browser Install Puppeteer: ```bash theme={null} npm install puppeteer ``` Puppeteer downloads a compatible version of Chromium automatically. In environments where you cannot install Chromium (e.g. some serverless platforms), use `puppeteer-core` with a pre-installed browser instead. The template receives the processed data object and returns an HTML string. All values are pre-computed — the template is only responsible for layout: ```javascript theme={null} import puppeteer from 'puppeteer'; function buildHtml(data) { const { period, denomination, holdings, totalValue, transactions, totalFees } = data; const holdingsRows = holdings .map(({ asset, amount, value }) => `${asset}${amount}${value} ${denomination}` ) .join(''); const txRows = transactions .map(({ date, id, status, origin, destination, denominatedAmount, denominatedAsset, value, txFees }) => ` ${date} ${id} ${status} ${origin.amount} ${origin.asset} ${destination.amount} ${destination.asset} ${denominatedAmount} ${denominatedAsset} ${value} ${denomination} ${txFees} ${denomination} ` ) .join(''); return `

Compliance Report

Period: ${period.from.slice(0, 10)} to ${period.to.slice(0, 10)}

Generated: ${new Date().toISOString().slice(0, 10)}

Denomination: ${denomination}

Holdings at end of period

${holdingsRows}
Asset Amount Value (${denomination})
Total${totalValue} ${denomination}

Transactions (${transactions.length})

${txRows}
Date Transaction ID Status Origin Destination Amount Value (${denomination}) Fees (${denomination})
Total fees${totalFees} ${denomination}
`; } export async function generateStatementPdf(data) { const html = buildHtml(data); const browser = await puppeteer.launch({ args: ['--no-sandbox'] }); try { const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'networkidle0' }); return await page.pdf({ format: 'A4', margin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' }, printBackground: true }); } finally { await browser.close(); } } ``` `generateStatementPdf` returns a `Buffer` containing the PDF, ready to be stored or delivered. ## Execute as a background task Offload report generation to an asynchronous worker to keep long-running generation from blocking your API. By returning immediately, your application remains responsive while the worker handles data retrieval, formatting, and file storage in the background. End users don't wait for a report — they request one and get notified when it's ready. Process outline: 1. The end user requests a report. Your backend responds immediately with a reference to the background job. 2. A worker runs the full pipeline: fetch → process → generate. 3. When the report is ready, your backend notifies the end user — via a retrieval link, email, or any other delivery method. ```javascript theme={null} // Route — accepts the request and enqueues the job // Adapt to your preferred job queue (BullMQ, Agenda, etc.) app.post('/reports/monthly', async (req, res, next) => { try { const { userId, year, month, denomination = 'GBP' } = req.body; const token = req.headers.authorization?.split(' ')[1]; const jobId = await reportQueue.add('generate-statement', { token, userId, year: Number(year), month: Number(month), denomination }); res.status(202).json({ jobId }); } catch (error) { next(error); } }); // Worker — runs the full pipeline in the background // Adapt to your queue library (BullMQ v2: new Worker('generate-statement', async job => {...}), Agenda, etc.) async function runReportJob(job) { const { token, userId, year, month, denomination } = job.data; const [{ statement: portfolio }, transactions] = await Promise.all([ fetchPortfolioStatement({ token, userId, year, month, denomination }), fetchAllTransactions({ token, userId, year, month, denomination }) ]); const data = processStatementData({ portfolio, transactions, denomination }); const pdf = await generateStatementPdf(data); const url = await storeReport(pdf, { userId, year, month }); // e.g. upload to S3 or equivalent storage await notifyUser(userId, { reportUrl: url }); // e.g. send email, webhook, or push notification } ``` If any step throws, the error propagates to the queue, which handles job failure and retry according to its configuration. For implementation details on `fetchPortfolioStatement` and `fetchAllTransactions` check [Fetching data](/developer-guides/statements/fetching-data). For details on `processStatementData` check [Preparing data](/developer-guides/statements/preparing-data). # Overview Source: https://developer.uphold.com/developer-guides/statements/overview The guides in this section cover how to retrieve monthly financial records for your users, work with exchange rates and denomination, and generate compliance-ready reports from that data. Statements provide two complementary views of a user's financial activity for a given period: a portfolio snapshot at the end of the period and a paginated list of all completed transactions. Together, they supply the data needed for tax reporting and regulatory compliance. ## Generating reports Generating a compliance report from statement data is a three-step process: Retrieve the portfolio snapshot and full transaction list for the period, paginating through all results. [How to fetch the data](/developer-guides/statements/fetching-data) Interpret the exchange rates, compute denominated values for each holding and transaction, and shape the data for the renderer. [How to prepare data](/developer-guides/statements/preparing-data) Build an HTML template from the processed data and render it to a PDF using a headless browser. [How to generate the report](/developer-guides/statements/generating-report) # Preparing data Source: https://developer.uphold.com/developer-guides/statements/preparing-data This guide covers how to compute currency conversions for holdings and transactions and structure the resulting data into a format ready for asynchronous document generation. ## Interpret the rates The `rates` object uses `{asset}-USD` keys — each representing the price of 1 unit of the asset in USD. When the denomination is not USD, a `USD-{denomination}` key is also included: ```json theme={null} { "BTC-USD": "68106.26", "ETH-USD": "2094.45", "USD-GBP": "0.75629" } ``` `"BTC-USD": "68106.26"` means 1 BTC = 68106.26 USD. Multiply by `USD-GBP` to get the GBP value. Portfolio rates reflect the end of the requested period. Transaction rates are captured at the time the transaction settled. Assets already held in the denomination currency (e.g. GBP when denomination is GBP) have no rate entry — their value is their amount directly. For a detailed explanation of how denomination works in this API, see [Denomination](/rest-apis/core-api/concepts#denomination) in the Core API concepts. ## Calculate portfolio values For each holding, convert the asset's USD rate by the `USD-{denomination}` rate: ``` 0.02 BTC × 68106.26 USD/BTC × 0.75629 GBP/USD = 1030.16 GBP 0.5 ETH × 2094.45 USD/ETH × 0.75629 GBP/USD = 792.01 GBP 250.00 GBP = 250.00 GBP (no rate needed) ``` In code: ```javascript theme={null} function convertToDenomination(amount, asset, rates, denomination) { if (asset === denomination) return parseFloat(amount); if (asset === 'USD') return parseFloat(amount) * parseFloat(rates[`USD-${denomination}`]); const usdRate = rates[`${asset}-USD`]; if (denomination === 'USD') return parseFloat(amount) * parseFloat(usdRate); return parseFloat(amount) * parseFloat(usdRate) * parseFloat(rates[`USD-${denomination}`]); } function computeHoldingValue(holding, rates, denomination) { return convertToDenomination(holding.total, holding.asset, rates, denomination).toFixed(2); } ``` ## Calculate transaction values Each transaction entry has a `denomination` field — the amount and currency the transaction was quoted in, used as the authoritative reference value for compliance purposes — and a `rates` object scoped to that transaction. To get the GBP value of a transaction, convert the denomination amount using the per-transaction rate: ```javascript theme={null} function computeTransactionValue(entry, denomination) { const { transaction, rates } = entry; const { amount, asset } = transaction.denomination; return convertToDenomination(amount, asset, rates, denomination).toFixed(2); } ``` For the GBP→BTC transaction from the previous step, `denomination.asset` is already `GBP`, so the GBP value is `100.00` directly — no rate lookup required. ## Build the payload Use the helpers above to transform the raw API response into a flat object ready for the report renderer: ```javascript theme={null} function processStatementData({ portfolio, transactions, denomination }) { // Holdings const holdings = portfolio.holdings.map(holding => ({ asset: holding.asset, amount: holding.total, value: computeHoldingValue(holding, portfolio.rates, denomination) })); const totalValue = holdings .reduce((sum, h) => sum + parseFloat(h.value), 0) .toFixed(2); // Transactions const processedTransactions = transactions.map(entry => { const { transaction, rates } = entry; const txFees = (transaction.fees ?? []) .reduce((sum, fee) => sum + convertToDenomination(fee.amount, fee.asset, rates, denomination), 0) .toFixed(2); return { date: transaction.completedAt.slice(0, 10), id: transaction.id, status: transaction.status, origin: { amount: transaction.origin.amount, asset: transaction.origin.asset }, destination: { amount: transaction.destination.amount, asset: transaction.destination.asset }, denominatedAmount: transaction.denomination.amount, denominatedAsset: transaction.denomination.asset, value: computeTransactionValue(entry, denomination), fees: transaction.fees ?? [], txFees }; }); const totalFees = processedTransactions .reduce((sum, t) => sum + parseFloat(t.txFees), 0) .toFixed(2); return { period: portfolio.period, denomination, holdings, totalValue, transactions: processedTransactions, totalFees }; } ``` Applied to the GBP example data from the previous step, this produces: ```json theme={null} { "period": { "from": "2025-03-01T00:00:00.000Z", "to": "2025-03-31T23:59:59.999Z" }, "denomination": "GBP", "holdings": [ { "asset": "BTC", "amount": "0.02", "value": "1030.16" }, { "asset": "ETH", "amount": "0.5", "value": "792.01" }, { "asset": "GBP", "amount": "250.00", "value": "250.00" } ], "totalValue": "2072.17", "transactions": [ { "date": "2025-03-23", "id": "8daa6dbd-21a0-4305-93c3-bd1d04bc575c", "status": "completed", "origin": { "amount": "100", "asset": "GBP" }, "destination": { "amount": "0.00186036", "asset": "BTC" }, "denominatedAmount": "100", "denominatedAsset": "GBP", "value": "100.00", "fees": [{ "amount": "0.5", "asset": "GBP", "type": "deposit" }], "txFees": "0.50" } ], "totalFees": "0.50" } ``` ## Next steps Build a PDF from the processed data and run the generation pipeline in a background worker. # Trade & Send integration overview Source: https://developer.uphold.com/developer-guides/trade-and-send/overview Move assets within your platform — trade between different assets for the same user, or send the same asset between two different users. The Enterprise API offers several ways to move value within your platform. Two of them are **trade** and **send**: A **trade** converts one asset into another for the same user (e.g. USD → BTC). A **send** moves the same asset from one user to another within your organization (e.g. USD → USD). Unlike platforms restricted to a fixed list of trading pairs, Uphold supports **any-to-any** conversion: a user can trade between any two supported assets directly, with no predefined pairs. ## How they compare | | Trade | Send | | ---------- | -------------------------- | -------------------------- | | Assets | Different (e.g. USD → BTC) | Same (e.g. USD → USD) | | Users | Same user, two accounts | Different users (same org) | | Rate | Locked at quote time | Always 1:1 | | Settlement | Instant | Instant | ## Start building Convert between different assets for the same user using the quote → commit flow. Move the same asset between two users in the same organization at a 1:1 rate. Click through the trade flow step by step. Click through the send flow step by step. # Interactive send walkthrough Source: https://developer.uphold.com/developer-guides/trade-and-send/send/interactive-send-flow Navigate through a peer-to-peer asset send. Click each step to see the matching API call and visual representation side by side.
```bash cURL theme={null} curl -X GET "https://api.enterprise.uphold.com/core/accounts?perPage=10" \ -H "Authorization: Bearer " ``` ```js JavaScript theme={null} const response = await fetch("https://api.enterprise.uphold.com/core/accounts?perPage=10", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.request( "GET", "https://api.enterprise.uphold.com/core/accounts?perPage=10", headers={ "Authorization": "Bearer ", } ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/accounts?perPage=10")) .header("Authorization", "Bearer ") .method("GET", HttpRequest.BodyPublishers.noBody()) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "accounts": [ { "id": "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec", "label": "My USD account", "asset": "USD", "balance": { "total": "250.00", "available": "250.00" } }, { "id": "c392a0de-5fc6-49a7-96e1-e4b3f9ef5b27", "label": "My BTC account", "asset": "BTC", "balance": { "total": "0.0015", "available": "0.0015" } }, { "id": "d7a1b3c2-8e4f-4d6a-b2c1-f9e8d7c6b5a4", "label": "My XRP account", "asset": "XRP", "balance": { "total": "120.50", "available": "120.50" } } ], "pagination": { "first": "https://api.enterprise.sandbox.uphold.com/core/accounts?page=1&perPage=10" } } ```
```bash cURL theme={null} curl -X POST "https://api.enterprise.uphold.com/core/transactions/quote" \ -H "Authorization: Bearer " \ -H "X-On-Behalf-Of: 5feba1e3-3f14-4fd9-b00d-f386df83042f" \ -H "Content-Type: application/json" \ -d '{ "origin": { "type": "account", "id": "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec" }, "destination": { "type": "account", "id": "d9f7a3c1-2b8e-4f5d-9c6a-1e2b3c4d5e6f" }, "denomination": { "asset": "USD", "amount": "50" } }' ``` ```js JavaScript theme={null} const response = await fetch("https://api.enterprise.uphold.com/core/transactions/quote", { method: "POST", headers: { "Authorization": "Bearer ", "X-On-Behalf-Of": "5feba1e3-3f14-4fd9-b00d-f386df83042f", "Content-Type": "application/json", }, body: JSON.stringify({ origin: { type: "account", id: "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec" }, destination: { type: "account", id: "d9f7a3c1-2b8e-4f5d-9c6a-1e2b3c4d5e6f" }, denomination: { asset: "USD", amount: "50" }, }), }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.request( "POST", "https://api.enterprise.uphold.com/core/transactions/quote", headers={ "Authorization": "Bearer ", "X-On-Behalf-Of": "5feba1e3-3f14-4fd9-b00d-f386df83042f", "Content-Type": "application/json", }, json={ "origin": {"type": "account", "id": "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec"}, "destination": {"type": "account", "id": "d9f7a3c1-2b8e-4f5d-9c6a-1e2b3c4d5e6f"}, "denomination": {"asset": "USD", "amount": "50"}, } ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/transactions/quote")) .header("Authorization", "Bearer ") .header("X-On-Behalf-Of", "5feba1e3-3f14-4fd9-b00d-f386df83042f") .header("Content-Type", "application/json") .method("POST", HttpRequest.BodyPublishers.ofString(""" { "origin": { "type": "account", "id": "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec" }, "destination": { "type": "account", "id": "d9f7a3c1-2b8e-4f5d-9c6a-1e2b3c4d5e6f" }, "denomination": { "asset": "USD", "amount": "50" } }""")) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "quote": { "id": "b7e4d2c1-9f3a-4b5e-8d7c-2e1f3a4b5c6d", "expiresAt": "2026-05-29T14:02:00.000Z", "origin": { "asset": "USD", "amount": "50.00", "rate": "1.00" }, "destination": { "asset": "USD", "amount": "50.00", "rate": "1.00" }, "denomination": { "asset": "USD", "amount": "50.00", "rate": "1.00", "target": "origin" }, "fees": [] } } ```
```bash cURL theme={null} curl -X POST "https://api.enterprise.uphold.com/core/transactions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "X-On-Behalf-Of: " \ -d '{ "quoteId": "b7e4d2c1-9f3a-4b5e-8d7c-2e1f3a4b5c6d" }' ``` ```js JavaScript theme={null} const response = await fetch("https://api.enterprise.uphold.com/core/transactions", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", "X-On-Behalf-Of": "", }, body: JSON.stringify({ quoteId: "b7e4d2c1-9f3a-4b5e-8d7c-2e1f3a4b5c6d", }), }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.request( "POST", "https://api.enterprise.uphold.com/core/transactions", headers={ "Authorization": "Bearer ", "Content-Type": "application/json", "X-On-Behalf-Of": "", }, json={ "quoteId": "b7e4d2c1-9f3a-4b5e-8d7c-2e1f3a4b5c6d", } ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/transactions")) .header("Authorization", "Bearer ") .header("Content-Type", "application/json") .header("X-On-Behalf-Of", "") .method("POST", HttpRequest.BodyPublishers.ofString(""" { "quoteId": "b7e4d2c1-9f3a-4b5e-8d7c-2e1f3a4b5c6d" }""")) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "transaction": { "id": "b7e4d2c1-9f3a-4b5e-8d7c-2e1f3a4b5c6d", "status": "processing", "origin": { "asset": "USD", "amount": "50.00" }, "destination": { "asset": "USD", "amount": "50.00" } } } ```
**Webhook event:** `core.transaction.status-changed` ```json Webhook payload theme={null} { "id": "e4f5a6b7-c8d9-4e0f-1a2b-c3d4e5f6a7b8", "type": "core.transaction.status-changed", "createdAt": "2026-05-29T14:01:55.612Z", "data": { "transaction": { "id": "d3e4f5a6-b7c8-4d9e-0f1a-b2c3d4e5f6a7", "status": "completed", "origin": { "asset": "USD", "amount": "50.00", "node": { "type": "account", "id": "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec", "ownerId": "5feba1e3-3f14-4fd9-b00d-f386df83042f" } }, "destination": { "asset": "USD", "amount": "50.00", "node": { "type": "account", "id": "d9f7a3c1-2b8e-4f5d-9c6a-1e2b3c4d5e6f", "ownerId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } }, "denomination": { "asset": "USD", "amount": "50.00", "rate": "1.00", "target": "origin" }, "fees": [], "quotedAt": "2026-05-29T14:01:50.123Z", "createdAt": "2026-05-29T14:01:55.000Z", "updatedAt": "2026-05-29T14:01:55.612Z" } } } ```
*** For the full prose walkthrough — capability checks, recipient resolution, and webhook handling — see the [via REST API](/developer-guides/trade-and-send/send/via-rest-api) guide. # Execute a send via the REST API Source: https://developer.uphold.com/developer-guides/trade-and-send/send/via-rest-api Move the same asset between two users in the same organization using the Uphold quote → transaction flow, initiated on behalf of the sender. A **send** transfers an asset from one user's account to another user's account holding the **same asset**, where both users belong to the **same organization** (for example, 50 USD from User A to User B). Sends follow a simple two-step flow: 1. **Create a quote** on behalf of the sender. 2. **Create a transaction** from that quote. Because both accounts hold the same asset, the amount sent and the amount received are always identical — there's no conversion or exchange rate involved. ## Prerequisites Before creating sends: * The **sender** must have completed onboarding and have the **`sends`** capability enabled. * The **recipient** must have completed onboarding and have the **`receives`** capability enabled. * The sender and recipient must belong to the **same organization**. Recipient discovery is not provided by the Enterprise API and must be implemented by your application (for example through QR codes, contact lists, usernames, or invitation flows). Verify capabilities using [List capabilities](/rest-apis/core-api/capabilities/list-user-capabilities): ```http theme={null} GET /core/users/{userId}/capabilities ``` ```json theme={null} { "code": "sends", "enabled": true, "restrictions": [], "requirements": [] } { "code": "receives", "enabled": true, "restrictions": [], "requirements": [] } ``` **Both accounts must be denominated in the same asset.** A send moves a single asset between two same-asset accounts (e.g. USD → USD). The recipient does not need an existing balance — a freshly created, zero-balance account in that asset can receive the send. ## Walkthrough Prefer to click through the flow? See the [interactive send walkthrough](/developer-guides/trade-and-send/send/interactive-send-flow) for a visual step-by-step guide. ## Identify the sender and recipient accounts A send involves two different users: * **Origin account** — the sender's account. * **Destination account** — the recipient's account. The destination account must belong to a different `ownerId` than the sender. Before creating a quote, verify the recipient account exists using [Get account](/rest-apis/core-api/accounts/get-account). ```http theme={null} GET /core/accounts/{accountId} ``` ```json theme={null} { "account": { "id": "d9f7a3c1-2b8e-4f5d-9c6a-1e2b3c4d5e6f", "label": "Jane's USD account", "asset": "USD", "balance": { "total": "100.00", "available": "100.00" } } } ``` The send quote must be created using: ```http theme={null} X-On-Behalf-Of: ``` This header identifies the sender and determines which user's balances and permissions are evaluated. Do not expose recipient `ownerId` values directly to end users. Instead, resolve account ownership server-side and display a friendly identifier such as a name, username, or contact entry. ## Create a quote Create a quote on behalf of the sender. The quote acts as a confirmation handle and validation step before funds move. Because both accounts hold the same asset, the amounts in the quote are always identical. ```http theme={null} POST /core/transactions/quote X-On-Behalf-Of: 5feba1e3-3f14-4fd9-b00d-f386df83042f { "origin": { "type": "account", "id": "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec" }, "destination": { "type": "account", "id": "d9f7a3c1-2b8e-4f5d-9c6a-1e2b3c4d5e6f" }, "denomination": { "asset": "USD", "amount": "50" } } ``` ```json [expandable] theme={null} { "quote": { "id": "b7e4d2c1-9f3a-4b5e-8d7c-2e1f3a4b5c6d", "expiresAt": "2026-05-29T14:02:00.000Z", "origin": { "asset": "USD", "amount": "50.00", "rate": "1.00" }, "destination": { "asset": "USD", "amount": "50.00", "rate": "1.00" }, "denomination": { "asset": "USD", "amount": "50.00", "rate": "1.00", "target": "origin" }, "fees": [] } } ``` For endpoint details, see [Create a quote](/rest-apis/core-api/transactions/create-quote). Quotes have a short validity window even at a 1:1 rate. If a quote expires, transaction creation returns `404 entity_not_found` — create a new quote and ask the sender to confirm again. ## Create the transaction Once the sender confirms the transfer, create the transaction from the quote. As with trades, the `quoteId` becomes the `transactionId`, allowing a single identifier to be used throughout the transfer lifecycle. Sends settle synchronously. In most cases, by the time the API responds, balances have already been updated. ```http theme={null} POST /core/transactions X-On-Behalf-Of: { "quoteId": "b7e4d2c1-9f3a-4b5e-8d7c-2e1f3a4b5c6d" } ``` ```json [expandable] theme={null} { "transaction": { "id": "b7e4d2c1-9f3a-4b5e-8d7c-2e1f3a4b5c6d", "status": "processing", "origin": { "asset": "USD", "amount": "50.00" }, "destination": { "asset": "USD", "amount": "50.00" } } } ``` ## Notify sender and recipient After the transaction succeeds: #### Notify the sender Display a confirmation such as: > Sent 50.00 USD to Jane Smith. Refresh balances using [Get account](/rest-apis/core-api/accounts/get-account). #### Notify the recipient Send an in-app, push, SMS, or email notification indicating that funds were received. The recipient webhook payload contains the same transaction `id` returned during transaction creation, allowing notifications to be deduplicated across systems. You now support peer-to-peer sends using the Uphold Enterprise API Suite. ## Common errors | Status | Code | Cause | Recommended action | | ------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | | `409` | `user_capability_failure` (`capability.code: "sends"`) | Sender does not have the `sends` capability enabled. | Verify onboarding status and sender capability requirements. | | `409` | `user_capability_failure` (`capability.code: "receives"`) | Recipient cannot receive funds. | Verify recipient onboarding and capability status. | | `404` | `entity_not_found` (`entity: "account"`) | Destination account does not exist or is not visible to your partner relationship. | Verify the recipient account ID before retrying. | | `404` | `entity_not_found` (`entity: "quote"`) | Quote expired or no longer exists. | Create a new quote and request confirmation again. | | `409` | `insufficient_balance` | Sender account does not have sufficient available funds. | Refresh balances and prompt the sender to enter a lower amount. | # Interactive trade walkthrough Source: https://developer.uphold.com/developer-guides/trade-and-send/trade/interactive-trade-flow Navigate through an asset-to-asset trade. Click each step to see the matching API call and visual representation side by side.
Tap **From** to pick the source account, then **To** to choose the destination asset. The API panel updates to show the relevant endpoint as you interact. The trade amount cannot exceed the available balance of the selected account.
```bash cURL theme={null} curl -X POST "https://api.enterprise.uphold.com/core/transactions/quote" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "origin": { "type": "account", "id": "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec" }, "destination": { "type": "account", "id": "c392a0de-5fc6-49a7-96e1-e4b3f9ef5b27" }, "denomination": { "asset": "USD", "amount": "100", "target": "origin" } }' ``` ```js JavaScript theme={null} const response = await fetch("https://api.enterprise.uphold.com/core/transactions/quote", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ origin: { type: "account", id: "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec" }, destination: { type: "account", id: "c392a0de-5fc6-49a7-96e1-e4b3f9ef5b27" }, denomination: { asset: "USD", amount: "100", target: "origin" }, }), }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.request( "POST", "https://api.enterprise.uphold.com/core/transactions/quote", headers={ "Authorization": "Bearer ", "Content-Type": "application/json", }, json={ "origin": {"type": "account", "id": "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec"}, "destination": {"type": "account", "id": "c392a0de-5fc6-49a7-96e1-e4b3f9ef5b27"}, "denomination": {"asset": "USD", "amount": "100", "target": "origin"}, } ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/transactions/quote")) .header("Authorization", "Bearer ") .header("Content-Type", "application/json") .method("POST", HttpRequest.BodyPublishers.ofString(""" { "origin": { "type": "account", "id": "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec" }, "destination": { "type": "account", "id": "c392a0de-5fc6-49a7-96e1-e4b3f9ef5b27" }, "denomination": { "asset": "USD", "amount": "100", "target": "origin" } }""")) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "quote": { "id": "43c0c58d-6fbd-464a-bf2b-f38fbfebb4ab", "expiresAt": "2026-05-29T11:47:05.109Z", "origin": { "asset": "USD", "amount": "100.00", "rate": "64564.596" }, "destination": { "asset": "BTC", "amount": "0.00154883", "rate": "0.0000155" }, "denomination": { "asset": "USD", "amount": "100.00", "rate": "1.00", "target": "origin" }, "fees": [] } } ```
```bash cURL theme={null} curl -X POST "https://api.enterprise.uphold.com/core/transactions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "quoteId": "43c0c58d-6fbd-464a-bf2b-f38fbfebb4ab", "metadata": { "externalId": 123 } }' ``` ```js JavaScript theme={null} const response = await fetch("https://api.enterprise.uphold.com/core/transactions", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ quoteId: "43c0c58d-6fbd-464a-bf2b-f38fbfebb4ab", metadata: { externalId: 123 }, }), }); const data = await response.json(); ``` ```python Python theme={null} import requests response = requests.request( "POST", "https://api.enterprise.uphold.com/core/transactions", headers={ "Authorization": "Bearer ", "Content-Type": "application/json", }, json={ "quoteId": "43c0c58d-6fbd-464a-bf2b-f38fbfebb4ab", "metadata": {"externalId": 123}, } ) data = response.json() ``` ```java Java theme={null} import java.net.http.*; import java.net.URI; var client = HttpClient.newHttpClient(); var req = HttpRequest.newBuilder() .uri(URI.create("https://api.enterprise.uphold.com/core/transactions")) .header("Authorization", "Bearer ") .header("Content-Type", "application/json") .method("POST", HttpRequest.BodyPublishers.ofString(""" { "quoteId": "43c0c58d-6fbd-464a-bf2b-f38fbfebb4ab", "metadata": { "externalId": 123 } }""")) .build(); var response = client.send(req, HttpResponse.BodyHandlers.ofString()); var body = response.body(); ``` ```json Response theme={null} { "transaction": { "id": "43c0c58d-6fbd-464a-bf2b-f38fbfebb4ab", "status": "processing", "origin": { "asset": "USD", "amount": "100.00" }, "destination": { "asset": "BTC", "amount": "0.00154883" } } } ```
**Webhook event:** `core.transaction.status-changed` ```json Webhook payload theme={null} { "id": "c2d3e4f5-a6b7-4c8d-9e0f-a1b2c3d4e5f6", "type": "core.transaction.status-changed", "createdAt": "2026-05-29T11:46:53.737Z", "data": { "transaction": { "id": "b1c2d3e4-f5a6-4b7c-8d9e-f0a1b2c3d4e5", "status": "completed", "origin": { "asset": "USD", "amount": "100.00", "node": { "type": "account", "id": "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec" } }, "destination": { "asset": "BTC", "amount": "0.00154883", "node": { "type": "account", "id": "c392a0de-5fc6-49a7-96e1-e4b3f9ef5b27" } }, "denomination": { "asset": "USD", "amount": "100.00", "rate": "1.00", "target": "origin" }, "fees": [], "quotedAt": "2026-05-29T11:46:47.494Z", "createdAt": "2026-05-29T11:46:53.110Z", "updatedAt": "2026-05-29T11:46:53.737Z" } } } ```
*** For the full prose walkthrough — prerequisite checks, error table, and denomination logic — see the [via REST API](/developer-guides/trade-and-send/trade/via-rest-api) guide. # Execute a trade via the REST API Source: https://developer.uphold.com/developer-guides/trade-and-send/trade/via-rest-api Convert between different assets for the same user using the Uphold quote → commit flow. Trades settle in under a second. A **trade** converts value between two accounts owned by the same user when those accounts hold **different assets** (for example, USD → BTC, ETH → USDC, or BTC → USD). Trades follow a simple two-step flow: 1. **Create a quote** to lock the exchange rate and amounts. 2. **Commit the quote** to execute the trade. Once committed, the trade settles instantly and is typically completed in under a second. ## Prerequisites Before creating trades: * The user must have [completed onboarding](/developer-guides/user-onboarding/overview). * The user must have the **`trades`** capability enabled. * **UK retail users** must clear a mandatory 24-hour financial-promotion cooldown after onboarding before they can trade. Until it elapses, `POST /core/transactions/quote` returns `409 user_capability_failure` with `restrictions: ["financial-promotion-cooldown-running"]` — surface this state explicitly in your UI rather than treating it as a generic failure. Verify the capability using [List capabilities](/rest-apis/core-api/capabilities/list-user-capabilities): ```http theme={null} GET /core/users/{userId}/capabilities ``` ```json theme={null} { "code": "trades", "enabled": true, "restrictions": [], "requirements": [] } ``` ## Walkthrough Prefer to click through the flow? See the [interactive trade walkthrough](/developer-guides/trade-and-send/trade/interactive-trade-flow) for a visual step-by-step guide. ## Select the origin and destination accounts A trade requires two accounts under the same `ownerId`: * **Origin account** — the asset being sold. * **Destination account** — the asset being purchased. The two accounts must hold different assets. Retrieve existing accounts using [List accounts](/rest-apis/core-api/accounts/list-accounts), or create the destination account with [Create account](/rest-apis/core-api/accounts/create-account). ```http theme={null} GET /core/accounts?perPage=10 ``` ```json [expandable] theme={null} { "accounts": [ { "id": "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec", "label": "My USD account", "asset": "USD", "balance": { "total": "250.00", "available": "250.00" } }, { "id": "c392a0de-5fc6-49a7-96e1-e4b3f9ef5b27", "label": "My BTC account", "asset": "BTC", "balance": { "total": "0.0015", "available": "0.0015" } } ] } ``` The origin account must have enough available balance to cover the trade. Only `balance.available` can be traded. Funds that are still pending settlement (`balance.total > balance.available`) cannot be used. ## Create a quote Create a quote to lock the exchange rate and determine the exact trade amounts. Specify the side you want to lock using `denomination.target` (`origin` or `destination`) and the amount using `denomination.amount`. Uphold calculates the corresponding amount on the opposite side. The quote response indicates which side was locked through `denomination.target`: | **denomination.asset** | **denomination.target** | **Meaning** | | ---------------------- | ----------------------- | --------------------------------------------------------- | | `origin.asset` | `origin` | Sell exactly the specified amount of the origin asset | | `destination.asset` | `destination` | Buy exactly the specified amount of the destination asset | ```http theme={null} POST /core/transactions/quote { "origin": { "type": "account", "id": "11de2405-1f1f-4a6e-8a39-acd97dc7f3ec" }, "destination": { "type": "account", "id": "c392a0de-5fc6-49a7-96e1-e4b3f9ef5b27" }, "denomination": { "asset": "USD", "amount": "100", "target": "origin" } } ``` ```json [expandable] theme={null} { "quote": { "id": "43c0c58d-6fbd-464a-bf2b-f38fbfebb4ab", "expiresAt": "2026-05-29T11:47:05.109Z", "origin": { "asset": "USD", "amount": "100.00", "rate": "64564.596" }, "destination": { "asset": "BTC", "amount": "0.00154883", "rate": "0.0000155" }, "denomination": { "asset": "USD", "amount": "100.00", "rate": "1.00", "target": "origin" }, "fees": [] } } ``` For request and response details, see [Create a quote](/rest-apis/core-api/transactions/create-quote). **Honor the quote expiration.** Each quote is valid only until the `expiresAt` timestamp in the response. Display the quote, collect the user's confirmation, and commit before it expires. If the quote has expired, creating the transaction returns `404 entity_not_found`. Create a new quote and ask the user to confirm again. ## Commit the quote After the user accepts the quoted rate, commit the quote by creating a transaction. The `quoteId` becomes the `transactionId`, allowing you to use a single identifier throughout the trade lifecycle. ```http theme={null} POST /core/transactions { "quoteId": "43c0c58d-6fbd-464a-bf2b-f38fbfebb4ab" } ``` ```json theme={null} { "transaction": { "id": "43c0c58d-6fbd-464a-bf2b-f38fbfebb4ab", "status": "processing", "origin": { "asset": "USD", "amount": "100.00" }, "destination": { "asset": "BTC", "amount": "0.00154883" } } } ``` For implementation details, see [Create a transaction](/rest-apis/core-api/transactions/create-transaction). ## Confirm settlement Trades typically reach a final state within a few hundred milliseconds. #### Recommended: Webhooks Subscribe to: `core.transaction.status-changed` When the trade completes, you'll receive a webhook containing the updated transaction status. #### Alternative: Polling Retrieve the transaction and poll until it reaches a terminal state. ```http theme={null} GET /core/transactions/{transactionId} ``` ```json theme={null} { "transaction": { "id": "43c0c58d-6fbd-464a-bf2b-f38fbfebb4ab", "status": "completed" } } ``` #### Trade transaction statuses | Status | Meaning | | ------------ | ------------------------------------------------------------------------------------------------ | | `processing` | Trade has been accepted and is being settled. This is the expected immediate state after commit. | | `completed` | Trade settled successfully and balances have been updated. | | `failed` | Trade could not be completed. Contact support and provide the transaction `id`. | ## Notify the user Once settlement completes: * Display a trade confirmation. * Show both sides of the conversion. * Refresh account balances. Example: > Sold 100.00 USD and received 0.00154883 BTC. Refresh balances using [Get account](/rest-apis/core-api/accounts/get-account). You now support asset-to-asset trades using the Uphold Enterprise API Suite. ## Common errors | Status | Code | Cause | Recommended action | | ------ | ------------------------------------------------------------------------------------ | --------------------------------------------------------- | -------------------------------------------------------------------------- | | `409` | `user_capability_failure` (`restrictions: ["financial-promotion-cooldown-running"]`) | UK retail user is still in the mandatory cooldown period. | Inform the user that trading becomes available after the cooldown expires. | | `409` | `user_capability_failure` (`capability.code: "trades"`) | User does not have the `trades` capability enabled. | Verify onboarding status and capability requirements. | | `404` | `entity_not_found` (`entity: "quote"`) | Quote expired or no longer exists. | Request a new quote and ask the user to confirm again. | | `409` | `insufficient_balance` | Origin account does not have sufficient available funds. | Refresh balances and prompt the user to enter a lower amount. | # Travel Rule — deposit flow Source: https://developer.uphold.com/developer-guides/travel-rule/deposit Step-by-step guide to resolving a Travel Rule request for information on an on-hold crypto deposit. This guide walks you through resolving a Travel Rule request for information on an on-hold crypto deposit — from detecting the on-hold status to resolving the RFI and allowing the transaction to proceed. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant W as Travel Rule Widget participant A as Uphold A-->>B: webhook: transaction.status-changed (on-hold) B-->>Usr: Notify the user (deposit on hold) B->>A: GET /core/requests-for-information?referenceId={transactionId} A-->>B: { requestsForInformation } B->>A: POST /widgets/travel-rule/sessions A-->>B: { session } B-->>U: { session } U->>W: Initialize widget W-->>U: ready Usr->>W: Complete form W->>A: PUT /core/requests-for-information/{requestForInformationId} A-->>W: { requestForInformation } W-->>U: complete { travelRule } A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user (outcome) ``` ## Detect the on-hold transaction When a crypto deposit is placed on hold due to a Travel Rule requirement, Uphold sends a `core.transaction.status-changed` webhook with `status: on-hold` and `statusDetails.reason: pending-requests-for-information`. ```json theme={null} { "id": "", "type": "core.transaction.status-changed", "createdAt": "2024-07-24T15:22:39Z", "data": { "transaction": { "id": "b97c7f64-1c34-4b6e-a8f2-3d5c4e9a1b72", "status": "on-hold", "statusDetails": { "reason": "pending-requests-for-information" } } } } ``` Abbreviated — the transaction object includes additional fields. If you are using polling instead of webhooks, check for `status: on-hold` and `statusDetails.reason: pending-requests-for-information` on the transaction object. ## Notify the user Surface the on-hold status to the user out-of-band (email or push notification) — deposits can sit on-hold indefinitely until resolved. ## List transaction RFIs Call [List request for information](/rest-apis/core-api/requests-for-information/list-requests-for-information) endpoint to retrieve all RFIs by `referenceId` (transaction or quote ID), then filter the results to keep only entries where type is "travel-rule". From that filtered set, check whether any RFI has a status of "pending" — if so, the transaction is still awaiting resolution. The deprecated [transaction-nested endpoint](/rest-apis/core-api/transactions/rfis/list-requests-for-information) still works but should not be used for new integrations. ```http theme={null} GET /core/requests-for-information?referenceId={transactionId} ``` ```json theme={null} { "requestsForInformation": [ { "id": "3f6d0c1e-a1bf-4b25-9802-2a3ee492d3c8", "type": "travel-rule", "status": "pending", "data": {}, "createdAt": "2024-07-24T15:22:39Z", "updatedAt": "2024-07-24T15:22:39Z" } ] } ``` If all travel-rule RFIs have a status of "ok", the transaction may have already been moved out of on-hold status automatically. Make sure to re-fetch the transaction and check its current status before taking further action. ## Resolve the RFI The Travel Rule Widget allows the user to resolve a travel rule RFI for a specific transaction. ### Create a widget session Create a session tied to the RFI by calling [Create session](/rest-apis/widgets-api/travel-rule/create-session) with `flow: deposit-form` and the `data` property containing the `requestForInformationId`. Each session is single-use and bound to a specific RFI. ```http theme={null} POST /widgets/travel-rule/sessions { "flow": "deposit-form", "data": { "requestForInformationId": "3f6d0c1e-a1bf-4b25-9802-2a3ee492d3c8" } } ``` A successful response returns the session data needed to initialize the widget. ```json [expandable] theme={null} { "session": { "flow": "deposit-form", "url": "https://travel-rule-widget.enterprise.uphold.com/", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "data": { "provider": "notabene", "parameters": { "init": { "authToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "nodeUrl": "https://api.notabene.id" }, "options": {}, "transaction": { "amountDecimal": 0.05, "asset": "BTC", "source": ["bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"], "customer": { "name": "John Doe", "type": "natural" } } } } } } ``` Widget sessions expire after 2 minutes. If the session expires before the user opens the widget — or while they are mid-form — the widget emits an `error` event. Create a new session and re-mount to let the user retry. ### Set up the widget Initialize the widget using the session returned from the API and mount it into your application. The widget does not unmount itself — always call `unmount()` after handling any event. ```javascript Web SDK [expandable] theme={null} import { TravelRuleWidget } from '@uphold/enterprise-travel-rule-widget-web-sdk'; const widget = new TravelRuleWidget<'deposit-form'>(session, { debug: true }); widget.on('ready', () => { // Widget has loaded and is ready for user interaction }); widget.on('complete', (event) => { // Widget has completed successfully — unmount the widget and listen for the webhook to confirm the transaction status change widget.unmount(); }); widget.on('cancel', () => { widget.unmount(); }); widget.on('error', (event) => { console.error('Travel Rule widget error:', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('travel-rule-container')); ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/travel-rule/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/travel-rule/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle the complete event Once the user finishes the form, the widget automatically resolves the RFI by calling [Update request for information](/rest-apis/core-api/requests-for-information/update-request-for-information) internally with the collected data. Calling [Update request for information](/rest-apis/core-api/requests-for-information/update-request-for-information) yourself is only needed if you're not using the widget and are resolving the Travel Rule requirement directly — not part of this widget-based flow. When `complete` fires, unmount the widget and confirm the RFI is resolved before proceeding — whether it is immediately `ok` or still pending depends on the [proof type](/developer-guides/travel-rule/proof-types) used: * **Synchronous proofs** (self-declaration, cryptographic signature) — the RFI is fully resolved as soon as `complete` fires. Proceed directly to [Monitoring for settlement](#monitoring-for-settlement) to listen for transaction status changes. * **Asynchronous proofs** (micro-transfer, e.g. a small on-chain test transaction) — `complete` firing does not mean the RFI is resolved yet; resolution depends on the micro-transfer settling on-chain and confirmed by Uphold. Wait for it to settle, then proceed to [Monitoring for settlement](#monitoring-for-settlement) to listen for transaction status changes. Use [Get request for information](/rest-apis/core-api/requests-for-information/get-request-for-information) to check which proof type was used and confirm the RFI's status. ```javascript Web SDK theme={null} widget.on('complete', () => { widget.unmount(); // Monitor the RFI status via webhook or polling, then monitor the transaction status to confirm the transaction is no longer on-hold }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': teardown(); // Monitor the RFI status via webhook or polling, then monitor the transaction status to confirm the transaction is no longer on-hold break; ``` ### Handle cancellations The `cancel` event fires when the user closes the widget without completing the form. The transaction remains `on-hold` until the RFI is resolved — create a new widget session for the same RFI to let the user retry. See [cancel event](/widgets/travel-rule/sdk-reference#cancel) for the event reference. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Redirect back or show a cancellation message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Redirect back or show a cancellation message break; ``` ### Handle errors The `error` event fires when an unrecoverable error occurs. See [error event](/widgets/travel-rule/sdk-reference#error) for the full error shape and available properties. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` ## Monitoring for settlement After the RFI is resolved, the transaction will move from `on-hold` to `processing` and then to either `completed` or `failed`. Monitor the transaction status using webhooks (recommended) or polling (fallback): * Webhook events (recommended): * [core.transaction.status-changed](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) * `status: processing` → transaction is being processed * `status: completed` → necessary confirmations reached * `status: failed` → transaction failed * Polling (fallback): [Get transaction](/rest-apis/core-api/transactions/get-transaction) When the transaction reaches `completed` or `failed`, notify the user of the outcome. ## Testing To trigger a Travel Rule RFI on a deposit, use a GB user account and send 30 XRP from an unhosted (self-custodial) wallet to the user's Uphold deposit address. Verify the following: 1. A `core.transaction.status-changed` webhook is received with `status: on-hold` and `statusDetails.reason: pending-requests-for-information`. 2. [List requests for information](/rest-apis/core-api/requests-for-information/list-requests-for-information) returns an RFI with `type: travel-rule` and `status: pending`. 3. After completing the widget flow, the RFI status changes to `ok` and the transaction moves back to `processing`. 4. The transaction status updates from `processing` to `completed` within a few minutes, assuming no other blockers. For the equivalent withdrawal guide, see [Travel Rule — withdrawal flow](/developer-guides/travel-rule/withdrawal). # Travel Rule overview Source: https://developer.uphold.com/developer-guides/travel-rule/overview Understand the Travel Rule regulatory requirement, when it applies to crypto transactions, and how Uphold's Travel Rule solution works. The Travel Rule is a global financial regulation that requires Virtual Asset Service Providers (VASPs) — including exchanges, custodians, and brokers — to collect and share identifying information about the sender and recipient of crypto transactions above certain value thresholds. Its purpose is to prevent money laundering, terrorist financing, and sanctions evasion. As a VASP, Uphold manages the compliance logic, the underlying data exchange with other VASPs (via Notabene), and the collection interface. You are responsible for surfacing that collection to your users at the right moment in the transaction flow. ## When it applies Travel Rule applies to crypto transactions only — fiat transfers (bank, card) are not in scope. Above jurisdiction-specific value thresholds, your integration must surface the data collection to the user via the Travel Rule Widget. From the integrator's perspective, the trigger is always the same: respond to the API signal (see [API signals](#api-signals)). What differs is what happens to the collected data downstream: **Withdrawals** — you collect beneficiary info from the user and submit it with the transaction. Uphold forwards it through its Travel Rule network (Notabene) to the counterparty. If the counterparty VASP is on the same network, it verifies the data and may reject the transaction — for example, by instructing Uphold not to send. If the counterparty is on a different Travel Rule network (such as TRUST), is a non-compliant VASP, or is an unhosted (self-custodied) wallet, no downstream exchange happens — but the data is still collected the same way. **Deposits** — when a crypto deposit arrives, Uphold cannot reliably tell whether it originated from a VASP on a different network, a non-compliant VASP, or an unhosted wallet. Any deposit above the threshold is placed on hold until the user provides the required originator info via the widget. Thresholds are dynamic and change as regulations evolve across jurisdictions. Uphold manages this logic entirely — you do not need to track or implement threshold rules. The API signals when a requirement is active; your integration only needs to respond to those signals. ## API signals Uphold signals when Travel Rule action is required on a transaction: * **Withdrawals** — the quote response includes `"travel-rule"` in the `requirements` array. This must be resolved before the transaction can be created. * **Deposits** — the transaction enters `on-hold` status with reason `pending-requests-for-information`, and a pending request for information of type `travel-rule` is attached. The deposit cannot complete until the RFI is resolved. ## Handling Travel Rule Look at the `travel-rule` requirement on withdrawal quotes and `on-hold` status on deposits. These are the signals that user action is required. For withdrawals, block the quote confirmation flow until the user completes the widget. For deposits, notify out-of-band — the hold may occur after the user has left the flow. Create a widget session and mount the Travel Rule Widget in your application. The widget handles the compliance collection on behalf of your platform. Depending on the user's jurisdiction and transaction, the widget presents one of two checks: * **Self-declaration** — the user attests the wallet belongs to them by providing their name and declaring their relationship to it. No cryptographic action is required. This is the default check in most jurisdictions. * **Proof of Ownership** — the user proves control of the wallet by signing a challenge message using their wallet software (for example, by approving an action in their wallet app or connecting a hardware wallet). Required when the user's jurisdiction mandates cryptographic proof; Uphold applies this automatically based on current regulations and user location. Uphold and Notabene determine which checks are shown based on internal compliance rules. ## Start building Resolve a Travel Rule RFI on an on-hold crypto deposit. Handle a Travel Rule requirement on a crypto withdrawal quote. # Travel Rule — withdrawal flow Source: https://developer.uphold.com/developer-guides/travel-rule/withdrawal Step-by-step guide to handling a Travel Rule requirement during a crypto withdrawal. This guide walks you through handling a Travel Rule requirement when creating a crypto withdrawal — from detecting the requirement on a quote to submitting the collected data with the transaction. ## Prerequisites * The user has [completed onboarding](/developer-guides/user-onboarding/overview) and has the required capabilities enabled. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant Usr as User participant U as Your App participant B as Your Backend participant W as Travel Rule Widget participant A as Uphold Usr->>U: Initiate withdrawal U->>B: Request quote B->>A: POST /core/transactions/quote A-->>B: { quote (requirements: ["travel-rule"]) } B-->>U: Surface Travel Rule requirement to user B->>A: POST /widgets/travel-rule/sessions A-->>B: { session } B-->>U: { session } U->>W: Initialize widget W-->>U: ready Usr->>W: Complete form W->>A: PUT /core/requests-for-information/{rfiId} A-->>W: { requestForInformation } W-->>U: complete { travelRule } B->>A: POST /core/transactions A-->>B: { transaction } B-->>U: { transaction } A-->>B: webhook: transaction.status-changed (completed/failed) B-->>Usr: Notify the user (outcome) ``` ## Detect the requirement When a quote is returned, check the `requirements` array. If it contains `travel-rule`, the requirement must be resolved before the transaction can be created. If `requirements` is empty, proceed directly to creating the transaction. ```json theme={null} { "quote": { "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0", "requirements": [ "travel-rule" ], "expiresAt": "2024-07-24T15:22:39Z" } } ``` ## Resolve the requirement The Travel Rule Widget allows the user to resolve a travel rule requirement for a specific quote. ### Create a widget session Create a session tied to the quote by calling [Create session](/rest-apis/widgets-api/travel-rule/create-session) with `flow: withdrawal-form` and the `data` property containing the `quoteId`. Each session is single-use and bound to a specific quote. ```http theme={null} POST /widgets/travel-rule/sessions { "flow": "withdrawal-form", "data": { "quoteId": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0" } } ``` A successful response returns the session data needed to initialize the widget. ```json [expandable] theme={null} { "session": { "flow": "withdrawal-form", "url": "https://travel-rule-widget.enterprise.uphold.com/", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "data": { "provider": "notabene", "parameters": { "init": { "authToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "nodeUrl": "https://api.notabene.id" }, "options": {}, "transaction": { "amountDecimal": 0.00121023, "asset": "BTC", "destination": "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" } }, "requestForInformation": { "id": "a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d", "quoteId": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0" } } } } ``` Widget sessions expire after 2 minutes. If the session expires before the user opens the widget — or while they are mid-form — the widget emits an `error` event. Create a new session and re-mount to let the user retry. If the quote has also expired, create a new quote first before creating a new session. ### Set up the widget Initialize the widget using the session returned from the API and mount it into your application. The widget does not unmount itself — always call `unmount()` after handling any event. ```javascript Web SDK [expandable] theme={null} import { TravelRuleWidget } from '@uphold/enterprise-travel-rule-widget-web-sdk'; const widget = new TravelRuleWidget<'withdrawal-form'>(session, { debug: true }); widget.on('ready', () => { // Widget has loaded and is ready for user interaction }); widget.on('complete', (event) => { // `event.detail.value` is the entire Travel Rule payload — forward it unchanged const { value: travelRule } = event.detail; sendToBackend({ travelRule }); widget.unmount(); }); widget.on('cancel', () => { widget.unmount(); }); widget.on('error', (event) => { console.error('Travel Rule widget error:', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('travel-rule-container')); ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/travel-rule/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/travel-rule/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ### Handle complete event Once the user finishes the form, the widget automatically resolves the RFI by calling [Update request for information](/rest-apis/core-api/requests-for-information/update-request-for-information) internally with the collected data. Calling [Update request for information](/rest-apis/core-api/requests-for-information/update-request-for-information) yourself is only needed if you're not using the widget and are resolving the Travel Rule requirement directly — not part of this widget-based flow. When `complete` fires, unmount the widget and confirm the RFI is resolved before proceeding — whether it is immediately `ok` or still pending depends on the [proof type](/developer-guides/travel-rule/proof-types) used: * **Synchronous proofs** (self-declaration, cryptographic signature) — the RFI is fully resolved as soon as `complete` fires. If the quote has expired, create a new quote and proceed directly to [Create transaction](/rest-apis/core-api/transactions/create-transaction). * **Asynchronous proofs** (micro-transfer, e.g. a small on-chain test transaction) — `complete` firing does not mean the RFI is resolved yet; resolution depends on the micro-transfer settling on-chain and confirmed by Uphold. Wait for it to settle, then create a new quote before creating the transaction. Use [Get request for information](/rest-apis/core-api/requests-for-information/get-request-for-information) to check which proof type was used and confirm the RFI's status. The original quote may have expired while the user was completing the widget form. If so, create a new quote before proceeding — the Travel Rule data collected by the widget remains valid. Include the same `travelRule` object in the transaction request with the updated `quoteId`. ```javascript Web SDK theme={null} widget.on('complete', () => { widget.unmount(); // Monitor the RFI status via webhook or polling, then create a new quote if expired and create the transaction }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'complete': teardown(); // Monitor the RFI status via webhook or polling, then create a new quote if expired and create the transaction break; ``` ### Handle cancellations The `cancel` event fires when the user closes the widget without completing the form. The quote is not affected — it remains valid until it expires, so a new widget session can be created for the same quote to let the user retry. See [cancel event](/widgets/travel-rule/sdk-reference#cancel) for the event reference. ```javascript Web SDK theme={null} widget.on('cancel', () => { widget.unmount(); // Redirect back or show a cancellation message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'cancel': teardown(); // Redirect back or show a cancellation message break; ``` ### Handle errors The `error` event fires when an unrecoverable error occurs. See [error event](/widgets/travel-rule/sdk-reference#error) for the full error shape and available properties. ```javascript Web SDK theme={null} widget.on('error', (event) => { console.error('Widget error:', event.detail.error); widget.unmount(); // Show a user-friendly error message }); ``` ```javascript JavaScript theme={null} // Inside the onMessage switch from Set up the widget case 'error': console.error('Widget error:', event.data.error); teardown(); // Show a user-friendly error message break; ``` ## Transaction failures Unlike a deposit hold, a withdrawal transaction is created right away, so failures surface after creation — either when the Travel Rule payload is rejected at creation time, or when the counterparty VASP later rejects the data. ### Transaction creation errors A transaction with an `unspecified-error` can indicate a rejected Travel Rule payload. The transaction was not completed. Collect fresh data via the widget and retry — the original `quoteId` remains valid unless it has since expired, in which case create a new quote first. ### Counterparty rejection After the transaction is created, the counterparty VASP has a window to review the Travel Rule data. If the data is rejected, Uphold emits a `core.transaction.status-changed` webhook with `status: failed` and `statusDetails.reason: travel-rule-verification-failed`. Common causes are the transaction being created before the RFI was fully resolved, or the beneficiary VASP being unrecognized or invalid. Notify the user and ask them to retry with a different destination. ## Testing To trigger a Travel Rule requirement on a withdrawal, use a GB user account and create an XRP withdrawal to an external address for 30 XRP. Verify the following: 1. The quote response includes `"travel-rule"` in the `requirements` array. 2. After [creating a widget session](/rest-apis/widgets-api/travel-rule/create-session), complete the form — verify via [Get request for information](/rest-apis/core-api/transactions/rfis/get-request-for-information) that the RFI status is `ok`. 3. Create a new quote with the same parameters and call [Create transaction](/rest-apis/core-api/transactions/create-transaction) with the new `quoteId`; the transaction is created successfully. 4. A `core.transaction.status-changed` webhook is received with `status: completed` (or `failed` if the counterparty rejects the data). 5. The transaction status updates to `completed` within a few minutes, assuming no other blockers. For the deposit flow, see [Travel Rule — deposit flow](/developer-guides/travel-rule/deposit). # Business user onboarding overview (KYB verification) Source: https://developer.uphold.com/developer-guides/user-onboarding/business/overview Onboard business clients with KYB and compliance workflows on the Uphold platform. Coverage, capabilities, and integration paths for business users. Preview Business onboarding verifies a company's identity, ownership, and control structure, ensuring compliance readiness for regulated financial services. It involves creating a business user, completing the required KYB processes, and confirming the user's capabilities are unlocked before they transact. KYB is available as a **preview**. The endpoints are live in **Sandbox** so you can start integrating today, but none of them are available in **Production** yet, and a few are still landing in Sandbox. Each endpoint page states its own availability. ## What's available today The KYB API reference covers the business-level processes and the associated persons tied to the business. Register a business user with `type: business` to start the KYB flow. The process model, statuses, associated persons, and the attestation flow. Once the business user exists, the KYB processes replace the KYC ones — see the [KYB introduction](/rest-apis/core-api/kyb/introduction) for how the two differ. ## What's coming Step-by-step onboarding guides, mirroring the [Individual](/developer-guides/user-onboarding/individual/overview) section, will follow as the preview progresses. If you are interested in onboarding business users, please [reach out to us](https://uphold.com/enterprise#contact). # Individual user onboarding overview (KYC verification) Source: https://developer.uphold.com/developer-guides/user-onboarding/individual/overview Verify retail user identity and unlock capabilities through Uphold's individual KYC processes, with Uphold-verified or partner-verified models. Individual onboarding verifies user identity and ensures compliance readiness for regulated financial services. It involves creating a user account and completing the KYC processes required for the user's country of residency. ## Verification responsibility Each KYC process can be verified by Uphold or by your organization (the partner). The table below shows which verification model each process supports. | Process | Uphold-verified | Partner-verified | | ---------------------------------------- | ------------------------- | ------------------------- | | Email, Phone | Not supported | Supported | | Profile | — | — | | Identity | Supported | Supported | | Proof of address | Supported | Supported | | Customer due diligence | Supported | Supported | | Enhanced due diligence | Not supported | Supported | | Crypto risk assessment (UK users) | Supported | Supported | | Self-categorization statement (UK users) | Supported | Supported | | Tax Details | Supported | Not supported | ## Regional requirements KYC requirements vary from region to region, and from partner to partner based on their risk appetite and regulatory interpretation. Below is a high-level overview of Uphold requirements. Refer to your Account Manager for specific requirements applicable to your integration. | Process / Region | UK | US | Notes | | -------------------------- | ------------------------------------------ | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Email, Phone** | Required¹ | Required¹ | ¹Only need to be collected (`input`) — verifying them (`output`) is optional but recommended, as it may improve transaction acceptance in certain payment flows | | **Profile** | Required | Required | | | **Identity** | Required | Required | | | **Proof of address** | Required | Conditional² | ²Only required when identity document does not have an address | | **Customer due diligence** | Required | Conditional³ | ³Only required when doing crypto deposits or withdrawals | | **Enhanced due diligence** | Conditional | Conditional | Only required for high-risk users | | **Crypto risk assessment** | Required | Exempt | UK only - FCA requirement | | **Self-Categorization** | Required | Exempt | UK only - FCA requirement | | **Tax Details** | Required | Required | | ## High-level onboarding flow ### 1. Create the user Fetch the applicable Terms of Service for the user's country of residency, present them for acceptance, and create the user. To expedite onboarding, collect the user's email, phone, and profile information (name, date of birth, citizenship, and address) and pass them directly in the [Create user](/rest-apis/core-api/users/create-user) endpoint, rather than submitting them afterward through separate KYC calls. ### 2. Complete KYC processes Establish the user's basic identity and contact information for regulatory compliance. User provides their email and proves ownership, usually via a confirmation link sent to their email. User provides their phone number and proves ownership, usually via an SMS OTP. User provides personal information, such as name, date of birth, citizenship, and residential address. Confirm the user is who they claim to be through government-issued identification or electronic verification (e-IDV). Confirm the user resides where they claim to through document verification or electronic verification. In certain regions (e.g., US), this verification will be automatically attempted if the provided document for Identity Verification contains address data (e.g. driver's license). Assess user risk profile and understand intended use. Risk assessment questionnaire to evaluate customer profile and intended use. Determines if Enhanced due diligence is needed. Additional documentation on source of funds and wealth. Only triggered if Customer due diligence indicates a high-risk profile. **UK users only** - Financial Conduct Authority regulatory requirements Knowledge assessment to evaluate user's understanding of cryptocurrency risks before trading. User self-identifies their investor experience level to determine appropriate protections. Comply with tax reporting obligations. ### 3. Monitor status & verify capabilities View completion status of all required processes and identify any remaining blockers. See what actions the user can perform: deposits, trades, withdrawals, and transaction limits. Subscribe to KYC webhooks for real-time status updates. ## Best practices Don't request all information upfront. Start with basic processes (email, phone, profile) and collect sensitive documents only when needed for specific capabilities. Provide clear requirements for document quality, accepted types, and file formats. Reduces verification failures significantly. ## Periodic review Some processes require periodic review, in which their status may change to `pending`, signaling that the user must provide updated information. * `profile`: The user must confirm their profile information, including their residential address, is still accurate. * `identity`: The user must provide up-to-date identity when their underlying document is about to expire. * `customerDueDiligence`: The user must redo the form after a certain period of time. * `selfCategorizationStatement`: The user must redo the form after a certain period of time. **When:** The periodic review might be triggered at any time, but usually it happens between 1-3 years after the last submission or when important documents are about to expire, such as the identity document (typically 30-90 days before the document's expiration date). **How to handle:** 1. Monitor KYC webhooks to catch `core.kyc.*.status-changed` events in real time. 2. Collect and submit updated information. # Onboard individual users via the REST API Source: https://developer.uphold.com/developer-guides/user-onboarding/individual/via-api Step-by-step guide to onboarding individual users via the Uphold REST API: create the user, complete KYC processes, and unlock capabilities. This guide walks through the complete individual user onboarding flow using the REST API — from creating the user to confirming their capabilities are unlocked. ## Prerequisites * API client credentials with permission to create users and manage KYC. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant U as Client App participant B as Your Backend participant A as Core API participant W as Webhooks U->>B: User provides personal, address, and contact details B->>A: GET /core/terms-of-service?type=general&country={country} A-->>B: { termsOfService[] } B-->>U: Display Terms of Service U->>B: User accepts Terms of Service B->>A: POST /core/users (with ToS + X-Uphold-User-Ip) A-->>B: { user } A-->>W: core.user.created B->>A: GET /core/kyc A-->>B: { processes with statuses } B->>A: PATCH /core/kyc/processes/email B->>A: POST /core/files + PATCH /core/kyc/processes/identity A-->>W: core.kyc.*.status-changed (for each process) B->>A: GET /core/capabilities A-->>B: { capabilities[] } B-->>U: User is ready to transact ``` ## Retrieve terms of service Before creating a user, retrieve the general Terms of Service applicable to their country of residence by calling the [List terms of service](/rest-apis/core-api/terms-of-service/list-terms-of-service) endpoint with `type=general` and the user's country code. ```http theme={null} GET /core/terms-of-service?type=general&country={country} ``` Display the Terms of Service content to the user and record their acceptance. ## Create the user Once the user has accepted the Terms of Service, call [Create user](/rest-apis/core-api/users/create-user) to register them on the platform. The `X-Uphold-User-Ip` [user context](/rest-apis/headers#user-context) header is **mandatory** when creating a user, as it records the user's IP address at the time of Terms of Service acceptance. ```http theme={null} POST /core/users { "type": "individual", "email": "john.doe@example.com", "termsOfService": "general-us-hq", "fullName": "John Doe", "birthdate": "1987-01-01", "primaryCitizenship": "US", "address": { "country": "US", "subdivision": "US-CA", "city": "Los Angeles", "line1": "100 Main Street", "postalCode": "90011" }, "phone": { "number": "+12125550123", "country": "US" } } ``` A successful response returns the created user's information. ```json Response theme={null} { "user": { "id": "cd21b26d-35d2-408a-9201-b8fdbef7a604", "type": "individual", "email": "john.doe@uphold.com", "fullName": "John Doe", "birthdate": "1987-01-01", "primaryCitizenship": "US", "address": { "country": "US", "subdivision": "US-CA", "city": "Los Angeles", "line1": "100 Main Street", "postalCode": "90011" }, "phone": { "number": "+12125550123", "country": "US" }, "createdAt": "2024-03-13T20:20:39Z", "updatedAt": "2024-03-13T20:20:39Z" } } ``` ```http theme={null} POST /core/users { "type": "individual", "email": "john.doe@example.com", "termsOfService": "general-gb-fca", "fullName": "John Doe", "birthdate": "1987-01-01", "primaryCitizenship": "GB", "address": { "country": "GB", "subdivision": "GB-MAN", "city": "Manchester", "line1": "1 High Street", "postalCode": "M4 1AA" }, "phone": { "number": "+447911123456", "country": "GB" } } ``` A successful response returns the created user's information. ```json Response theme={null} { "user": { "id": "cd21b26d-35d2-408a-9201-b8fdbef7a604", "type": "individual", "email": "john.doe@uphold.com", "fullName": "John Doe", "birthdate": "1987-01-01", "primaryCitizenship": "GB", "address": { "country": "GB", "subdivision": "GB-MAN", "city": "Manchester", "line1": "1 High Street", "postalCode": "M4 1AA" }, "phone": { "number": "+447911123456", "country": "GB" }, "createdAt": "2024-03-13T20:20:39Z", "updatedAt": "2024-03-13T20:20:39Z" } } ``` You can optionally include custom [entity metadata](/rest-apis/entity-metadata) in the `metadata` field to store your own business data (e.g. external IDs or tracking parameters). Subscribe to the `core.user.created` webhook to be notified asynchronously. ## Check required processes The KYC processes required for a user are determined by the Terms of Service they accepted at registration — different ToS codes map to different regulatory regimes with different requirements. After creating the user, call [Get KYC overview](/rest-apis/core-api/kyc/get-overview) to see the full list of applicable processes and their current statuses. ```http theme={null} GET /core/kyc ``` A successful response returns the list of processes with their statuses. Processes still waiting for the user to provide information or verification start as `pending`, while any process whose data was already completed at [user creation](#create-the-user) (e.g. `profile`) may already be `ok`. ```json Response [expandable] theme={null} { "email": { "code": "email", "status": "pending" }, "phone": { "code": "phone", "status": "pending" }, "profile": { "code": "profile", "status": "ok" }, "identity": { "code": "identity", "status": "pending" }, "proofOfAddress": { "code": "proof-of-address", "status": "pending" }, "customerDueDiligence": { "code": "customer-due-diligence", "status": "pending" }, "enhancedDueDiligence": { "code": "enhanced-due-diligence", "status": "exempt" }, "cryptoRiskAssessment": { "code": "crypto-risk-assessment", "status": "exempt" }, "selfCategorizationStatement": { "code": "self-categorization-statement", "status": "exempt" }, "taxDetails": { "code": "tax-details", "status": "pending" }, "screening": { "code": "screening", "status": "pending" }, "risk": { "code": "risk", "status": "pending" } } ``` Processes with `status: "exempt"` are not required for this user's region. See the [regional requirements](/developer-guides/user-onboarding/individual/overview#regional-requirements) for the full requirements matrix per region. ## Complete KYC processes | Process | Category | | ------------------------------------------------------------- | ---------------------- | | [email](#email-and-phone), [phone](#email-and-phone) | Direct submission | | [profile](#profile) | Direct submission | | [identity](#identity) | File-based | | [proofOfAddress](#proof-of-address) | File-based | | [customerDueDiligence](#customer-due-diligence) | Form-based | | [enhancedDueDiligence](#enhanced-due-diligence) | File-based | | [cryptoRiskAssessment](#crypto-risk-assessment) | Form-based | | [selfCategorizationStatement](#self-categorization-statement) | Form-based | | [taxDetails](#tax-details) | Form-based | | [screening](#screening-and-risk), [risk](#screening-and-risk) | Background (automatic) | `profile` may already be `ok` if you provided that data at [user creation](#create-the-user) — check the process status before submitting it again. Verifying `email` and `phone` (submitting `output.verifiedAt`) is optional — submitting `input` on its own registers the value as unverified (`pending`). See [Email and phone](#email-and-phone) below. Some processes have dependencies (`verification.dependencies`) that must be satisfied before submission: * `identity` and `proofOfAddress` depend on `phone` and `profile` being completed — the data collected in these processes is used for data matching against the submitted documents. * `enhancedDueDiligence` is only triggered after `customerDueDiligence` returns a high-risk score — submit it only when prompted. * `taxDetails` depends on `profile` being completed — the user details are combined with tax information to correctly set up the user's tax configuration. * All other processes can be submitted in any order or in parallel. *** ### Email and phone Register the user's phone number. Call [Update phone](/rest-apis/core-api/kyc/update-phone) with `input` containing the phone number. ```http theme={null} PATCH /core/kyc/processes/phone { "input": { "phone": "+447911123456", "country": "GB" } } ``` A successful response returns the submitted information with a `pending` status. ```json Response theme={null} { "phone": { "code": "phone", "status": "pending", "verification": { "model": "partner-verified", "method": "manual", "dependencies": [] }, "input": { "phone": "+447911123456", "country": "GB" } } } ``` **Verifying the value** If your organization has already verified the phone number directly, include `output` with the verification datetime alongside `input` to mark the process `ok` immediately — or submit it in a later call to upgrade an existing `pending` registration. ```http theme={null} PATCH /core/kyc/processes/phone { "input": { "phone": "+447911123456", "country": "GB" }, "output": { "verifiedAt": "2025-01-29T10:32:00Z" } } ``` ```json Response theme={null} { "phone": { "code": "phone", "status": "ok", "verification": { "model": "partner-verified", "method": "manual", "dependencies": [] }, "input": { "phone": "+447911123456", "country": "GB" }, "output": { "verifiedAt": "2025-01-29T10:32:00Z" } } } ``` This mode is under construction. Contact your Account Manager for the latest availability. Email works the same way — since it's already registered at [user creation](#create-the-user), you'll typically only call [Update email](/rest-apis/core-api/kyc/update-email) to verify it, submitting `output.verifiedAt` once your organization has verified the address directly. **Verification is optional.** Collecting the user's email and phone (submitting `input` only) is enough to satisfy onboarding requirements — verifying them (`output.verifiedAt`) is optional. Verifying when possible is still recommended, since it may improve transaction acceptance in certain payment flows. *** ### Profile Declare the user's personal information and residential address — name, date of birth, citizenship, and address — by calling [Update profile](/rest-apis/core-api/kyc/update-profile) with the relevant fields directly in `input`; if these were already supplied at [user creation](#create-the-user), this process may already be `ok`, so call the endpoint only to fill in missing fields or update existing ones. ```http theme={null} PATCH /core/kyc/processes/profile { "input": { "fullName": "John Doe", "birthdate": "1987-01-01", "primaryCitizenship": "US", "address": { "city": "Los Angeles", "line1": "100 Main Street", "postalCode": "90011" } } } ``` A successful response returns the submitted information with an `ok` status, along with `verification.rules` describing which fields apply for this user and whether they can still be edited. ```json Response theme={null} { "profile": { "code": "profile", "status": "ok", "verification": { "model": "uphold-verified", "method": "manual", "dependencies": [], "rules": { "fullName": { "presence": "required", "editable": true }, "birthdate": { "presence": "required", "editable": true }, "birthplace": { "presence": "disallowed", "editable": false, "rules": { "country": { "presence": "required", "editable": false }, "town": { "presence": "required", "editable": false } } }, "primaryCitizenship": { "presence": "required", "editable": true }, "otherCitizenships": { "presence": "disallowed", "editable": false }, "address": { "presence": "required", "editable": true, "rules": { "subdivision": { "presence": "required", "editable": true }, "city": { "presence": "required", "editable": true }, "line1": { "presence": "required", "editable": true }, "line2": { "presence": "optional", "editable": true }, "postalCode": { "presence": "required", "editable": true } } } } }, "input": { "fullName": "John Doe", "birthdate": "1987-01-01", "primaryCitizenship": "US", "address": { "country": "US", "subdivision": "US-CA", "city": "Los Angeles", "line1": "100 Main Street", "postalCode": "90011" } } } } ``` ```http theme={null} PATCH /core/kyc/processes/profile { "input": { "fullName": "John Doe", "birthdate": "1987-01-01", "primaryCitizenship": "GB", "address": { "city": "Manchester", "line1": "1 High Street", "postalCode": "M4 1AA" } } } ``` A successful response returns the submitted information with an `ok` status, along with `verification.rules` describing which fields apply for this user and whether they can still be edited. ```json Response theme={null} { "profile": { "code": "profile", "status": "ok", "verification": { "model": "uphold-verified", "method": "manual", "dependencies": [], "rules": { "fullName": { "presence": "required", "editable": true }, "birthdate": { "presence": "required", "editable": true }, "birthplace": { "presence": "disallowed", "editable": false, "rules": { "country": { "presence": "required", "editable": false }, "town": { "presence": "required", "editable": false } } }, "primaryCitizenship": { "presence": "required", "editable": true }, "otherCitizenships": { "presence": "disallowed", "editable": false }, "address": { "presence": "required", "editable": true, "rules": { "subdivision": { "presence": "required", "editable": true }, "city": { "presence": "required", "editable": true }, "line1": { "presence": "required", "editable": true }, "line2": { "presence": "optional", "editable": true }, "postalCode": { "presence": "required", "editable": true } } } } }, "input": { "fullName": "John Doe", "birthdate": "1987-01-01", "primaryCitizenship": "GB", "address": { "country": "GB", "subdivision": "GB-MAN", "city": "Manchester", "line1": "1 High Street", "postalCode": "M4 1AA" } } } } ``` Fields that are disallowed or no longer editable for this user (e.g. additional citizenships) don't fail the request — they're silently ignored and listed in a `warnings` object on the response. ```json Response theme={null} { "profile": { // ... profile fields ... }, "warnings": { "input": [ { "code": "verification_rule_violated", "message": "Field 'input.otherCitizenships' was ignored as its presence is disallowed", "details": { "property": "input.otherCitizenships", "rule": "presence" } } ] } } ``` *** ### Identity Verify that the user is the person they claim to be. Your organization performs identity verification through an already-approved method and submits the result to Uphold. Two sub-types are supported: Call [Create file](/rest-apis/core-api/files/create-file) for each document to obtain an upload URL. ```http theme={null} POST /core/files { "category": "image", "contentType": "image/png" } ``` A successful response returns an `upload` object with a presigned URL and form data for uploading the file to the provided storage service. ```json Response theme={null} { "file": { "id": "38cce774-b225-41ed-a9fd-f9c9777a13de", "status": "pending", "category": "image", "contentType": "image/png", "upload": { "url": "https://example.com/upload", "formData": { "X-Amz-Algorithm": "AWS4-HMAC-SHA256", "X-Amz-Credential": "ASIE4FQORBNQHNQD5CG6/20240801/us-east-1/s3/aws4_request", "X-Amz-Date": "20240801T102500Z", "X-Amz-Security-Token": "IQoJb3JpZ2luX2VjEIv//////////...JBr6h2HkzH/aFSArQcs=", "X-Amz-Signature": "b4da8cf1a59327988a8108c965a4789fc8ba2d20f5bffda076106b2b254def29", "Key": "4c13b6f5-987e-43df-bc12-042b58307a80", "Policy": "eyJjb25kaXRpb25zIjpbeyJidWN...TA6MjU6MDAuMjAyWiJ9" }, "expiresAt": "2024-03-13T20:20:39Z" } } } ``` Upload the file to the provided `upload.url` using the returned `upload.formData` fields as multipart form parameters. Call [Update identity](/rest-apis/core-api/kyc/update-identity), referencing the file IDs in `input.media` and setting `output` to the verification results from your provider. ```http theme={null} PATCH /core/kyc/processes/identity { "type": "document-submission", "input": { "media": [ { "context": "photo-document-front", "fileId": "470b2192-893f-4ce6-9daa-816d4c319a84" }, { "context": "photo-selfie", "fileId": "5d3a1c72-bd4f-4e2a-a123-9f8765432100" } ] }, "output": { "provider": "veriff", "document": { "type": "driving-license", "number": "D1234567", "country": "US", "expiresAt": "2026-03-13" }, "person": { "givenName": "John", "familyName": "Doe", "birthdate": "1987-01-01", "gender": "male" }, "verifiedAt": "2025-01-29T10:36:00Z" } } ``` A successful response returns the submitted information with an `ok` status. ```json Response theme={null} { "identity": { "code": "identity", "status": "ok", "type": "document-submission", "verification": { "model": "partner-verified", "method": "manual", "dependencies": ["phone", "profile"], "submittable": false }, "input": { "media": [ { "context": "photo-document-front", "fileId": "470b2192-893f-4ce6-9daa-816d4c319a84" }, { "context": "photo-selfie", "fileId": "5d3a1c72-bd4f-4e2a-a123-9f8765432100" } ] }, "output": { "provider": "veriff", "document": { "type": "driving-license", "number": "D1234567", "country": "US", "expiresAt": "2026-03-13" }, "person": { "givenName": "John", "familyName": "Doe", "birthdate": "1987-01-01", "gender": "male" }, "verifiedAt": "2025-01-29T10:36:00Z" } } } ``` ```http theme={null} PATCH /core/kyc/processes/identity { "type": "document-submission", "input": { "media": [ { "context": "photo-document-front", "fileId": "470b2192-893f-4ce6-9daa-816d4c319a84" }, { "context": "photo-selfie", "fileId": "5d3a1c72-bd4f-4e2a-a123-9f8765432100" } ] }, "output": { "provider": "veriff", "document": { "type": "passport", "number": "7700225VH", "country": "GB", "expiresAt": "2026-03-13" }, "person": { "givenName": "John", "familyName": "Doe", "birthdate": "1987-01-01", "gender": "male" }, "verifiedAt": "2025-01-29T10:36:00Z" } } ``` A successful response returns the submitted information with an `ok` status. ```json Response theme={null} { "identity": { "code": "identity", "status": "ok", "type": "document-submission", "verification": { "model": "partner-verified", "method": "manual", "dependencies": ["phone", "profile"], "submittable": false }, "input": { "media": [ { "context": "photo-document-front", "fileId": "470b2192-893f-4ce6-9daa-816d4c319a84" }, { "context": "photo-selfie", "fileId": "5d3a1c72-bd4f-4e2a-a123-9f8765432100" } ] }, "output": { "provider": "veriff", "document": { "type": "passport", "number": "7700225VH", "country": "GB", "expiresAt": "2026-03-13" }, "person": { "givenName": "John", "familyName": "Doe", "birthdate": "1987-01-01", "gender": "male" }, "verifiedAt": "2025-01-29T10:36:00Z" } } } ``` Your organization performs electronic identity verification through an already-approved provider that checks the user's declared information against public and third-party records, without requiring physical documents, and submits the result to Uphold. Call [Update identity](/rest-apis/core-api/kyc/update-identity), populating `input.data` with the user's declared information and `output` with the provider and verification datetime. ```http theme={null} PATCH /core/kyc/processes/identity { "type": "electronic-verification", "input": { "data": { "givenName": "John", "familyName": "Doe", "birthdate": "1987-01-01", "citizenshipCountry": "US", "address": { "country": "US", "city": "Los Angeles", "line1": "100 Main Street", "postalCode": "90011" } } }, "output": { "provider": "onfido", "verifiedAt": "2025-01-29T10:36:00Z" } } ``` A successful response returns the submitted information with an `ok` status. ```json Response theme={null} { "identity": { "code": "identity", "status": "ok", "type": "electronic-verification", "verification": { "model": "partner-verified", "method": "manual", "dependencies": ["phone", "profile"], "submittable": false }, "input": { "data": { "givenName": "John", "familyName": "Doe", "birthdate": "1987-01-01", "citizenshipCountry": "US", "address": { "country": "US", "city": "Los Angeles", "line1": "100 Main Street", "postalCode": "90011" } } }, "output": { "provider": "onfido", "verifiedAt": "2025-01-29T10:36:00Z" } } } ``` ```http theme={null} PATCH /core/kyc/processes/identity { "type": "electronic-verification", "input": { "data": { "givenName": "John", "familyName": "Doe", "birthdate": "1987-01-01", "citizenshipCountry": "GB", "address": { "country": "GB", "city": "Manchester", "line1": "1 High Street", "postalCode": "M4 1AA" } } }, "output": { "provider": "onfido", "verifiedAt": "2025-01-29T10:36:00Z" } } ``` A successful response returns the submitted information with an `ok` status. ```json Response theme={null} { "identity": { "code": "identity", "status": "ok", "type": "electronic-verification", "verification": { "model": "partner-verified", "method": "manual", "dependencies": ["phone", "profile"], "submittable": false }, "input": { "data": { "givenName": "John", "familyName": "Doe", "birthdate": "1987-01-01", "citizenshipCountry": "GB", "address": { "country": "GB", "city": "Manchester", "line1": "1 High Street", "postalCode": "M4 1AA" } } }, "output": { "provider": "onfido", "verifiedAt": "2025-01-29T10:36:00Z" } } } ``` This type does not unblock the same capabilities as document submission. See the [comparison table](/rest-apis/core-api/kyc/update-identity#when-to-use-document-submission-verification-vs-electronic-verification) for details. Uphold performs identity verification on the user's behalf. Two methods are supported: Launch the Uphold-hosted KYC Widget to collect identity documents and complete verification through a pre-built UI. Ingest an existing identity verification from your KYC provider (Sumsub, Veriff) without building a custom mapping layer. *** ### Proof of address Verify the user's residential address. The process may already be `ok` in certain regions (e.g., US) if the `identity` process was completed with an identity document containing an address. Your organization performs proof-of-address verification through a contracted provider and submits the result to Uphold. Two sub-types are supported: Call [Create file](/rest-apis/core-api/files/create-file) to obtain an upload URL for the document. ```http theme={null} POST /core/files { "category": "document", "contentType": "image/png" } ``` A successful response returns an `upload` object with a presigned URL and form data for uploading the file to the storage provider. ```json Response theme={null} { "file": { "id": "38cce774-b225-41ed-a9fd-f9c9777a13de", "status": "pending", "category": "document", "contentType": "image/png", "upload": { "url": "https://example.com/upload", "formData": { "X-Amz-Algorithm": "AWS4-HMAC-SHA256", "X-Amz-Credential": "ASIE4FQORBNQHNQD5CG6/20240801/us-east-1/s3/aws4_request", "X-Amz-Date": "20240801T102500Z", "X-Amz-Security-Token": "IQoJb3JpZ2luX2VjEIv//////////...JBr6h2HkzH/aFSArQcs=", "X-Amz-Signature": "b4da8cf1a59327988a8108c965a4789fc8ba2d20f5bffda076106b2b254def29", "Key": "4c13b6f5-987e-43df-bc12-042b58307a80", "Policy": "eyJjb25kaXRpb25zIjpbeyJidWN...TA6MjU6MDAuMjAyWiJ9" }, "expiresAt": "2024-03-13T20:20:39Z" } } } ``` Upload the file to the provided `upload.url` using the returned `upload.formData` fields as multipart form parameters. Call [Update proof-of-address](/rest-apis/core-api/kyc/update-proof-of-address), referencing the file ID in `input.media` and setting `output` to the verification results from your provider. ```http theme={null} PATCH /core/kyc/processes/proof-of-address { "type": "document-submission", "input": { "media": [ { "context": "address-proof", "fileId": "459c0447-8916-4bad-bb67-f2354fcfb10b" } ] }, "output": { "provider": "sumsub", "person": { "givenName": "John", "familyName": "Doe", "address": { "country": "US", "subdivision": "US-CA", "city": "Los Angeles", "line1": "100 Main Street", "postalCode": "90011" } }, "verifiedAt": "2025-01-29T10:37:00Z" } } ``` A successful response returns the submitted information with an `ok` status. ```json Response theme={null} { "proofOfAddress": { "code": "proof-of-address", "status": "ok", "type": "document-submission", "verification": { "model": "partner-verified", "method": "manual", "dependencies": ["phone", "profile"] }, "input": { "media": [ { "context": "address-proof", "fileId": "459c0447-8916-4bad-bb67-f2354fcfb10b" } ] }, "output": { "provider": "sumsub", "person": { "givenName": "John", "familyName": "Doe", "address": { "country": "US", "subdivision": "US-CA", "city": "Los Angeles", "line1": "100 Main Street", "postalCode": "90011" } }, "verifiedAt": "2025-01-29T10:37:00Z" } } } ``` ```http theme={null} PATCH /core/kyc/processes/proof-of-address { "type": "document-submission", "input": { "media": [ { "context": "address-proof", "fileId": "459c0447-8916-4bad-bb67-f2354fcfb10b" } ] }, "output": { "provider": "sumsub", "person": { "givenName": "John", "familyName": "Doe", "address": { "country": "GB", "subdivision": "GB-MAN", "city": "Manchester", "line1": "1 High Street", "line2": "Northern Quarter", "postalCode": "M4 1AA" } }, "verifiedAt": "2025-01-29T10:37:00Z" } } ``` A successful response returns the submitted information with an `ok` status. ```json Response theme={null} { "proofOfAddress": { "code": "proof-of-address", "status": "ok", "type": "document-submission", "verification": { "model": "partner-verified", "method": "manual", "dependencies": ["phone", "profile"] }, "input": { "media": [ { "context": "address-proof", "fileId": "459c0447-8916-4bad-bb67-f2354fcfb10b" } ] }, "output": { "provider": "sumsub", "person": { "givenName": "John", "familyName": "Doe", "address": { "country": "GB", "subdivision": "GB-MAN", "city": "Manchester", "line1": "1 High Street", "line2": "Northern Quarter", "postalCode": "M4 1AA" } }, "verifiedAt": "2025-01-29T10:37:00Z" } } } ``` Some KYC providers can verify the user's address electronically, without requiring a physical document, by checking the declared address against public and third-party records. Call [Update proof-of-address](/rest-apis/core-api/kyc/update-proof-of-address), populating `input.data` with the user's declared information and `output` with the provider and verification datetime. ```http theme={null} PATCH /core/kyc/processes/proof-of-address { "type": "electronic-verification", "input": { "data": { "givenName": "John", "familyName": "Doe", "address": { "country": "US", "subdivision": "US-CA", "city": "Los Angeles", "line1": "100 Main Street", "postalCode": "90011" } } }, "output": { "provider": "sumsub", "verifiedAt": "2025-01-29T10:37:00Z" } } ``` A successful response returns the submitted information with an `ok` status. ```json Response theme={null} { "proofOfAddress": { "code": "proof-of-address", "status": "ok", "type": "electronic-verification", "verification": { "model": "partner-verified", "method": "manual", "dependencies": ["phone", "profile"] }, "input": { "data": { "givenName": "John", "familyName": "Doe", "address": { "country": "US", "subdivision": "US-CA", "city": "Los Angeles", "line1": "100 Main Street", "postalCode": "90011" } } }, "output": { "provider": "sumsub", "verifiedAt": "2025-01-29T10:37:00Z" } } } ``` ```http theme={null} PATCH /core/kyc/processes/proof-of-address { "type": "electronic-verification", "input": { "data": { "givenName": "John", "familyName": "Doe", "address": { "country": "GB", "subdivision": "GB-MAN", "city": "Manchester", "line1": "1 High Street", "line2": "Northern Quarter", "postalCode": "M4 1AA" } } }, "output": { "provider": "sumsub", "verifiedAt": "2025-01-29T10:37:00Z" } } ``` A successful response returns the submitted information with an `ok` status. ```json Response theme={null} { "proofOfAddress": { "code": "proof-of-address", "status": "ok", "type": "electronic-verification", "verification": { "model": "partner-verified", "method": "manual", "dependencies": ["phone", "profile"] }, "input": { "data": { "givenName": "John", "familyName": "Doe", "address": { "country": "GB", "subdivision": "GB-MAN", "city": "Manchester", "line1": "1 High Street", "line2": "Northern Quarter", "postalCode": "M4 1AA" } } }, "output": { "provider": "sumsub", "verifiedAt": "2025-01-29T10:37:00Z" } } } ``` Uphold performs proof-of-address verification on the user's behalf. Two methods are supported: Launch the Uphold-hosted KYC Widget to collect proof-of-address documents and complete verification through a pre-built UI. Ingest an existing proof-of-address verification from your KYC provider (Sumsub, Veriff) without building a custom mapping layer. *** ### Customer due diligence Assess the user's financial profile and risk level based on their expected activities, source of funds, and other relevant information. Uphold performs customer due diligence checks based on the user's profile and declared intent. Call [Update customer due diligence](/rest-apis/core-api/kyc/update-customer-due-diligence) with `input` containing the answers for each form step, repeating until `status` returns `ok`. Customer due diligence is a form-based process with multiple steps. The required fields may vary based on the user's region, expected activities, and other factors. Always check the returned `hint` object for the specific schema to display. See [Dynamic forms](/developer-guides/resources/dynamic-forms/introduction) for guidance on rendering the form and submitting the data. ```http theme={null} PATCH /core/kyc/processes/customer-due-diligence { "input": { "formId": "ae80271a-a3f0-453b-8dc6-36b777817217", "answers": { "intent": { "expected-activities": [ "cryptocurrency_investing", "deposit_withdraw_crypto" ] } } } } ``` A successful response returns the submitted information with an `ok` status, along with the calculated risk score and verification datetime. ```json Response theme={null} { "customerDueDiligence": { "code": "customer-due-diligence", "status": "ok", "verification": { "model": "uphold-verified", "method": "manual", "dependencies": [] }, "input": { "formId": "ae80271a-a3f0-453b-8dc6-36b777817217", "answers": { "intent": { "expected-activities": [ "cryptocurrency_investing", "deposit_withdraw_crypto" ] }, "financial-information": { "source-of-funds": "salary", "annual-income-range": "40000-60000-GBP", "expected-annual-deposits-range": "0-5000-GBP" }, "employment": { "status": "employed" }, "employment-details": { "industry": "engineering" } } }, "output": { "score": "low", "expiresAt": "2025-01-01T00:00:00Z", "verifiedAt": "2022-01-01T00:00:00Z" } } } ``` **Understanding score** The `output.score` reflects the user's assessed risk level. The higher the score, the shorter the recollection period before the user must resubmit. **Understanding expiration** The `output.expiresAt` is the deadline by which the user must resubmit customer due diligence — it opens for recollection 60 days before that date, at which point the process status reverts to `pending`. The user remains approved during this window and can continue to operate without restrictions until the deadline is reached. Your organization collects the user's financial profile information, calculates the user's risk score, then submits the result to Uphold. Call [Update customer due diligence](/rest-apis/core-api/kyc/update-customer-due-diligence) with `input` containing the user's financial profile information and `output` containing the calculated risk score and verification datetime. ```http theme={null} PATCH /core/kyc/processes/customer-due-diligence { "input": { "answers": { "financial-profile": { "expected-activities": [ "cryptocurrency_investing" ], "source-of-funds": "salary", "annual-income-range": "40000-60000-GBP", "savings-and-investments-range": "0-5000-GBP", "employment-status": "employed", "employment-industry": "Engineering", "employment-occupation": "Galactic Vibe Curator" } } }, "output": { "score": "low", "expiresAt": "2026-01-01T00:00:00Z", "verifiedAt": "2023-01-01T00:00:00Z" } } ``` A successful response returns the submitted information with an `ok` status. ```json Response theme={null} { "customerDueDiligence": { "code": "customer-due-diligence", "status": "ok", "verification": { "model": "partner-verified", "method": "manual", "dependencies": [] }, "input": { "formId": "ae80271a-a3f0-453b-8dc6-36b777817217", "answers": { "financial-profile": { "expected-activities": [ "cryptocurrency_investing" ], "source-of-funds": "salary", "annual-income-range": "40000-60000-GBP", "savings-and-investments-range": "0-5000-GBP", "employment-status": "employed", "employment-industry": "Engineering", "employment-occupation": "Galactic Vibe Curator" } } }, "output": { "score": "low", "expiresAt": "2026-01-01T00:00:00Z", "verifiedAt": "2023-01-01T00:00:00Z" } } } ``` **Understanding expiration** Even though `output.expiresAt` is set by your organization, the process will automatically re-open for recollection 60 days before that date, reverting to `pending` until the user resubmits. The user remains approved during this window and can continue to operate without restrictions until the deadline is reached. *** ### Enhanced due diligence Enhanced due diligence is an additional layer of verification required for users with a high customer due diligence score. Your organization performs enhanced due diligence through an already-approved method, such as manual review of source-of-funds document (e.g. pay stub, bank statement, portfolio statement), and submits the result to Uphold. Call [Create file](/rest-apis/core-api/files/create-file) to obtain an upload URL for the source-of-funds document. ```http theme={null} POST /core/files { "category": "document", "contentType": "image/png" } ``` A successful response returns an `upload` object with a presigned URL and form data for uploading the file to the storage provider. ```json Response theme={null} { "file": { "id": "57a3ecf2-5404-4654-9ece-feb504a69f86", "status": "pending", "category": "document", "contentType": "image/png", "upload": { "url": "https://example.com/upload", "formData": { "X-Amz-Algorithm": "AWS4-HMAC-SHA256", "X-Amz-Credential": "ASIE4FQORBNQHNQD5CG6/20240801/us-east-1/s3/aws4_request", "X-Amz-Date": "20240801T102500Z", "X-Amz-Security-Token": "IQoJb3JpZ2luX2VjEIv//////////...JBr6h2HkzH/aFSArQcs=", "X-Amz-Signature": "b4da8cf1a59327988a8108c965a4789fc8ba2d20f5bffda076106b2b254def29", "Key": "4c13b6f5-987e-43df-bc12-042b58307a80", "Policy": "eyJjb25kaXRpb25zIjpbeyJidWN...TA6MjU6MDAuMjAyWiJ9" }, "expiresAt": "2024-03-13T20:20:39Z" } } } ``` Upload the file to the provided `upload.url` using the returned `upload.formData` fields as multipart form parameters. Call [Update enhanced due diligence](/rest-apis/core-api/kyc/update-enhanced-due-diligence), referencing the file ID in `input.media` and setting `output` containing the verification datetime. ```http theme={null} PATCH /core/kyc/processes/enhanced-due-diligence { "input": { "media": [ { "context": "source-of-funds-proof", "fileId": "57a3ecf2-5404-4654-9ece-feb504a69f86" } ] }, "output": { "verifiedAt": "2025-01-29T10:38:00Z" } } ``` A successful response returns the submitted information with an `ok` status. ```json Response theme={null} { "enhancedDueDiligence": { "code": "enhanced-due-diligence", "status": "ok", "verification": { "model": "partner-verified", "method": "manual", "dependencies": ["customer-due-diligence"] }, "input": { "media": [ { "context": "source-of-funds-proof", "fileId": "57a3ecf2-5404-4654-9ece-feb504a69f86" } ] }, "output": { "verifiedAt": "2025-01-29T10:38:00Z" } } } ``` This mode is under construction. Contact your Account Manager for the latest availability. *** ### Crypto risk assessment Assess the user's knowledge of cryptocurrency risks and their suitability for engaging in crypto-related activities. **UK users only** - Financial Conduct Authority regulatory requirements Uphold administers a crypto risk assessment quiz to the user, with a different set of questions per attempt, and evaluates the answers to determine whether the user is approved for crypto-related activities. Call [Update crypto risk assessment](/rest-apis/core-api/kyc/update-crypto-risk-assessment) with `input` containing the answers for each form step, repeating until `output` is updated. Crypto risk assessment is a form-based process with multiple steps. The required fields may vary based on the user's region, current attempt, and other factors. Always check the returned `hint` object for the specific schema to display. See [Dynamic forms](/developer-guides/resources/dynamic-forms/introduction) for guidance on rendering the form and submitting the data. ```http theme={null} PATCH /core/kyc/processes/crypto-risk-assessment { "input": { "formId": "7a0f4229-e3de-4dfd-8f91-9b1308b2dc33", "answers": { "decision-making": { "who-makes-investment-decisions": "i_decide" } } } } ``` A successful response returns the submitted information with an `ok` status, along with approval result, attempts used and remaining, and verification datetime. ```json Response theme={null} { "cryptoRiskAssessment": { "code": "crypto-risk-assessment", "status": "ok", "verification": { "model": "uphold-verified", "method": "manual", "dependencies": [] }, "input": { "formId": "7a0f4229-e3de-4dfd-8f91-9b1308b2dc33", "answers": { "decision-making": { "who-makes-investment-decisions": "i_decide" }, "loss-tolerance": { "acceptable-loss": "full_loss_possible" }, "volatility-understanding": { "expected-price-behaviour": "highly_volatile" }, "liquidity-awareness": { "selling-timeline": "may_be_delayed" }, "regulatory-protection": { "compensation-coverage": "no_coverage" }, "portfolio-allocation": { "share-of-net-assets": "small_share" }, "complexity-acknowledgement": { "product-mechanics": "complex_and_risky" } } }, "output": { "result": "approved", "attempts": { "used": 1, "maximum": 5 }, "verifiedAt": "2022-01-01T00:00:00Z" } } } ``` **Understanding result** When the `output.result` is `approved`, the user is cleared for crypto-related activities. When `rejected`, the user can either retry the quiz (when attempts remain) or will be offboarded (when the maximum attempts have been reached). **Understanding attempts** Use the `output.attempts` object to track how many attempts the user has made and the maximum allowed. When a cooldown is active, `retryAt` is present and indicates the earliest datetime the user can submit a new attempt. ```json theme={null} { "attempts": { "used": 1, "maximum": 5, "retryAt": "2025-01-29T12:00:00Z" } } ``` **Understanding offboarding** If a failed attempt occurs, the `output.offboard` object is present. `endsAt` indicates either the deadline by which the user must submit a new attempt (when attempts remain), or the expected offboarding datetime (when the maximum has been reached). `completedAt` is set to the effective datetime when the user was offboarded. ```json theme={null} { "offboard": { "endsAt": "2025-06-29T00:00:00Z", "completedAt": "2025-01-29T12:00:00Z" } } ``` Your organization administers a crypto risk assessment quiz to the user and submits the results to Uphold. Call [Update crypto risk assessment](/rest-apis/core-api/kyc/update-crypto-risk-assessment) with `input` containing the form answers and `output` containing the approval result, attempts used and remaining, and verification datetime. ```http theme={null} PATCH /core/kyc/processes/crypto-risk-assessment { "input": { "answers": { "crypto-quiz": { "decision-making": "i_decide", "loss-tolerance": "full_loss_possible", "volatility-understanding": "highly_volatile", "liquidity-awareness": "may_be_delayed", "regulatory-protection": "no_coverage", "portfolio-allocation": "small_share", "complexity-acknowledgement": "complex_and_risky" } } }, "output": { "result": "approved", "attempts": { "used": 1, "maximum": 5 }, "verifiedAt": "2023-01-01T00:00:00Z" } } ``` A successful response returns the submitted information with an `ok` status. ```json Response theme={null} { "cryptoRiskAssessment": { "code": "crypto-risk-assessment", "status": "ok", "verification": { "model": "partner-verified", "method": "manual", "dependencies": [] }, "input": { "formId": "7a0f4229-e3de-4dfd-8f91-9b1308b2dc33", "answers": { "crypto-quiz": { "decision-making": "i_decide", "loss-tolerance": "full_loss_possible", "volatility-understanding": "highly_volatile", "liquidity-awareness": "may_be_delayed", "regulatory-protection": "no_coverage", "portfolio-allocation": "small_share", "complexity-acknowledgement": "complex_and_risky" } } }, "output": { "result": "approved", "attempts": { "used": 1, "maximum": 5 }, "verifiedAt": "2023-01-01T00:00:00Z" } } } ``` *** ### Self-categorization statement Collect the user's self-declared financial profile and categorize them accordingly. **UK users only** - Financial Conduct Authority regulatory requirements Uphold administers a self-categorization statement form to the user, and evaluates the answers to determine the user's category. Call [Update self-categorization statement](/rest-apis/core-api/kyc/update-self-categorization-statement) with `input` containing the answers for each form step, repeating until `output` is updated. Self-categorization statement is a form-based process with multiple steps. The required fields may vary based on the user's region, category, and other factors. Always check the returned `hint` object for the specific schema to display. See [Dynamic forms](/developer-guides/resources/dynamic-forms/introduction) for guidance on rendering the form and submitting the data. ```http theme={null} PATCH /core/kyc/processes/self-categorization-statement { "input": { "formId": "ac33651f-f2d3-47c4-8e8d-06fb87361f5c", "answers": { "investor": { "type": "restricted_investor" } } } } ``` A successful response returns the submitted information with an `ok` status, along with the categorization result and verification datetime. ```json Response theme={null} { "selfCategorizationStatement": { "code": "self-categorization-statement", "status": "ok", "verification": { "model": "uphold-verified", "method": "manual", "dependencies": [] }, "input": { "formId": "ac33651f-f2d3-47c4-8e8d-06fb87361f5c", "answers": { "investor": { "type": "restricted_investor" }, "investor-profile": { "invested-less-than-10-percent": "yes", "invested-percentage": "5", "invest-less-than-10-percent": "yes", "invest-percentage": "5", "investor-type-confirmation": true } } }, "output": { "result": "approved", "attempts": { "used": 1, "maximum": 30 }, "expiresAt": "2025-01-01T00:00:00Z", "verifiedAt": "2022-01-01T00:00:00Z" } } } ``` **Understanding result** When the `output.result` is `approved`, the user is cleared for the activities allowed for their category. When `rejected`, the user can either retry the statement (when attempts remain) or will be offboarded (when the maximum attempts have been reached). **Understanding attempts** Use the `output.attempts` object to track how many attempts the user has made and the maximum allowed. ```json theme={null} { "attempts": { "used": 1, "maximum": 30 } } ``` **Understanding expiration** The `output.expiresAt` is the deadline by which the user must resubmit the self-categorization statement — it opens for recollection 60 days before that date, at which point the process status reverts to `pending`. The user remains approved during this window and can continue to operate without restrictions until the deadline is reached. Your organization administers a self-categorization statement form to the user and submits the results to Uphold. Call [Update self-categorization statement](/rest-apis/core-api/kyc/update-self-categorization-statement) with `input` containing the form answers and `output` containing the categorization result, attempts used and remaining, expiration, and verification datetime. ```http theme={null} PATCH /core/kyc/processes/self-categorization-statement { "input": { "answers": { "investor-category": { "type": "high_net_worth_investor", "annual-income-above-100000": "yes", "annual-income": 120000, "net-assets-above-250000": "yes", "net-assets": 255000 } } }, "output": { "result": "approved", "attempts": { "used": 1, "maximum": 30 }, "expiresAt": "2026-01-01T00:00:00Z", "verifiedAt": "2023-01-01T00:00:00Z" } } ``` A successful response returns the submitted information with an `ok` status. ```json Response theme={null} { "selfCategorizationStatement": { "code": "self-categorization-statement", "status": "ok", "verification": { "model": "partner-verified", "method": "manual", "dependencies": [] }, "input": { "answers": { "investor-category": { "type": "high_net_worth_investor", "annual-income-above-100000": "yes", "annual-income": 120000, "net-assets-above-250000": "yes", "net-assets": 255000 } } }, "output": { "result": "approved", "attempts": { "used": 1, "maximum": 30 }, "expiresAt": "2026-01-01T00:00:00Z", "verifiedAt": "2023-01-01T00:00:00Z" } } } ``` **Understanding expiration** Even though `output.expiresAt` is set by your organization, the process will automatically re-open for recollection 60 days before that date, reverting to `pending` until the user resubmits. The user remains approved during this window and can continue to operate without restrictions until the deadline is reached. *** ### Tax details Collect the user's tax residency and tax identification information for tax reporting purposes. Uphold collects tax details through a two-step form. The first step asks for the user's countries of tax residency. Based on the response, the second step requests the required tax identification document numbers for each specified country. See [Dynamic forms](/developer-guides/resources/dynamic-forms/introduction) for guidance on rendering the form and submitting the data. Call [Update tax details](/rest-apis/core-api/kyc/update-tax-details) with the countries where the user is tax resident. The required information varies based on the user's region. Always check the form schema returned in the `hint` dynamically to determine which fields to collect. Not required for US. Uphold's internal rules allow only one tax residency for US users, so `taxResidency.countries` is already pre-filled with `["US"]`. Skip straight to the next step. ```http theme={null} PATCH /core/kyc/processes/tax-details { "input": { "taxResidency": { "countries": ["GB"] } } } ``` The response returns `pending` with the next form step in `hint`, pre-filled with the required tax identification documents for each declared country. ```json Response theme={null} { "taxDetails": { "code": "tax-details", "status": "pending", "verification": { "model": "uphold-verified", "method": "manual", "dependencies": ["profile"] }, "input": { "taxResidency": { "countries": ["GB"] }, "taxIdentification": { "documents": [ { "type": "tin", "country": "GB" } ] } }, "hint": { "type": "form", "schema": { "type": "object", "properties": { /* ... */ } }, "uiSchema": { "type": "Categorization", "elements": [ /* ... */ ] } } } } ``` Call [Update tax details](/rest-apis/core-api/kyc/update-tax-details) again with the tax identification document numbers and certification of the information's accuracy. The required information varies based on the user's region. Always check the form schema returned in the `hint` dynamically to determine which fields to collect. ```http theme={null} PATCH /core/kyc/processes/tax-details { "input": { "taxIdentification": { "documents": [ { "type": "tin", "country": "US", "value": "123456789" } ], "signature": "John Doe", "certify": true } } } ``` The `signature` must match the user's legal name. A successful response returns the submitted information with an `ok` status. The form is always returned — even when the process is `ok` — so the user can update their tax details when needed. ```json Response theme={null} { "taxDetails": { "code": "tax-details", "status": "ok", "verification": { "model": "uphold-verified", "method": "manual", "dependencies": ["profile"] }, "input": { "taxResidency": { "countries": [ "US" ] }, "taxIdentification": { "documents": [ { "type": "tin", "country": "US", "number": "123456789" } ], "signature": "John Doe", "certify": true } }, "output": { "result": "approved", "verifiedAt": "2022-01-01T00:00:00Z" }, "hint": { "type": "form", "schema": { "type": "object", "properties": { /* ... */ } }, "uiSchema": { "type": "Categorization", "elements": [ /* ... */ ] } } } } ``` ```http theme={null} PATCH /core/kyc/processes/tax-details { "input": { "taxIdentification": { "documents": [ { "type": "tin", "country": "GB", "value": "1234567890" } ], "certify": true } } } ``` A successful response returns the submitted information with an `ok` status. The form is always returned — even when the process is `ok` — so the user can update their tax details when needed. ```json Response theme={null} { "taxDetails": { "code": "tax-details", "status": "ok", "verification": { "model": "uphold-verified", "method": "manual", "dependencies": ["profile"] }, "input": { "taxResidency": { "countries": [ "GB" ] }, "taxIdentification": { "documents": [ { "type": "tin", "country": "GB", "number": "6405444702" } ], "certify": true } }, "output": { "result": "approved", "verifiedAt": "2022-01-01T00:00:00Z" }, "hint": { "type": "form", "schema": { "type": "object", "properties": { /* ... */ } }, "uiSchema": { "type": "Categorization", "elements": [ /* ... */ ] } } } } ``` This mode is under construction. Contact your Account Manager for the latest availability. *** ### Screening and risk Uphold automatically screens users against sanctions lists and assesses their risk level based on various factors, such as their location, transaction patterns, and other relevant data. ```json theme={null} { "screening": { "code": "screening", "status": "ok", "verification": { "model": "uphold-verified", "method": "automatic", "triggers": ["profile", "identity"], "dependencies": [] }, "output": { "result": "approved" } }, "risk": { "code": "risk", "status": "ok", "verification": { "model": "uphold-verified", "method": "automatic", "dependencies": [] }, "output": { "result": "approved" } } } ``` **Understanding result** When the `output.result` is `approved`, the user has passed screening or risk assessment. When `rejected`, the user has been flagged for potential issues and may require further review or offboarding. ## Monitor user onboarding Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [core.user.created](/rest-apis/core-api/users/webhooks/user-created) * [core.kyc.\*.status-changed](/rest-apis/core-api/kyc/webhooks) * `status: pending` → waiting for the user to provide information * `status: running` → Uphold is processing the provided information * `status: ok` → the process has been successfully verified * `status: failed` → verification failed — the user may need to resubmit * `status: exempt` → the process is not required for this user * Polling (fallback): [Get KYC overview](/rest-apis/core-api/kyc/get-overview) ## Check capabilities Always check if the required capabilities for the user's intended activities are unlocked before allowing them to transact. Call [List capabilities](/rest-apis/core-api/capabilities/list-user-capabilities) to check restrictions and requirements for each capability. ```http theme={null} GET /core/capabilities ``` The response includes the list of capabilities with any outstanding `requirements` or `restrictions`. ```json Response theme={null} { "capabilities": [ { "key": "crypto-deposits", "enabled": true, "requirements": [], "restrictions": [] }, { "key": "crypto-withdrawals", "enabled": true, "requirements": [ "user-must-submit-identity" ], "restrictions": [] } ] } ``` The user is now onboarded and ready to transact. # Onboard individual users via the KYC Connector Source: https://developer.uphold.com/developer-guides/user-onboarding/individual/via-kyc-connector Use the Uphold KYC Connector to ingest verifications from Sumsub or Veriff, mapping provider payloads to KYC processes without bespoke integration work. The [KYC Connector](/rest-apis/kyc-connector-api/introduction) is an ingestion layer between third-party KYC providers and Uphold's platform. Instead of manually mapping provider-specific payloads to Uphold's KYC model, you create an **ingestion** and the KYC Connector handles extraction and submission on your behalf. The KYC Connector covers four processes: `profile`, `address`, `identity`, and `proofOfAddress`. Any remaining required processes (e.g. `customerDueDiligence`, `taxDetails`) must still be completed via the [REST API](/developer-guides/user-onboarding/individual/via-api). ## Prerequisites * API client credentials with permission to create users and create ingestions. * Your Sumsub account [must be configured](/rest-apis/kyc-connector-api/sumsub/overview#setup-requirements) for KYC data sharing. ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant U as Client App participant B as Your Backend participant A as Core API participant K as KYC Connector API participant S as KYC Provider participant W as Webhooks U->>B: User provides personal, address, and contact details B->>A: GET /core/terms-of-service?type=general&country={country} A-->>B: { termsOfService[] } B-->>U: Display Terms of Service U->>B: User accepts Terms of Service B->>A: POST /core/users (with ToS + X-Uphold-User-Ip) A-->>B: { user } B-->>U: User completes verification (existing provider flow) U->>S: User completes KYC (identity, proof-of-address) S-->>B: Applicant approved (webhook or callback) B->>S: Generate share token for applicant S-->>B: { shareToken } B->>K: POST /kyc-connector/sumsub/ingestions (shareToken + processes) K-->>B: { ingestion } A-->>W: kyc-connector.sumsub.ingestion.status-changed (running → finished) B->>A: GET /core/kyc (verify process statuses) B->>A: PATCH /core/kyc/* (complete remaining processes via API) B->>A: GET /core/capabilities A-->>B: { capabilities[] } B-->>U: User is ready to transact ``` ## Create the user The user creation step is identical to the [REST API approach](/developer-guides/user-onboarding/individual/via-api#create-the-user): fetch the applicable Terms of Service, display them to the user, and call [Create User](/rest-apis/core-api/users/create-user): ```http theme={null} GET /core/terms-of-service?type=general&country={country} POST /core/users ``` The `X-Uphold-User-Ip` [user context](/rest-apis/headers#user-context) header is **mandatory** when creating a user. ## Collect KYC data Your existing KYC provider flow requires no changes. Once the user completes verification and the applicant is approved, generate a **share token** from your backend. This token authorizes Uphold to copy the applicant's KYC data from your provider account. ## Create an ingestion Call [Create ingestion](/rest-apis/kyc-connector-api/sumsub/create-ingestion) with the share token and the list of KYC processes you want to ingest. ```json theme={null} POST /kyc-connector/sumsub/ingestions { "shareToken": "_act-sbx-jwt-eyJhbGciOiJub25lIn0.eyJqdGkiOiJ0ZXN0IiwidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbSJ9.", "processes": [ "identity", "proof-of-address" ] } ``` A successful response returns an `ingestion` object with details about the ingestion and its initial status: ```json Response theme={null} { "ingestion": { "id": "019b2184-1ca9-7dd6-b7a5-ec242def68b0", "userId": "123e4567-e89b-12d3-a456-426614174000", "status": "queued", "processes": [ "identity", "proof-of-address" ], "provider": "sumsub", "parameters": { "shareToken": "_act-sbx-jwt-eyJhbGciOiJub25lIn0.eyJqdGkiOiJ0ZXN0IiwidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbSJ9." }, "createdAt": "2026-01-15T14:23:01.819Z", "updatedAt": "2026-01-15T14:23:01.819Z" } } ``` ## Monitor the ingestion Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [kyc-connector.sumsub.ingestion.created](/rest-apis/kyc-connector-api/sumsub/webhooks/ingestion-created) * [kyc-connector.sumsub.ingestion.status-changed](/rest-apis/kyc-connector-api/sumsub/webhooks/ingestion-status-changed) * `status: queued` → ingestion created, waiting to be picked up * `status: running` → fetching and submitting provider data to Uphold * `status: finished` → processing complete * Polling (fallback): [Get ingestion](/rest-apis/kyc-connector-api/sumsub/get-ingestion) When the ingestion reaches `finished`, inspect the `result` field for each process. A `completed` result means the data was successfully extracted and submitted to Uphold — it does **not** mean the KYC process itself has been verified. Always confirm KYC process statuses using [Get KYC Overview](/rest-apis/core-api/kyc/get-overview). ## Complete onboarding process Once the ingestion finishes, the KYC Connector has submitted only the processes it supports (`profile`, `address`, `identity`, `proof-of-address`). Use [Get KYC Overview](/rest-apis/core-api/kyc/get-overview) to confirm which processes are now `ok` and identify what still needs to be collected. Any remaining required processes (e.g. `customerDueDiligence`, `taxDetails`) must still be completed. Check required processes and prompt the user to submit any outstanding ones. Call the relevant endpoints to submit the data and update process statuses until all are `ok`. Embed the KYC Widget to let the user complete the remaining processes through a pre-built UI. ## Check capabilities Always check if the required capabilities for the user's intended activities are unlocked before allowing them to transact. Call [List capabilities](/rest-apis/core-api/capabilities/list-user-capabilities) to check restrictions and requirements for each capability. ```http theme={null} GET /core/capabilities ``` The response includes the list of capabilities with any outstanding `requirements` or `restrictions`. ```json Response theme={null} { "capabilities": [ { "key": "crypto-deposits", "enabled": true, "requirements": [], "restrictions": [] }, { "key": "crypto-withdrawals", "enabled": true, "requirements": [ "user-must-submit-identity" ], "restrictions": [] } ] } ``` The user is now onboarded and ready to transact. ## Prerequisites * API client credentials with permission to create users, manage Veriff config, and create ingestions. * One or more Veriff integrations configured with the appropriate verification flows (IDV, PoA). ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant U as Client App participant B as Your Backend participant A as Core API participant K as KYC Connector API participant V as Veriff participant W as Webhooks B->>K: PUT /kyc-connector/veriff/config (one-time setup) K-->>B: { config } U->>B: User provides country of residence B->>A: GET /core/terms-of-service?type=general&country={country} A-->>B: { termsOfService[] } B-->>U: Display Terms of Service U->>B: User accepts Terms of Service B->>A: POST /core/users (with ToS + X-Uphold-User-Ip) A-->>B: { user } B-->>U: User completes verification (existing provider flow) U->>V: User completes KYC (identity, proof-of-address) V-->>B: Session approved (webhook or callback) B->>K: POST /kyc-connector/veriff/ingestions (sessions + processes) K-->>B: { ingestion } A-->>W: kyc-connector.veriff.ingestion.status-changed (running → finished) B->>A: GET /core/kyc (verify process statuses) B->>A: PATCH /core/kyc/* (complete remaining processes via API) B->>A: GET /core/capabilities A-->>B: { capabilities[] } B-->>U: User is ready to transact ``` ## Configure the provider Before creating any ingestions, you must register your Veriff integration credentials with Uphold. This is a **one-time setup** (or whenever you need to update credentials or integrations). Call [Set Veriff config](/rest-apis/kyc-connector-api/veriff/set-config) with your integration details: ```json theme={null} PUT /kyc-connector/veriff/config { "integrations": [ { "name": "my-idv-integration", "apiKey": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "sharedSecretKey": "f0e1d2c3-b4a5-6789-0fed-cba987654321", "processes": [ "profile", "identity" ] }, { "name": "my-poa-integration", "apiKey": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "sharedSecretKey": "e1d2c3b4-a596-7890-fedc-ba9876543210", "processes": [ "address", "proof-of-address" ] } ] } ``` You can configure multiple integrations if you use separate Veriff integrations for different processes (e.g. one for IDV, another for PoA). ## Create the user The user creation step is identical to the [REST API approach](/developer-guides/user-onboarding/individual/via-api#create-the-user): fetch the applicable Terms of Service, display them to the user, and call [Create User](/rest-apis/core-api/users/create-user): ```http theme={null} GET /core/terms-of-service?type=general&country={country} POST /core/users ``` The `X-Uphold-User-Ip` [user context](/rest-apis/headers#user-context) header is **mandatory** when creating a user. ## Collect KYC data Your existing Veriff verification flow requires no changes. Once the user completes verification and the session is approved, you will have the **session ID** ready to use. ## Create an ingestion Call [Create ingestion](/rest-apis/kyc-connector-api/veriff/create-ingestion) with the session details and the list of KYC processes you want to ingest. Each session references an **integration name** from your provider configuration and the **session ID** from Veriff. ```json theme={null} POST /kyc-connector/veriff/ingestions { "processes": [ "profile", "address", "identity", "proof-of-address" ], "sessions": [ { "sessionId": "550e8400-e29b-41d4-a716-446655440000", "integrationName": "my-idv-integration", "processes": [ "profile", "identity" ] }, { "sessionId": "661f9511-f30c-52e5-b827-557766551111", "integrationName": "my-poa-integration", "processes": [ "address", "proof-of-address" ] } ] } ``` A successful response returns an `ingestion` object with details about the ingestion and its initial status: ```json Response theme={null} { "ingestion": { "id": "019b2184-1ca9-7dd6-b7a5-ec242def68b0", "userId": "123e4567-e89b-12d3-a456-426614174000", "status": "queued", "processes": [ "profile", "address", "identity", "proof-of-address" ], "provider": "veriff", "parameters": { "sessions": [ { "sessionId": "550e8400-e29b-41d4-a716-446655440000", "integrationName": "my-idv-integration", "processes": [ "profile", "identity" ] }, { "sessionId": "661f9511-f30c-52e5-b827-557766551111", "integrationName": "my-poa-integration", "processes": [ "address", "proof-of-address" ] } ] }, "createdAt": "2026-01-15T14:23:01.819Z", "updatedAt": "2026-01-15T14:23:01.819Z" } } ``` ## Monitor the ingestion Prefer **webhooks** for real-time updates, or fall back to **polling** if webhooks are not feasible. * Webhook events (recommended): * [kyc-connector.veriff.ingestion.created](/rest-apis/kyc-connector-api/veriff/webhooks/ingestion-created) * [kyc-connector.veriff.ingestion.status-changed](/rest-apis/kyc-connector-api/veriff/webhooks/ingestion-status-changed) * `status: queued` → ingestion created, waiting to be picked up * `status: running` → fetching and submitting provider data to Uphold * `status: finished` → processing complete * Polling (fallback): [Get ingestion](/rest-apis/kyc-connector-api/veriff/get-ingestion) When the ingestion reaches `finished`, inspect the `result` field for each process. A `completed` result means the data was successfully extracted and submitted to Uphold — it does **not** mean the KYC process itself has been verified. Always confirm KYC process statuses using [Get KYC Overview](/rest-apis/core-api/kyc/get-overview). ## Complete onboarding process Once the ingestion finishes, the KYC Connector has submitted only the processes it supports (`profile`, `address`, `identity`, `proof-of-address`). Use [Get KYC Overview](/rest-apis/core-api/kyc/get-overview) to confirm which processes are now `ok` and identify what still needs to be collected. Any remaining required processes (e.g. `customerDueDiligence`, `taxDetails`) must still be completed. Check required processes and prompt the user to submit any outstanding ones. Call the relevant endpoints to submit the data and update process statuses until all are `ok`. Embed the KYC Widget to let the user complete the remaining processes through a pre-built UI. ## Check capabilities Always check if the required capabilities for the user's intended activities are unlocked before allowing them to transact. Call [List capabilities](/rest-apis/core-api/capabilities/list-user-capabilities) to check restrictions and requirements for each capability. ```http theme={null} GET /core/capabilities ``` The response includes the list of capabilities with any outstanding `requirements` or `restrictions`. ```json Response theme={null} { "capabilities": [ { "key": "crypto-deposits", "enabled": true, "requirements": [], "restrictions": [] }, { "key": "crypto-withdrawals", "enabled": true, "requirements": [ "user-must-submit-identity" ], "restrictions": [] } ] } ``` The user is now onboarded and ready to transact. # Onboard individual users via the KYC Widget Source: https://developer.uphold.com/developer-guides/user-onboarding/individual/via-kyc-widget Collect KYC data from individual users using the Uphold KYC Widget, without building a custom verification interface. The [KYC Widget](/widgets/kyc/introduction) is an embeddable UI component for collecting KYC data from individual users. Instead of building and maintaining a custom verification interface, you embed the Widget and it handles forms, document uploads, and process state on your behalf. It's fully customizable — themes, fonts, and brand colors — so it can match your app's look and feel. See the [KYC Widget SDK reference](/widgets/kyc/sdk-reference) for the full configuration reference. The KYC Widget currently covers three processes: `identity`, `profile`, and `proof-of-address`. Any remaining required processes (e.g. `customerDueDiligence`, `enhancedDueDiligence`, `taxDetails`) must still be completed via the [REST API](/developer-guides/user-onboarding/individual/via-api) or the [KYC Connector](/developer-guides/user-onboarding/individual/via-kyc-connector). ## Prerequisites * API client credentials with the scopes to create users and access the KYC Widget. * The KYC Widget SDK is installed in your frontend. See [Installation and setup](/widgets/kyc/installation-and-setup). ## Walkthrough ```mermaid theme={null} sequenceDiagram autonumber participant U as Client App participant B as Your Backend participant A as Core API participant K as KYC Widget participant W as Webhooks U->>B: User provides personal, address, and contact details B->>A: GET /core/terms-of-service?type=general&country={country} A-->>B: { termsOfService[] } B-->>U: Display Terms of Service U->>B: User accepts Terms of Service B->>A: POST /core/users (with ToS + X-Uphold-User-Ip) A-->>B: { user } B->>A: POST /widgets/kyc/sessions A-->>B: { session } B-->>U: { session } U->>K: Initialize Widget U->>K: User completes verification processes K-->>U: complete U->>U: Unmount Widget, show pending state A-->>W: core.identity.status-changed / core.proof-of-address.status-changed B->>A: GET /core/kyc (verify process statuses) B->>A: PATCH /core/kyc/* (complete remaining processes via API) B->>A: GET /core/capabilities A-->>B: { capabilities[] } B-->>U: User is ready to transact ``` ## Retrieve terms of service Before creating a user, retrieve the general Terms of Service applicable to their country of residence by calling the [List terms of service](/rest-apis/core-api/terms-of-service/list-terms-of-service) endpoint with `type=general` and the user's country code. ```http theme={null} GET /core/terms-of-service?type=general&country={country} ``` Display the Terms of Service content to the user and record their acceptance. ## Create the user Once the user has accepted the Terms of Service, call [Create user](/rest-apis/core-api/users/create-user) to register them on the platform. The `X-Uphold-User-Ip` [user context](/rest-apis/headers#user-context) header is **mandatory** when creating a user, as it records the user's IP address at the time of Terms of Service acceptance. ```http theme={null} POST /core/users { "type": "individual", "email": "john.doe@example.com", "termsOfService": "general-us-hq", "fullName": "John Doe", "birthdate": "1987-01-01", "primaryCitizenship": "US", "address": { "country": "US", "subdivision": "US-CA", "city": "Los Angeles", "line1": "100 Main Street", "postalCode": "90011" }, "phone": { "number": "+12125550123", "country": "US" } } ``` A successful response returns the created user's information. ```json Response theme={null} { "user": { "id": "cd21b26d-35d2-408a-9201-b8fdbef7a604", "type": "individual", "email": "john.doe@uphold.com", "fullName": "John Doe", "birthdate": "1987-01-01", "primaryCitizenship": "US", "address": { "country": "US", "subdivision": "US-CA", "city": "Los Angeles", "line1": "100 Main Street", "postalCode": "90011" }, "phone": { "number": "+12125550123", "country": "US" }, "createdAt": "2024-03-13T20:20:39Z", "updatedAt": "2024-03-13T20:20:39Z" } } ``` ```http theme={null} POST /core/users { "type": "individual", "email": "john.doe@example.com", "termsOfService": "general-gb-fca", "fullName": "John Doe", "birthdate": "1987-01-01", "primaryCitizenship": "GB", "address": { "country": "GB", "subdivision": "GB-MAN", "city": "Manchester", "line1": "1 High Street", "postalCode": "M4 1AA" }, "phone": { "number": "+447911123456", "country": "GB" } } ``` A successful response returns the created user's information. ```json Response theme={null} { "user": { "id": "cd21b26d-35d2-408a-9201-b8fdbef7a604", "type": "individual", "email": "john.doe@uphold.com", "fullName": "John Doe", "birthdate": "1987-01-01", "primaryCitizenship": "GB", "address": { "country": "GB", "subdivision": "GB-MAN", "city": "Manchester", "line1": "1 High Street", "postalCode": "M4 1AA" }, "phone": { "number": "+447911123456", "country": "GB" }, "createdAt": "2024-03-13T20:20:39Z", "updatedAt": "2024-03-13T20:20:39Z" } } ``` You can optionally include custom [entity metadata](/rest-apis/entity-metadata) in the `metadata` field to store your own business data (e.g. external IDs or tracking parameters). Subscribe to the `core.user.created` webhook to be notified asynchronously. ## Create a session Call [Create kyc session](/rest-apis/widgets-api/kyc/create-session) on your backend with the `verify` flow. Specify `processes` to limit which verification steps the user sees, or omit it to run all available processes. ```http theme={null} POST /widgets/kyc/sessions { "flow": "verify", "processes": ["identity", "proof-of-address"] } ``` ```json theme={null} { "session": { "flow": "verify", "data": { "processes": ["identity", "proof-of-address"] }, "url": "https://kyc-widget.enterprise.uphold.com/...", "token": "GEbRxBN...edjnXbL" } } ``` Pass `response.session` to your frontend (for example, as part of your page response or via your own API endpoint). The SDK takes that `session` object as a single argument. ## Set up the Widget Initialize the widget for the `verify` flow using the session data returned from the API. ```javascript Web SDK [expandable] theme={null} import { KycWidget } from '@uphold/enterprise-kyc-widget-web-sdk'; const widget = new KycWidget(session); widget.on('ready', () => { // Hide your loading state }); widget.on('complete', () => { widget.unmount(); // Show a pending state — verification outcome arrives via webhook }); widget.on('cancel', () => { widget.unmount(); // Return the user to the previous screen }); widget.on('error', (event) => { console.error('KYC error:', event.detail.error); widget.unmount(); }); widget.mountIframe(document.getElementById('kyc-container')); ``` ```html JavaScript [expandable] theme={null}
```
Both examples above are for web applications — either creating the iframe yourself or letting the SDK do it. For native apps using a WebView, see [Native apps with the SDK](/widgets/kyc/installation-and-setup#native-apps-with-the-sdk) for the SDK's native-bundling pattern, or [Setup with JavaScript](/widgets/kyc/installation-and-setup#setup-with-javascript) for the no-SDK approach that loads the session `url` directly as the WebView's top-level page. ## Monitor outcomes The `complete` event signals submission, not approval. Final outcomes arrive asynchronously on your backend via webhooks. Subscribe to the relevant events for the processes you requested: * [`identity.status-changed`](/rest-apis/core-api/kyc/webhooks/identity-status-changed) — identity verification result * [`proof-of-address.status-changed`](/rest-apis/core-api/kyc/webhooks/proof-of-address-status-changed) — proof of address result In Sandbox, proof-of-address submissions are automatically approved — no validation is performed on the documents you submit for this process through the Widget at the moment. You can also poll [Get KYC overview](/rest-apis/core-api/kyc/get-overview) (`GET /core/kyc`) at any time to check the current status of all KYC processes for a user. # User onboarding overview — KYC, KYB, and capabilities Source: https://developer.uphold.com/developer-guides/user-onboarding/overview Establish user eligibility and compliance readiness with Uphold's onboarding model. Compare KYC and KYB methods, verification responsibility, and capabilities. Onboarding establishes users' **eligibility** and **compliance readiness** so they can access regulated financial actions on the Uphold Platform. ## Implementation decisions Before you start building, you must make two key decisions that define your system architecture, compliance responsibilities, and operational complexity: 1. **Which onboarding method you'll use** 2. **Who is responsible for verifying user information** ## 1. Choose your implementation method * You want to maintain control over the UI * You have specific branding requirements * You need a deep system integration * You want to maintain control over the UI * You already use Sumsub or Veriff * You don't want to modify your existing onboarding flows * You're comfortable with a managed UI * You need the fastest time-to-market * You have limited development resources ### Compare implementation methods | Characteristics | API | Connector | Widget | | -------------------- | --------------------------------------------------- | --------------------------------------------------- | --------------------------- | | **What you build** | Complete UI + backend orchestration using Core APIs | UI + provider integration | Embed widget component | | **Development time** | High (weeks-months) | Medium (1-2 weeks) | Low (days-weeks) | | **UX control** | Full control | Full control | Limited (Uphold-managed UI) | | **Maintenance** | You maintain all components | You maintain the UI, Uphold maintains the connector | Uphold maintains the widget | | **Customization** | Full UI customization | Full UI customization | Widget configuration only | ## 2. Choose verification responsibility When onboarding users, you must also decide who will be responsible for verifying the information collected. ### Verification models | Comparison | **Uphold-verified** | **Partner-verified** | | ----------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | Description | You collect the user data and Uphold verifies it. | You collect and verify user data, then submit the results to Uphold. | | Best if | You don't want to build or maintain verification systems. | You have existing onboarding infrastructure, or want full control over the user experience. | | Compliance | Uphold is responsible for compliance as the verifier. | Your organization is responsible for compliance as the verifier. | | Approvals | No approvals needed. | The Uphold compliance team must approve your verification processes. | ## Supported regions The Enterprise API Suite currently supports users in the following regions: * **United Kingdom** * **United States of America** * **European Union** Coming soon * **Rest of the world** Coming soon Contact your Account Manager to have more details about EU and rest of the world. ## Next Steps Complete step-by-step guide for Individual user verification. Businesses Preview } icon="briefcase" href="/developer-guides/user-onboarding/business/overview"> KYB API reference and preview availability for Business user verification. # Introduction to the FIX APIs Source: https://developer.uphold.com/fix-apis/introduction If you are interested in using this API for pricing and trading, please [reach out to us](https://uphold.com/enterprise#contact). # Building with AI Source: https://developer.uphold.com/get-started/build-with-ai Use the Uphold Enterprise docs with LLMs and AI coding tools. Feed llms.txt and llms-full.txt to ChatGPT, Claude, Cursor, or your own agents to scaffold integrations faster. AI assistants are how developers read docs now. The Uphold Enterprise documentation is built to be consumed by large language models as easily as by people — so you can drop our entire API surface into ChatGPT, Claude, Cursor, or your own agent and start building in minutes. ## What's inside the files We follow the [llms.txt standard](https://llmstxt.org) and publish two complementary files. Pick the one that fits your context window and task. A **structured index** of the entire documentation — every guide, API reference, and concept as a titled, described link. Best for giving an assistant a map of what exists so it can fetch the right page on demand. The **complete documentation** concatenated into a single file — every page's full content in one place. Best for dropping the whole knowledge base into a large context window for deep, end-to-end work. Between them, your assistant gets full coverage of: Account setup and your first API call. KYC, KYB, and capabilities. FPS, SEPA, ACH, FedNow/RTP, Wire. Debit & credit deposits and withdrawals. 50+ networks, deposits and withdrawals. Every endpoint, payload, and webhook. Payment, Travel Rule, and KYC widgets. Dynamic forms, errors, pagination, rate limits. ## Why build with AI? Generate working integration code for onboarding, deposits, withdrawals, and webhooks without leaving your editor. Ground your assistant in the real API contract so it stops hallucinating endpoints and parameters. The files are generated from this site, so your AI context updates the moment the docs do. ## How to use llms-full.txt The fastest way to get answers grounded in our docs. Open [developer.uphold.com/llms-full.txt](https://developer.uphold.com/llms-full.txt) and copy its contents, or download it. Start a new conversation and paste the file (or attach it). Then ask your question — for example: > Using the attached Uphold docs, write a Node.js function that onboards an individual US user and sets up an ACH deposit method. Follow up naturally. Because the model has the full API contract, it can refine payloads, add error handling, and wire up webhooks accurately. Hitting context limits? Use [llms.txt](https://developer.uphold.com/llms.txt) instead — it's a compact index the model can use to request only the specific pages it needs. Bring the docs directly into your coding workflow. In Cursor, run **`@Docs` → Add new doc** and paste `https://developer.uphold.com/llms-full.txt`. Other AI IDEs offer a similar "add documentation / custom context" option. Mention the doc source in your prompt (e.g. `@Uphold`) and ask the assistant to scaffold or debug an integration inline. Fetch the file at runtime to ground your own LLM application or agent. ```bash cURL theme={null} curl https://developer.uphold.com/llms-full.txt ``` ```python Python theme={null} import requests docs = requests.get("https://developer.uphold.com/llms-full.txt").text # Pass `docs` as system/context to your model of choice ``` ```javascript Node.js theme={null} const res = await fetch("https://developer.uphold.com/llms-full.txt"); const docs = await res.text(); // Pass `docs` as system/context to your model of choice ``` `llms-full.txt` is large. For token-sensitive or production agents, prefer fetching [llms.txt](https://developer.uphold.com/llms.txt) as an index and pulling individual pages on demand — append `.md` to any docs URL to get its raw Markdown. ## Next steps End-to-end walkthroughs for onboarding and money movement. Embeddable Payment, Travel Rule, and KYC widgets. Full reference for every endpoint, payload, and webhook. # Make your first API call Source: https://developer.uphold.com/get-started/make-your-first-api-call Make your first Uphold Enterprise API call using the public Postman workspace, with pre-configured Sandbox and Production environments and example requests. The fastest way to make your first API call is through our public [Postman workspace](https://www.postman.com/uphold/workspace/enterprise-api). It includes pre-configured requests for every endpoint, automated scripts that chain variables between calls, and ready-to-use Sandbox and Production environments — no boilerplate required. Open the [Enterprise API Postman workspace](https://www.postman.com/uphold/workspace/enterprise-api) and fork the collection to your own Postman account. Forking gives you a personal copy you can edit freely, and lets you pull upstream updates as the collection evolves. Enterprise API Postman workspace In the top-right corner of Postman, open the environment selector and choose the environment you want to target: * **Sandbox** — isolated testing environment with test funds and data. Recommended for development. * **Production** — live environment. Targeting Production may result in unintended changes to live data. Always validate against Sandbox first. Click the **Variables icon** next to the environment selector to open the variables drawer and set the following: | Variable | Value | | -------------------- | --------------------------------------------- | | `auth.client_id` | Your Client ID from the Enterprise Portal | | `auth.client_secret` | Your client secret from the Enterprise Portal | Postman variables drawer with client credentials If you are using the public workspace without forking, please be aware that credentials must be set through the variables drawer as shown above. Editing them via the Environments tab is not supported since the workspace is read-only. Don't have credentials yet? Follow the [Set up your account](/get-started/set-up-your-account) guide to create your API client. Lift off! Start with the [Authentication token](https://www.postman.com/uphold/workspace/enterprise-api/request/34958229-01b72490-f8c2-47c4-9449-83cb06726948?action=share\&source=copy-link\&creator=42626858\&active-environment=27bc7058-5bbb-457e-9234-c1abcba54ea8) request. It exchanges your client credentials for an access token. Once you send it, the collection scripts automatically store the token in the `auth.access_token` variable — subsequent requests will use it without any manual steps. The same applies throughout the collection: resource IDs (e.g. `core.user.id`, `core.account.id`, `core.transaction.id`) are captured after create/fetch operations and reused in follow-up requests automatically. This means you can work through a full flow — authenticate, create a user, create a quote, execute a transaction — by running requests in sequence without manually copying values between them. # Get started with the Uphold Enterprise API Suite Source: https://developer.uphold.com/get-started/overview Build enterprise-grade crypto products with the Uphold Enterprise API Suite — onboard users and move value across banks, cards, and crypto.

Uphold Developer Docs

Build with Uphold

Build enterprise-grade crypto products on the Uphold Enterprise API Suite — onboard users and move value across banks, cards, and crypto.

One platform. Everything you need

Each guide walks you through a full integration, end-to-end.

Start building

Full control, embedded flows, or institutional trading.

Ready to start?

Check how to set up your account to start building.

01

Register your organization

Navigate to the Enterprise Portal. Fill in your company details and set your administrator credentials.

02

Access the Sandbox

A fully isolated testing ground that mirrors Production but uses test funds and data.

03

Create an API Client

Navigate to Clients in the Portal, create a new client, and select the scopes your application needs.

04

Test your integration

Use your generated credentials to make your first API call via Postman or cURL.

# Set up your Uphold Enterprise account Source: https://developer.uphold.com/get-started/set-up-your-account Register your organization in the Enterprise Portal to access the Sandbox and Production environments, manage credentials, and start integrating the APIs. Before you can interact with the Enterprise API Suite, you must onboard through the [**Enterprise Portal.**](https://portal.enterprise.uphold.com/) The Enterprise Portal is your central mission control to manage the **Sandbox** and **Production** environments. Navigate to the [Enterprise Portal](https://portal.enterprise.uphold.com/register). Fill in your company details and set your administrator credentials. Once logged in, you have immediate access to the **Sandbox environment**. This is a fully isolated testing ground that mirrors Production behavior but uses test funds and data, allowing you to validate your integration safely. In the portal sidebar, navigate to **Clients** and click **Create new client**. Give your client a name (e.g., "Development App") and select the **Scopes** (permissions) your application needs. Enterprise Portal client creation Click **Generate client secret** to create your `Client ID` and `Client Secret`. Your credentials are ready. Now make your first API call using the Postman collection or cURL. # Authentication Source: https://developer.uphold.com/rest-apis/authentication Authenticate Uphold REST API requests with OAuth2 access tokens. Includes token endpoints, scopes, and unauthorized error handling for 401 responses. All requests must be authenticated or they will be rejected with a `401 Unauthorized` status code. ## OAuth2 The REST APIs use the industry standard [OAuth2](https://oauth.net/2/) protocol for authentication. It's a well defined and widely used specification for token-based authentication and authorization. ### Grant types Because the REST APIs are meant to be consumed by businesses, the supported grant type is [client credentials](https://oauth.net/2/grant-types/client-credentials/). For this grant, you need to provide a valid client ID and client secret to create access tokens. To obtain an access token, you may call the [Request OAuth2 token](./core-api/authentication/request-oauth2-token) endpoint. You can then use access tokens to authenticate subsequent requests by adding the `Authorization: Bearer {accessToken}` HTTP header. You can manage your clients in Enterprise Portal. Please note that client secrets act like passwords, so be sure to keep them secure! ### Subjects Calls to the REST API endpoints are always contextualized with a subject. A subject represents the actor performing the action, which can be one of the following: * `client`: The OAuth2 client itself, used for operations that don't require user context * `user:individual`: An individual user within an organization * `user:business`: A business user within an organization Depending on your client configuration, the client's tokens may be able to target different subjects: * **Organization-wide clients** default to the `client` subject but can act on behalf of any user within an organization, provided they have the `core.users:act-on-behalf-of` scope. Add the `X-On-Behalf-Of: user {userId}` HTTP header to the request, where `{userId}` is the ID of the user you want to target. * **Single-user clients** are associated with a specific user within the organization and can only perform actions on behalf of that user. No additional header is required for these clients. However, you may optionally include the `X-On-Behalf-Of: user {userId}` HTTP header, but the `{userId}` must match the user associated with the client. ### Scopes Clients are associated with a set of scopes that define the permissions of tokens. This allows you to create as many clients as needed, each with a different set of permissions based on your requirements. If you attempt to call an endpoint that requires certain scopes, but the token you are using doesn't have them, you will receive a `403 Forbidden` status code. ## API keys API keys are another widely used way to authenticate requests, but they are not supported at this time. If you have a use case that requires API keys, please reach out to your Account Manager. ## User blocked If a user is internally blocked, every request to non `GET` endpoints will fail with `409` HTTP status code and the following body: ```json theme={null} { "code": "operation-not-allowed", "message": "The current status of the user does not allow calling this endpoint", "reasons": ["user-blocked"] } ``` # REST API changelog Source: https://developer.uphold.com/rest-apis/changelog Track Uphold REST API releases, breaking changes, enhancements, and new features. Includes RSS feed for KYC, transactions, and other endpoint updates. ### Optional verification for email and phone **Summary** The user's `email` and `phone` can now be updated by submitting `input` on its own — previously rejected, this now registers the value as unverified (`pending`), regardless of the verification model in place. Submitting `input` together with `output.verifiedAt` continues to work exactly as before, marking the process `ok` immediately. The overall KYC status and user capabilities remain `ok` even while `email` and/or `phone` are `pending`. Verifying them is still recommended, though, as it may improve transaction acceptance in certain payment flows. **Documentation** * [Update email](/rest-apis/core-api/kyc/update-email) * [Update phone](/rest-apis/core-api/kyc/update-phone) * [KYC verification models](/rest-apis/core-api/kyc/introduction#verification) * [Onboard individual users](/developer-guides/user-onboarding/individual/overview) ### Google Pay deposits **Summary** Introduced support for deposits using Google Pay as an Alternative Payment Method (APM). **Documentation** * [APM overview](/developer-guides/apm-transfers/overview) * [Google Pay deposit flow](/developer-guides/apm-transfers/deposit/via-rest-api/google-pay) ### Generalized Request for Information endpoints **Summary** New root-level endpoints — [List](/rest-apis/core-api/requests-for-information/list-requests-for-information), [Get](/rest-apis/core-api/requests-for-information/get-request-for-information), and [Update](/rest-apis/core-api/requests-for-information/update-request-for-information) request for information — replace the transaction-nested RFI endpoints. RFIs can now be looked up by `referenceId`, which accepts a quote ID, transaction ID, or (for future RFI types) other entity IDs. **Deprecated:** The transaction-nested endpoints (`/core/transactions/{transactionId}/requests-for-information[/{id}]`) are deprecated but continue to work, including their backward-compatible behavior of returning `200` with `warnings` for already-resolved RFIs instead of a `409`. **Behavioral note:** The new root-level update endpoint returns `409 conflict` when the RFI is already resolved/expired, rather than the `200`-with-warnings fallback of the deprecated endpoint. Integrations should handle the 409 case. **Documentation** * [Requests for information](/rest-apis/core-api/requests-for-information/list-requests-for-information) ### Travel Rule RFI-based withdrawal flow **Summary** Crypto withdrawals now use the same Request for Information (RFI) mechanism as deposits to handle Travel Rule requirements. The widget resolves the RFI automatically once the user completes the form. The previously documented flow of passing Travel Rule data via `params.travelRule` on transaction creation is now deprecated. **Behavioral change (non-breaking):** `params.travelRule` on [Create transaction](/rest-apis/core-api/transactions/create-transaction) is now deprecated. It continues to be accepted but has no effect on the transaction. **Removal date:** 2026-10-03 **Details** * **RFI expiry**: RFI objects now include an `expiresAt` timestamp. After an RFI expires, create a new quote with the same parameters to generate a new RFI. * **New error**: `request_for_information_expired` (HTTP 409) is returned when attempting to update an expired RFI. * **Proof types**: The RFI specifies which proof types are acceptable (`SelfDeclarationProof`, `MicroTransferProof`, `CryptographicalProof`). Withdrawals default to `SelfDeclarationProof` when no requirement is given. **Documentation** * [Withdrawal flow](/developer-guides/travel-rule/withdrawal) (rewritten for RFI-based approach) * [Proof types](/developer-guides/travel-rule/proof-types) (new guide) * [Requests for information](/rest-apis/core-api/requests-for-information) **Actions required** * Remove `params.travelRule` from your [Create transaction](/rest-apis/core-api/transactions/create-transaction) calls — the field is deprecated, currently has no effect, and will be fully removed. The Travel Rule Widget handles the RFI automatically once the user completes the form. ### Apple Pay deposits and withdrawals **Summary** Introduced support for deposits and withdrawals using Apple Pay as an Alternative Payment Method (APM). **Documentation** * [APM overview](/developer-guides/apm-transfers/overview) * [Apple Pay deposit flow](/developer-guides/apm-transfers/deposit/via-rest-api/apple-pay) * [Apple Pay withdrawal flow](/developer-guides/apm-transfers/withdrawal/via-rest-api/apple-pay) ### Simplified KYC Process **Summary** A user's profile (name, date of birth, citizenship, and address) can now be provided directly in the [Create user](/rest-apis/core-api/users/create-user) endpoint, completing the `profile` process up front instead of a separate KYC call. Email and phone can also be passed at creation, though their processes still require verification. **Behavioral change (non-breaking):** the `profile` process now also covers the user's address, so completing `profile` marks the user's address as `ok`. The standalone `address` process remains available but is deprecated. **Details** * **Create user**: optional `fullName`, `birthdate`, `address`, and `phone` fields can be provided at creation — the profile fields complete the `profile` process, while `phone` is stored but its process still requires verification. * **Update profile**: `input` is now a flat object, so you can patch `fullName`, `birthdate`, `primaryCitizenship`, `otherCitizenships`, `birthplace`, and `address` fields individually instead of resubmitting a full dynamic form. The response adds a `verification.rules` object describing each field's `presence` and `editable` state; disallowed or non-editable fields are ignored rather than rejected, and reported in a new `warnings` object. * **Get KYC overview**: the `profile` process now includes `verification.rules`, so the field rules are always available before updating the user's profile. **Documentation** * [Create user](/rest-apis/core-api/users/create-user) * [Update profile](/rest-apis/core-api/kyc/update-profile) * [Get KYC overview](/rest-apis/core-api/kyc/get-overview) **Actions required** * **Create user**: replace `country`, `subdivision`, and `citizenshipCountry` with `address.*` and `primaryCitizenship` (the deprecated fields keep working for now). * **Update profile**: submit the flat `input` fields and read `verification.rules` instead of relying on `hint`/`input.details`. * **Update address**: migrate your calls to [Update profile](/rest-apis/core-api/kyc/update-profile). ### PayPal deposits and withdrawals **Summary** Introduced support for deposits and withdrawals using PayPal as an Alternative Payment Method (APM). **Details** * Added `apm` to the [network types](/rest-apis/core-api/assets/introduction#types-of-networks). * Added `apm` to the [rail types](/rest-apis/core-api/assets/introduction#types-of-rails). * Added `apm` to the [external account types](/rest-apis/core-api/external-accounts/introduction#types-of-external-accounts). * An authorized PayPal account is managed as [external accounts](/rest-apis/core-api/external-accounts/introduction). **Documentation** * [APM overview](/developer-guides/apm-transfers/overview) * [PayPal deposit flow](/developer-guides/apm-transfers/deposit/via-rest-api/paypal) * [PayPal withdrawal flow](/developer-guides/apm-transfers/withdrawal/via-rest-api/paypal) ### Bank account numbers containing only zeros are now rejected **Summary** Creating a bank external account with an account number consisting entirely of zeros now returns `400 request_invalid`. Account numbers must contain between 4 and 17 digits and cannot be made up exclusively of zeros. **Documentation** * [Create external account](/rest-apis/core-api/external-accounts/create-external-account) **Actions required** * If your integration relies on accepting any 4–17 digit account number, update your validation to require at least one non-zero digit. ### USD bank deposit simulation **Summary** Sandbox simulation for USD bank rails is now available via the [Simulate bank deposit](/rest-apis/core-api/accounts/test-helpers/simulate-bank-deposit) test helper. **Details** * **ACH**: Simulate ACH deposits with optional remitter fields including `secCode` (`CCD`, `PPD`, `WEB`), `transactionType` (`Pull`, `Push`), and `serviceType` (`Standard`, `SameDay`). * **FedNow / RTP**: Simulate FedNow and RTP deposits with optional remitter name, account number, and routing number. * **Wire**: Simulate Wire deposits with optional remitter name and address fields. **Documentation** * [Simulate bank deposit](/rest-apis/core-api/accounts/test-helpers/simulate-bank-deposit) ### Invalid amounts now return `400 request_invalid` **Summary** Submitting a zero or negative `amount` when creating quotes or simulating bank and crypto deposits now returns `400 request_invalid`. **Documentation** * [Create quote](/rest-apis/core-api/transactions/create-quote) * [Simulate bank deposit](/rest-apis/core-api/accounts/test-helpers/simulate-bank-deposit) * [Simulate crypto deposit](/rest-apis/core-api/accounts/test-helpers/simulate-crypto-deposit) **Actions required** * If your integration catches `409 transaction_amount_invalid` for zero or negative amount submissions, update your error handling to expect `400 request_invalid` instead. * If your integration does not validate the `amount` field before calling the API, add a guard to reject zero and negative values on your side. The API will now return `400 request_invalid` for these cases, but validating early produces a better user experience. ### Deprecated `partnerOnboardedAt` field when creating a user **Summary** The `partnerOnboardedAt` field is now deprecated and no longer required when creating a user. Your integration will keep working until the field is fully removed, but we recommend updating it now so you no longer depend on this field. See the actions below for details. **Removal date:** 2026-08-03 **Documentation** * [Create user](/rest-apis/core-api/users/create-user) * [Get user](/rest-apis/core-api/users/get-user) **Actions required** 1. Stop sending `partnerOnboardedAt` in the request body of [Create User](/rest-apis/core-api/users/create-user) requests. The field is no longer needed and will be ignored once it is fully removed. 2. If you still rely on storing and reading this date: * Adjust your integration to send it as `metadata.partnerOnboardedAt` instead of `partnerOnboardedAt` when creating the user. * Adjust your integration to read it from [Get metadata](/rest-apis/entity-metadata#retrieving-metadata) instead of `partnerOnboardedAt` on user objects. If actions on step 2 do not apply to you, your integration will keep working even after the field is fully removed. However, we still recommend you take action on step 1. ### Request field validation constraints **Summary** Added length constraints to several request fields to ensure input values fall within supported ranges. Affected endpoints now return a `400 request_invalid` error with details about the violated constraint. **Details** The most impactful changes reduce previously accepted limits: * **KYC name fields** (`givenName`, `familyName`, across identity and tax-details processes): max reduced from **200 → 100** * **Address fields** (shared): `line1` (max 150), `line2` (max 100), `postalCode` (max 12), `city` (max 100) Other fields received new length limits, including KYC document numbers, quote destination details, transfer references, and file content types. ### KYC verification model **Summary** Every KYC process is now explicit about who is responsible for verifying it — replacing the previous implicit authoritativeness model. **Details** * **Verification object:** Every KYC process now includes a `verification` object with `model` (`uphold-verified` or `partner-verified`), `method` (`manual` or `automatic`), `triggers`, and `dependencies`. * **`uphold-verified` for `identity` and `proofOfAddress`:** These processes now support the `uphold-verified` model, meaning verification is driven by a third-party provider session hosted by Uphold. No explicit update can be made — Uphold is notified by the provider and updates the process automatically. **Documentation** * [KYC verification](/rest-apis/core-api/kyc/introduction#verification) ### Crypto risk assessment updated for AAQ v3 **Summary** The Crypto Risk Assessment form for UK users has been refreshed to align with the latest revision of the FCA's appropriateness assessment requirements (AAQ v3). Because the form is delivered as a [dynamic form](/developer-guides/resources/dynamic-forms/introduction), integrations that render `hint.schema` and `hint.uiSchema` pick up the new questionnaire automatically. **Details** * New users complete the updated AAQ v3 questionnaire. * Users who already completed the previous version are not required to reassess. **Documentation** * [Update crypto risk assessment](/rest-apis/core-api/kyc/update-crypto-risk-assessment) * [Dynamic forms](/developer-guides/resources/dynamic-forms/introduction) ### Transaction and portfolio statements **Summary** Added two new endpoints to retrieve monthly statements for a user's portfolio holdings and transactions. **Details** * **Portfolio statement**: Returns holdings per asset at the end of a given period, including exchange rates. * **Transactions statement**: Returns a paginated list of transactions for a given period, with full transaction details and exchange rates. * Both endpoints accept `year`, `month`, and an optional `denomination` parameter (single asset code, defaults to `USD`) to control the currency used for exchange rates. **Documentation** * [Get portfolio statement](/rest-apis/core-api/statements/get-portfolio-statement) * [Get transactions statement](/rest-apis/core-api/statements/get-transactions-statement) * [Statements developer guide](/developer-guides/statements/overview) ### Bank address destination node on ACH withdrawals **Summary** ACH withdrawal transactions now support bank-address as a destination node, allowing withdrawals without a linked external account (e.g., microdeposit disbursements during bank account ownership verification). **Documentation** * [Transaction nodes](/rest-apis/core-api/transactions/introduction#transaction-nodes) **Action required** * 🔍 Update your integration to handle ACH withdrawal transactions where `transaction.destination.node.type` is `bank-address`. ### Dynamic forms: restructured options with field dependencies and validation rules **Summary** The dynamic forms used in KYC processes have been enhanced with new capabilities and a restructured options format. These changes affect the `profile` and `taxDetails` processes. **Details** * **Restructured `data` option:** The `dataSource` and `exclude` options have been restructured under a `data` object with `source` and `exclude` properties, with `subdivisions` added as a new supported source alongside `countries`. * **New `format` option:** Custom display formats are now defined in the UI Schema via `options.format` (e.g., `postal-code` for postal code inputs). Standard formats like `date` remain in the JSON Schema. * **New `rules` option:** Controls can now define client-side validation rules such as `difference-greater-than-or-equal-to-threshold` and `difference-less-than-or-equal-to-threshold`, enabling constraints like age validation. * **New `dependsOn` option:** Controls can declare dependencies on other fields, enabling dynamic updates (e.g., subdivision list updates when country changes). **Documentation** * [Dynamic Forms — UI Schema](/developer-guides/resources/dynamic-forms/ui-schema#custom-options) * [Dynamic Forms — Schema](/developer-guides/resources/dynamic-forms/schema) * [Dynamic Forms — Rendering](/developer-guides/resources/dynamic-forms/rendering) **Action required** * ⚠️ **Breaking Change:** The `dataSource` option has been replaced by `data.source`, and `exclude` is now nested under `data.exclude`. Update your custom renderers to use the new structure. * 🔍 Implement support for the new `rules`, `dependsOn`, and `format` options in your custom renderers. ### Limit details on transaction amount errors **Summary** Transaction amount validation errors now include a `limit` object in the error details, providing clearer information about which limit was hit and what the allowed bounds are. * **Maximum and minimum amount limits**: The `limit` object now contains `maximumAmount` or `minimumAmount`, indicating the allowed bound for the transaction. * **Periodic limits**: When a daily, weekly, or monthly spending limit is exceeded, the API now returns a `transaction_amount_invalid` error. The `rule` field encodes the period (e.g. `amount-exceeds-daily-limit`) and the `limit` object includes both the total allowed amount (`maximumAmount`) and the remaining allowance for the period (`remainingAmount`). **Documentation** * [Create quote](/rest-apis/core-api/transactions/create-quote) * [Create transaction](/rest-apis/core-api/transactions/create-transaction) * [Transaction amount errors](/rest-apis/core-api/transactions/introduction#transaction-amount-errors) **Action required** * ⚠️ **Deprecation:** Update your integrations to use `limit.maximumAmount` or `limit.minimumAmount` instead of `threshold.value` in `transaction_amount_invalid` error details. * The `threshold` field is still returned but will be removed in a future release. ### Simulate crypto deposit **Summary** A new test helper endpoint is now available to simulate a crypto deposit in Sandbox. This endpoint allows you to test how your application handles incoming crypto deposits without needing to perform an actual testnet transfer. **Documentation** * [Simulate crypto deposit](/rest-apis/core-api/accounts/test-helpers/simulate-crypto-deposit) * [Testnets and token faucets](/rest-apis/core-api/accounts/test-helpers/fund-sandbox-accounts) ### SEPA deposits and withdrawals **Summary** Introduced support for deposits and withdrawals via the SEPA network. **Details** * **Deposit method**: Added support for SEPA deposits. * **External accounts**: SEPA accounts are automatically linked after the first successful deposit. * **Withdrawals**: Quotes and transactions now support linked SEPA accounts as a destination. * **Simulate bank deposit**: Added support for simulating SEPA deposits in the Sandbox environment. **Documentation** * [Networks and rails](/rest-apis/core-api/assets/introduction) * [Set up account deposit method](/rest-apis/core-api/accounts/set-up-account-deposit-method) * [External account support](/rest-apis/core-api/external-accounts/introduction#types-of-external-accounts) * [Simulate bank deposit](/rest-apis/core-api/accounts/test-helpers/simulate-bank-deposit) ### ACH deposits and withdrawals **Summary** Introduced support for deposits and withdrawals via the Automated Clearing House (ACH), along with the FedNow and Wire US bank rails. **Details** * **Account deposit method**: USD accounts can now be set up with a bank deposit method using US bank rails (ACH, FedNow, Wire). * **External accounts**: US bank accounts can now be added as external accounts for withdrawals. * **Create quote**: The external account node now accepts an optional `network` field to select a specific US bank rail when the external account supports multiple networks. * **Transaction nodes**: Bank deposits produce a `bank-address` node that identifies the network used for the transfer. * **Rail constraints**: Bank rails now support an `allowed-deposit-accounts` constraint to restrict deposits to the user's default account. **Documentation** * [Networks and rails](/rest-apis/core-api/assets/introduction) * [Set up account deposit method](/rest-apis/core-api/accounts/set-up-account-deposit-method) * [Create external account](/rest-apis/core-api/external-accounts/create-external-account) * [Create quote](/rest-apis/core-api/transactions/create-quote) * [Transactions](/rest-apis/core-api/transactions/introduction) ### Proof-of-address support for Veriff in KYC Connector API **Summary** The Veriff KYC Connector now supports the `proof-of-address` process. Proof-of-address data (e.g. utility bills, bank statements) can now be ingested from Veriff PoA sessions, in addition to the previously supported `profile`, `address`, and `identity` processes. **Documentation** * [Veriff provider overview](/rest-apis/kyc-connector-api/veriff/overview) * [Onboarding via KYC Connector](/developer-guides/user-onboarding/individual/via-kyc-connector) ### Filter assets and networks by type **Summary** The [List Assets](/rest-apis/core-api/assets/list-assets) and [List Networks](/rest-apis/core-api/assets/list-networks) endpoints now support a `type` query parameter, allowing results to be filtered by type (e.g. `crypto`, `fiat`). **Documentation** * [List assets](/rest-apis/core-api/assets/list-assets) * [List networks](/rest-apis/core-api/assets/list-networks) ### Custom TTL when creating quotes **Summary** Partners can now specify a custom `ttl` when creating a quote, changing the platform default. This is useful when processing deposits or withdrawals through your own rails (e.g. a custom card processor), where the user needs more time to complete the transaction before the quote expires. This feature must be enabled per organization — contact your Account Manager to request access. **Documentation** * [Create quote — TTL](/rest-apis/core-api/transactions/create-quote#ttl) ### Errors with `date_invalid` code now use hyphenated `details.rule` values **Summary** Error responses with the `date_invalid` code have been updated to use hyphenated values in the `details.rule` field for better consistency. This change affects all endpoints that return `date_invalid` errors. **Details** The following rule values have changed: | Before | After | | ----------------------------------------------- | ----------------------------------------------- | | `difference_greater_than_threshold` | `difference-greater-than-threshold` | | `difference_greater_than_or_equal_to_threshold` | `difference-greater-than-or-equal-to-threshold` | | `difference_less_than_threshold` | `difference-less-than-threshold` | | `difference_less_than_or_equal_to_threshold` | `difference-less-than-or-equal-to-threshold` | **Action required** * ⚠️ **Breaking Change:** Update any error handling logic that matches on `details.rule` values to use the new hyphenated format. ### List default accounts endpoint **Summary** Added an endpoint to list the default account for each asset owned by a user. Results can be filtered by asset code using the `asset` query parameter. Default accounts are where funds are credited when a push deposit does not include a `reference`, or when the network does not support targeting a specific account. **Documentation** * [List default accounts](/rest-apis/core-api/accounts/list-default-accounts) * [Default accounts](/rest-apis/core-api/accounts/introduction#default-accounts) ### Veriff provider support in KYC Connector API **Summary** Added Veriff as a new KYC provider in the KYC Connector API. Partners can now ingest and normalize KYC data from Veriff, in addition to the existing Sumsub support. **Details** * **Supported processes:** `profile`, `address`, and `identity`. * **Configuration management:** New endpoints to get and set Veriff integration configuration, including session-to-process mapping and Veriff API credentials. * **Webhooks:** Real-time notifications for ingestion lifecycle events (`ingestion-created` and `ingestion-status-changed`). **Documentation** * [Veriff provider overview](/rest-apis/kyc-connector-api/veriff/overview) * [Create Veriff ingestion](/rest-apis/kyc-connector-api/veriff/create-ingestion) * [Get Veriff configuration](/rest-apis/kyc-connector-api/veriff/get-config) ### List default accounts endpoint **Summary** Added an endpoint to list the default account for each asset owned by a user. Results can be filtered by asset code using the `asset` query parameter. Default accounts are where funds are credited when a push deposit does not include a `reference`, or when the network does not support targeting a specific account. **Documentation** * [List default accounts](/rest-apis/core-api/accounts/list-default-accounts) * [Default accounts](/rest-apis/core-api/accounts/introduction#default-accounts) ### Delete user endpoint requires review information **Summary** The [Delete User](/rest-apis/core-api/users/delete-user) endpoint now requires a request body with review information when deleting a user account. **Details** The request body must include a `review` object with the following properties: * `reason` (required): The reason code for deleting the user account. Must be one of: `other`, `closure-per-user-request`, `compliance-violation`, `fraud-violation`, `business-decision`. * `note` (optional): Additional notes about the account deletion. **Documentation** * [Delete User](/rest-apis/core-api/users/delete-user) **Action required** * ⚠️ **Breaking Change:** The endpoint now requires a request body with a `review` object. Update all calls to include `review.reason` (required) and optionally `review.note` in the request body. ### New asset information endpoint **Summary** Added asset information endpoint with descriptive details about each asset. The response supports locale selection via the `Accept-Language` header and format selection via the `format` query parameter (`text` by default, or `html`). The Market Pulse API endpoints have been reorganized on the documentation for better discoverability, but the paths and schemas remain unchanged. **Documentation** * [Get asset information](/rest-apis/market-pulse-api/assets/get-asset-information) ### News articles limit parameter **Summary** Changed default news articles to 10 and added a `limit` query parameter allowing partners to control the number of articles returned. The parameter accepts values between 1 and 10. **Documentation** * [List general news](/rest-apis/market-pulse-api/general/list-general-news) * [List asset news](/rest-apis/market-pulse-api/assets/list-asset-news) ### Update set metadata response code **Summary** We've updated the set metadata endpoint to return a 200 response code and the resulting metadata. ### Market Pulse API **Summary** Introduced the Market Pulse API, providing partners with access to real-time market insights and latest news. **Documentation** * [List asset news](/rest-apis/market-pulse-api/assets/list-asset-news) * [List general news](/rest-apis/market-pulse-api/general/list-general-news) * [Get asset statistics](/rest-apis/market-pulse-api/assets/get-asset-statistics) ### New KYC Connector API **Summary** We've launched the KYC Connector API, a new integration layer that connects third-party KYC providers with Uphold's platform. This API eliminates the need to build and maintain custom integrations by automatically ingesting and normalizing provider data into Uphold's KYC model for processes like profile, address, identity, and proof-of-address verification. **Details** * **Workflow-based ingestion:** Create ingestions that represent a KYC ingestion workflow for a given user and track its status via polling or webhooks. * **Multi-process support:** Ingest profile, address, identity, and proof-of-address KYC processes in a single workflow. * **Multi-provider support:** Ingest from multiple providers, starting with Sumsub support in this release and more providers coming soon. **Documentation** * [KYC Connector API Introduction](/rest-apis/kyc-connector-api/introduction) * [Sumsub provider](/rest-apis/kyc-connector-api/sumsub/overview) ### Deprecated `amount-limit-exceeded` transaction status reason code **Summary** The transaction status reason code `amount-limit-exceeded` has been deprecated and replaced with `provider-maximum-limit-exceeded` to provide clearer error information when transactions fail due to provider-imposed limits. **Documentation** * [Transactions](/rest-apis/core-api/transactions/introduction) **Action required** * ⚠️ **Breaking Change:** Update your integrations to check for `provider-maximum-limit-exceeded` instead of `amount-limit-exceeded` in the `statusDetails.reason` field of failed transactions. * ❌ The `amount-limit-exceeded` reason code will no longer be returned. ### Business user support in Widgets **Summary** Business user support is now available across all widgets, enabling partners to [create sessions](/rest-apis/widgets-api/payment/create-session) and embed widget experiences for business users as well as individuals. The [Capabilities endpoints](/rest-apis/core-api/capabilities/introduction) have also been enhanced to reflect business user permissions and features. Please note that for the [Payment Widget](/widgets/payment/introduction), card withdrawals and bank transfers for business users will be supported in a future release. **Documentation** * [Payment Widget](/widgets/payment/introduction) * [Travel Rule Widget](/widgets/travel-rule/introduction) * [Create session](/rest-apis/widgets-api/payment/create-session) * [Capabilities endpoints](/rest-apis/core-api/capabilities/introduction) ### Metadata support in the API **Summary** Introduced new endpoints to manage metadata for API resources. Partners can now programmatically create, update, retrieve, and delete custom metadata for supported entities, enabling more flexible integrations and resource annotations. This feature allows you to attach arbitrary key-value data to resources, such as accounts or users, to support custom workflows, tagging, or additional business logic. **Documentation** * [Entity metadata](/rest-apis/entity-metadata) * [Core API metadata endpoints](/rest-apis/core-api/metadata) ### Travel Rule compliance for crypto transactions **Summary** Added Travel Rule support in the Core API: endpoints and response hints to collect the required originator and beneficiary information for crypto deposits and withdrawals. **Details** * **Transaction RFIs:** New endpoints to [get](/rest-apis/core-api/transactions/rfis/get-request-for-information), [list](/rest-apis/core-api/transactions/rfis/list-requests-for-information) and [update](/rest-apis/core-api/transactions/rfis/update-request-for-information) requests for information (RFIs) for crypto deposits that require Travel Rule data. * **Quote Requirements:** Quote responses may include a `travel-rule` requirement when additional information is needed before executing a crypto withdrawal. **Documentation** * [Transactions](/rest-apis/core-api/transactions/introduction) — support for transactions subject to the Travel Rule * [Create session](/rest-apis/widgets-api/travel-rule/create-session) — create `deposit-form` and `withdrawal-form` sessions for the Travel Rule widget; see the [Travel Rule Widget](/widgets/travel-rule/installation-and-setup) for installation and setup instructions. **Actions required** * 🔔 Monitor [`core.transaction.status-changed`](/rest-apis/core-api/transactions/webhooks/transaction-status-changed) for transactions with status `on-hold`. * 🔍 Check for `travel-rule` requirements in quote responses when creating crypto withdrawal quotes. ### Validate network address endpoint **Summary** Introduced a new endpoint to programmatically validate the format of a network address before initiating a transaction. This helps ensure only valid crypto addresses are used, reducing the risk of user errors and failed transfers. Currently, validation is supported for addresses on crypto networks. **Documentation** * [Validate network address](/rest-apis/core-api/assets/validate-network-address) ### Support for multiple address formats in crypto deposit methods **Summary** The [Set up account deposit method](/rest-apis/core-api/accounts/set-up-account-deposit-method) endpoint now supports multiple crypto address formats for a single deposit method. Partners can now retrieve and display all supported address formats (e.g., `native-segwit`, `wrapped-segwit`, `pubkey-hash`) for a given network and asset, improving user experience and compatibility. **Documentation** * [Set up account deposit method](/rest-apis/core-api/accounts/set-up-account-deposit-method) ### New public Postman workspace **Summary** We've launched a new public Postman Workspace to reduce friction when accessing our API collections, enabling a smoother developer-experience. The previous invite-only workspace will remain accessible until 25th of January 2026, but it won't receive further updates. **Actions required** * Follow the updated [Make your first API call](/get-started/make-your-first-api-call) guide to set up the new Postman workspace. While the setup process is largely unchanged, environment variables have been renamed for clarity. **Documentation** * [Make your first API call](/get-started/make-your-first-api-call) ### Skip assets cooldowns test helper **Summary** Introduced a new [test helper](/rest-apis/test-helpers) endpoint to instantly bypass asset cooldowns in Sandbox environments. Asset cooldowns temporarily restrict features like `buy` and `deposit` for compliance reasons. This test helper allows you to skip the waiting period during development and testing, enabling immediate access to all asset features. For example, when testing with UK users, you can bypass the 24-hour cooldown and immediately test deposit or purchase flows. **Documentation** * [Skip assets cooldowns](/rest-apis/core-api/assets/test-helpers/skip-assets-cooldowns) * [Assets cooldowns](/rest-apis/core-api/assets/introduction#cooldowns) ### Business users **Summary** The API has been expanded to support business users, enabling interactions similar to those available for individual users. At this stage, business users must be created and KYB'ed outside the API. Reach out to your Account Manager if you have a use-case where business users are needed. Once verified, a business user can be managed through the API, including creating accounts, linking external accounts, and initiating transactions. **Documentation** * [Authentication - Subjects](/rest-apis/authentication#subjects) The documentation now includes badges to indicate the supported subject types for each endpoint. ### Terms of Service update for EEA users **Summary** Added `general-pt-bop` as a new Terms of Service code for EEA users, available starting **December 10, 2025** at 10:00 AM UTC. The existing `general-lt-fcs` code remains accepted through the end of 2025. **Documentation** * [Create user](/rest-apis/core-api/users/create-user) * [List Terms of Service](/rest-apis/core-api/terms-of-service/list-terms-of-service) **Actions required** * Use `general-pt-bop` for new EEA users starting December 10, 2025 at 10:00 AM UTC. * Display updated Terms of Service to existing EEA users (previous acceptances automatically migrated). ### EU and UK tax reporting regulatory requirements **Summary** Enhanced KYC processes to support EU and UK tax reporting regulatory requirements. The [`profile`](./core-api/kyc/update-profile) and [`taxDetails`](./core-api/kyc/update-tax-details) processes now dynamically adapt to the user's country, collecting additional required properties for compliance. **Enforcement dates** * **EU users:** Starting on December 10, 2025 at 10:00 AM UTC for all users. * **UK users:** Starting on December 30, 2025 at 10:00 AM UTC for newly registered users. **Details** * **Profile:** Collects place of birth and other citizenships for EU users * **Tax Details:** Supports tax address and multiple tax residence countries with flexible data collection when tax IDs cannot be provided * **Dynamic Forms:** Uses [JSON Forms](https://jsonforms.io/) with progressive disclosure based on user responses **Documentation** * [Get KYC overview](/rest-apis/core-api/kyc/get-overview) * [Update profile](/rest-apis/core-api/kyc/update-profile) * [Update tax details](/rest-apis/core-api/kyc/update-tax-details) **Actions required** * ⚠️ **Breaking Change:** `profile` object structure has changed in [Get KYC overview](/rest-apis/core-api/kyc/get-overview) and [Update profile](/rest-apis/core-api/kyc/update-profile) endpoints: * Properties moved to `input.details` (previously `input`). * The `citizenshipCountry` has been renamed to `primaryCitizenship`. * New `profile.hint` object with dynamic `schema` and `uiSchema`. * The legacy static structure is deprecated but still supported for backward compatibility. See [Update profile](/rest-apis/core-api/kyc/update-profile#body-input) for both versions. * Update response parsing and property names in your integration. * 🔍 Check `taxDetails.status` and use `taxDetails.hint.schema` to collect required properties. ### Crypto deposits and withdrawals **Summary** Expanded support for crypto funding flows, enabling seamless deposits and withdrawals in crypto. **Details** * **New Capabilities:** Introduced [`bank-deposits`](/rest-apis/core-api/capabilities/list-user-capabilities) and [`crypto-deposits`](/rest-apis/core-api/capabilities/list-user-capabilities). The existing `deposits` capability remains available for other deposit types. * **Networks:** Added `network.type` to [`List Networks`](/rest-apis/core-api/assets/list-networks) as a validation hint for frontend integrations. * **Quotes / Transactions:** The `crypto-address` node now includes an `execution` object supporting three modes — `onchain`, `offchain`, and `simulated`. Learn more about it [here](/developer-guides/crypto-transfers/withdrawal/via-rest-api#execution-modes). * **CDD for US Users:** All users (including US users) will require to [Update customer due diligence](/rest-apis/core-api/kyc/update-customer-due-diligence) before making a crypto deposit or crypto withdrawal. **Documentation** * [Crypto deposit flow](/developer-guides/crypto-transfers/deposit/via-rest-api) * [Crypto withdrawal flow](/developer-guides/crypto-transfers/withdrawal/via-rest-api) * [Create quote with node type `crypto-address` as destination](/rest-apis/core-api/transactions/create-quote#crypto-address) * [Transaction schema with node `execution` object](/rest-apis/core-api/transactions/create-transaction#response-transaction-origin-node-execution-transaction-hash) **Actions required** * ⚠️ Update your integrations to read `destination.node.execution.transactionHash` instead of `destination.node.transactionHash`. * ❌ The old property will no longer return a value. * 🔔 Ensure your app handles Customer Due Diligence for all users (including US users) before enabling crypto deposits or withdrawals. ### Assets cooldown ended webhook **Summary** Introduced a new webhook that notifies platforms when a user's asset cooldown period ends. This enables partners to update asset availability, unlock trading actions, and notify users in real-time, without relying on polling. **Documentation** * [Assets cooldown ended](/rest-apis/core-api/assets/webhooks/assets-cooldown-ended) **Actions required** * ✅ No changes needed if cooldown tracking is not required for your use case. * 🔔 Subscribe to this webhook to refresh asset states and proactively notify users when cooldowns end. ### Topper API **Summary** Introduced the Topper API to enable KYC sharing between partners and the [Topper Widget](/widgets/topper/introduction), allowing partners to complete the KYC of a Topper user directly via the Core API. **Documentation** * [Identify a user](/rest-apis/topper-api/kyc-sharing/identify-user) * [Create session](/rest-apis/topper-api/kyc-sharing/create-session) ### Portfolio endpoints **Summary** Introduced a group of endpoints to provide aggregated insights into a user's financial position across all accounts. **Documentation** * [Portfolio endpoints](/rest-apis/core-api/portfolio/introduction) ### FPS deposits and withdrawals **Summary** Introduced support for deposits and withdrawals via the Faster Payments System (FPS). **Documentation** * Added [`Bank`](/rest-apis/core-api/assets/list-networks#bank) to the [network types](/rest-apis/core-api/assets/introduction#types-of-networks). * Added [`Bank (FPS)`](/rest-apis/core-api/accounts/set-up-account-deposit-method#bank-fps) to the [Account deposit methods](/rest-apis/core-api/accounts/set-up-account-deposit-method#bank-fps). * Added `Bank` to the [external account types](/rest-apis/core-api/external-accounts/introduction#types-of-external-accounts). * Added [`unique-account-number-viban`](/rest-apis/core-api/capabilities/list-user-capabilities) to the list of Capabilities. ### Asset ordering **Summary** Introduced support for sorting assets by various criteria, including market cap, price, and price variance. This enhancement enables partners to retrieve ordered asset lists from the API, eliminating the need for custom sorting logic. **Documentation** * [List assets sorting](/rest-apis/core-api/assets/list-assets#sorting) ### Widgets API **Summary** Introduced the Widgets API, enabling partners to create secure sessions for embedding Uphold widgets in their applications. **Documentation** * [Create session](/rest-apis/widgets-api/payment/create-session) for the [Payment Widget](/widgets/payment/introduction). ### Asset cooldowns **Summary** Enhanced the [Assets](/rest-apis/core-api/assets/introduction#asset-shape) schema by introducing a `cooldowns` property to provide details about any active cooldowns applied to the asset. This allows partners to understand which assets are temporarily restricted and when they become available again. **Documentation** * [Cooldowns](/rest-apis/core-api/assets/introduction#cooldowns) ### Core API **Summary** Introduced the Core API, providing the foundational infrastructure for building financial applications on Uphold. **Included features** This initial release introduced all foundational components of the Core API, including: * [Authentication endpoint](/rest-apis/core-api/authentication/request-oauth2-token) to request access tokens using the OAuth2 protocol. * [Countries endpoints](/rest-apis/core-api/countries/introduction) to retrieve information about the supported countries. * [Users endpoints](/rest-apis/core-api/users/create-user) to manage users, providing full CRUD capabilities and real-time webhooks to stay informed about user changes. * [KYC endpoints](/rest-apis/core-api/kyc/introduction) to manage KYC processes, ensuring that users are compliant with regulatory requirements. It also includes webhooks to notify about KYC status changes in real time. * [Capabilities endpoints](/rest-apis/core-api/capabilities/introduction) to retrieve capabilities, the actions and features that users can perform on the platform. * [Terms of Service endpoints](/rest-apis/core-api/terms-of-service/introduction) to retrieve applicable Terms of Service and record user acceptance, ensuring compliance with legal requirements. * [Files endpoints](/rest-apis/core-api/files/create-file) to generate upload and download links that support file-based KYC processes. * [Assets endpoints](/rest-apis/core-api/assets/introduction) to retrieve information about the assets available on the platform, as well as the networks and rails used to transfer them. * [Accounts endpoints](/rest-apis/core-api/accounts/introduction) to manage users' accounts, along with webhooks to asynchronously notify about account changes and balance updates. * [External accounts endpoints](/rest-apis/core-api/external-accounts/introduction) to link and manage financial accounts that users own outside the platform, such as debit or credit cards, enabling both pull deposits (moving funds into Uphold) and withdrawals (sending funds out of Uphold). * [Transactions endpoints](/rest-apis/core-api/transactions/introduction) to initiate and retrieve transactions through a unified RFQ (Request for Quote) model, supporting deposits, withdrawals, trades, and transfers across multiple asset types, along with webhooks to notify about transaction changes. **Documentation** * [Core API concepts](/rest-apis/core-api/concepts) # Archive account Source: https://developer.uphold.com/rest-apis/core-api/accounts/archive-account _media/specs/core-openapi.mintlify.json delete /core/accounts/{accountId} Archive an existing account. # Create account Source: https://developer.uphold.com/rest-apis/core-api/accounts/create-account _media/specs/core-openapi.mintlify.json post /core/accounts Create a new account. You can optionally include custom [entity metadata](../../entity-metadata) in the `metadata` field to store your own business data (e.g., account purpose, configuration, custom labels). If not provided during creation, you can add it later using [Set metadata](../metadata/set-metadata). # Get account Source: https://developer.uphold.com/rest-apis/core-api/accounts/get-account _media/specs/core-openapi.mintlify.json get /core/accounts/{accountId} Retrieve an existing account by id. # Get account deposit method Source: https://developer.uphold.com/rest-apis/core-api/accounts/get-account-deposit-method _media/specs/core-openapi.mintlify.json get /core/accounts/{accountId}/deposit-method Gets deposit method for depositing into an account externally. Use this endpoint to retrieve the deposit method for a given account, asset, and network. This is useful for determining available deposit options before initiating a deposit setup. You can check which assets support deposits by calling the [List Assets](../assets/list-assets) endpoint and filtering for assets that have `deposit` included in their features. Once you select an asset, you can determine the supported networks by calling the [List Rails](../assets/list-rails) endpoint for that asset and filtering for rails that also include `deposit` in their features. For more details, refer to the **[Assets section](../assets/introduction)**. Calling this endpoint will not trigger the [asynchronous setup process](./set-up-account-deposit-method#asynchronous-setup-process) of the deposit method which some networks require. You will need to call the [Set up Account Deposit Method](./set-up-account-deposit-method) endpoint to trigger it. # Accounts API introduction Source: https://developer.uphold.com/rest-apis/core-api/accounts/introduction Manage user accounts on the Uphold platform. Each account holds the balance of a single asset and acts as a container for the user's funds in Uphold. The accounts group of endpoints allows you to manage the user's accounts on the platform. A user may have many accounts, each one holding the balance of a specific asset. They act as a container for the user's funds stored in Uphold. ## Balance An account has two types of balances: * Available balance: The amount of funds that can be used for transactions. * Total balance: The total amount of funds in the account, including any pending transactions. ## Funding an account There are several ways to fund an account, that is, to have its balance increased. ### Pull deposits A pull deposit allows the platform to initiate the transfer of funds from the user's external account (e.g., a credit card or linked bank account), provided the user has given authorization. Pull deposits are processed through [external accounts](../external-accounts/introduction), which must be linked and authorized by the user before the platform can pull funds. To initiate a pull deposit, [create a quote](../transactions/create-quote) where the origin is the external account and the destination is the user's account. ### Push deposits A push deposit occurs when the user sends funds from an external source to their account on the platform. To facilitate a push deposit, use the [Set up account deposit method](./set-up-account-deposit-method) endpoint. This endpoint replies with the necessary deposit details for the user to complete the deposit from their side. ### From other accounts You can also fund accounts by moving funds between them, even if they hold different assets. To do this, [create a quote](../transactions/create-quote) specifying the origin and destination accounts. ## Default accounts When generating details for a push deposit using [Set up account deposit method](./set-up-account-deposit-method) endpoint, the response may include a `reference` field, which identifies the destination account to deposit into. If the user sends a push deposit without including the `reference`, the funds will be credited to the user's default account for that asset. Furthermore, there are cases in which rails do not support targeting a specific account for deposits. All deposits over those rails will be credited to the user's default account for the asset. To find out the user's default accounts for each asset, use the [List default accounts](./list-default-accounts) endpoint. ## Archiving accounts Accounts are not permanently deleted. If an external deposit is made to an archived account, the account will be automatically unarchived, and the user will successfully receive the funds. When this occurs, a [core.account.unarchived](./webhooks/account-unarchived) webhook will be triggered to notify you. # List accounts Source: https://developer.uphold.com/rest-apis/core-api/accounts/list-accounts _media/specs/core-openapi.mintlify.json get /core/accounts List accounts owned by a user. # List default accounts Source: https://developer.uphold.com/rest-apis/core-api/accounts/list-default-accounts _media/specs/core-openapi.mintlify.json get /core/accounts/defaults List the default account for each asset owned by a user. [Default accounts](./introduction#default-accounts) are used when receiving push deposits that have no `reference` or when a rail does not support targeting a specific account. # Set up account deposit method Source: https://developer.uphold.com/rest-apis/core-api/accounts/set-up-account-deposit-method _media/specs/core-openapi.mintlify.json put /core/accounts/{accountId}/deposit-method Sets up a deposit method for depositing into an account externally. Use this endpoint to initiate the setup of a deposit method for an account. You can check which assets support deposits by calling the [List Assets](../assets/list-assets) endpoint and filtering for assets that have `deposit` included in their features. Once you select an asset, you can determine the supported networks by calling the [List Rails](../assets/list-rails) endpoint for that asset and filtering for rails that also include `deposit` in their features. For more details, refer to the **[Assets section](../assets/introduction)**. ## Asynchronous setup process Some deposit methods require an asynchronous setup process depending on the network. * Calling this endpoint for the first time will trigger the setup of the underlying deposit method. * If the deposit method is not immediately available, you can use the [Get Account Deposit Method](./get-account-deposit-method) endpoint with the same inputs to check the deposit method state. If the deposit method is already set up, calling this endpoint again with the same inputs will not trigger a new setup process and instead will return the existing deposit method. ## Bank deposits When setting up a bank deposit method, the bank details you receive depend on the subject requesting the setup: * **Individual users** receive virtual account details under their own name. Deposits without the provided `reference` will still be credited to the correct user, but funds will be deposited in a default account. * **Business users** may receive virtual account details under their own name or a master account in Uphold's name. In the latter case, the `reference` uniquely identifies both the business and target account and **must** be included in every transfer; otherwise, the funds won't be credited automatically and may be returned. # Fund Sandbox accounts Source: https://developer.uphold.com/rest-apis/core-api/accounts/test-helpers/fund-sandbox-accounts All the ways to fund a Sandbox account on the Uphold platform — bank deposit simulation, crypto deposit simulation, and other test helpers for testing flows. This page lists every way to fund a Sandbox account so you can perform a deposit and use the resulting balance for further testing. ## Simulated bank deposit Use the [Simulate bank deposit](./simulate-bank-deposit) test helper to credit a Sandbox account as if funds had arrived via bank transfer. Set up the target account first with [Setup account deposit method](../set-up-account-deposit-method) and use the returned details when calling the simulator. ## Simulated crypto deposit Use the [Simulate crypto deposit](./simulate-crypto-deposit) test helper to credit a Sandbox account as if a crypto deposit had been received. Set up the target account first with [Setup account deposit method](../set-up-account-deposit-method) and use the returned details when calling the simulator. Because the deposit is simulated, the transaction hash is not available on any blockchain. If you need an actual on-chain transaction, use the [testnet flow below](#crypto-deposit-via-testnets) instead. ## Crypto deposit via testnets When testing crypto deposit and withdrawal flows in Sandbox, Uphold integrates testnet blockchains so you can simulate real on-chain activity without using real funds. Use the details returned by the [Setup account deposit method](../set-up-account-deposit-method) endpoint to deposit testnet tokens into your Sandbox account. **Uphold does not supply test tokens.** You must source them yourself using the faucets listed below, or any other preferred alternative. ### Recommended token faucets | Asset | Testnet | Faucet | | ---------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | BTC | Testnet | [Bitcoin Testnet Faucet](https://bitcoinfaucet.uo1.net/), [Coinfaucet](https://coinfaucet.eu/en/btc-testnet/), [testnet-faucet.com](https://testnet-faucet.com/btc-testnet/) | | ETH | Sepolia | [Infura Sepolia Faucet](https://www.infura.io/faucet/sepolia), [GetBlock Faucet](https://getblock.io/faucet/eth-sepolia/) | | XRP | Ripple Testnet | [XRP Faucets](https://xrpl.org/resources/dev-tools/xrp-faucets) | | SOL | Devnet | [Solana Faucet](https://faucet.solana.com/), [SolFaucet](https://solfaucet.com/), [SolFaucet (Toga)](https://solfaucet.togatech.org/) | | USDC, EURC | Multiple | [Circle Faucet](https://faucet.circle.com/) | | Multiple | Multiple | [Chainlink Faucet](https://faucets.chain.link/) | | Multiple | Multiple | [Chainstack Faucet](https://faucet.chainstack.com/) | Please note that faucet availability and token limits are managed by third parties and may vary over time. Crypto withdrawals in Sandbox are simulated by default and do not reach the blockchain. Refer to your Account Manager if you require on-chain withdrawal testing. ## Card deposit using test cards Use the test cards below to simulate card deposit flows in the Sandbox environment without processing real transactions. These are cards you can use to create an [external account](../../external-accounts/create-external-account) and use it as the `origin` when [creating a quote](../../transactions/create-quote). ### Test cards The following cards are available for testing across different networks, countries, and card types. | Card Number | Type | Country | Features | Method | | ------------------ | ------ | ------- | ------------------- | ------- | | `5131072454408923` | Credit | FR | Deposit | N/A | | `5163150000000005` | Credit | AU | Deposit | N/A | | `5555555555554444` | Credit | BR | Deposit | N/A | | `5454545454545454` | Credit | US | Deposit | N/A | | `2221000000000009` | Credit | US | Deposit | N/A | | `5163613613613613` | Debit | AU | Deposit | N/A | | `5105105105105100` | Credit | US | Deposit | N/A | | `5502514549870410` | Debit | FR | Deposit | Instant | | `5355223761921186` | Debit | GB | Deposit, Withdrawal | Instant | | `5573606426146833` | Debit | GB | Deposit, Withdrawal | Instant | | `5318773012490080` | Debit | US | Deposit, Withdrawal | Instant | | `5355224542121849` | Debit | GB | Deposit, Withdrawal | N/A | | `5574357535453624` | Debit | GB | Deposit | N/A | | `5436031030606378` | Credit | MU | Deposit | N/A | | `5569757734785691` | Debit | SG | Deposit | N/A | | `5518832400606463` | Debit | US | Deposit | N/A | | `5385308360135181` | Credit | US | Deposit | N/A | | `5259410220714099` | Credit | US | Deposit | N/A | | `5121073611487018` | Credit | US | Deposit | N/A | | `5291144083573579` | Credit | US | Deposit | N/A | | Card Number | Type | Country | Features | Method | | ------------------ | ------ | ------- | ------------------- | ------- | | `4444333322221111` | Credit | US | Deposit | N/A | | `4111111111111111` | Debit | PL | Deposit | N/A | | `4447336775378848` | Debit | US | Deposit | N/A | | `4485040371536584` | Credit | US | Deposit | N/A | | `4024007186645015` | Credit | US | Deposit | N/A | | `4452927588210665` | Credit | US | Deposit | N/A | | `4485597929486000` | Credit | US | Deposit | N/A | | `4243754271700719` | Credit | GB | Deposit | N/A | | `4000000000000002` | Credit | US | Deposit | N/A | | `4000000000000028` | Credit | US | Deposit | N/A | | `4000000000001091` | Credit | US | Deposit | N/A | | `4000000000000036` | Credit | US | Deposit | N/A | | `4024764449971519` | Debit | US | Deposit, Withdrawal | Instant | | `4462030000000000` | Debit | GB | Deposit, Withdrawal | Instant | | `4917300800000000` | Debit | GB | Deposit | N/A | | `4929421234600821` | Credit | GB | Deposit | N/A | | `4921817844445119` | Debit | GB | Deposit, Withdrawal | Instant | | `4659105569051157` | Debit | GB | Deposit, Withdrawal | Instant | | `4658584090000001` | Debit | GB | Deposit, Withdrawal | Instant | | `4659465888705671` | Debit | GB | Deposit | N/A | | `4242424242424242` | Credit | GB | Deposit | N/A | | `4917610000000000` | Credit | PL | Deposit | N/A | All test cards accept any three-digit CVV as valid, and any future expiry date in the MM/YY format. ### Simulating errors To trigger a specific error response, use the corresponding reserved amount when creating a transaction with any of the test cards above. All other amounts will result in a successful transaction. | Amount | Error Code | Description | | ------- | ----------------------- | ----------------------------------------------------- | | `12.12` | `card_unauthorized` | The card is not authorized for this transaction. | | `15.15` | `card_declined_by_bank` | The issuing bank declined the transaction. | | `20.20` | `card_expired` | The card has expired. | | `26.26` | `insufficient_funds` | The card has insufficient funds to cover the amount. | | `34.34` | `velocity` | The card has exceeded its transaction velocity limit. | | `60.60` | `card_unauthorized` | The card is not authorized for this transaction. | ## Testing APMs Each alternative payment method (APM) has its own way of being tested in Sandbox. Follow the steps for the method you want to test below. ### Setting up Apple Pay To test Apple Pay in Sandbox, we must use Apple's own Sandbox testing environment, using a dedicated Apple ID and Apple's test cards so you can go through the full deposit and withdrawal flow without moving real funds. Apple Pay's Sandbox mode uses a dedicated Apple ID for testing, separate from your personal Apple ID. Create one in App Store Connect under **Users and Access → Sandbox → Testers**, or reuse an existing Sandbox tester. On your Apple Pay-capable device, go to **Settings → Wallet & Apple Pay** (iOS) or **System Settings → Wallet & Apple Pay** (macOS) and sign in to the **Sandbox Apple Account** with the tester credentials. This is separate from the device's main Apple ID — no sign-out required. While signed in to the Sandbox Apple Account, add one of Apple's test cards to Wallet. Apple Pay recognizes it as a Sandbox card, so no real funds move regardless of the amount you transact. Use Apple Pay to authorize, where you will see a "TEST CARD" badge, and complete the transaction. Unlike Production, [Apple Pay servers don't require domain verification in the Sandbox environment](https://developer.apple.com/documentation/applepaywebmerchantregistrationapi/preparing-merchant-domains-for-verification#Host-the-domain-verification-file), so you can test web integrations without registering your domain first. See Apple's [Apple Pay sandbox testing](https://developer.apple.com/apple-pay/sandbox-testing/) guide for the full details, including region-specific test card numbers. See the Apple Pay developer guides for a full walkthrough: [Deposit via Payment Widget](/developer-guides/apm-transfers/deposit/via-payment-widget/apple-pay) and [Withdrawal via Payment Widget](/developer-guides/apm-transfers/withdrawal/via-payment-widget/apple-pay). ### Setting up Google Pay Testing Google Pay in Sandbox uses Google Pay's own test environment, so you can go through the full deposit flow without moving real funds. Google Pay automatically provides test cards for you to use, so you can make test transactions without any additional setup. Sign in at [Google](https://accounts.google.com/) with your account — or sign up if you don't have one yet. Select one of Google Pay's preset test cards to authorize and complete the transaction. See the Google Pay developer guides for a full walkthrough: [Deposit via Payment Widget](/developer-guides/apm-transfers/deposit/via-payment-widget/google-pay) and [Deposit via REST API](/developer-guides/apm-transfers/deposit/via-rest-api/google-pay). ### Setting up PayPal Testing PayPal in Sandbox relies on PayPal's own sandbox environment to authorize transactions, so you can go through the full deposit and withdrawal flow without moving real funds. Sign in at the [PayPal developer site](https://developer.paypal.com/) with your PayPal account — or sign up if you don't have one yet — then follow PayPal's guide to [create a sandbox account](https://developer.paypal.com/tools/sandbox/accounts/). Choose the same country for your PayPal sandbox account as the country of the user you're testing with. Use the PayPal sandbox account credentials to authorize and complete the transaction. See the PayPal developer guides for a full walkthrough: [Deposit via REST API](/developer-guides/apm-transfers/deposit/via-rest-api/paypal) and [Withdrawal via REST API](/developer-guides/apm-transfers/withdrawal/via-rest-api/paypal). # Simulate bank deposit Source: https://developer.uphold.com/rest-apis/core-api/accounts/test-helpers/simulate-bank-deposit _media/specs/core-openapi.mintlify.json post /core/accounts/test-helpers/bank-deposits Simulate an incoming bank deposit for testing purposes. This endpoint allows you to simulate a bank deposit based on the details returned by the [Setup Account Deposit Method](../set-up-account-deposit-method) endpoint. It is useful for testing how your application handles incoming deposits without needing to perform an actual bank transfer. The request body is a discriminated union on the `network` field. Required fields vary by network: | Network | Required fields | | -------- | ------------------------------------------------------------------------------------------------------- | | `fps` | `sortCode`, `accountNumber`, `reference`, `asset`, `amount`, `remitter` (name, sortCode, accountNumber) | | `sepa` | `iban`, `asset`, `amount`, `remitter` (name, bic, iban) | | `ach` | `accountNumber`, `routingNumber`, `amount` | | `fednow` | `accountNumber`, `amount` | | `rtp` | `accountNumber`, `amount` | | `wire` | `accountNumber`, `amount` | # Simulate crypto deposit Source: https://developer.uphold.com/rest-apis/core-api/accounts/test-helpers/simulate-crypto-deposit _media/specs/core-openapi.mintlify.json post /core/accounts/test-helpers/crypto-deposits Simulate an incoming crypto deposit for testing purposes. This endpoint allows you to simulate a crypto deposit based on the details returned by the [Setup account deposit method](../set-up-account-deposit-method) endpoint. It is useful for testing how your application handles incoming crypto deposits without needing to perform an actual testnet transfer. **Since this is a simulated deposit and does not use a real testnet, the transaction hash will not be available on the blockchain.** Check out [Testnets and token faucets](./fund-sandbox-accounts) for instructions on how to fund your Sandbox account using an actual testnet transaction. Depending on the country and/or the amount, the simulated crypto deposit may be placed `on-hold` pending Requests for Information (RFIs). See [Handle on-hold transactions](/developer-guides/crypto-transfers/deposit/via-rest-api#handle-on-hold-transactions) for more details. # Update account Source: https://developer.uphold.com/rest-apis/core-api/accounts/update-account _media/specs/core-openapi.mintlify.json patch /core/accounts/{accountId} Update an existing account. # Account archived Source: https://developer.uphold.com/rest-apis/core-api/accounts/webhooks/account-archived _media/specs/core-openapi.mintlify.json webhook core.account.archived An account has been archived. # Account Balance Changed Source: https://developer.uphold.com/rest-apis/core-api/accounts/webhooks/account-balance-changed _media/specs/core-openapi.mintlify.json webhook core.account.balance-changed The balance of an account has changed. # Account Created Source: https://developer.uphold.com/rest-apis/core-api/accounts/webhooks/account-created _media/specs/core-openapi.mintlify.json webhook core.account.created An account has been created. # Account unarchived Source: https://developer.uphold.com/rest-apis/core-api/accounts/webhooks/account-unarchived _media/specs/core-openapi.mintlify.json webhook core.account.unarchived An account has been unarchived. # Get asset Source: https://developer.uphold.com/rest-apis/core-api/assets/get-asset _media/specs/core-openapi.mintlify.json get /core/assets/{asset} Retrieve an asset by code. If the [subject](../../authentication#subjects) calling the endpoint is a user, the response will be contextualized accordingly. As an example, the `features` field will indicate the available features for the user. You can retrieve several assets in a single request by using the [Get Many Assets](./get-many-assets) endpoint. # Get asset rates Source: https://developer.uphold.com/rest-apis/core-api/assets/get-asset-rates _media/specs/core-openapi.mintlify.json get /core/assets/{asset}/rates Retrieve asset rates against other assets. ## Denomination The `denomination` query parameter allows you to denominate rates against one or multiple assets. If no value is set for this parameter, the rates for all available assets will be returned. There is a specific set of assets allowed, including but not limited to: `USD`, `EUR`, `GBP`, `AUD`, `CAD`, `NZD`, `MXN`, and `BTC`. If you need a particular asset added to this list, please reach out to your Account Manager. For a more detailed explanation of the denomination concept, check the [Core Concepts](../concepts#denomination) page. #### Retrieving multiple asset rates at once You can use the denomination currency as a proxy for retrieving the specific asset rates you're interested in. This way, you won't have to: * Make one Get Asset Rates request per each asset rate desired * Discard all the undesired assets rates retrieved by default For example, if you want to get the rates for `BTC` and `ETH` in `USD`: 1. Define `asset` as `USD` 2. Set `denomination` as the list of desired assets: `BTC,ETH` 3. Calculate the inverse for each returned asset rate: $\frac{1}{\text{rate}}$ You can specify up to 100 asset codes in the `denomination` parameter per request. # Get asset historical rates Source: https://developer.uphold.com/rest-apis/core-api/assets/get-historical-rates _media/specs/core-openapi.mintlify.json get /core/assets/{asset}/historical-rates Retrieve historical rates for a specific asset. ## Denomination The `denomination` query parameter allows you to denominate rates against another asset. If no value is set for this parameter, it defaults to `USD`. There is a specific set of assets allowed, including but not limited to: `USD`, `EUR`, `GBP`, `AUD`, `CAD`, `NZD`, `MXN`, and `BTC`. If you need a particular asset added to this list, please reach out to your Account Manager. For a more detailed explanation of the denomination concept, check the [Core Concepts](../concepts#denomination) page. ## Interval The `interval` query parameter determines the overall timespan of historical data and the frequency at which data points are added (rolled up). Each interval value defines how far back in time the data spans, and how granular each returned data point is within that range: | Interval | Description | Rollup Frequency | | :----------- | :--------------------------------- | :--------------- | | `one-hour` | Past hour of historical data | 30 seconds | | `one-day` | Past day of historical data | 10 minutes | | `one-week` | Past week of historical data | 1 hour | | `one-month` | Past month of historical data | 6 hours | | `one-year` | Past year of historical data | 2 days | | `five-years` | Past five years of historical data | 1 week | # Get network Source: https://developer.uphold.com/rest-apis/core-api/assets/get-network _media/specs/core-openapi.mintlify.json get /core/networks/{network} Retrieve a network by code. # Assets API introduction Source: https://developer.uphold.com/rest-apis/core-api/assets/introduction Retrieve information about assets supported on Uphold, plus the networks and rails used to transfer them. Includes asset shape, types, and rate endpoints. The assets group of endpoints provides information about the assets available on the platform, as well as the networks and rails used to transfer them. ## Assets Assets represent the various financial instruments available on the platform, categorized by their type and features. ### Asset shape An asset has the following properties: ```json [expandable] theme={null} { "code": "BTC", "name": "Bitcoin", "type": "crypto", "symbol": "₿", "decimals": 8, "logo": "https://cdn.uphold.com/assets/BTC.svg", "features": [ "buy", "deposit", "sell", "transfer", "withdraw" ], "cooldowns": [] } ``` ### Types of assets There are different types of assets available on the platform: National currencies issued by governments and regulated by central banks. Some examples of fiat assets are `USD`, `EUR`, and `GBP`. Digital currencies that use cryptography for security and operate on decentralized networks. Some examples of crypto assets are `BTC`, `ETH`, and `USDT`. ### Features Each asset has a unique `code` and a set of features. These features determine the type of transactions that can be performed with the asset: * `buy`: The ability to purchase the asset, that is, when it's specified as the `destination` in a transaction (e.g., asset associated with the destination node) and the `origin` node has a different asset. * `transfer`: The ability to transfer the asset, that is, when the `origin` and `destination` node assets are the same. * `sell`: The ability to sell the asset, that is, when it's specified as the `origin` node in a transaction (e.g., asset associated with the account) and the destination node asset is different. * `deposit`: The ability to perform a deposit of the asset when specified as origin node of a transaction (e.g., through an [external account](../external-accounts/introduction)). * `withdraw`: The ability to perform a withdrawal of the asset when specified as origin node of a transaction (e.g., through an [external account](../external-accounts/introduction)). Please note that these features can be contextualized with a user, meaning that a user may have access to some features of an asset and not others. ### Cooldowns Cooldowns indicate temporary restrictions on specific features such as buy, deposit, or withdraw. When an asset is under a cooldown, the corresponding features will be temporarily unavailable until the cooldown period expires. ```json [expandable] theme={null} { "cooldowns": [ { "rule": "financial-promotions", "features": [ "buy", "deposit" ], "endsAt": "2025-04-02T09:07:41.952Z" } ] } ``` Below you can find the list of possible scenarios: * `financial-promotions`: The rule applies during the first 24 hours after a user's account is created, and affects the `buy` and `deposit` features. ## Networks Networks are the underlying protocols through which the transfer of certain assets is made. ### Network shape A network has the following base properties: ```json theme={null} { "code": "bitcoin", "type": "crypto", "name": "Bitcoin", "logo": "https://cdn.uphold.com/assets/BTC.svg" } ``` The network may have additional properties depending on the `type`. For example, the network above has two more properties named `exampleAddress` and `explorer`, which are available for networks of type `crypto`. ### Types of networks There are different types of networks available on the platform: Networks of type `crypto` are blockchains that use cryptography to secure transactions. Examples of such networks are `bitcoin`, `ethereum`, and `xrp-ledger`. Networks of type `card` are used for credit and debit card transactions. There is a single network of this type, which is `card`. Networks of type `bank` refer to networks that facilitate direct bank transactions. Examples of such networks are `sepa`, `fps`, and `ach`. Networks of type `apm` are operated by alternative payment providers. ## Rails A rail is a combination of an asset and a network, which together determine whether a deposit or a withdrawal of a given asset under that network is possible. ### Rail shape A rail has the following base properties: ```json [expandable] theme={null} { "type": "crypto", "network": "ethereum", "method": "crypto-transaction", "asset": "USDC", "features": [ "deposit", "withdraw" ], "decimals": 6 } ``` The rail may have additional properties depending on the `type`. For example, the rail above has one more property named `contractAddress`, which is available for rails of type `crypto`. In some rare cases, the `decimals` property on the rail is different than the `decimals` defined in the asset. If you are initiating a deposit or a withdrawal transaction, use the `decimals` of the corresponding rail to truncate or round the decimal places in the user interface. ### Types of rails There are different types of rails available on the platform, which are analogous to the types of networks: Rails of type `crypto` are used for transferring crypto assets through blockchains. Rails of type `card` are used for credit and debit card transactions. Rails of type `bank` are used for direct bank transactions. Rails of type `apm` are used for transactions through alternative payment methods. ### Features and deposits / withdrawals A deposit is possible if: * The rail has the `deposit` feature. * The origin asset has the `deposit` feature. * The destination asset has: * The `transfer` feature if the origin and destination assets are the same. * The `buy` feature if converting between assets. A withdrawal is possible if: * The rail has the `withdraw` feature. * The origin asset has: * The `transfer` feature if the origin and destination assets are the same. * The `sell` feature if converting between assets. * The destination asset has the `withdraw` feature. # List assets Source: https://developer.uphold.com/rest-apis/core-api/assets/list-assets _media/specs/core-openapi.mintlify.json get /core/assets List assets supported by the platform. If the [subject](../../authentication#subjects) calling the endpoint is a user, the response will be contextualized accordingly. As an example, the `features` field will indicate the available features for the user. You can retrieve several assets in a single request by using the [Get Many Assets](./get-many-assets) endpoint. ## Sorting You can sort the assets by using the `sort` query parameter. Below are the available sorting options: To sort by name, use the `sort=name:asc` or `sort=name:desc` to sort in ascending or descending order, respectively. To sort by code, use the `sort=code:asc` or `sort=code:desc` to sort in ascending or descending order, respectively. To sort by current price, use the `sort=price:asc` or `sort=price:desc` to sort in ascending or descending order, respectively. The `price-delta` allows you to sort assets by gainers and losers, showing assets with the highest and lowest price changes in a given interval. To sort by price delta, use the `sort=price-delta:asc:` or `sort=price-delta:desc:` to sort in ascending or descending order, respectively, where `interval` can be `one-hour`, `one-day`, `one-week`, `one-month`, `one-year`, or `five-years`. To sort by market cap, use the `sort=market-cap:asc` or `sort=market-cap:desc` to sort in ascending or descending order, respectively. # List networks Source: https://developer.uphold.com/rest-apis/core-api/assets/list-networks _media/specs/core-openapi.mintlify.json get /core/networks List networks supported by the platform. # List rails Source: https://developer.uphold.com/rest-apis/core-api/assets/list-rails _media/specs/core-openapi.mintlify.json get /core/rails List rails supported by the platform. If the [subject](../../authentication#subjects) calling the endpoint is a user, the response will be contextualized accordingly. As an example, the `features` field will indicate the available features for the user. # Skip assets cooldowns Source: https://developer.uphold.com/rest-apis/core-api/assets/test-helpers/skip-assets-cooldowns _media/specs/core-openapi.mintlify.json put /core/assets/test-helpers/skip-assets-cooldowns Skips assets cooldowns for testing purposes. There are [cooldowns](../introduction#cooldowns) that temporarily restrict certain asset features. By calling this endpoint, you can immediately bypass those cooldowns, rather than waiting for them to expire naturally. For example, when a user residing in GB is created, a 24-hour cooldown is applied before they can access certain asset functionalities. Using this endpoint clears that cooldown instantly, allowing the user to proceed without delay. This is a [**Test Helper**](../../../test-helpers) endpoint and can only be used in Sandbox. # Validate network address Source: https://developer.uphold.com/rest-apis/core-api/assets/validate-network-address _media/specs/core-openapi.mintlify.json post /core/networks/{network}/validate-address Validate an address for a specific network. # Assets cooldown ended Source: https://developer.uphold.com/rest-apis/core-api/assets/webhooks/assets-cooldown-ended _media/specs/core-openapi.mintlify.json webhook core.assets.cooldown-ended The cooldown period for a set of assets has ended. # Request OAuth2 token Source: https://developer.uphold.com/rest-apis/core-api/authentication/request-oauth2-token _media/specs/core-openapi.mintlify.json post /core/oauth2/token Request an access token using OAuth2 protocol. # Get capability Source: https://developer.uphold.com/rest-apis/core-api/capabilities/get-user-capability _media/specs/core-openapi.mintlify.json get /core/capabilities/{capability} Retrieve a user capability by code. # Capabilities API introduction Source: https://developer.uphold.com/rest-apis/core-api/capabilities/introduction Capabilities define the actions and features users can perform on the Uphold platform. Learn the capability shape, statuses, and how to query user permissions. Capabilities define the important actions and features that a user can perform on the platform. ## Capability shape A capability has the following shape: ```json [expandable] theme={null} { "code": "trades", "name": "Trades", "enabled": true, "requirements": [ "user-must-submit-identity", "user-must-submit-customer-due-diligence", "user-must-submit-proof-of-address" ], "restrictions": [] } ``` ## Enabled / disabled When the capability has `enabled` set to false, then it has at least one restriction that prevents the user from performing the action. In this situation, there is nothing the user can do to enable the capability. When the capability is `enabled`, the user is able to perform the action but may be subject to requirements, if any. The requirements are usually tied to KYC processes or Terms of Service that were not accepted. # List capabilities Source: https://developer.uphold.com/rest-apis/core-api/capabilities/list-user-capabilities _media/specs/core-openapi.mintlify.json get /core/capabilities List user capabilities. # Core API concepts and data model Source: https://developer.uphold.com/rest-apis/core-api/concepts Learn the core concepts and data model behind the Uphold Enterprise API Suite — users, accounts, transactions, and assets — to design your integration. This page introduces the core concepts and data model that underpin the API suite, providing a foundational understanding of how the financial infrastructure operates. Familiarity with these concepts will help you navigate the API documentation and design your integration effectively. ## Data model Core Data Model The diagram above outlines the key concepts and their relationships, providing a clear view of how the financial infrastructure operates. When a **User** registers, they must specify their **Country of residence** and accept the applicable **Terms of Service**, which establish the legal framework for using the platform. To ensure compliance, every **User** must complete **Know Your Customer (KYC) processes**, a set of verification steps required before accessing financial services. The platform supports three types of KYC processes: **basic, form-based, and file-based**. For the file-based verification steps, the **User** must upload the required **Files** (e.g., government ID, proof-of-address) to provide the necessary documentation for verification. A **User's KYC status** and the **Terms of Service** they accept directly influence their **Capabilities** on the platform. **Capabilities** define what important actions a **User** is allowed to perform. Depending on the **Capabilities** granted, the **User** may be restricted from performing certain operations, such as transacting or linking an **External Account**, e.g., a bank or a credit card. Each **User** owns one or more **Accounts**, each storing balance of **Asset**. **Users** interact with their accounts primarily through **Transactions**, which modify **Account balances** by **trading, depositing, or withdrawing assets**. Deposits and withdrawals are powered by **Rails**, which determine how assets are transferred within a **Network**. You can also create **External Accounts**, which enable users to link bank accounts or credit/debit cards. Unlike push deposits, **External Accounts** allow the platform to **pull funds** to an Account, with the **User**'s authorization. **External Accounts** can also be used as a withdrawal destination, enabling two-way transactions. ## Key concepts ### Denomination Denomination currency refers to the currency unit in which the value of a transaction, trade, or financial asset is expressed. It provides a consistent way to measure value across different assets or currencies. Additionally, knowing the denomination helps in assessing exposure to foreign exchange fluctuations. The denomination currency is not necessarily the same as the currency used to pay. A user can buy an asset priced in EUR (denomination currency), but pay in USD. As another example, users might: * Hold balances in one currency (e.g., GBP) * View prices in another (e.g., USD) * Transact in yet another (e.g., BTC) Specific examples: 1. **Buying Crypto** * You buy 1 BTC priced at \$60,000 USD. * USD is the denomination currency (price reference). * You pay in EUR → conversion happens at settlement. 2. **Cross-Border Payment** * A vendor invoice is denominated in GBP. * You pay using USD. * GBP remains the denomination currency (the expected value); USD is converted to match that amount. 3. **Portfolio Holdings** * A portfolio lists all assets valued in a single denomination currency, like USD, giving a unified view even if balances are held in multiple currencies. 4. **Statements** * A portfolio statement expresses all holdings in the requested denomination asset using period-end exchange rates. * A transactions statement records the denomination asset and exchange rates at the time each transaction completed. # Get country Source: https://developer.uphold.com/rest-apis/core-api/countries/get-country _media/specs/core-openapi.mintlify.json get /core/countries/{country} Retrieve a country by code. # Countries API introduction Source: https://developer.uphold.com/rest-apis/core-api/countries/introduction Retrieve information about the countries supported by the Uphold platform, including the country shape, supported assets, and KYC requirements per country. The countries group of endpoints allows you to retrieve information about the countries supported by the platform. ## Country shape A country has the following shape: ```json [expandable] theme={null} { "country": { "code": "GB", "name": "United Kingdom", "asset": "GBP", "restrictions": [], "subdivisions": [ { "code": "GB-ABC", "name": "Armagh, Banbridge and Craigavon", "restrictions": [] }, { "code": "GB-ABD", "name": "Aberdeenshire", "restrictions": [] }, { "code": "GB-ABE", "name": "Aberdeen City", "restrictions": [] }, { "code": "GB-AGB", "name": "Argyll and Bute", "restrictions": [] }, { "code": "GB-AGY", "name": "Isle of Anglesey", "restrictions": [] }, // ... ] } } ``` Country and subdivision codes follow the [ISO 3166](https://en.wikipedia.org/wiki/ISO_3166-2) standard, more specifically, the two letter codes for country codes. ## Restrictions Countries and their subdivisions are subject to restrictions that may affect the availability of services, which are exposed through the API. Here's a list of different restrictions that may apply: * `residence` - Users whose official residence belongs to a country or subdivision with this restriction are not allowed. * `citizenship` - Users whose citizenship belongs to a country with this restriction are not allowed. * `geolocation` - Users accessing the platform from a country with this restriction are not allowed (inferred from `X-Uphold-User-Ip` and `X-Uphold-User-Country` headers). * `phone` - Phone numbers belonging to a country with this restriction are not allowed. # List countries Source: https://developer.uphold.com/rest-apis/core-api/countries/list-countries _media/specs/core-openapi.mintlify.json get /core/countries List countries supported by the platform. # Create external account Source: https://developer.uphold.com/rest-apis/core-api/external-accounts/create-external-account _media/specs/core-openapi.mintlify.json post /core/external-accounts Create an external account for the user. You can optionally include custom [entity metadata](../../entity-metadata) in the `metadata` field to store your own business data (e.g., cardholder name, tracking information, custom labels). If not provided during creation, you can add it later using [Set metadata](../metadata/set-metadata). # Delete external account Source: https://developer.uphold.com/rest-apis/core-api/external-accounts/delete-external-account _media/specs/core-openapi.mintlify.json delete /core/external-accounts/{externalAccountId} Delete an existing external account. # Get external account Source: https://developer.uphold.com/rest-apis/core-api/external-accounts/get-external-account _media/specs/core-openapi.mintlify.json get /core/external-accounts/{externalAccountId} Retrieve an existing external account by id. # External accounts API introduction Source: https://developer.uphold.com/rest-apis/core-api/external-accounts/introduction Manage external financial accounts, such as user-owned debit and credit cards or bank accounts, used for pull deposits and withdrawals out of Uphold. The external accounts group of endpoints allows you to manage financial accounts that users own outside the platform, such as debit or credit cards. External accounts can be used for both **pull deposits** (moving funds into Uphold) and **withdrawals** (sending funds out of Uphold). ## External account shape An external account includes the following properties: ```json [expandable] theme={null} { "id": "aa6e6efa-8d73-497c-8278-0347f459bd68", "ownerId": "1e32fca3-23f7-40ed-bc1b-de10c790182d", "type": "card", "status": "ok", "label": "My Visa Card", "asset": "GBP", "network": "visa", "features": ["deposit", "withdraw"], "createdAt": "2024-06-01T00:00:00Z", "updatedAt": "2024-07-15T00:00:00Z" } ``` Each external account has a `status` that reflects its availability: * `processing`: The account is being verified. * `ok`: The account is valid and ready to use. * `failed`: The verification failed — check `statusDetails.reason` for the failure reason. * `restricted` / `blocked`: The account is temporarily or permanently unusable. ## Types of external accounts There are different types of external accounts available on the platform: External accounts of type `card` represent **credit or debit cards** manually linked by the user for **fiat deposits and withdrawals**. External accounts of type `bank` represent **bank accounts** used for **fiat deposits and withdrawals** via networks like **FPS**, **SEPA** or **ACH**. These accounts are **automatically created** after the user's first successful deposit, using the bank details returned by [Set Up Account Deposit Method](../accounts/set-up-account-deposit-method) endpoint. External accounts of type `apm` represent authorized accounts at some alternative payment providers (currently **PayPal**) used for **fiat deposits and withdrawals**. These accounts are **automatically created** the first time a user transacts with the provider, using the details returned by the Payment Widget's [Authorize](/developer-guides/money-movement/payment-widget/authorize) flow. ## When to use external accounts * **Bank push deposits**: A `bank` external account represents the user's originating bank account and is used to identify them in recurring transactions — [via API](/developer-guides/bank-transfers/deposit/via-rest-api) or [via Payment Widget](/developer-guides/bank-transfers/deposit/via-payment-widget). * **Bank withdrawals**: A `bank` external account is used as the destination of a quote to pay out to the user's bank account — [via API](/developer-guides/bank-transfers/withdrawal/via-rest-api) or [via Payment Widget](/developer-guides/bank-transfers/withdrawal/via-payment-widget). * **Card deposits**: A `card` external account is linked by the user and used as the origin of a quote. Card authorization may be required — [via API](/developer-guides/card-transfers/deposit/via-rest-api) or [via Payment Widget](/developer-guides/card-transfers/deposit/via-payment-widget). * **Card withdrawals**: A `card` external account is linked by the user and used as the destination of a quote — [via API](/developer-guides/card-transfers/withdrawal/via-rest-api) or [via Payment Widget](/developer-guides/card-transfers/withdrawal/via-payment-widget). * **APM deposits**: An `apm` external account is linked by the user and used as the origin of a quote, for APM providers that support account authorization (e.g., `paypal`) — [via API](/developer-guides/apm-transfers/deposit/via-rest-api) or [via Payment Widget](/developer-guides/apm-transfers/deposit/via-payment-widget). * **APM withdrawals**: An `apm` external account is linked by the user and used as the destination of a quote, for APM providers that support account authorization (e.g., `paypal`) — [via API](/developer-guides/apm-transfers/withdrawal/via-rest-api) or [via Payment Widget](/developer-guides/apm-transfers/withdrawal/via-payment-widget). ## Related guides Fund an account via bank transfer. Payout to a bank via quote-based transfers. Fund an account from a credit or debit card. Cash out to a credit or debit card. Fund an account from an alternative payment method. Pay out to an alternative payment method. ## Managed UI option If you need a managed UI to collect payment or bank details, consider the Payment Widget (`select-for-deposit` / `select-for-withdrawal`) and then consume the returned `depositMethod` or `external-account` with the Core API. ## Testing To test card external accounts in the Sandbox environment, use the [Test Cards](../accounts/test-helpers/fund-sandbox-accounts#card-deposit-using-test-cards) reference, which lists available test card numbers and reserved amounts to simulate specific error scenarios. # List external accounts Source: https://developer.uphold.com/rest-apis/core-api/external-accounts/list-external-accounts _media/specs/core-openapi.mintlify.json get /core/external-accounts List external accounts for the user. # Update external account Source: https://developer.uphold.com/rest-apis/core-api/external-accounts/update-external-account _media/specs/core-openapi.mintlify.json patch /core/external-accounts/{externalAccountId} Update an existing external account. # External account created Source: https://developer.uphold.com/rest-apis/core-api/external-accounts/webhooks/external-account-created _media/specs/core-openapi.mintlify.json webhook core.external-account.created An external account has been created. # External account deleted Source: https://developer.uphold.com/rest-apis/core-api/external-accounts/webhooks/external-account-deleted _media/specs/core-openapi.mintlify.json webhook core.external-account.deleted An external account has been deleted. # External account status changed Source: https://developer.uphold.com/rest-apis/core-api/external-accounts/webhooks/external-account-status-changed _media/specs/core-openapi.mintlify.json webhook core.external-account.status-changed The status of an external account has been changed. # Create file Source: https://developer.uphold.com/rest-apis/core-api/files/create-file _media/specs/core-openapi.mintlify.json post /core/files Create a new file. Uploading a file is a two step process. You start by creating a placeholder for the file and then using the `upload` details of the response to actually upload the file. Each file has a unique `id` which is used across the API when you need to reference it. You can optionally include custom [entity metadata](../../entity-metadata) in the `metadata` field to store your own business data (e.g., descriptions, related entity IDs, processing metadata). If not provided during creation, you can add it later using [Set metadata](../metadata/set-metadata). Below you will find examples in several programming languages to do the upload: The following example uses [`file-type`](https://www.npmjs.com/package/file-type) package to determine the content-type of the file. ```js theme={null} import { fileTypeFromFile } from 'file-type'; import fs from 'node:fs'; // Function to upload file. // Takes `file` record from the response and `filePath` which points to the file on disk that needs to be uploaded. const uploadFile = async (file, filePath) => { const { url, formData } = file.upload; // Add form data. const form = new FormData(); for (const [key, value] of Object.entries(formData)) { form.append(key, value); } // Add content type, determined by file-type package. // This must match the `contentType` of the file record. const { mime } = await fileTypeFromFile(filePath); form.append('Content-Type', mime); // Add file blob. const blob = await fs.openAsBlob(filePath) form.append('File', blob); const response = await fetch(url, { method: 'POST', body: form, }); if (!response.ok) { throw new Error(`Failed to upload file: ${response.statusText}`); } console.log('File uploaded!') }; ``` # Get file Source: https://developer.uphold.com/rest-apis/core-api/files/get-file _media/specs/core-openapi.mintlify.json get /core/files/{fileId} Retrieve an existing file by id. The returned download URL is a signed URL that is only valid for a limited time, noted by the `download.expiresAt` field. # List files settings Source: https://developer.uphold.com/rest-apis/core-api/files/list-files-settings _media/specs/core-openapi.mintlify.json get /core/files/settings List files upload settings. # Create associated person Source: https://developer.uphold.com/rest-apis/core-api/kyb/associated-persons/create-associated-person _media/specs/core-openapi.mintlify.json post /core/kyb/associated-persons Create a new associated person for a business user. This endpoint is currently available in **Sandbox** only. It is not yet available in **Production**. **Create associated person** registers an individual tied to the business, such as a beneficial owner or an authorized signer, and returns the `id` you use to complete their processes. ## Types `type` is the only required field, and it determines what the person represents: * `beneficial-owner`: An individual who ultimately owns or controls a share of the business. Accepts `ownershipPercentage`. * `authorized-signer`: An individual authorized to act on behalf of the business. Does not accept `ownershipPercentage`. * `beneficial-owner-and-authorized-signer`: An individual who is both. Accepts `ownershipPercentage`. The type cannot be changed after creation. To correct it, [delete](./delete-associated-person) the person and create them again. ## Progressive creation Every field other than `type` is optional at creation, so you can register a person as soon as you know their role and fill in the rest later through [Update associated person profile](./update-profile). # Delete associated person Source: https://developer.uphold.com/rest-apis/core-api/kyb/associated-persons/delete-associated-person _media/specs/core-openapi.mintlify.json delete /core/kyb/associated-persons/{personId} Delete an associated person from a business user. This endpoint is currently available in **Sandbox** only. It is not yet available in **Production**. **Delete associated person** removes an individual from the business, discarding their `profile`, `identity`, and `proofOfAddress` processes along with everything they submitted. Deletion is irreversible. Recreating the person starts all three of their processes over from `pending`, including any identity or proof-of-address verification that had already been completed. # Get associated person Source: https://developer.uphold.com/rest-apis/core-api/kyb/associated-persons/get-associated-person _media/specs/core-openapi.mintlify.json get /core/kyb/associated-persons/{personId} Get a single associated person by their ID. This endpoint is currently available in **Sandbox** only. It is not yet available in **Production**. **Get associated person** returns a single individual associated with the business, with the full detail of their processes. Use [List associated persons](./list-associated-persons) when you need every person at once. # List associated persons Source: https://developer.uphold.com/rest-apis/core-api/kyb/associated-persons/list-associated-persons _media/specs/core-openapi.mintlify.json get /core/kyb/associated-persons List the associated persons for a business user. This endpoint is currently available in **Sandbox** only. It is not yet available in **Production**. **List associated persons** returns every individual currently associated with the business, each with the full detail of their `profile`, `identity`, and `proofOfAddress` processes. [Get overview](../get-overview) returns the same list under `processes.associatedPersons.persons` when called with `?detailed=associatedPersons`, so you can fetch the business and its associated persons in a single call. # Update associated person identity Source: https://developer.uphold.com/rest-apis/core-api/kyb/associated-persons/update-identity _media/specs/core-openapi.mintlify.json patch /core/kyb/associated-persons/{personId}/processes/identity Update the identity process for an associated person. This endpoint is not yet available in **Sandbox** or **Production**. **Update associated person identity** verifies that an individual tied to the business is who they claim to be, through a government-issued identity document. More information will be supplied once this endpoint is complete. # Update associated person profile Source: https://developer.uphold.com/rest-apis/core-api/kyb/associated-persons/update-profile _media/specs/core-openapi.mintlify.json patch /core/kyb/associated-persons/{personId}/processes/profile Update the profile information for an associated person. This endpoint is currently available in **Sandbox** only. It is not yet available in **Production**. **Update associated person profile** submits the personal information of an individual tied to the business: their name, role, date and place of birth, address, legal identifiers, and ownership percentage. The endpoint is partial — fields you omit keep their current value, so you can complete a person's profile across several calls after [creating](./create-associated-person) them with their type alone. # Update associated person proof-of-address Source: https://developer.uphold.com/rest-apis/core-api/kyb/associated-persons/update-proof-of-address _media/specs/core-openapi.mintlify.json patch /core/kyb/associated-persons/{personId}/processes/proof-of-address Update the proof-of-address process for an associated person. This endpoint is not yet available in **Sandbox** or **Production**. **Update associated person proof-of-address** verifies the residential address of an individual tied to the business, through a supporting document such as a utility bill or a bank statement. More information will be supplied once this endpoint is complete. # Associated person created Source: https://developer.uphold.com/rest-apis/core-api/kyb/associated-persons/webhooks/associated-person-created _media/specs/core-openapi.mintlify.json webhook core.kyb.associated-person.created An associated person has been created. # Associated person deleted Source: https://developer.uphold.com/rest-apis/core-api/kyb/associated-persons/webhooks/associated-person-deleted _media/specs/core-openapi.mintlify.json webhook core.kyb.associated-person.deleted An associated person has been deleted. # Associated person identity status changed Source: https://developer.uphold.com/rest-apis/core-api/kyb/associated-persons/webhooks/identity-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyb.associated-person.identity.status-changed The associated person identity status has been changed. # Associated person profile status changed Source: https://developer.uphold.com/rest-apis/core-api/kyb/associated-persons/webhooks/profile-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyb.associated-person.profile.status-changed The associated person profile status has been changed. # Associated person proof-of-address status changed Source: https://developer.uphold.com/rest-apis/core-api/kyb/associated-persons/webhooks/proof-of-address-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyb.associated-person.proof-of-address.status-changed The associated person proof-of-address status has been changed. # Get overview Source: https://developer.uphold.com/rest-apis/core-api/kyb/get-overview _media/specs/core-openapi.mintlify.json get /core/kyb Get the KYB overview of a business user. This endpoint is currently available in **Sandbox** only. It is not yet available in **Production**. **Get overview** is the endpoint that provides a summary of the business user's KYB processes. By default, only the `code` and `status` of each KYB process are included in the response. If you need detailed information, you can use the `detailed` query parameter to specify the KYB processes you want more detail on, which will bring their `input` and `output` fields, as well as the full list of associated persons. Getting the detailed KYB information is more expensive in terms of performance and should be used only when necessary. ## Overall status The `summary` object describes the business user's overall standing, independently of the individual processes: | Status | Meaning | | ------------ | ---------------------------------------------------------------------------------------------------- | | `pending` | The platform is still expecting information from the business. | | `ok` | The business has been verified. | | `restricted` | The business has been verified but the platform has limited certain [capabilities](../capabilities). | | `blocked` | The business is not allowed to operate on the platform. | The accompanying `reason` field carries additional context for the current status, and is `none` when there is nothing further to report. # KYB API introduction Source: https://developer.uphold.com/rest-apis/core-api/kyb/introduction Manage KYB processes on the Uphold platform to keep business users compliant with regulatory requirements. Learn the process model, statuses, associated persons, and the attestation flow. The KYB group of endpoints allows you to manage the Know Your Business processes available on the platform, ensuring that your business users are compliant with regulatory requirements. KYB applies exclusively to users created with `type: business` through the [Create user](../users/create-user) endpoint. Individual users are onboarded through the [KYC](../kyc/introduction) endpoints instead. The KYB endpoints are in **preview**. None of them are available in **Production** yet, and only some are available in **Sandbox** — each endpoint page states its own availability. ## How it's designed Every KYB process has four fundamental properties: * `code`: A unique identifier for the process. * `status`: As the name implies, this is the status of the process. * `input`: The data provided by your organization when completing the process. * `output`: The data that came out of verifying the input, present only on processes that produce a verification result. ### Statuses The `status` field can have the following values: * `pending`: The process is pending information. * `running`: The process is currently being verified. * `ok`: The process has been successfully verified. * `failed`: The process has failed verification. * `exempt`: The process is exempt from verification. You may subscribe to [webhooks](#webhooks) for when the status of a KYB process changes. ## KYB processes ### List of processes * [`profile`](./update-profile): Process associated with the business's own information, such as legal entity name, formation date, industry, legal identifiers, and addresses. * [`documents`](./update-documents): Process associated with the corporate documents evidencing the business's incorporation, ownership, control, standing, and tax identity. * [`associatedPersons`](./associated-persons/list-associated-persons): Process associated with the individuals tied to the business, such as beneficial owners and authorized signers. Each person carries their own set of processes: * [`profile`](./associated-persons/update-profile): The person's identifying information. * [`identity`](./associated-persons/update-identity): Identity verification to prove the person is who they claim to be. * [`proofOfAddress`](./associated-persons/update-proof-of-address): Address verification to prove the person lives where they claim to live. * [`financialInstitution`](./update-financial-institution): Process associated with the regulatory information required from businesses operating in the `financial-institutions-and-money-services` industry sector. It is `exempt` for every other sector. * [`attestation`](./trigger-attestation): Background process that runs the verification checks over everything the business submitted, producing the final KYB decision. ### File-based processes The `documents` and `financialInstitution` processes, as well as the `identity` and `proofOfAddress` processes of each associated person, are completed by referencing previously uploaded files. To complete a file-based process: 1. **Create the file** — call [Create file](../files/create-file) with the `document` category. Use the returned `upload` object to upload the file directly to the storage provider. 2. **Submit the process** — call the corresponding endpoint referencing the file `id`. Every KYB file must belong to the `document` category. Referencing a file from another category fails with `file_invalid`, and referencing a file that was created but never uploaded fails with the same code. ## Associated persons Associated persons are the individuals the platform must know about in order to verify the business. Each person is created with a `type`: * `beneficial-owner`: An individual who ultimately owns or controls a share of the business. Supports `ownershipPercentage`. * `authorized-signer`: An individual authorized to act on behalf of the business. * `beneficial-owner-and-authorized-signer`: An individual who is both. Supports `ownershipPercentage`. Every associated person carries three processes of their own, each with the same `code`/`status`/`input`/`output` shape as the business-level processes: * [`profile`](./associated-persons/update-profile): The person's name, role, date and place of birth, address, legal identifiers, and ownership percentage. * [`identity`](./associated-persons/update-identity): Identity verification through a government-issued document. * [`proofOfAddress`](./associated-persons/update-proof-of-address): Address verification through a supporting document. The `identity` and `proofOfAddress` processes expose an extra `type` field indicating how they are being completed: `none` while nothing has been submitted, or `document-submission` once documents have been provided. The `associatedPersons` process aggregates the status of every person. It only reaches `ok` once all of them have completed their own processes. ## Attestation The `attestation` process is the final step of KYB. It does not accept input — you [trigger](./trigger-attestation) it once the business has submitted everything else, and Uphold runs a set of checks that populate `output.checks`. Each check exposes a `code`, a `status` (`not-started`, `skipped`, `in-progress`, `in-review`, `approved`, or `rejected`), and optionally a `reason` and a `note` explaining the result. If the attestation process fails, you should consult the checks to understand what went wrong and how you or the end-user can fix it. Once the attestation process succeeds, the business is considered verified and compliant with regulatory requirements. ## Webhooks Subscribe to webhooks to be notified as the processes progress: * `core.kyb.{process}.status-changed`: a business-level process changed status. * `core.kyb.associated-person.created` and `core.kyb.associated-person.deleted`: an associated person was added or removed. * `core.kyb.associated-person.{process}.status-changed`: a process of a specific associated person changed status. The payload includes `associatedPersonId`. See [Webhooks](../../webhooks) for delivery, retries, and signature verification. # Trigger attestation Source: https://developer.uphold.com/rest-apis/core-api/kyb/trigger-attestation _media/specs/core-openapi.mintlify.json post /core/kyb/processes/attestation/trigger Trigger the attestation process for a business user, initiating all verifications that will populate the attestation output. This endpoint is not yet available in **Sandbox** or **Production**. **Trigger attestation** starts the final step of KYB, running every verification check over the information the business submitted. More information will be supplied once this endpoint is complete. # Update documents Source: https://developer.uphold.com/rest-apis/core-api/kyb/update-documents _media/specs/core-openapi.mintlify.json patch /core/kyb/processes/documents Update the documents process for a business user. This endpoint is currently available in **Sandbox** only. It is not yet available in **Production**. **Update documents** attaches the corporate documents that evidence the business's existence, ownership, control, standing, and tax identity. Each document is submitted as a slot referencing the `id` of a previously uploaded file. The endpoint is partial — slots you omit keep their current file. ## Document slots Document slots are **functional**: each one serves a purpose — proving incorporation, ownership, control, good standing, or tax identity — rather than being tied to a specific, regionally named piece of paperwork. The examples below are illustrative; the exact document that satisfies a slot, and whether the slot is required at all, varies per region. See [Regional requirements](#regional-requirements) for the full matrix. The document evidencing the incorporation of the business, such as a certificate of incorporation or articles of association. The document evidencing the ownership of the business, such as a shareholder register or an ownership structure chart naming the beneficial owners. The document evidencing who controls the business, such as a register of directors and/or officers. The document evidencing that the business is still active, such as a certificate of good standing. The document evidencing the tax identity of the business, such as a tax registration certificate. ## Regional requirements Document requirements vary from region to region, and from partner to partner based on their risk appetite and regulatory interpretation. The region is determined by the general [Terms of Service](../terms-of-service/introduction#general-terms) the business accepted when it was [created](../users/create-user), not by its registration or operating address. Only the documents collected through the API are listed here — those Uphold obtains itself, through a lookup or an automated verification, are never requested from you. For UK businesses that accounts for every slot, so the `documents` process is `exempt` in its entirety and there is nothing to submit. | Document / Region | UK | US | Notes | | ------------------- | --------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------- | | **`incorporation`** | Exempt | Required | — | | **`ownership`** | Exempt | Conditional¹ | ¹Not required for sole proprietors | | **`control`** | Exempt | Conditional² | ²Not required for sole proprietors, nor for private limited companies with a single 100% owner | | **`standing`** | Exempt | Conditional³ | ³Only when the platform cannot verify standing autonomously | | **`taxIdentity`** | Exempt | Required | — | ## Submitting a document 1. **Create the file** — call [Create file](../files/create-file) with the `document` category, then upload the file using the returned `upload` object. 2. **Attach it** — call this endpoint with the file `id` in the corresponding slot, e.g. `input.incorporation.fileId`. To detach a document, send `null` for its slot. # Update financial institution Source: https://developer.uphold.com/rest-apis/core-api/kyb/update-financial-institution _media/specs/core-openapi.mintlify.json patch /core/kyb/processes/financial-institution Update the financial institution process for a business user. This process is required when the business operates in the `financial-institutions-and-money-services` industry sector. This endpoint is currently available in **Sandbox** only. It is not yet available in **Production**. **Update financial institution** collects the regulatory information required from businesses that are themselves regulated entities. This process only applies when the business declares the `financial-institutions-and-money-services` sector in its [profile](./update-profile). For every other sector the process is `exempt` and there is nothing to submit. ## Fields The process combines free-text declarations with supporting documents: * `regulatorsAndLicenses`: who the business is regulated by, and what licenses it holds. * `moneyTransmitterLicenses`: the business's money transmitter license numbers and issuers. Relevant for US money transmitters. * `compliancePolicies`: the file evidencing the business's compliance policies. * `amlAudit`: the file evidencing the business's most recent AML audit. * `mlroDocumentation`: the file evidencing the appointment and responsibilities of the business's Money Laundering Reporting Officer. The endpoint is partial — fields you omit keep their current value. Documents are created and uploaded using the [Create file](../files/create-file) endpoint with the `document` category. To detach one, send `null` for its field. # Update profile Source: https://developer.uphold.com/rest-apis/core-api/kyb/update-profile _media/specs/core-openapi.mintlify.json patch /core/kyb/processes/profile Update the profile process for a business user. This endpoint is currently available in **Sandbox** only. It is not yet available in **Production**. **Update profile** submits the business's own identifying information: its legal entity name, formation date, industry, government-issued legal identifiers, and addresses. The endpoint is partial — fields you omit keep their current value, so you can build the profile up across several calls as the business provides information. ## Fields set at user creation Some profile fields are established when the business user is created and cannot be changed here: * `legalEntityType` is fixed at creation and is only readable through [Get overview](./get-overview). * `registrationAddress.country` and `operatingAddress.country` are fixed at creation. You can still update the remaining address fields, such as `subdivision`, `city`, `line1`, `line2`, and `postalCode`. You can seed most of the profile directly in the [Create user](../users/create-user) request instead of submitting it afterwards. ## Industry The `industry` object classifies what the business actually does, through a `sector` and its corresponding `subSector`. `money-transmitters`, `remittance-services`, `vasp-casp`, `payment-processors`, `fx-providers`, `other-regulated-nbfi` Businesses in this sector must also complete the [financial institution](./update-financial-institution) process. `exchanges`, `custodial-wallet-providers`, `non-custodial-wallet-providers`, `miners`, `nft-platforms`, `defi-projects` `private-trusts`, `roth-ira`, `llc-spe-for-holding-assets` `family-offices`, `pooled-investment-vehicles`, `private-investment-funds` `law-firms`, `accounting-firms`, `consultants`, `company-formation-or-secretarial-services` `it-services`, `saas`, `gaming-studios`, `cybersecurity`, `data-analytics`, `ai-firms` `online-marketplaces`, `merchants`, `wholesalers`, `direct-to-consumer-retailers` `brokerages`, `property-developers`, `management-companies`, `investment-vehicles` `jewelry`, `art`, `precious-metals`, `automobiles`, `auction-houses` When no sector applies, use `other` and describe the business's activity in the `otherSector` field instead of a `subSector`. ## Legal identifiers `legalIds` carries the government-issued identifiers of the business. Two types are supported: * `tax-id`: the business's tax identification number. * `registration-id`: the business's company registration number. Each identifier declares its own `country`. Only those issued by the country of the business's `registrationAddress` count towards completing the process, but the array is not limited to them — ask the business whether it is registered for tax anywhere else, and declare those identifiers here as well. Submitting an empty array clears every previously submitted identifier. Which identifiers are required depends on the country the business is registered in: | Type / Region | UK | US | Notes | | --------------------- | --------------------------------- | ------------------------- | ----- | | **`tax-id`** | Required (UTRN) | Required | — | | **`registration-id`** | Required (EIN or SSN) | Not applicable | — | Tax ID format validation only runs in **Production**. In **Sandbox**, any non-empty value is accepted. Failures can be triggered by using `INVALID`. # Associated persons status changed Source: https://developer.uphold.com/rest-apis/core-api/kyb/webhooks/associated-persons-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyb.associated-persons.status-changed The status of associated persons has been changed. # Attestation status changed Source: https://developer.uphold.com/rest-apis/core-api/kyb/webhooks/attestation-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyb.attestation.status-changed The attestation status has been changed. # Documents status changed Source: https://developer.uphold.com/rest-apis/core-api/kyb/webhooks/documents-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyb.documents.status-changed The documents status has been changed. # Financial institution status changed Source: https://developer.uphold.com/rest-apis/core-api/kyb/webhooks/financial-institution-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyb.financial-institution.status-changed The financial institution status has been changed. # Profile status changed Source: https://developer.uphold.com/rest-apis/core-api/kyb/webhooks/profile-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyb.profile.status-changed The profile status has been changed. # Get overview Source: https://developer.uphold.com/rest-apis/core-api/kyc/get-overview _media/specs/core-openapi.mintlify.json get /core/kyc Get the KYC overview of an individual user. **Get overview** is the endpoint that provides a summary of the user's KYC process. By default, only the status of each KYC process is included in the response. If you need detailed information, you can use the `detailed` query parameter to specify the KYC processes you want more detail, which will bring their `input`, `output` fields as well as `hint` if applicable. Getting the detailed KYC information is more expensive in terms of performance and should be used only when necessary. ## Overall status The `summary` object describes the user's overall standing, independently of the individual processes: | Status | Meaning | | ------------ | ------------------------------------------------------------------------------------------------ | | `pending` | The platform is still expecting information from the user. | | `ok` | The user has been verified. | | `restricted` | The user has been verified but the platform has limited certain [capabilities](../capabilities). | | `blocked` | The user is not allowed to operate on the platform. | The accompanying `reason` field carries additional context for the current status, and is `none` when there is nothing further to report. # KYC API introduction Source: https://developer.uphold.com/rest-apis/core-api/kyc/introduction Manage KYC processes on the Uphold platform to keep users compliant with regulatory requirements. Learn the process model, statuses, and verification flow. The KYC group of endpoints allows you to manage KYC processes available on the platform, ensuring that your users are compliant with regulatory requirements. KYC applies exclusively to users created with `type: individual` through the [Create user](../users/create-user) endpoint. Business users are onboarded through the [KYB](../kyb/introduction) endpoints instead. ## How it's designed Every KYC is designed as a process that has five fundamental properties: * `code`: A unique identifier for the process. * `status`: As the name implies, this is the status of the process. * `verification`: An object describing how the process should be completed, including who is responsible for verification, how the process is driven, and any dependencies on other processes. * `input`: The input is data provided by the user when prompted to complete the process. * `output`: The output is data that came out from verifying the input data provided by the user. ### Statuses The `status` field can have the following values: * `exempt`: The process is exempt from verification. * `pending`: The process is pending information from the user. * `processing`: The process is currently being verified. * `ok`: The process has been successfully verified. * `failed`: The process has failed verification. You may subscribe to [webhooks](./webhooks) for when the status of a KYC process changes. ### Verification Every KYC process exposes a `verification` object that describes how it must be completed for the current user and organization: ```json theme={null} { "verification": { "model": "partner-verified", "method": "manual", "dependencies": ["phone", "profile", "address"] } } ``` * `model`: who is responsible for verifying the data. * `uphold-verified`: Uphold always produces the `output`. Depending on the process, verification is either driven by a third-party provider session (no explicit update possible) or by `input` submitted by your organization. * `partner-verified`: Your organization performs the verification directly, submitting both `input` and `output` together for immediate `ok` status. Some processes also accept `input` on its own, registering the value as unverified (`pending`) until you submit `output.verifiedAt` in a later call — check each process's endpoint documentation for support. * `method`: whether the process requires explicit input or advances automatically. * `manual`: requires explicit input from the user or your organization (e.g., form answers, documents, or direct data submission). * `automatic`: Uphold runs this without user input, triggered automatically when the processes listed in `triggers` complete. * `triggers`: the list of processes whose completion causes this process to run automatically. Only present when `method` is `automatic` and at least one trigger is defined. * `dependencies`: the list of processes that must be completed before this process can be submitted. Only present when the process has at least one prerequisite. * `rules`: only present on some processes — a per-field map describing each field's `presence` (`required`/`optional`/`disallowed`) and whether it is still `editable` for the current user. ## KYC processes ### List of processes * [`email`](./update-email): Process associated with updating the user's email. * [`phone`](./update-phone): Process associated with updating the user's phone number. * [`profile`](./update-profile): Process associated with updating the user's personal information, such as name, date of birth, citizenship, and residential address. * [`identity`](./update-identity): Process associated with updating the user's identity, usually through a government-issued ID. * [`proofOfAddress`](./update-proof-of-address): Process associated with updating the user's proof-of-address, usually through a utility bill or bank statement. * [`customerDueDiligence`](./update-customer-due-diligence): Process associated with performing due diligence on the customer, ensuring compliance with regulatory requirements. * [`enhancedDueDiligence`](./update-enhanced-due-diligence): Process associated with providing proof after completing the `customerDueDiligence` process that is needed in certain high-risk scenarios. * [`cryptoRiskAssessment`](./update-crypto-risk-assessment): Process associated with performing analysis on the user's knowledge about crypto and associated risks (`GB` residents only). * [`selfCategorizationStatement`](./update-self-categorization-statement): Process associated with identifying the user's investor profile, including risk level and investment preferences (`GB` residents only). * [`taxDetails`](./update-tax-details): Process associated with collecting and verifying the user's tax-related information. * `screening`: Background process associated with checking user provided data against official lists of sanctioned parties. * `risk`: Background process associated with checking user provided data and activity patterns. ### Form-based processes Some processes, such as `customerDueDiligence` and `taxDetails`, are composed of a form with questions the user must answer. For these types of processes, you get a `hint` property which includes a JSON Schema and UI Schema that define the form structure. To complete a form-based process: 1. **Fetch the form schema** — call [Get KYC Overview](./get-overview) with `?detailed={process}` to retrieve the `hint`. 2. **Render the form** — use the hint to display the current questions. See [Dynamic Forms](/developer-guides/resources/dynamic-forms/introduction) for rendering guidance. 3. **Submit answers** — call `PATCH /core/kyc/processes/{process}` with `input.formId` and `input.answers`. 4. **Repeat** — continue until `status` leaves `pending` and `output` is correctly populated. Forms are progressive — answers to one question may change which questions follow. Never hardcode the question set; always re-fetch the hint after each submission. When a form-based process is `partner-verified` for your organization, skip the form cycle — collect the data through your own means, then submit both `input` and `output` in a single PATCH. The process transitions to `ok` immediately. ### File-based processes Some processes, such as `identity`, `proofOfAddress` and `enhancedDueDiligence`, require users to upload necessary documents to complete the verification. To complete a file-based process: 1. **Create the file** — call [Create File](../files/create-file) for each document. Use the returned `upload` object to upload the file directly to the storage provider. 2. **Submit the process** — call `PATCH /core/kyc/processes/{process}` referencing the file `id` in `input.media`. You can look up the exact file categories each KYC process requires by checking the documentation for each endpoint. ### Background processes There are background processes, such as `screening` and `risk`, that happen behind the scenes as the user provides data in other KYC processes and engages in activities on the platform, such as transactions. There are no associated endpoints to update these processes, since their underlying verification is based on internal monitoring done by Uphold. ## Periodic review Some processes require periodic review, in which their status may change to `pending`, signaling that the user must provide updated information. * `profile`: The user must confirm their profile information, including their residential address, is still accurate.The user must confirm their profile information is still accurate. * `identity`: The user must provide up-to-date identity when their underlying document is about to expire. * `customerDueDiligence`: The user must redo the form after a certain period of time. * `selfCategorizationStatement`: The user must redo the form after a certain period of time. ## Webhooks Subscribe to webhooks to be notified as the processes progress: * `core.kyc.{process}.status-changed`: a KYC process changed status. Emitted for every process, including the background `screening` and `risk` processes that have no update endpoint. See [Webhooks](../../webhooks) for delivery, retries, and signature verification. # Update address Source: https://developer.uphold.com/rest-apis/core-api/kyc/update-address _media/specs/core-openapi.mintlify.json patch /core/kyc/processes/address Update the address process for an individual user. **Update address** is the endpoint through which the user can update their address of residence. This process solely updates the user's address of residence. It does **not** perform any verification. For address verification, refer to the [proof-of-address](./update-proof-of-address) process. ## Subdivision update requirements The `subdivision` field is only accepted in the request **if both** of the following conditions are met: * The [proof-of-address](./update-proof-of-address) process is [`partner-verified`](./introduction#verification) for your organization. * The `country` field is **not** set to `US`. If the `subdivision` field is provided without meeting the above conditions, the request is rejected with a `409 Conflict` error — `operation_not_allowed` with `subdivision-update-non-authoritative-proof-of-address` (when proof-of-address is `uphold-verified`) or `subdivision-update-restricted-by-residence-country` (when `country` is `US`) in `details.reasons`. # Update crypto risk assessment Source: https://developer.uphold.com/rest-apis/core-api/kyc/update-crypto-risk-assessment _media/specs/core-openapi.mintlify.json patch /core/kyc/processes/crypto-risk-assessment Update the crypto risk assessment process for an individual user. **Update crypto risk assessment** is the endpoint used to submit the form assessing a user's knowledge of cryptocurrencies and associated risks. This process is **exempt** for all users residing outside of Great Britain (GB). When calling [`GET /core/kyc?detailed=cryptoRiskAssessment`](./get-overview), you will get a `hint` property which includes a [dynamic form](/developer-guides/resources/dynamic-forms/introduction) schema and UI schema. The `hint` property will also be available in responses of this endpoint, in case there are still questions to be answered. For more information about forms, refer to the [form-based processes](./introduction#form-based-processes) section. # Update customer due diligence Source: https://developer.uphold.com/rest-apis/core-api/kyc/update-customer-due-diligence _media/specs/core-openapi.mintlify.json patch /core/kyc/processes/customer-due-diligence Update the customer due diligence process for an individual user. **Update customer due diligence** is the endpoint used to submit the form that collects financial information and determines the user's risk level. When calling [`GET /core/kyc?detailed=customerDueDiligence`](./get-overview), you will get a `hint` property which includes a [dynamic form](/developer-guides/resources/dynamic-forms/introduction) schema and UI schema. The `hint` property will also be available in responses of this endpoint, in case there are still questions to be answered. For more information about forms, refer to the [form-based processes](./introduction#form-based-processes) section. # Update email Source: https://developer.uphold.com/rest-apis/core-api/kyc/update-email _media/specs/core-openapi.mintlify.json patch /core/kyc/processes/email Update the email process for an individual user. **Update email** is the endpoint through which the user registers their email address. Submitting `input` on its own registers the email as unverified (`pending`). Include `output.verifiedAt` to mark it `ok` immediately, or submit it in a later call to verify an existing `pending` registration. See [verification models](/rest-apis/core-api/kyc/introduction#verification) for details. # Update enhanced due diligence Source: https://developer.uphold.com/rest-apis/core-api/kyc/update-enhanced-due-diligence _media/specs/core-openapi.mintlify.json patch /core/kyc/processes/enhanced-due-diligence Update the enhanced due diligence process for an individual user. **Update enhanced due diligence** is the endpoint used to collect additional documentation when a user is categorized as **high risk** based on the outcome of the [customer-due-diligence](./update-customer-due-diligence) process. This process typically starts with a status of **exempt**, but it may be triggered automatically if the user's risk profile is elevated during standard due diligence. ## Source of Funds Requirement To complete the enhanced due diligence process, the user must provide **proof of source of funds**, based on the response provided for their primary source of income. As an example, if the declared source is **salary**, the user must submit a **salary receipt** as proof. The document is provided as a media file in `input.media`. At this time, only a single media file is supported for this process. A document that proves the user's source of funds. File Category: `document` (which allows pdf files as well as typical images mime-types) Files are created and uploaded using the [Create File](../files/create-file) endpoint. # Update identity Source: https://developer.uphold.com/rest-apis/core-api/kyc/update-identity _media/specs/core-openapi.mintlify.json patch /core/kyc/processes/identity Update the identity process for an individual user. The **Update identity** endpoint is used for verifying that a user is who they claim to be. Identity verification can be done in two ways: ## Via document submission The user must provide actual ID documents (e.g., passport, ID card) and biometrics (e.g., selfie) to prove their identity. There are media files required for the identity verification process that must be passed as `input.media`. The following table lists the supported contexts and their respective file categories. The front side of the photo document. File Category: `document` (which allows pdf files as well as typical images content-types) The back side of the photo document. File Category: `document` (which allows pdf files as well as typical images content-types) A selfie of the user. File Category: `image` A selfie of the user holding the photo document. File Category: `image` A recording of the user going through the process of taking pictures. File Category: `video` Files are created and uploaded using the [Create File](../files/create-file) endpoint. ## Via electronic verification (e-IDV) Some KYC providers allow for identity verification through electronic means, i.e., submission of documents is not provided. This is usually done by checking the user's declared information against public and third-party records, such as government, credit, banking, and utility records. e-IDV must be explicitly requested from your assigned **Account Manager** and approved by **Uphold's Compliance team** before it can be enabled. ## When to use document submission verification vs electronic verification The table below indicates whether each method is adequate for proving user identity. | Transfer type | Electronic verification | Document submission | | ---------------------- | ------------------------------- | ------------------- | | **Card deposits** | Enough for FCA registered firms | Enough | | **Bank deposits** | Not enough | Enough | | **Bank withdrawals** | Not enough | Enough | | **Crypto deposits** | Not enough | Enough | | **Crypto withdrawals** | Not enough | Enough | ## Verification rejection reasons When the process completes with `status: failed`, `output.reason` indicates why the verification was rejected: * `data-mismatch`: the submitted data does not match the document. * `duplicate`: the document or identity is already associated with another user. * `underage`: the user does not meet the minimum age requirement. * `provider-verification-rejected`: the upstream provider rejected the submission. When present, `output.providerDetails.reasons[]` carries provider-specific codes that can be surfaced to the user. # Update phone Source: https://developer.uphold.com/rest-apis/core-api/kyc/update-phone _media/specs/core-openapi.mintlify.json patch /core/kyc/processes/phone Update the phone process for an individual user. **Update phone** is the endpoint through which the user registers their phone number. Submitting `input` on its own registers the phone as unverified (`pending`). Include `output.verifiedAt` to mark it `ok` immediately, or submit it in a later call to verify an existing `pending` registration. See [verification models](/rest-apis/core-api/kyc/introduction#verification) for details. # Update profile Source: https://developer.uphold.com/rest-apis/core-api/kyc/update-profile _media/specs/core-openapi.mintlify.json patch /core/kyc/processes/profile Update the profile process for an individual user. **Update profile** is the endpoint through which the user can update their personal information, such as name, date of birth, citizenship, and residential address. This process solely updates the user's personal information and residential address. It does **not** perform identity or address verification. For identity verification, refer to the [identity](./update-identity) process. For address verification, refer to the [proof-of-address](./update-proof-of-address) process. ## Region-specific rules Some fields are only accepted based on the user's region. When a field isn't accepted, it's ignored rather than rejected (see [Warnings](#warnings)). * `subdivision`: only accepted when the [proof-of-address](./update-proof-of-address) process is [`partner-verified`](./introduction#verification) for your organization **and** the `country` field is **not** set to `US`. * `birthplace`: only accepted when the `address.country` field is set to a country that requires it for regulatory compliance. * `otherCitizenships`: only accepted when the `address.country` field is set to a country that requires it for regulatory compliance. ## Warnings If you submit a field whose `presence` is `disallowed` or whose `editable` is `false`, it's silently ignored rather than rejected, and reported in a `warnings` object on the response: ```json theme={null} { "profile": { // ... profile fields ... }, "warnings": { "input": [ { "code": "verification_rule_violated", "message": "Field 'input.otherCitizenships' was ignored as its presence is disallowed", "details": { "property": "input.otherCitizenships", "rule": "presence" } } ] } } ``` # Update proof-of-address Source: https://developer.uphold.com/rest-apis/core-api/kyc/update-proof-of-address _media/specs/core-openapi.mintlify.json patch /core/kyc/processes/proof-of-address Update the proof-of-address process for an individual user. **Update proof-of-address** is the endpoint through which the user proves their declared address of residence. ## Via document submission If electronic verification is not possible, the user must provide a document to prove their address, usually by providing a utility bill or bank statement. The document is provided as a media file, passed in `input.media`. At this time, only a single media file is supported for this process. A document that proves the user's address of residence. File Category: `document` (which allows pdf files as well as typical images content-types) Files are created and uploaded using the [Create File](../files/create-file) endpoint. ## Via electronic verification Some KYC providers allow for address verification through electronic means, without requiring providing any documents. They usually do this by checking the user's declared address of residence against public and third-party records, such as government, credit, banking, and utility records. ## Verification rejection reasons When the process completes with `status: failed`, `output.reason` indicates why the verification was rejected: * `address-mismatch`: the submitted document does not match the declared address. * `name-mismatch`: the name on the submitted document does not match the user's profile. * `provider-verification-rejected`: the upstream provider rejected the submission. When present, `output.providerDetails.reasons[]` carries provider-specific codes that can be surfaced to the user. # Update self-categorization statement Source: https://developer.uphold.com/rest-apis/core-api/kyc/update-self-categorization-statement _media/specs/core-openapi.mintlify.json patch /core/kyc/processes/self-categorization-statement Update the self-categorization statement process for an individual user. **Update self-categorization statement** is the endpoint used to submit the form that determines the user's investor profile, including risk level and investment preferences. This process is **exempt** for all users residing outside of Great Britain (GB). When calling [`GET /core/kyc?detailed=selfCategorizationStatement`](./get-overview), you will get a `hint` property which includes a [dynamic form](/developer-guides/resources/dynamic-forms/introduction) schema and UI schema. The `hint` property will also be available in responses of this endpoint, in case there are still questions to be answered. For more information about forms, refer to the [form-based processes](./introduction#form-based-processes) section. # Update tax details Source: https://developer.uphold.com/rest-apis/core-api/kyc/update-tax-details _media/specs/core-openapi.mintlify.json patch /core/kyc/processes/tax-details Update the tax details process for an individual user. **Update tax details** is the endpoint used to collect and submit tax information required by applicable regulatory frameworks. This includes tax residency details, taxpayer identification numbers, and certification of the accuracy of the provided information. Requirements vary by region — for example, US citizens must provide information for submitting [IRS Form W-9](https://www.irs.gov/pub/irs-pdf/fw9.pdf), while EU residents must declare their countries of tax residence and relevant tax identification numbers. When calling [`GET /core/kyc?detailed=taxDetails`](./get-overview), you will get a `hint` property which includes a [dynamic form](/developer-guides/resources/dynamic-forms/introduction) schema and UI schema. The `hint` property will also be available in responses of this endpoint, in case there are still questions to be answered. For more information about forms, refer to the [form-based processes](./introduction#form-based-processes) section. # Address status Changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/address-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.address.status-changed The address status has been changed. # Crypto risk assessment status changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/crypto-risk-assessment-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.crypto-risk-assessment.status-changed The crypto risk assessment status has been changed. # Customer due diligence status changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/customer-due-diligence-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.customer-due-diligence.status-changed The customer due diligence status has been changed. # Email status changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/email-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.email.status-changed The email status has been changed. # Enhanced due diligence status changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/enhanced-due-diligence-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.enhanced-due-diligence.status-changed The enhanced due diligence status has been changed. # Identity status changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/identity-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.identity.status-changed The identity status has been changed. # Phone status changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/phone-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.phone.status-changed The phone status has been changed. # Profile status changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/profile-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.profile.status-changed The profile status has been changed. # Proof-of-address status changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/proof-of-address-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.proof-of-address.status-changed The proof-of-address status has been changed. # Risk status changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/risk-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.risk.status-changed The risk status has been changed. # Screening status changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/screening-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.screening.status-changed The screening status has been changed. # Self categorization statement status changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/self-categorization-statement-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.self-categorization-statement.status-changed The self categorization statement status has been changed. # Tax details status changed Source: https://developer.uphold.com/rest-apis/core-api/kyc/webhooks/tax-details-status-changed _media/specs/core-openapi.mintlify.json webhook core.kyc.tax-details.status-changed The tax details status has been changed. # Delete metadata Source: https://developer.uphold.com/rest-apis/core-api/metadata/delete-metadata _media/specs/core-openapi.mintlify.json delete /core/{entity}/{entityId}/metadata Delete metadata for a given entity. **Delete metadata** removes all custom metadata associated with a given entity. This is a permanent operation that cannot be undone. Use the `If-Match` header to ensure you're deleting the expected version of the metadata, preventing accidental deletion of data that was modified concurrently by another process. This operation permanently deletes all metadata. If you only need to remove specific properties, use the [Update metadata](./update-metadata) endpoint with a JSON Patch `remove` operation instead. # Get metadata Source: https://developer.uphold.com/rest-apis/core-api/metadata/get-metadata _media/specs/core-openapi.mintlify.json get /core/{entity}/{entityId}/metadata Retrieve metadata for a given entity. **Get metadata** retrieves the custom metadata associated with a given entity. The response includes an `ETag` header representing the current version of the metadata. You can use this value with the `If-None-Match` header in subsequent requests to efficiently check if the metadata has changed—if it hasn't, the server returns `304 Not Modified` without the response body, saving bandwidth. Returns `404 Not Found` if no metadata exists for the entity. Create metadata using the [Set metadata](./set-metadata) endpoint. # Set metadata Source: https://developer.uphold.com/rest-apis/core-api/metadata/set-metadata _media/specs/core-openapi.mintlify.json put /core/{entity}/{entityId}/metadata Create or update metadata for a given entity. **Set metadata** creates or updates custom metadata for a given entity. This endpoint follows idempotent PUT semantics: * **Create**: Returns `201 Created` if metadata doesn't exist * **Replace**: Returns `200 OK` if metadata already exists The response includes an `ETag` header representing the version of the metadata, which can be used with `If-Match` or `If-None-Match` headers for conditional requests to prevent concurrent update conflicts. Each PUT request replaces the entire metadata object. Make sure to include all properties you want to retain. # Update metadata Source: https://developer.uphold.com/rest-apis/core-api/metadata/update-metadata _media/specs/core-openapi.mintlify.json patch /core/{entity}/{entityId}/metadata Update existing metadata for a given entity. **Update metadata** modifies specific properties using [JSON Patch](http://jsonpatch.com/) operations. Unlike [Set metadata](./set-metadata), this enables partial updates without replacing the entire object. Supported operations: **add**, **replace**, **remove**, **move**, **copy**, and **test**. The response includes an `ETag` header for the new version. Use `If-Match` to prevent conflicting concurrent modifications. Operations are applied sequentially and validated to ensure metadata remains valid. # Get account historical balance Source: https://developer.uphold.com/rest-apis/core-api/portfolio/get-portfolio-account-historical-balance _media/specs/core-openapi.mintlify.json get /core/portfolio/accounts/{accountId}/historical-balance Retrieves the historical balance of a specific account, including the total and available balance over time. ## Denomination The `denomination` query parameter allows you to denominate rates against another asset. If no value is set for this parameter, it defaults to `USD`. There is a specific set of assets allowed, including but not limited to: `USD`, `EUR`, `GBP`, `AUD`, `CAD`, `NZD`, `MXN`, and `BTC`. If you need a particular asset added to this list, please reach out to your Account Manager. For a more detailed explanation of the denomination concept, check the [Core Concepts](../concepts#denomination) page. ## Interval The `interval` query parameter determines the overall timespan of historical data and the frequency of the data points (rollup frequency). Each interval value defines how far back in time the data spans, and how granular each returned data point is within that range: | Interval | Description | Rollup Frequency | | :---------- | :---------------------------- | :--------------- | | `one-hour` | Past hour of historical data | 30 seconds | | `one-day` | Past day of historical data | 10 minutes | | `one-week` | Past week of historical data | 1 hour | | `one-month` | Past month of historical data | 6 hours | | `one-year` | Past year of historical data | 2 days | # Get account performance Source: https://developer.uphold.com/rest-apis/core-api/portfolio/get-portfolio-account-performance _media/specs/core-openapi.mintlify.json get /core/portfolio/accounts/{accountId}/performance Retrieves the performance of an account in the portfolio, including the average cost, total invested, and unrealized gains (gains and losses). You can retrieve the performance of several accounts in the portfolio by using the [Get Many Accounts Performance](./get-portfolio-many-accounts-performance) endpoint. All calculations are performed internally in USD. When a denomination asset other than USD is used, amounts are converted using current market exchange rates. This can lead to discrepancies in portfolio performance values, which tend to be minor for assets that remain relatively stable against USD. ## Denomination The `denomination` query parameter allows you to denominate rates against another asset. If no value is set for this parameter, it defaults to `USD`. For a more detailed explanation of the denomination concept, check the [Core Concepts](../concepts#denomination) page. ## Interval The `interval` query parameter determines the time period for which performance data is calculated and returned. This allows you to analyze account performance over different timeframes, from recent short-term activity to comprehensive long-term investment tracking. Each interval value corresponds to a specific historical period: | Interval | Description | | :--------- | :---------------------------- | | `one-hour` | Past hour of historical data | | `one-day` | Past day of historical data | | `all-time` | All historical data available | # Get asset historical balance Source: https://developer.uphold.com/rest-apis/core-api/portfolio/get-portfolio-asset-historical-balance _media/specs/core-openapi.mintlify.json get /core/portfolio/assets/{asset}/historical-balance Retrieves the historical balance of a specific asset in the portfolio, including the total and available balance over time. ## Denomination The `denomination` query parameter allows you to denominate rates against another asset. If no value is set for this parameter, it defaults to `USD`. There is a specific set of assets allowed, including but not limited to: `USD`, `EUR`, `GBP`, `AUD`, `CAD`, `NZD`, `MXN`, and `BTC`. If you need a particular asset added to this list, please reach out to your Account Manager. For a more detailed explanation of the denomination concept, check the [Core Concepts](../concepts#denomination) page. ## Interval The `interval` query parameter determines the overall timespan of historical data and the frequency of the data points (rollup frequency). Each interval value defines how far back in time the data spans, and how granular each returned data point is within that range: | Interval | Description | Rollup Frequency | | :---------- | :---------------------------- | :--------------- | | `one-hour` | Past hour of historical data | 30 seconds | | `one-day` | Past day of historical data | 10 minutes | | `one-week` | Past week of historical data | 1 hour | | `one-month` | Past month of historical data | 6 hours | | `one-year` | Past year of historical data | 2 days | # Get asset performance Source: https://developer.uphold.com/rest-apis/core-api/portfolio/get-portfolio-asset-performance _media/specs/core-openapi.mintlify.json get /core/portfolio/assets/{asset}/performance Retrieves the performance of an asset in the portfolio, including the average cost, total invested, and unrealized gains (gains and losses). You can retrieve the performance of several assets in the portfolio by using the [Get Many Assets Performance](./get-portfolio-many-assets-performance) endpoint. All calculations are performed internally in USD. When a denomination asset other than USD is used, amounts are converted using current market exchange rates. This can lead to discrepancies in portfolio performance values, which tend to be minor for assets that remain relatively stable against USD. ## Denomination The `denomination` query parameter allows you to denominate rates against another asset. If no value is set for this parameter, it defaults to `USD`. For a more detailed explanation of the denomination concept, check the [Core Concepts](../concepts#denomination) page. ## Interval The `interval` query parameter determines the time period for which performance data is calculated and returned. This allows you to analyze asset performance over different timeframes, from recent short-term activity to comprehensive long-term investment tracking. Each interval value corresponds to a specific historical period: | Interval | Description | | :--------- | :---------------------------- | | `one-hour` | Past hour of historical data | | `one-day` | Past day of historical data | | `all-time` | All historical data available | # Get historical balance Source: https://developer.uphold.com/rest-apis/core-api/portfolio/get-portfolio-historical-balance _media/specs/core-openapi.mintlify.json get /core/portfolio/historical-balance Retrieves the historical balance of the portfolio, including the total and available balance over time. ## Denomination The `denomination` query parameter allows you to denominate rates against another asset. If no value is set for this parameter, it defaults to `USD`. There is a specific set of assets allowed, including but not limited to: `USD`, `EUR`, `GBP`, `AUD`, `CAD`, `NZD`, `MXN`, and `BTC`. If you need a particular asset added to this list, please reach out to your Account Manager. For a more detailed explanation of the denomination concept, check the [Core Concepts](../concepts#denomination) page. ## Interval The `interval` query parameter determines the overall timespan of historical data and the frequency of the data points (rollup frequency). Each interval value defines how far back in time the data spans, and how granular each returned data point is within that range: | Interval | Description | Rollup Frequency | | :---------- | :---------------------------- | :--------------- | | `one-hour` | Past hour of historical data | 30 seconds | | `one-day` | Past day of historical data | 10 minutes | | `one-week` | Past week of historical data | 1 hour | | `one-month` | Past month of historical data | 6 hours | | `one-year` | Past year of historical data | 2 days | # Get overview Source: https://developer.uphold.com/rest-apis/core-api/portfolio/get-portfolio-overview _media/specs/core-openapi.mintlify.json get /core/portfolio Retrieves an overview of the portfolio, including the total portfolio value and a breakdown of individual holdings. ## Denomination The `denomination` query parameter allows you to denominate rates against another asset. If no value is set for this parameter, it defaults to `USD`. For a more detailed explanation of the denomination concept, check the [Core Concepts](../concepts#denomination) page. # Get performance Source: https://developer.uphold.com/rest-apis/core-api/portfolio/get-portfolio-performance _media/specs/core-openapi.mintlify.json get /core/portfolio/performance Retrieves the performance of the portfolio, including the average cost, total invested, and unrealized gains (gains and losses). All calculations are performed internally in USD. When a denomination asset other than USD is used, amounts are converted using current market exchange rates. This can lead to discrepancies in portfolio performance values, which tend to be minor for assets that remain relatively stable against USD. ## Denomination The `denomination` query parameter allows you to denominate rates against another asset. If no value is set for this parameter, it defaults to `USD`. For a more detailed explanation of the denomination concept, check the [Core Concepts](../concepts#denomination) page. ## Interval The `interval` query parameter determines the time period for which performance data is calculated and returned. This allows you to analyze account performance over different timeframes, from recent short-term activity to comprehensive long-term investment tracking. Each interval value corresponds to a specific historical period: | Interval | Description | | :--------- | :---------------------------- | | `one-hour` | Past hour of historical data | | `one-day` | Past day of historical data | | `all-time` | All historical data available | # Portfolio API introduction Source: https://developer.uphold.com/rest-apis/core-api/portfolio/introduction Retrieve aggregated user financial position data on Uphold: portfolio overview, performance metrics, and historical balances across all accounts and assets. The portfolio group of endpoints provides aggregated insights into a user's financial position across all accounts. These endpoints allow you to track current holdings, performance metrics, and historical balances, offering a comprehensive view of investment performance and portfolio evolution over time. ## Core concepts ### Overview The portfolio **overview** provides a snapshot of current holdings and total portfolio value. This represents the 'What you own right now?' view of a user's financial position, including: * Total portfolio value across all assets * Breakdown of each asset holding with available and total balances ### Performance Portfolio **performance** focuses on investment analytics and profitability metrics. This answers the question 'How well are your investments doing?' by providing: * **Average cost**: The average price paid of an asset * **Total invested**: The total amount of funds invested in the portfolio * **Unrealized gains/losses**: The difference between current value and total invested Performance calculations consider both realized and unrealized gains, providing accurate investment analytics that reflect true portfolio value changes over time. The endpoints offer multiple levels of granularity: Aggregate performance across all user accounts, providing a unified view of investment success. Includes total returns, overall gain/loss percentages, and portfolio-wide investment metrics. Individual account performance metrics, allowing you to analyze which specific accounts are performing best. Essential for understanding asset allocation effectiveness. Bulk performance data retrieval for multiple accounts in a single API call. Optimized for dashboard applications that need to display performance across many accounts efficiently. Individual asset performance metrics, allowing you to analyze which specific asset types are performing best. Essential for understanding asset allocation effectiveness. Bulk performance data retrieval for multiple assets in a single API call. Optimized for dashboard applications that need to display performance across many assets efficiently. ### Historical balance **Historical balance** tracking provides time-series data showing how portfolio values have evolved. This enables trend analysis and 'How did we get here?' insights through: * Data spanning the past hour, day, week, month, or year * Total and available balance history * Account-specific or asset-specific balance evolution ## Use cases The portfolio endpoints power key features in financial applications. Here are examples of how these endpoints are used in the Uphold Wallet to provide comprehensive portfolio insights: Core Data Model Core Data Model The two images above demonstrate real-world implementations of portfolio data. Here's how each endpoint contributes to building these comprehensive views: #### Portfolio overview screen | Annotation | Endpoint | Description | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | 1 & 2 | [Get Portfolio Overview](./get-portfolio-overview) | Core Portfolio Data: The total portfolio balance and individual asset holdings | | 3 | [Get Portfolio Historical Balance](./get-portfolio-historical-balance) | Historical Performance Visualization: The portfolio performance graph displays historical balance trends | | 4 | [Get Portfolio Performance](./get-portfolio-performance) | Performance Metrics: The portfolio performance percentage is calculated | | 5 | [Get Portfolio Account Performance](./get-portfolio-account-performance), [Get Many Accounts Performance](./get-portfolio-many-accounts-performance) | Account-Level Performance: Individual account performance indicators, powered by either single or multiple account endpoints | #### Account detail screen The individual account view focuses on specific account analytics: | Annotation | Endpoint | Description | | ---------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | 1 & 2 | [Get Portfolio Account Performance](./get-portfolio-account-performance) | Account Balance and Performance metrics | | 3 | [Get Portfolio Account Historical Balance](./get-portfolio-account-historical-balance) | Account-specific performance chart showing historical balance | # Get request for information Source: https://developer.uphold.com/rest-apis/core-api/requests-for-information/get-request-for-information _media/specs/core-openapi.mintlify.json get /core/requests-for-information/{requestForInformationId} Retrieve an existing request for information by id. **Get request for information** retrieves a single RFI by its id, including its current `status` and, once resolved, the `data` submitted to satisfy it. # List requests for information Source: https://developer.uphold.com/rest-apis/core-api/requests-for-information/list-requests-for-information _media/specs/core-openapi.mintlify.json get /core/requests-for-information List requests for information for a specific reference id. **List requests for information** returns every RFI associated with a given quote or transaction, identified by the `referenceId` query parameter. The `referenceId` query parameter accepts either a quote ID or a transaction ID — pass whichever ID the RFI is associated with. # Update request for information Source: https://developer.uphold.com/rest-apis/core-api/requests-for-information/update-request-for-information _media/specs/core-openapi.mintlify.json put /core/requests-for-information/{requestForInformationId} Update an existing request for information. **Update request for information** submits the `data` needed to resolve a pending RFI (e.g. Travel Rule originator/beneficiary information), transitioning its `status` away from `pending`. If the RFI has expired (check the `expiresAt` timestamp), the endpoint returns a `409 request_for_information_expired` error. Create a new quote with the same destination parameters to generate a fresh RFI. This endpoint returns a `409 operation_not_allowed` error if the RFI `status` is not `pending` — for example, if it has already been resolved or has expired. Unlike the deprecated transaction-nested endpoint, this endpoint does not fall back to a `200` response with warnings in this situation. Integrations must handle the `409` case. # Get portfolio statement Source: https://developer.uphold.com/rest-apis/core-api/statements/get-portfolio-statement _media/specs/core-openapi.mintlify.json get /core/statements/portfolio Retrieves the portfolio statement for a given period. # Get transactions statement Source: https://developer.uphold.com/rest-apis/core-api/statements/get-transactions-statement _media/specs/core-openapi.mintlify.json get /core/statements/transactions Retrieves the transactions statement for a given period. # Introduction Source: https://developer.uphold.com/rest-apis/core-api/statements/introduction The statements group of endpoints provides monthly financial records for a user's portfolio and transactions. These are designed for reporting and reconciliation purposes, giving a complete view of a user's financial activity and holdings for a given period. ## Available statements | Type | Description | | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | | [Portfolio statement](./get-portfolio-statement) | Holdings per asset at the end of the period, with exchange rates in the requested denomination. | | [Transactions statement](./get-transactions-statement) | Paginated list of completed transactions during the period, with full transaction details and per-entry exchange rates. | ## Related guides Retrieve monthly portfolio snapshots and transaction history, and generate compliance reports for your users. # Accept terms of service Source: https://developer.uphold.com/rest-apis/core-api/terms-of-service/accept-terms-of-service _media/specs/core-openapi.mintlify.json post /core/terms-of-service/{termsOfService}/accept Accept terms of service by code. The `X-Uphold-User-Ip` [user context](../../headers#user-context) header is mandatory on this request, so that the user's IP gets recorded when accepting any Terms of Service. # Get terms of service Source: https://developer.uphold.com/rest-apis/core-api/terms-of-service/get-terms-of-service _media/specs/core-openapi.mintlify.json get /core/terms-of-service/{termsOfService} Retrieve terms of service by code. If the [subject](../../authentication#subjects) calling the endpoint is a user, the returned response will be contextualized accordingly. As an example, the response will contain the date of acceptance of terms the user has previously accepted. # Terms of Service API introduction Source: https://developer.uphold.com/rest-apis/core-api/terms-of-service/introduction Manage Uphold platform Terms of Service that users must accept to transact. Each ToS has a unique code, with content provided by Uphold during onboarding. The Uphold platform has a set of Terms of Service that users must accept to use the platform. Terms of Service are identified by a unique `code`, and the actual content is provided by Uphold's legal team during your onboarding. ## General terms In order to create a user on the platform, the user must accept the general Uphold Terms of Service applicable to their country. Upon [user creation](../users/create-user), you must provide the general Terms of Service that the user is agreeing to. You can retrieve the applicable general Terms of Service by calling [`GET /core/terms-of-service?type=general&country={country}`](./list-terms-of-service) based on the user's country of residence. ## Other types of terms Depending on what features you want to provide to users, they may need to accept additional Terms of Service. These will be visible for you in their [capabilities](../capabilities/introduction), as requirements. For example, the `unique-account-number-viban` capability will have a requirement of `user-must-accept-unique-account-number-viban-terms-of-service`. You should register acceptance of these additional Terms of Service by calling the [Accept terms of service](./accept-terms-of-service) endpoint. ## Updates to terms Uphold may modify the Terms of Service at any time. If changes are made, you will receive notice so that you can update your integration accordingly. Furthermore, users that agree with a specific Terms of Service also agree with its future modifications. Still, you are required to notify your users of those changes. # List terms of service Source: https://developer.uphold.com/rest-apis/core-api/terms-of-service/list-terms-of-service _media/specs/core-openapi.mintlify.json get /core/terms-of-service List terms of service of the platform. If the [subject](../../authentication#subjects) calling the endpoint is a user, the returned response will be contextualized accordingly. As an example, the response will contain the date in which the user accepted each terms, if applicable. # Create quote Source: https://developer.uphold.com/rest-apis/core-api/transactions/create-quote _media/specs/core-openapi.mintlify.json post /core/transactions/quote Create a quote for a transaction. ## Origin and destination The `origin` and `destination` objects identify the source and destination of funds respectively for the transaction. Read more about the [Anatomy of a quote request](/rest-apis/core-api/transactions/introduction) to understand the different types of nodes you can use as origin and destination. Refer to the API spec below to see how each node type is expressed in the request. ## Denomination The `denomination` object defines what is being moved, how much, and which side of the trade the amount applies to precisely. * `asset`: The currency or asset in which `amount` is expressed — for example, `GBP`, `USD`, or `BTC`. Does not need to match the asset of either account; Uphold will convert as needed. * `amount`: The amount to transfer, expressed as a decimal string (e.g. `"100.00"`). * `target`: Controls which side of the trade receives the exact `amount`: * `origin` — Debits exactly `amount` from the origin. Fees are deducted before the destination receives funds. * `destination` — Credits exactly `amount` to the destination. Fees are added on top of what is debited from the origin. #### Example: How target affects fee handling Consider a trade of 500 GBP to BTC with a 2% fee: | Target | Debited from origin | Credited to destination | | ------------- | -------------------------- | -------------------------------------------- | | `origin` | Exactly 500 GBP | BTC equivalent of 490 GBP (after 10 GBP fee) | | `destination` | 510 GBP (500 + 10 GBP fee) | BTC equivalent of exactly 500 GBP | Use `origin` when you want to control exactly how much leaves the sender. Use `destination` when you want to control exactly how much arrives for the recipient. For a more comprehensive explanation of the denomination concept, see the [Core Concepts](/rest-apis/core-api/concepts#denomination) page. ## TTL There are cases in which the default TTL may not be sufficient, such as when you are performing deposits or withdrawals through your own rails (e.g.: your own card processor). In such cases, you want a quote to remain valid for long enough to allow the user to complete the transaction on your side before it expires on our side. The `ttl` field allows you to extend the validity of the quote to accommodate those cases. When omitted, the platform applies a default TTL based on the transaction type. This feature must be enabled for your organization. Contact your **Account Manager** to request access and agree on the maximum allowed TTL. The `expiresAt` field in the response indicates the exact time when the quote will expire, which takes into account the default TTL or the custom `ttl` provided in the request. Use this field to drive quote refresh logic in your UI — schedule the **next refresh slightly before this time** (e.g. a few seconds earlier) to account for network and infrastructure latency, ensuring the user always sees a valid quote. ## Requirements Some quotes require additional information before they can be executed. The `requirements` array in the response lists what is needed — if it's empty, no extra information is needed and you can proceed to create the transaction directly. Read more about [requirements](/rest-apis/core-api/transactions/introduction#requirements). # Create transaction Source: https://developer.uphold.com/rest-apis/core-api/transactions/create-transaction _media/specs/core-openapi.mintlify.json post /core/transactions Create transaction from a quote. The transaction ID will be the same as the quote ID to allow for easy tracking and correlation. You can optionally include custom [entity metadata](../../entity-metadata) in the `metadata` field to store your own business data (e.g., reference numbers, categorization, reconciliation data). If not provided during creation, you can add it later using [Set metadata](../metadata/set-metadata). Some types of transactions require you to pass [user context](../../headers#user-context) headers, such as `X-Uphold-User-Ip` and `X-Uphold-User-Agent` Headers. # Get transaction Source: https://developer.uphold.com/rest-apis/core-api/transactions/get-transaction _media/specs/core-openapi.mintlify.json get /core/transactions/{transactionId} Retrieve an existing transaction by id. # Transactions API introduction Source: https://developer.uphold.com/rest-apis/core-api/transactions/introduction Initiate and retrieve transactions on the Uphold platform. Transactions are defined by origin, destination, and direction nodes, with quotes and statuses. The transactions group of endpoints allows you to initiate and retrieve transactions from the platform. ## Transaction nodes Every transaction is defined by three fundamental properties: * `origin`: Defines the origin node of the transaction. * `destination`: Defines the destination node of the transaction. * `denomination`: Defines what and how much is being moved. The `origin` and `destination` are each represented by a node. The following node types are supported across the platform: Account nodes refer to the user's [accounts](../accounts), which are containers of funds of a given asset, stored in Uphold. External accounts refer to the user's [external accounts](../external-accounts), which allow users to deposit and withdraw funds associated with an external source. Crypto addresses enable the use of crypto wallet addresses as transaction nodes within the platform. Crypto transactions can be executed in different modes depending on the destination and environment. Most transactions are processed **on-chain**, directly on the blockchain network. However, when both parties are Uphold users, crypto withdrawals can be processed **off-chain** within Uphold's infrastructure, eliminating network fees and reducing processing time. For development and testing purposes, transactions can be **simulated** without affecting actual blockchain state (user balances will be affected though). The execution mode applied to each transaction is indicated by the [`execution.mode`](./create-transaction#response-transaction-destination-node-execution) property in the crypto address node of the API response. Bank address nodes represent the originating bank account of a push bank deposit, in the cases when adding the originating bank account as an external account is not supported by the platform. They carry the `network` used for the transfer (e.g. `ach`). Alternative Payment Method (APM) nodes represent payment methods such as PayPal. They enable users to deposit and withdraw funds using their preferred payment provider. ## Initiating a quote-based transaction Most transactions are RFQ (Request for Quote) based. This applies to: * **Withdrawals**: Moving funds out to external accounts or crypto addresses. * **Trades**: Converting between different assets. * **Transfers**: Moving funds between accounts of the same asset. * **Pull deposits**: Deposits where you initiate the transaction (e.g., credit or debit card deposits). To create a quote, call the [Create quote](/rest-apis/core-api/transactions/create-quote) endpoint and present the user with the quote details. Quotes have a unique ID and are valid for a limited time. While the user hasn't confirmed the quote, keep refreshing it before it expires. If the user accepts the quote, execute the transaction by calling the [Create transaction](/rest-apis/core-api/transactions/create-transaction) endpoint, passing the quote ID. ### Anatomy of a quote request The supported [node types](#transaction-nodes) differ between `origin` and `destination`: * **Origin**: account external-account apm * **Destination**: account external-account crypto-address apm This model allows you to create transactions between different types of nodes in a unified manner, such as between an account and an external account, or between an account and a crypto address. With Uphold's **Anything to Anything** system, you can handle different classes of assets in the origin and destination on a single transaction. For example, you can do a fiat deposit using an external account directly into a BTC account, or you can withdraw from a BTC account directly to an XRP crypto address. ### Requirements Some transactions need additional information before they can be executed. The `requirements` array in the quote response lists what is needed — if it's empty, no extra information is needed and you can proceed to create the transaction directly. | Requirement | How to resolve | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `travel-rule` | Open the [Travel Rule Widget](/developer-guides/travel-rule/overview), have the user fill in the required data — the widget automatically resolves the RFI. | | `authorize:apple-pay` | Run the [Payment Widget's Authorize flow](/developer-guides/apm-transfers/deposit/via-rest-api/apple-pay#authorize-and-create-the-transaction) to authorize the transaction with Apple Pay. | | `authorize:google-pay` | Run the [Payment Widget's Authorize flow](/developer-guides/apm-transfers/deposit/via-rest-api/google-pay#authorize-and-create-the-transaction) to authorize the transaction with Google Pay. | | `authorize:paypal` | Run the [Payment Widget's Authorize flow](/developer-guides/apm-transfers/deposit/via-rest-api/paypal#authorize-and-create-the-transaction) to authorize the user's PayPal account. | | `authorize:3ds` | Pass a `returnUrl` as the requirement when creating the transaction. See [Card deposit](/developer-guides/card-transfers/deposit/via-rest-api#confirm-and-create-transaction) for a detailed walkthrough. | ### Related guides Withdraw crypto from an account. Payout to a bank via quote-based transfers. Fund an account from a credit or debit card. Cash out to a credit or debit card. Fund an account from an alternative payment method. Pay out to an alternative payment method. ## Initiating an external transaction Some transactions are initiated externally: * **Crypto deposits**: Occur when crypto is sent to a deposit address generated via [Set up account deposit method](/rest-apis/core-api/accounts/set-up-account-deposit-method). * **Bank deposits**: Occur when funds are sent to the bank deposit details generated via [Set up account deposit method](/rest-apis/core-api/accounts/set-up-account-deposit-method). * **Bank disbursements**: Occur when funds are pulled from an unlinked external account (e.g., microdeposit disbursements during bank account ownership verification). These transactions are created automatically by the platform when incoming funds are detected. You can monitor them via [Webhooks](/rest-apis/core-api/transactions/webhooks) or by polling the transaction endpoints. The supported [node types](#transaction-nodes) for external transactions are: * **Origin**: account external-account crypto-address bank-address * **Destination**: account external-account crypto-address bank-address ### Related guides Fund an account with crypto. Fund an account via bank transfer. ### Requests for information (RFIs) When a transaction is placed on hold with reason `pending-requests-for-information`, that means it requires additional information to proceed. The pending requests for information (RFIs) can be managed using the [Requests for information](/rest-apis/core-api/requests-for-information) endpoints. Check out the [Handle on-hold transactions](/developer-guides/crypto-transfers/deposit/via-rest-api#handle-on-hold-transactions) section of the crypto deposit guide for a detailed walkthrough. **Proof types:** Transactions may require the user to provide proof to resolve an RFI. Learn about the available proof types in [Proof types for Travel Rule](/developer-guides/travel-rule/proof-types). ## Transaction lifecycle Once a transaction is created, it goes through a series of statuses: * `processing`: The transaction is being processed. This is the initial status of a transaction. * `on-hold`: The transaction is on hold and requires further action by Uphold agents. Once the hold is resolved, the transaction will continue processing. * `completed`: The transaction has been successfully completed. * `failed`: The transaction has failed. ## Transaction types Transactions do not have a type per se, but they can be classified based on the nodes involved in the transaction. The following are the most common types of transactions: * **Deposit**: A transaction in which the origin is external, such as an external account or a crypto address. * **Withdrawal**: A transaction in which the destination is external, such as an external account or a crypto address. * **Trade**: A transaction in which the origin and destination are accounts of different assets. * **Transfer**: A transaction in which the origin and destination are accounts of the same asset. ## Relation with capabilities A transaction can require one or more capabilities to be in a valid state: * `trades`: Required when the underlying origin and destination assets are different. * `deposits`: Required when the underlying origin is external, such as an external account of type `card`. * `receives`: Required when the user receives funds from another user. * `sends`: Required when the user sends funds to another user. * `crypto-deposits`: Required when the underlying origin is a crypto address. * `crypto-withdrawals`: Required when the underlying destination network is of type `crypto`. * `bank-deposits`: Required when the underlying origin is an external account of type `bank`. * `bank-withdrawals`: Required when the underlying destination network is of type `bank`. * `cards`: Required when transacting with a card. * `card-withdrawals`: Required when the underlying destination network is of type `card`. * `apple-pay`: Required when transacting with Apple Pay. * `apple-pay-withdrawals`: Required when the payment method is `apple-pay`. * `google-pay`: Required when transacting with Google Pay. * `paypal`: Required when transacting with PayPal. * `paypal-withdrawals`: Required when the underlying destination network is of type `paypal`. ## Transaction amount errors The API returns `transaction_amount_invalid` error when the specified amount violates a platform rule. Below is a breakdown of the cases your integration should handle. ### Per-transaction limits Depending on the type of transaction and the amount on the denomination, you may hit minimum or maximum single limits. If the amount exceeds the maximum, the error `rule` will be set to `maximum-limit-exceeded` and the `limit` object provides the maximum allowed amount under `maximumAmount`: ```json theme={null} { "code": "transaction_amount_invalid", "message": "The amount maximum limit was exceeded", "details": { "context": "body", "property": "denomination", "rule": "maximum-limit-exceeded", "limit": { "maximumAmount": "150" } } } ``` If the amount is below the minimum, the error `rule` will be set to `minimum-limit-not-met` and the `limit` object provides the minimum allowed amount under `minimumAmount`: ```json theme={null} { "code": "transaction_amount_invalid", "message": "The amount minimum limit was not met", "details": { "context": "body", "property": "denomination", "rule": "minimum-limit-not-met", "limit": { "minimumAmount": "0.85" } } } ``` ### Periodic limits Users may have periodic spending limits (daily, weekly, or monthly) that are enforced when transacting. If the transaction amount would exceed the period's allowance, the error `rule` encodes the period that was exceeded. The `limit` object provides the total allowed amount for that period (`maximumAmount`) and how much of it is still available (`remainingAmount`): ```json theme={null} { "code": "transaction_amount_invalid", "message": "The amount exceeds the daily limit", "details": { "context": "body", "property": "denomination", "rule": "amount-exceeds-daily-limit", "limit": { "maximumAmount": "100.00", "remainingAmount": "80.00" } } } ``` Possible rules: `amount-exceeds-daily-limit`, `amount-exceeds-weekly-limit`, `amount-exceeds-monthly-limit`. ### Unsettled funds Some deposit networks (e.g. ACH) do not settle immediately. While a deposit is pending settlement, the respective funds will be locked for withdrawal. If a transaction that moves funds off the user — such as a withdrawal — relies on those locked funds, the request is rejected with `rule` set to `transacting-unsettled-funds`. The `unsettledFunds` object provides the settlement timestamp (`settlementAt`) — which indicates when the funds will be unlocked — the amount locked required to perform the transaction (`shortfallAmount`), and the reasons the funds are locked (`reasons`): ```json theme={null} { "code": "transaction_amount_invalid", "message": "The amount is invalid due to unsettled funds", "details": { "context": "body", "property": "denomination", "rule": "transacting-unsettled-funds", "unsettledFunds": { "settlementAt": "2024-07-26T13:00:00.000Z", "shortfallAmount": "0.95", "reasons": [ "pending-ach-deposit-settlement" ] } } } ``` # List account transactions Source: https://developer.uphold.com/rest-apis/core-api/transactions/list-account-transactions _media/specs/core-openapi.mintlify.json get /core/accounts/{accountId}/transactions List transactions for an account. # List transactions Source: https://developer.uphold.com/rest-apis/core-api/transactions/list-transactions _media/specs/core-openapi.mintlify.json get /core/transactions List transactions associated with a user. # Transaction created Source: https://developer.uphold.com/rest-apis/core-api/transactions/webhooks/transaction-created _media/specs/core-openapi.mintlify.json webhook core.transaction.created A new transaction has been created. # Transaction status changed Source: https://developer.uphold.com/rest-apis/core-api/transactions/webhooks/transaction-status-changed _media/specs/core-openapi.mintlify.json webhook core.transaction.status-changed The transaction status has been changed. # Create user Source: https://developer.uphold.com/rest-apis/core-api/users/create-user _media/specs/core-openapi.mintlify.json post /core/users Create a new user. **Create user** registers a new user with their identity and compliance information. Users must read and agree with the [general Terms of Service](../terms-of-service/introduction#general-terms) associated with their country of residence, and you must provide it in the `termsOfService` field of the request. You can optionally include custom [entity metadata](../../entity-metadata) in the `metadata` field to store your own business data (e.g., external IDs, settings, tracking parameters). If not provided during creation, you can add it later using [Set metadata](../metadata/set-metadata) endpoint. The `X-Uphold-User-Ip` [user context](../../headers#user-context) header is **mandatory** to record the user's IP when accepting the Terms of Service. ## Completing KYC at creation For individual users, you can optionally provide KYC data directly in this request instead of submitting it afterward through separate calls: `fullName`, `birthdate`, and `address` complete the [profile](../kyc/update-profile) process. You can also provide `phone`, but the [phone](../kyc/update-phone) process still requires verification. This is entirely optional — you can also create the user with the minimum required fields and complete these processes later. # Delete user Source: https://developer.uphold.com/rest-apis/core-api/users/delete-user _media/specs/core-openapi.mintlify.json delete /core/users/me Delete an existing user. Deleting a user is a sensitive action that must be taken with caution. It's reversible but it will require a manual process to restore the user. The platform will not allow you to delete a user if they have pending transactions or a positive balance. In case of a positive balance, you must ask the user to withdraw their funds before proceeding with the deletion. If a deposit is received to an account of a deleted user, the funds will be kept captive and a manual process to release them will be required. # Get user Source: https://developer.uphold.com/rest-apis/core-api/users/get-user _media/specs/core-openapi.mintlify.json get /core/users/me Retrieve an existing user. # User created Source: https://developer.uphold.com/rest-apis/core-api/users/webhooks/user-created _media/specs/core-openapi.mintlify.json webhook core.user.created A new user has been created. # User deleted Source: https://developer.uphold.com/rest-apis/core-api/users/webhooks/user-deleted _media/specs/core-openapi.mintlify.json webhook core.user.deleted A user has been deleted. # Request webhook management link Source: https://developer.uphold.com/rest-apis/core-api/webhooks/request-management-link _media/specs/core-openapi.mintlify.json post /core/webhooks/management-link Request a webhook management link to configure webhooks. # Entity metadata Source: https://developer.uphold.com/rest-apis/entity-metadata Attach custom JSON metadata to Uphold entities like users, accounts, and transactions to extend them with your business data and external system mappings. Metadata allows you to store custom, persistent data associated with various entities in the Uphold system. Each entity instance can have its own metadata as a JSON object with a flexible schema tailored to your integration needs. ## Use cases Metadata enables you to extend Uphold entities with your own business data: * **External system integration**: Map Uphold entities to your internal records using custom identifiers * **Application state**: Store user preferences, feature flags, or configuration per entity * **Analytics & tracking**: Add custom tags, cohort identifiers, or attribution parameters * **Business logic**: Persist data needed for your workflows, compliance, or operational rules ## Working with metadata All metadata operations are performed via dedicated endpoints for each entity type. The endpoints follow a consistent pattern: * **Set metadata**: `PUT ////metadata` - Create or replace metadata * **Get metadata**: `GET ////metadata` - Retrieve metadata * **Update metadata**: `PATCH ////metadata` - Partially update metadata using JSON Patch * **Delete metadata**: `DELETE ////metadata` - Remove all metadata Where: * `` is the API name (e.g., `core`) * `` is the entity type supported within the `` (e.g., `users`, `accounts`) * `` is the unique identifier of the specific entity instance (e.g., user ID, account ID) For each API that supports metadata, refer to the respective documentation to see the list of supported entities. ### Creating metadata **Option 1: During entity creation** Include a `metadata` field when creating entities. Here's an example of creating a user with metadata: ```json theme={null} POST /core/users { "email": "john.doe@example.com", "termsOfService": "general-gb-fca", "primaryCitizenship": "GB", "address": { "country": "GB", "subdivision": "GB-MAN" }, "metadata": { "externalId": "usr_12345", "tier": "premium" } } ``` If metadata creation fails, the entity is still created successfully. The response will include an `errors.metadata` property. See [Error handling](#errors-during-entity-creation) for details. **Option 2: Using the dedicated endpoint** Use the **Set metadata** endpoint after entity creation. Here's an example of adding metadata to the authenticated user: ```json theme={null} PUT /core/users/me/metadata { "externalId": "usr_12345", "tier": "premium" } ``` ### Retrieving metadata To fetch the metadata for an entity, use the **Get metadata** endpoint. Here's an example of retrieving metadata for the authenticated user: ``` GET /core/users/me/metadata ``` Response: ```json theme={null} { "metadata": { "externalId": "usr_12345", "tier": "premium" } } ``` ### Updating metadata **Option 1: Full replacement** To completely replace the existing metadata, use the **Set metadata** endpoint. Here's an example of updating the metadata for the authenticated user: ```json theme={null} PUT /core/users/me/metadata { "externalId": "usr_12345", "tier": "enterprise" } ``` **Option 2: Partial update** To modify specific properties, use the **Update metadata** endpoint with a [JSON Patch](http://jsonpatch.com/) payload. Here's an example of partially updating the metadata for the authenticated user: ```json theme={null} PATCH /core/users/me/metadata [ { "op": "replace", "path": "/tier", "value": "enterprise" }, { "op": "add", "path": "/region", "value": "us-west" } ] ``` ### Deleting metadata To remove all metadata associated with an entity, use the **Delete metadata** endpoint. Here's an example of deleting the metadata for the authenticated user: ``` DELETE /core/users/me/metadata ``` ## Error handling ### Size limit exceeded Metadata payloads have a maximum size of **1024 Unicode characters**. If the payload exceeds this limit, a `413 Payload Too Large` error is returned: ```json theme={null} { "code": "content_too_large", "message": "The entity metadata size is greater than maximum size limit", "details": { "threshold": { "unit": "characters", "limit": "1024" } } } ``` ### Errors during entity creation When creating an entity with metadata, if the metadata operation fails, **the entity is still created successfully**. The response includes an `errors.metadata` property describing the issue: ```json theme={null} { "user": { "id": "cd21b26d-35d2-408a-9201-b8fdbef7a604", "email": "john.doe@example.com" // ... other user properties ... }, "errors": { "metadata": { "code": "content_too_large", "message": "The entity metadata size is greater than maximum size limit", "details": { "threshold": { "unit": "characters", "limit": "1024" } } } } } ``` This ensures that entity creation is not blocked by metadata issues. You can add the metadata afterward using the **Set metadata** endpoint. ## HTTP conditional requests Metadata endpoints support standard HTTP conditional request headers for concurrency control and efficient caching. These headers are optional but recommended to prevent conflicts and optimize bandwidth. **`ETag` response header**: This header is included in responses to represent the current revision of the metadata. **`If-Match` request header**: When updating (via PUT or PATCH) or deleting metadata, include this header with the `ETag` value from a previous response to ensure you're modifying the expected version. If the `ETag` does not match the current version, a `412 Precondition Failed` error is returned, preventing accidental overwrites. **`If-None-Match` request header**: When retrieving metadata, include this header with the `ETag` value from a previous response. If the metadata has not changed, a `304 Not Modified` response is returned, saving bandwidth. Additionally, when creating metadata and you want to ensure none already exists, you can use the `If-None-Match` header with a value of `*`. If metadata already exists, a `412 Precondition Failed` error will be returned. # Error handling Source: https://developer.uphold.com/rest-apis/errors Handle Uphold REST API errors using documented error codes and messages from the OpenAPI spec, with structured error shapes and HTTP status conventions. Error handling is a core design principle of the API. This helps you build robust integrations and applications that present meaningful error messages to end users. ## Comprehensive list All possible error scenarios are enumerated as part of the OpenAPI spec, resulting in a comprehensive list of error codes and messages. You may see these errors on every endpoint's page, by cycling through the different HTTP status codes in the response examples. Core Data Model Core Data Model ## Consistent shape The API follows a consistent approach to describe errors. Every error response includes a standard payload with the following fields: ```json [expandable] theme={null} { // A code that identifies the error. "code": "country_not_supported", // A developer readable message describing the error. "message": "The country is not supported", // Additional details about the error. "details": {} } ``` ## HTTP status codes Every error response includes an HTTP status code to indicate the nature of the error. Here are some of the most common status codes you might encounter: * `400 Bad Request`: The request is malformed or didn't pass validation according to the endpoint's OpenAPI schema. * `401 Unauthorized`: The request is missing authentication credentials or the provided credentials are invalid. * `403 Forbidden`: The request is authenticated but doesn't have the necessary permissions (token scopes) to access the resource. * `404 Not Found`: The requested resource doesn't exist. * `409 Conflict`: The request failed due to a business logic error. * `429 Too Many Requests`: The client has sent too many requests in a given amount of time and is being rate-limited. However, the `code` field in the error payload is where you primarily should look as it provides a more granular description of the failure. # Common headers Source: https://developer.uphold.com/rest-apis/headers Reference for the common request and response headers used across Uphold REST APIs to pass user context, idempotency keys, and other request metadata. The REST APIs use custom headers for passing context in requests and returning useful metadata in responses. This page documents the headers that are common to all endpoints. Some endpoints may include additional headers specific to their functionality. ## Request headers ### User context The REST APIs are not meant to be called directly by your frontend applications, but rather by your backend systems. A side-effect of this is that Uphold is unable to extract context of users' requests, such as the user's IP address, user agent, and other information that would be typically available otherwise. To address this, Uphold provides a way for you to pass users' context via headers. This context enriches the request and provides better insights into end users' actions. In fact, **certain APIs require** parts of the context to be present in order to function correctly, so you should include it in all API requests made on behalf of your end users. Include the following headers in your API requests: * `X-Uphold-User-Ip`: The IP address of the end user. * `X-Uphold-User-Agent`: The user agent string of the end user's device. * `X-Uphold-User-Origin`: The original `Origin` header if the request was made from a browser. * `X-Uphold-User-Country`: The country code ([ISO 3166](https://en.wikipedia.org/wiki/ISO_3166-2) - two letter) associated with the geolocation of the end user. * `X-Uphold-User-Subdivision`: The subdivision code ([ISO 3166](https://en.wikipedia.org/wiki/ISO_3166-2)) associated with the geolocation of the end user. * `X-Uphold-User-City`: The city associated with the geolocation of the end user. ## Response headers ### Request id Every API response includes an `X-Uphold-Request-Id` header containing a unique identifier for the request. You can use this identifier when contacting Uphold support to help troubleshoot specific requests. ``` X-Uphold-Request-Id: 9d71b4edeb46c8f9-MAD ``` # Introduction to the Uphold Enterprise REST APIs Source: https://developer.uphold.com/rest-apis/introduction Overview of Uphold's modular REST API suite for building enterprise crypto products. Includes OpenAPI specs, User onboarding, Transactions, Portfolio, and more. Uphold offers a suite of REST APIs that allow you to interact with the platform programmatically, enabling you to build your application on top of its services. The REST APIs are modular in principle and can be combined to serve different business needs and to build custom solutions. [OpenAPI](https://www.openapis.org/) specifications are available for all APIs, which is a standard to describe HTTP APIs. You may use these specifications to generate client libraries in your preferred programming language. The APIs provide fundamental building blocks, forming the backbone of the REST API suite. With these building blocks, you can build a wide range of solutions with a high degree of flexibility. Core building blocks, including users, assets, accounts, transactions, and more. Ingest KYC data from third-party providers into the Uphold platform. Market news and statistics for digital assets. Generate URLs to embed widgets in your application. Interact with Topper product features and functionalities. # KYC Connector API introduction Source: https://developer.uphold.com/rest-apis/kyc-connector-api/introduction Connect third-party KYC providers like Sumsub and Veriff to Uphold's platform. Ingestions map provider payloads to KYC processes without bespoke code. The KYC Connector API is an API designed to connect third-party KYC providers with Uphold's platform, without you having to build and maintain bespoke integrations. It functions as an ingestion layer between mainstream KYC providers and Uphold's platform [KYC processes](../core-api/kyc/introduction#kyc-processes), such as `profile`, `address`, `identity` and `proofOfAddress`. ## Key features and benefits * Compatible with multiple KYC providers, so you can connect whichever provider you use without any custom integration work. * The KYC Connector API ingests and normalizes provider data for you. * Enable your platform to continue relying on your existing KYC provider. ## Supported providers
A leading and comprehensive verification solution that focuses on compliance and user experience. An AI-driven, scalable verification that helps businesses meet compliance requirements.
A leading and comprehensive verification solution that focuses on compliance and user experience. An AI-driven, scalable verification that helps businesses meet compliance requirements.
## Ingestions KYC Connector is a **workflow-based ingestion system**. An ingestion pulls KYC data from an existing user verification on the provider side and submits it to Uphold's platform on their behalf. The verification must already be approved before you can create one. When creating an ingestion, you provide a reference to the user's verification and specify which KYC processes to ingest — the API handles fetching and normalizing the provider data for you. ### Statuses The ingestion workflow follows these statuses: * **Queued**: When you create an ingestion, it starts in the `queued` state. * **Running**: The ingestion will be quickly picked up by a worker, which changes its status to `running` and starts processing it. During this phase, the worker interacts with the provider's API to retrieve KYC data and files for the specified processes. * **Finished**: Once the ingestion processing comes to an end, its status changes to `finished`. You can track ingestions in two ways: * **Polling**: Poll the endpoint that retrieves an ingestion by its ID. * **Webhooks**: Subscribe to webhooks to be notified when an ingestion status changes. ### Results Under the `result` field, you can find the outcome of the ingestion of each KYC process. For example, if you created an ingestion to ingest both `identity` and `proofOfAddress` processes, the result will contain the outcome of both processes. Each process will have its own status, which can be `processing`, `completed`, or `failed` as well as extracted data and an error if the ingestion for that process failed. Below are examples of possible results for an `identity` ingestion: ```json theme={null} { "result": { "identity": { "status": "processing" } } } ``` ```json theme={null} { "result": { "identity": { "status": "completed", "data": { "document": { "type": "passport", "number": "7700225VH", "country": "GB", "expiresAt": "2026-03-13" }, "person": { "givenName": "John", "familyName": "Doe", "birthdate": "1987-01-01", "gender": "male" }, "verifiedAt": "2020-01-01T00:00:00Z" }, "files": [ { "id": "123e4567-e89b-12d3-a456-426614174000", "category": "document", "context": "photo-document-front", "contentType": "image/png", "size": 204800, "providerDetails": { "fileId": "732150141" } } ] } } } ``` ```json theme={null} { "result": { "identity": { "status": "failed", "error": { "code": "provider_data_not_available", "message": "Profile data not available in Sumsub applicant info", "hint": "Most likely the share token refers to an applicant without identity verification process." } } } } ``` ### Important note on `completed` status When a process status is marked as `completed`, it means that the data has been **extracted and submitted** to Uphold's platform. However, it does **not** necessarily mean that the KYC process is "ok". You should always check the actual KYC process status after ingestion using the [Get KYC Overview](../core-api/kyc/get-overview) endpoint. For instance: * The `profile` process ingestion can be `completed` but the actual `profile` KYC process status might still be `pending` in case there are still fields to be submitted. * The `identity` process ingestion can be `completed` but the actual `identity` KYC process status might be `failed` in situations like a duplicate identity conflict with another user or if the user is underage. Simply put, `completed` indicates successful data extraction and submission to the platform, not a successful KYC verification result. # Create Sumsub ingestion Source: https://developer.uphold.com/rest-apis/kyc-connector-api/sumsub/create-ingestion _media/specs/kyc-connector-openapi.mintlify.json post /kyc-connector/sumsub/ingestions Create a new Sumsub ingestion. # Get Sumsub ingestion Source: https://developer.uphold.com/rest-apis/kyc-connector-api/sumsub/get-ingestion _media/specs/kyc-connector-openapi.mintlify.json get /kyc-connector/sumsub/ingestions/{ingestionId} Retrieve an existing Sumsub ingestion by id. # List Sumsub ingestions Source: https://developer.uphold.com/rest-apis/kyc-connector-api/sumsub/list-ingestions _media/specs/kyc-connector-openapi.mintlify.json get /kyc-connector/sumsub/ingestions List Sumsub ingestions made by a user. # Sumsub KYC Connector overview Source: https://developer.uphold.com/rest-apis/kyc-connector-api/sumsub/overview Use Sumsub Reusable KYC with the Uphold KYC Connector to share verifications between your Sumsub account and Uphold. Includes setup and configuration. The Sumsub KYC Connector leverages [Sumsub's Reusable KYC](https://docs.sumsub.com/docs/reusable-kyc) feature to enable secure and compliant sharing of KYC data between your Sumsub account and Uphold's platform (using Uphold's Sumsub account). ## Setup requirements Before you can use the Sumsub KYC Connector, both the **donor** (your Sumsub account where users complete verification) and the **recipient** (Uphold's Sumsub account) must be properly configured in Sumsub's dashboard to enable Reusable KYC data sharing. Please reach out to your Uphold account manager to help coordinate the setup with you. Once the setup is complete, sharing happens via a [share token](https://docs.sumsub.com/reference/generate-share-token) that you must generate for a given applicant. Uphold uses this token to copy the applicant's KYC data and documents. ## Supported processes The Sumsub KYC Connector supports ingestion of the following KYC processes. Each process has specific considerations you should be aware of: ### Profile Ingests [profile](../../core-api/kyc/introduction.mdx#kyc-processes) information such as the user's full name, date of birth, place of birth, and citizenship. The source fields from Sumsub used for this process are as follows: * `fullName`: Sourced from `fixedInfo.firstName`, `fixedInfo.middleName`, and `fixedInfo.lastName`. Falls back to the same fields on `info` if `fixedInfo` is not available. * `birthdate`: Sourced from `fixedInfo.dob` or `info.dob`. * `birthplace`: Sourced from `fixedInfo.countryOfBirth` for the country and `fixedInfo.placeOfBirth` or `info.stateOfBirth` for the town. Falls back to the same fields on `info` if `fixedInfo` is not available. * `primaryCitizenship`: Sourced from `fixedInfo.nationality` or `info.nationality`. Please check the [`ingestion.result.profile`](./get-ingestion#response-ingestion-result-profile) field in the specification for more details. Furthermore, here's a breakdown of errors that can happen for this process: | Error Code | Scenarios | | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | provider\_data\_not\_available | • Profile data is not available for the Sumsub applicant | | provider\_data\_invalid | • Sumsub applicant contains first or middle name but no last name
• Profile data is incomplete (when birthplace country or town is provided but not both)
• Extracted profile data failed validation | | sumsub\_client\_error | • Sumsub API returned an error when fetching applicant data | | sumsub\_applicant\_review\_state\_invalid | • Sumsub applicant review answer is not approved (GREEN) on the donor
• Sumsub applicant is in an unexpected review status (`init`, `onHold`, `awaitingUser`) | | provider\_data\_readiness\_timeout\_exceeded | • Max retries exceeded waiting for provider data to be ready | | workflow\_error | • Workflow was canceled
• Workflow timed out
• Workflow had an unrecoverable error | | unknown\_error | • Any unhandled error during the workflow | ### Address Ingests [address](../../core-api/kyc/introduction.mdx#kyc-processes) information by matching addresses from the Sumsub applicant against the user's country and subdivision provided via the [Create User](../../core-api/users/create-user.mdx#body-country) endpoint. The ingestion follows a **prioritized matching** strategy: 1. **Declared addresses**: If the applicant has `fixedInfo.addresses` (declared addresses), these are the only only addresses added to the prioritized list (step 2 and 3 are skipped). 2. **Address from verification step**: If the applicant has completed a **Proof of address** verification step with a `GREEN` (approved) review result, the address from the associated document is added to the prioritized list. 3. **All other applicant addresses**: All other addresses from the applicant's `info.addresses` are added to the prioritized list with addresses sourced from `proofOfAddress` documents prioritized over other sources. Then, the ingestion attempts to find a compatible address using the following matching criteria in order: 1. **Country and subdivision match**: From the prioritized list, the ingestion looks for an address that matches both the user's existing subdivision and country in Uphold. 2. **Country-only match**: If no exact match is found and either the user's existing address or the Sumsub address has an empty subdivision, the ingestion will match by country only. If no compatible address is found through this matching process, the address ingestion will fail with a `provider_data_not_available` error. Please check the [`ingestion.result.address`](./get-ingestion#response-ingestion-result-address) field in the specification for more details. Furthermore, here's a breakdown of errors that can happen for this process: | Error Code | Scenarios | | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | provider\_data\_not\_available | • No address data found for the Sumsub applicant | | provider\_data\_invalid | • Address data is incomplete (missing required fields)
• Address country does not match the user's declared address country
• Extracted address data failed validation | | operation\_not\_allowed | • Subdivision does not match user-declared subdivision (US residents only, state)
• Subdivision updates are not allowed for the organization | | sumsub\_client\_error | • Sumsub API returned an unrecoverable error | | sumsub\_applicant\_review\_state\_invalid | • Sumsub applicant review answer is not approved (GREEN) on the donor
• Sumsub applicant is in an unexpected review status (`init`, `onHold`, `awaitingUser`) | | workflow\_error | • Workflow was canceled
• Workflow timed out
• Workflow had an unrecoverable error | | unknown\_error | • Any unhandled error during the workflow | ### Identity Ingests government-issued [identity](../../core-api/kyc/introduction.mdx#kyc-processes) data, including document type, number, issuing country, expiration date, and biographic information. This process requires that the Sumsub verification level completed by the applicant includes both **Identity document** and **Selfie** verification steps. The exact settings for these steps (e.g., document types allowed) should match Uphold's KYC compliance requirements. If the applicant has completed both steps but the settings do not align with Uphold's requirements, the ingestion for this process will fail. Please consult with your Account Manager to ensure proper configuration. Please check the [`ingestion.result.identity`](./get-ingestion#response-ingestion-result-identity) field in the specification for more details. Furthermore, here's a breakdown of errors that can happen for this process:
| Error Code | Scenarios | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | provider\_data\_readiness\_timeout\_exceeded | • Max retries exceeded waiting for provider data to be ready | | provider\_data\_not\_available | • No identity verification step found for the Sumsub applicant
• No selfie verification step found for the Sumsub applicant | | provider\_data\_invalid | • Sumsub's identity verification step review answer is not approved (GREEN)
• Sumsub's selfie verification step review answer is not GREEN
• Identity data extracted from Sumsub is incomplete (missing required fields)
• Extracted identity data failed validation (e.g., user is under age) | | provider\_file\_download\_failed | • User has reached the maximum number of files allowed
• Timeout waiting for file to be downloaded
• Downloaded file status is invalid | | dependent\_process\_failed | • Dependent profile process has failed | | operation\_not\_allowed | • Current status of the identity process does not allow updates
• User cannot submit identity due to incomplete declared information | | sumsub\_client\_error | • Sumsub API returned an unrecoverable error | | sumsub\_applicant\_review\_state\_invalid | • Sumsub applicant review answer is not approved (GREEN) on the donor
• Sumsub applicant is in an unexpected review status (`init`, `onHold`, `awaitingUser`) | | workflow\_error | • Workflow was canceled
• Workflow timed out
• Workflow had an unrecoverable error | | unknown\_error | • Any unhandled error during the workflow |
### Proof-of-address Ingests [proof-of-address](../../core-api/kyc/introduction.mdx#kyc-processes) documentation, such as utility bills, bank statements, or government correspondence. This process requires that the Sumsub verification level completed by the applicant includes a **Proof of address** verification step. The exact settings for this step (e.g., document types allowed) should match Uphold's KYC compliance requirements. If the applicant has completed a step but the settings do not align with Uphold's requirements, the ingestion for this process will fail. Please consult with your Account Manager to ensure proper configuration. Please check the [`ingestion.result.proofOfAddress`](./get-ingestion#response-ingestion-result-address) field in the specification for more details. Furthermore, here's a breakdown of errors that can happen for this process:
| Error Code | Scenarios | | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | provider\_data\_readiness\_timeout\_exceeded | • Max retries exceeded waiting for provider data to be ready | | provider\_data\_not\_available | • No proof-of-address verification step found for the Sumsub applicant | | provider\_data\_invalid | • Sumsub's proof-of-address verification step review answer is not approved (GREEN)
• Proof-of-address data extracted from Sumsub is incomplete (missing required fields)
• Extracted proof-of-address data failed validation | | provider\_file\_download\_failed | • User has reached the maximum number of files allowed
• Timeout waiting for file to be downloaded
• Downloaded file status is invalid | | dependent\_process\_failed | • Dependent profile process has failed
• Dependent address process has failed | | operation\_not\_allowed | • Current status of the proof-of-address process does not allow updates
• User cannot submit proof-of-address due to incomplete declared information | | sumsub\_client\_error | • Sumsub API returned an unrecoverable error | | sumsub\_applicant\_review\_state\_invalid | • Sumsub applicant review answer is not approved (GREEN) on the donor
• Sumsub applicant is in an unexpected review status (`init`, `onHold`, `awaitingUser`) | | workflow\_error | • Workflow was canceled
• Workflow timed out
• Workflow had an unrecoverable error | | unknown\_error | • Any unhandled error during the workflow |
By default, Sumsub does **not** extract OCR data in **sandbox** (e.g., address details) from proof-of-address documents uploaded by the applicant. If you try to ingest proof-of-address of an applicant that has no OCR data extracted, the following error will surface: ```json theme={null} { "proofOfAddress": { "status": "failed", "error": { "code": "provider_data_not_available", "message": "No idDoc matching proof-of-address verification step found in Sumsub applicant info", "step": "fetch-provider-data" } } } ``` As a workaround, you can manually fill in the OCR data in the Sumsub dashboard for the applicant, which will allow the ingestion to succeed. # Sumsub ingestion created Source: https://developer.uphold.com/rest-apis/kyc-connector-api/sumsub/webhooks/ingestion-created _media/specs/kyc-connector-openapi.mintlify.json webhook kyc-connector.sumsub.ingestion.created A new Sumsub ingestion has been created. # Sumsub ingestion status changed Source: https://developer.uphold.com/rest-apis/kyc-connector-api/sumsub/webhooks/ingestion-status-changed _media/specs/kyc-connector-openapi.mintlify.json webhook kyc-connector.sumsub.ingestion.status-changed The Sumsub ingestion status has been changed. # Create Veriff ingestion Source: https://developer.uphold.com/rest-apis/kyc-connector-api/veriff/create-ingestion _media/specs/kyc-connector-openapi.mintlify.json post /kyc-connector/veriff/ingestions Create a new Veriff ingestion. # Get Veriff config Source: https://developer.uphold.com/rest-apis/kyc-connector-api/veriff/get-config _media/specs/kyc-connector-openapi.mintlify.json get /kyc-connector/veriff/config Retrieve the Veriff provider configuration for the organization. # Get Veriff ingestion Source: https://developer.uphold.com/rest-apis/kyc-connector-api/veriff/get-ingestion _media/specs/kyc-connector-openapi.mintlify.json get /kyc-connector/veriff/ingestions/{ingestionId} Retrieve an existing Veriff ingestion by id. # List Veriff ingestions Source: https://developer.uphold.com/rest-apis/kyc-connector-api/veriff/list-ingestions _media/specs/kyc-connector-openapi.mintlify.json get /kyc-connector/veriff/ingestions List Veriff ingestions made by a user. # Veriff KYC Connector overview Source: https://developer.uphold.com/rest-apis/kyc-connector-api/veriff/overview Use the Uphold KYC Connector with Veriff's verification platform to share KYC data between your Veriff Station integration and Uphold's KYC processes. The Veriff KYC Connector integrates with [Veriff's verification platform](https://www.veriff.com/) to enable secure and compliant sharing of KYC data between your Veriff integration and Uphold's platform. ## Setup requirements Before you can use the Veriff KYC Connector, you must have at least one Veriff integration set up in your [Veriff Station dashboard](https://station.veriff.com/integrations), with the appropriate verification flows enabled. Refer to [Veriff's developer documentation](https://devdocs.veriff.com/) for guidance on setting up integrations. ## Getting started ### Configuring your integrations on Uphold Once you have your Veriff integrations ready, you need to register them with Uphold using the [Set Veriff config](./set-config) endpoint. This is required before creating any ingestions. For each integration, you will need: * **Integration name**: A name of your choice to identify the integration. * **API key** and **shared secret key**: Found in your [Veriff Station dashboard](https://station.veriff.com/integrations) under the integration's settings. Uphold stores these credentials securely, so that when creating an ingestion you only need to reference the **integration name** and the **session ID**. ### Selecting a session A session represents a Veriff verification for a user. Only sessions in an **approved** state are accepted. The KYC processes that can be ingested depend on the verification flow and features enabled in your Veriff integration. For example: * A standard IDV session with document and selfie verification can be used to ingest `profile`, `address`, and `identity` processes. * A PoA session with address validation can be used to ingest `address` and `proof-of-address` processes. ### Creating ingestions To [create an ingestion](./create-ingestion), you provide the **session ID** and **integration name** for each Veriff session you want to process. You can include both an IDV and a PoA session in a single ingestion. ```json POST /kyc-connector/veriff/ingestions theme={null} { "processes": [ "profile", "address", "identity", "proof-of-address" ], "sessions": [ { "processes": [ "profile", "identity" ], "sessionId": "550e8400-e29b-41d4-a716-446655440000", "integrationName": "my-idv-integration" }, { "processes": [ "address", "proof-of-address" ], "sessionId": "661f9511-f30c-52e5-b827-557766551111", "integrationName": "my-poa-integration" } ] } ``` ## Supported processes The Veriff KYC Connector supports ingestion of the following KYC processes. Each process has specific requirements and considerations you should be aware of: ### Profile Ingests [profile](../../core-api/kyc/introduction.mdx#kyc-processes) information such as the user's full name, date of birth, and citizenship. The source fields from Veriff used for this process are as follows: * `fullName`: Sourced from `person.firstName` and `person.lastName`. * `birthdate`: Sourced from `person.dateOfBirth`. * `birthplace`: Sourced from `person.placeOfBirth`. Only extracted when both the town and country are present in the Veriff verification; if either is missing, this field will not be populated. * `primaryCitizenship`: Sourced from `person.nationality` or `person.citizenship`. Please check the [`ingestion.result.profile`](./get-ingestion#response-ingestion-result-profile) field in the specification for more details. Furthermore, here's a breakdown of errors that can happen for this process: | Error Code | Scenarios | | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | provider\_data\_not\_available | • Profile data is not available for the Veriff verification
• No IDV session provided | | veriff\_session\_not\_approved | • Veriff session is not in an approved state | | provider\_data\_recheck\_failed | • Verification data did not pass validation checks | | provider\_config\_error | • Failed to fetch or parse the organization's provider config
• Integration referenced in session not found in provider config | | veriff\_client\_error | • Veriff API returned an error when fetching verification data | | workflow\_error | • Workflow was canceled
• Workflow timed out
• Workflow had an unrecoverable error | | unknown\_error | • Any unhandled error during the workflow | ### Address Ingests [address](../../core-api/kyc/introduction.mdx#kyc-processes) information by matching addresses from the Veriff verification against the user's subdivision and country provided via the [Create User](../../core-api/users/create-user.mdx#body-country) endpoint. Address data can come from **either an IDV or PoA Veriff session**: 1. **Address matching (PoA feature)**: If the PoA "address matching" feature is enabled for your integration in the Veriff platform, the user's declared address is extracted directly from the matching result. 2. **Person addresses fallback**: Otherwise, the ingestion searches all addresses in the verification's person data for one that matches the user's declared country and/or subdivision, prioritizing a country + subdivision match over a country-only match. When both session types are provided, the PoA address matching data always takes precedence over the IDV person addresses fallback. If no compatible address is found through this process, the address ingestion will fail with a `provider_data_not_available` error. Please check the [`ingestion.result.address`](./get-ingestion#response-ingestion-result-address) field in the specification for more details. Furthermore, here's a breakdown of errors that can happen for this process: | Error Code | Scenarios | | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | provider\_data\_not\_available | • No address data found in the Veriff verification
• No IDV or PoA session provided | | veriff\_session\_not\_approved | • Veriff session is not in an approved state | | provider\_data\_recheck\_failed | • Verification data did not pass validation checks | | provider\_config\_error | • Failed to fetch or parse the organization's provider config
• Integration referenced in session not found in provider config | | veriff\_client\_error | • Veriff API returned an unrecoverable error | | workflow\_error | • Workflow was canceled
• Workflow timed out
• Workflow had an unrecoverable error | | unknown\_error | • Any unhandled error during the workflow | ### Identity Ingests government-issued [identity](../../core-api/kyc/introduction.mdx#kyc-processes) data, including document type, number, issuing country, expiration date, and biographic information. This process **requires a Veriff IDV session** that includes both identity document verification and a selfie/biometric check. Please check the [`ingestion.result.identity`](./get-ingestion#response-ingestion-result-identity) field in the specification for more details. Furthermore, here's a breakdown of errors that can happen for this process:
| Error Code | Scenarios | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | provider\_data\_not\_available | • No person data found in Veriff verification
• No IDV session provided | | provider\_data\_invalid | • No document data found in Veriff verification | | veriff\_session\_not\_approved | • Veriff session is not in an approved state | | provider\_data\_recheck\_failed | • Verification data did not pass validation checks | | provider\_config\_error | • Failed to fetch or parse the organization's provider config
• Integration referenced in session not found in provider config | | provider\_file\_download\_failed | • User has reached the maximum number of files allowed
• Timeout waiting for file to be downloaded
• Downloaded file status is invalid | | dependent\_process\_failed | • Dependent profile process has failed | | operation\_not\_allowed | • Current status of the identity process does not allow updates
• User cannot submit identity due to incomplete declared information | | veriff\_client\_error | • Veriff API returned an unrecoverable error | | workflow\_error | • Workflow was canceled
• Workflow timed out
• Workflow had an unrecoverable error | | unknown\_error | • Any unhandled error during the workflow |
### Proof-of-address Ingests [proof-of-address](../../core-api/kyc/introduction.mdx#kyc-processes) documentation, such as utility bills, bank statements, or government correspondence. This process **requires a PoA session**. Veriff's PoA verification must be enabled for your Veriff integration. The ingestion extracts the proof-of-address data using the following priority: 1. **Address validation (PoA feature)**: If the PoA **address validation** feature is enabled for your integration in the Veriff platform, the address is extracted directly from the validation result. 2. **Address matching (PoA feature)**: If the PoA **address matching** feature is enabled for your integration in the Veriff platform, the verified address is extracted directly from the matching result. Please check the [`ingestion.result.proofOfAddress`](./get-ingestion#response-ingestion-result-proofOfAddress) field in the specification for more details. Furthermore, here's a breakdown of errors that can happen for this process:
| Error Code | Scenarios | | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | provider\_data\_not\_available | • No proof-of-address data found in the Veriff PoA session
• No PoA session provided | | veriff\_session\_not\_approved | • Veriff session is not in an approved state | | provider\_data\_recheck\_failed | • Verification data did not pass validation checks | | provider\_config\_error | • Failed to fetch or parse the organization's provider config
• Integration referenced in session not found in provider config | | provider\_file\_download\_failed | • User has reached the maximum number of files allowed
• Timeout waiting for file to be downloaded
• Downloaded file status is invalid | | dependent\_process\_failed | • Dependent profile process has failed
• Dependent address process has failed | | operation\_not\_allowed | • Current status of the proof-of-address process does not allow updates
• User cannot submit proof-of-address due to incomplete declared information | | veriff\_client\_error | • Veriff API returned an unrecoverable error | | workflow\_error | • Workflow was canceled
• Workflow timed out
• Workflow had an unrecoverable error | | unknown\_error | • Any unhandled error during the workflow |
# Set Veriff config Source: https://developer.uphold.com/rest-apis/kyc-connector-api/veriff/set-config _media/specs/kyc-connector-openapi.mintlify.json put /kyc-connector/veriff/config Create or update the Veriff provider configuration for the organization. # Veriff ingestion created Source: https://developer.uphold.com/rest-apis/kyc-connector-api/veriff/webhooks/ingestion-created _media/specs/kyc-connector-openapi.mintlify.json webhook kyc-connector.veriff.ingestion.created A new Veriff ingestion has been created. # Veriff ingestion status changed Source: https://developer.uphold.com/rest-apis/kyc-connector-api/veriff/webhooks/ingestion-status-changed _media/specs/kyc-connector-openapi.mintlify.json webhook kyc-connector.veriff.ingestion.status-changed The Veriff ingestion status has been changed. # Get asset information Source: https://developer.uphold.com/rest-apis/market-pulse-api/assets/get-asset-information _media/specs/market-pulse-openapi.mintlify.json get /market-pulse/assets/{assetCode}/information Retrieve descriptive information for a specific asset. # Get asset statistics Source: https://developer.uphold.com/rest-apis/market-pulse-api/assets/get-asset-statistics _media/specs/market-pulse-openapi.mintlify.json get /market-pulse/assets/{assetCode}/statistics Retrieve real-time market statistics for a specific asset. # List asset news Source: https://developer.uphold.com/rest-apis/market-pulse-api/assets/list-asset-news _media/specs/market-pulse-openapi.mintlify.json get /market-pulse/assets/{assetCode}/news Retrieve the latest news for a specific asset. # List general news Source: https://developer.uphold.com/rest-apis/market-pulse-api/general/list-general-news _media/specs/market-pulse-openapi.mintlify.json get /market-pulse/news Retrieve the latest general market news. # Pagination Source: https://developer.uphold.com/rest-apis/pagination Paginate Uphold REST API list responses using page-based and cursor-based pagination. Includes parameters, response metadata, and best-practice usage. Some endpoints may return a large number of results, so pagination is available to help you request only a subset of the results at a time. ## Types of pagination There are two types of pagination used underneath: * **Page-based pagination**: This method uses a paging parameter to indicate the starting point of records to return. * **Cursor-based pagination**: This method utilizes a unique identifier to determine the position in the data set. There are different advantages and disadvantages to each method, but performance wise cursor-based pagination is generally more efficient. Currently, most of the endpoints are offset-based, but they are being migrated to cursor-based. To make this transparent to you, the pagination response object is standardized to work with both methods. ## Pagination in responses When an endpoint supports pagination, the response will include a `pagination` object with the following properties: * `next`: The URL to the next page. This property will be omitted if the result set is empty or if the next page has no results. * `previous`: The URL to the previous page. This property will be omitted if the result set is empty or if there's no previous page. * `first`: The URL to the first page. This property will be omitted if the result set is empty. Here are a few examples of how the `pagination` object may look like in the response: ```json On first page theme={null} { "pagination": { "first": "https://api.enterprise.uphold.com/core/transactions?page=1&perPage=10", "next": "https://api.enterprise.uphold.com/core/transactions?page=2&perPage=10", } } ``` ```json On second page theme={null} { "pagination": { "first": "https://api.enterprise.uphold.com/core/transactions?page=1&perPage=10", "next": "https://api.enterprise.uphold.com/core/transactions?page=3&perPage=10", "previous": "https://api.enterprise.uphold.com/core/transactions?page=1&perPage=10", } } ``` ```json On last page theme={null} { "pagination": { "first": "https://api.enterprise.uphold.com/core/transactions?page=1&perPage=10", "previous": "https://api.enterprise.uphold.com/core/transactions?page=10&perPage=10", } } ``` Make sure to use these URLs when requesting pages instead of hardcoding them, otherwise your implementation may break as endpoints migrate to cursor-based pagination in the future. ## Number of results per page To control how many results are returned per page, you can use the `perPage` query parameter. For example, to request 100 results per page, you would add `perPage=100` query parameter to the URL. The `perPage` parameter will automatically be included in the URLs in the `pagination` object, so you don't need to worry about it when following the pagination links. # Rate limits Source: https://developer.uphold.com/rest-apis/rate-limits Understand Uphold REST API rate limits and 429 Too Many Requests responses. Covers the three rate limit types and best practices for retries and backoff. To ensure the stability and reliability of the Enterprise APIs, safeguards are in place against bursts of incoming traffic. If you exceed the allowed number of requests in a short period, you may receive error responses with a `429 Too Many Requests` status code. ## Type of rate limits There are three types of rate limits enforced: * **Global rate limits**: These limits apply globally and cap the total number of requests your integration can make within a specific time period. * **Specific endpoint rate limits**: These limits apply to certain endpoints and cap the total number of requests made to that endpoint within a specific time period. * **User rate limits**: These limits apply to requests made on behalf of a user and cap the total number of requests made by that user within a specific time period. The exact limits may vary per partner and contract. You will receive detailed rate limit information for your integration during onboarding. ## Rate limit headers When you receive a `429 Too Many Requests` response, you also receive a `Reset-After` header with the amount of time in seconds you should wait before making another request. # Test helpers Source: https://developer.uphold.com/rest-apis/test-helpers Use Uphold Sandbox-only test helpers to simulate deposits, withdrawals, and compliance state changes — speeding up integration testing without real events. Test helpers are specialized endpoints designed to help you simulate various external events, scenarios, and states within the **Sandbox environment**, such as receiving deposits, handling withdrawals, or triggering compliance statuses. Test helpers are only available in the Sandbox environment and will return `404 Not Found` if called in Production. ## Why use test helpers? During development and testing, you often need to simulate external events that would normally be triggered by third-party systems or real-world actions. To simplify these simulations, test helpers enable you to: Quickly and reliably simulate events without waiting for real-world processes (e.g., an incoming bank deposit). Test edge cases and failure scenarios like account opening rejection and user sanctioned. Validate your application's integration with the APIs in a controlled environment. Build comprehensive automated test suites with predictable external event simulation. Verify your handling of specific event-driven behaviors and webhook notifications. Prepare and validate your implementation before moving to Production. ## Key concepts * **Event triggering:** API-driven activation of events to simulate real-world scenarios. * **Asynchronous behavior:** Many test helpers return a `202 Accepted` status, indicating that the request has been accepted and processing is asynchronous. * **Resource management:** Automatically create or modify resources in response to simulated external events. Before using test helpers, keep the following considerations in mind: * Behavior in Sandbox may differ slightly from Production in terms of performance, response times, and event handling. * Ensure your integration code is environment-aware to prevent accidental calls to Sandbox endpoints from Production environments. Explore the detailed documentation for each test helper to start simulating events and scenarios, streamlining your testing and integration workflow. You can find these endpoints within the **Test helpers** folder, located under each API's group of endpoints in the API documentation. # Create session Source: https://developer.uphold.com/rest-apis/topper-api/kyc-sharing/create-session _media/specs/topper-openapi.mintlify.json post /topper/kyc-sharing/sessions Create a new session for KYC sharing. # Identify user Source: https://developer.uphold.com/rest-apis/topper-api/kyc-sharing/identify-user _media/specs/topper-openapi.mintlify.json post /topper/kyc-sharing/identify-user Identify a user for KYC sharing. # API versioning Source: https://developer.uphold.com/rest-apis/versioning How Uphold versions REST APIs to maintain backward compatibility and provide a clear upgrade path for breaking changes when they become necessary. The REST APIs will be versioned to ensure backward compatibility and provide a clear upgrade path. For the time being, Uphold is committed to maintaining compatibility and avoiding breaking changes. Should any become necessary, you will receive reasonable notice in advance. More details about the versioning strategy will be shared when the need for backward-incompatible changes arises. # Webhooks Source: https://developer.uphold.com/rest-apis/webhooks Receive near real-time events from Uphold's REST API endpoints via webhooks. Subscribe to user, account, transaction, KYC, and other event types. Webhooks are a way for you to receive near real-time events of what's happening on the platform. ## Event types Each group of endpoints has its own set of events that you can subscribe to. For example, the `Users` group of endpoints of the `Core API` has events like `core.user.created`, `core.user.deleted`, etc. You may find the list of events that you can subscribe to in the documentation of the group of endpoints you are interested in. ## Consistent shape Every webhook event has a consistent shape, with the following structure: ```json [expandable] theme={null} { // A unique identifier for the event. "id": "6a3d8cea-5250-4bd0-a443-eb07b04c85d4", // The type of the event. "type": "core.users.created", // The timestamp at which the event was created. "createdAt": "2025-02-20T21:21:09.943Z", // The data associated with the event, specific to the event type. "data": {} } ``` ## Signature check To prevent attackers from impersonating services by sending fake webhooks, each webhook delivery is signed with a unique key specific to the receiving endpoint. This signature allows you to verify that the webhook genuinely originates from Uphold and that only legitimate webhooks are processed. Each webhook call includes three headers with additional information that are used for verification: * `Webhook-Id`: The unique message identifier for the delivery. This identifier will be the same when the same webhook is being resent (e.g., due to a previous failure). * `Webhook-Timestamp`: Timestamp in seconds since epoch of when the webhook was sent. * `Webhook-Signature`: The Base64 encoded list of signatures (space delimited). To learn more about how to verify the signature, refer to the [Webhook Signature Verification](https://docs.svix.com/receiving/verifying-payloads/why) from the webhook provider, Svix. They offer a SDK for different languages to help you with the verification process or you can implement the verification manually by following their guide. Do not confuse `Webhook-Id` and `Webhook-Timestamp` headers with `id` and `createdAt` fields in the event payload. The former are headers sent by Svix and are scoped to a delivery, while the latter are fields generated by the Uphold platform. ## Retry schedule When a webhook delivery fails (e.g., due to a non-2xx response or timeout), the system will automatically retry the delivery based on an exponential backoff strategy. The schedule starts with short intervals and gradually increases over time, helping to avoid overwhelming the receiver. Retries continue for up to 24 hours or until a successful response is received. In addition to automatic retries, you can also trigger manual redelivery attempts through the Enterprise Portal, under the Webhooks section. This is useful for immediate reprocessing or debugging. For detailed information on the retry behavior, refer to the [retry policy](https://docs.svix.com/retries) from the webhook provider, Svix. ## Subscribing to webhooks The easiest way to subscribe to webhooks is through the Webhooks Portal. Not only that, but you can also check delivery logs and test your webhook endpoint to ensure it is working correctly. There are two ways to get access to the Webhooks Portal: * Via the REST API by calling the [Request webhook management link](./core-api/webhooks/request-management-link) endpoint. * Via the Enterprise Portal, under the Webhooks section. ## IP whitelisting If your webhook receiving endpoint is behind a firewall or NAT, you may need to allow traffic from Svix's IP addresses (the webhook provider). You can find the list of Svix's IP addresses at [https://docs.svix.com/webhook-ips.json](https://docs.svix.com/webhook-ips.json). # Create session Source: https://developer.uphold.com/rest-apis/widgets-api/kyc/create-session _media/specs/widgets-openapi.mintlify.json post /widgets/kyc/sessions Create a new session for the KYC Widget. # Create session Source: https://developer.uphold.com/rest-apis/widgets-api/payment/create-session _media/specs/widgets-openapi.mintlify.json post /widgets/payment/sessions Create a new session for the Payment Widget. # Create session Source: https://developer.uphold.com/rest-apis/widgets-api/travel-rule/create-session _media/specs/widgets-openapi.mintlify.json post /widgets/travel-rule/sessions Create a new session for the Travel Rule Widget. # Widgets changelog Source: https://developer.uphold.com/widgets/changelog Track Uphold Widget releases — Payment Widget, Travel Rule Widget, and others — with new features, enhancements, and breaking changes (RSS available). ### Google Pay support in Payment Widget **Summary** Introduced support for Google Pay deposits in the [Payment Widget](/widgets/payment/introduction). Users authorize each transfer with the native Google Pay sheet. **Documentation** * [APM overview](/developer-guides/apm-transfers/overview) * [Google Pay deposit flow](/developer-guides/apm-transfers/deposit/via-payment-widget/google-pay) ### Crypto withdrawal RFI support in Travel Rule Widget **Summary** The Travel Rule Widget now resolves the pending RFI automatically once the user completes the `withdrawal-form` flow. **Details** * **RFI resolution**: The widget calls [Update request for information](/rest-apis/core-api/requests-for-information/update-request-for-information) internally — no backend call needed from your integration. **Documentation** * [Withdrawal flow](/developer-guides/travel-rule/withdrawal) (updated) * [Create session](/rest-apis/widgets-api/travel-rule/create-session) ### Apple Pay support in Payment Widget **Summary** Introduced support for Apple Pay deposits and withdrawals in the [Payment Widget](/widgets/payment/introduction). Users authorize each transfer with the native Apple Pay sheet. The `authorize` flow also supports a new **headless mode** that renders only the Apple Pay button, letting you embed it directly into your own layout. See [`AuthorizeMode`](/widgets/payment/sdk-reference#authorizemode). This feature requires `@uphold/enterprise-payment-widget-web-sdk` version `0.15.0` or higher. **Documentation** * [APM overview](/developer-guides/apm-transfers/overview) * [Apple Pay deposit flow](/developer-guides/apm-transfers/deposit/via-payment-widget/apple-pay) * [Apple Pay withdrawal flow](/developer-guides/apm-transfers/withdrawal/via-payment-widget/apple-pay) ### Profile process support for the KYC Widget **Summary** The [KYC Widget](/widgets/kyc/introduction) now supports the `profile` process, in addition to `identity` and `proof-of-address`. You can collect the user's basic information (name, date of birth, citizenship, and address) directly within the Widget by including `profile` in the session's `processes`. **Documentation** * [Installation and setup](/widgets/kyc/installation-and-setup) * [Onboard users via KYC Widget](/developer-guides/user-onboarding/individual/via-kyc-widget) ### PayPal support in Payment Widget **Summary** Introduced support for PayPal deposits and withdrawals in the [Payment Widget](/widgets/payment/introduction). This feature requires `@uphold/enterprise-payment-widget-web-sdk` version `0.14.0` or higher. **Documentation** * [APM overview](/developer-guides/apm-transfers/overview) * [PayPal deposit flow](/developer-guides/apm-transfers/deposit/via-payment-widget/paypal) * [PayPal withdrawal flow](/developer-guides/apm-transfers/withdrawal/via-payment-widget/paypal) ### Responsive layout on larger screens and the `layout` option **Summary** Uphold Widgets now adapt to the available space. On phones they fill their container, and on larger screens (tablets and up) they render as a centered, fixed-size card. This applies to the [KYC Widget](/widgets/kyc/introduction), [Payment Widget](/widgets/payment/introduction), and [Travel Rule Widget](/widgets/travel-rule/introduction). To give applications control over this framing, all three Widgets now accept a new optional `layout` option: * `'boxed'` (default): on larger screens (tablets and up), centers the Widget and frames its content as a fixed-size card; on smaller screens it fills its container. * `'fluid'`: renders the Widget so it fills its container, with no frame or centering, so your own container (e.g. a modal) acts as the frame. This option is optional — the responsive layout is enabled by default (`'boxed'`), so no code changes are required to adopt it. Set `layout: 'fluid'` only when you want to provide your own framing. The `layout` option requires `@uphold/enterprise-kyc-widget-web-sdk` version `0.3.0`, `@uphold/enterprise-payment-widget-web-sdk` version `0.14.0`, or `@uphold/enterprise-travel-rule-widget-web-sdk` version `0.7.0` or higher. **Documentation** * [KYC Widget configuration options](/widgets/kyc/sdk-reference#options) * [Payment Widget configuration options](/widgets/payment/sdk-reference#options) * [Travel Rule Widget configuration options](/widgets/travel-rule/sdk-reference#options) * [WidgetLayout type](/widgets/payment/sdk-reference#widgetlayout) ### New design system and richer theming for Payment and Travel Rule Widgets **Summary** The [Payment Widget](/widgets/payment/introduction) and [Travel Rule Widget](/widgets/travel-rule/introduction) now run on Uphold's new design system. This brings a refreshed visual appearance to both Widgets. On top of this, theme customization is now consistent across all Widgets through a shared `WidgetThemeOption` type. In addition to forcing `light` or `dark` mode, applications can now customize brand colors, typography, and per-component styles (button, input, and card border radii) on the Payment and Travel Rule Widgets, matching the customization already available on the KYC Widget. The Travel Rule Widget now also fully respects the selected appearance, including in dark mode. Rich theme customization on the Payment Widget requires `@uphold/enterprise-payment-widget-web-sdk` version `0.13.0` or higher. Rich theme customization on the Travel Rule Widget requires `@uphold/enterprise-travel-rule-widget-web-sdk` version `0.6.0` or higher. **Documentation** * [Payment Widget configuration options](/widgets/payment/sdk-reference#options) * [Travel Rule Widget configuration options](/widgets/travel-rule/sdk-reference#options) * [WidgetThemeOption type](/widgets/payment/sdk-reference#widgetthemeoption) ### KYC Widget **Summary** Introduced the [KYC Widget](/widgets/kyc/introduction), a fully Uphold-managed, embeddable solution for collecting KYC data from your users without building a custom verification interface. The Widget currently supports the `identity` and `proof-of-address` processes. Support for additional processes will be added in future releases. **Documentation** * [KYC Widget introduction](/widgets/kyc/introduction) * [Installation and setup](/widgets/kyc/installation-and-setup) * [SDK reference](/widgets/kyc/sdk-reference) * [Onboard users via KYC Widget](/developer-guides/user-onboarding/individual/via-kyc-widget) ### Asset selection in crypto withdrawal flow **Summary** The crypto withdrawal flow in the [Payment Widget](/widgets/payment/introduction) now supports crypto asset selection, along network and destination address, simplifying the user journey and concentrating all data collection required for a crypto withdrawal inside the Payment Widget. The `complete` event payload for `via: "crypto-network"` selections now includes a new `selection.asset` field containing the selected asset code (e.g. `BTC`, `XRP`). The updated `CryptoNetworkSelection` type ships in `@uphold/enterprise-payment-widget-web-sdk` version `0.12` or higher — partners should upgrade to that version to get proper TypeScript support for the new `selection.asset` field. **Documentation** * [Crypto withdrawal via the Payment Widget](/developer-guides/crypto-transfers/withdrawal/via-payment-widget) * [CryptoNetworkSelection type](/widgets/payment/sdk-reference#withdrawalselection) ### Dark mode support in Payment Widget **Summary** Introduced dark mode support in the [Payment Widget](/widgets/payment/introduction). By default, the Widget automatically matches the user's system appearance preference. Applications embedding the Widget can also force a specific theme by passing the `theme` option when instantiating it. Theme override support requires `@uphold/enterprise-payment-widget-web-sdk` version `0.11` or higher. **Documentation** * [Payment Widget configuration options](/widgets/payment/sdk-reference#options) ### ACH support in Payment Widget **Summary** Introduced support for ACH bank transfers in the [Payment Widget](/widgets/payment/introduction), allowing users in the United States to perform ACH deposits and withdrawals. **Documentation** * [ACH deposit](/developer-guides/bank-transfers/deposit/via-payment-widget/ach) * [ACH withdrawal](/developer-guides/bank-transfers/withdrawal/via-payment-widget/ach) ### Account limit per asset in Payment Widget **Summary** Introduced the `maxAccountsPerAsset` option in the [Payment Widget](/widgets/payment/introduction), allowing partners to set the number of accounts that can be created per asset when generating bank deposit details. Check the documentation for more details on how this option works. **Documentation** * [maxAccountsPerAsset option](/widgets/payment/sdk-reference#options) ### Crypto support and payment method filtering in Payment Widget **Summary** Introduced support for cryptocurrency deposits and withdrawals in the [Payment Widget](/widgets/payment/introduction). Users can now generate details for crypto deposits and provide destination addresses for crypto withdrawals. Partners can now configure which payment methods to make available to their users through the new `paymentMethods` option. This allows filtering by payment type (card, bank, crypto), and for crypto and bank options, filtering which assets are available to the user. **Documentation** * [Payment method filtering](/widgets/payment/sdk-reference#options) * [Crypto deposit](/developer-guides/crypto-transfers/deposit/via-payment-widget) * [Crypto withdrawal](/developer-guides/crypto-transfers/withdrawal/via-payment-widget) ### Travel Rule Widget **Summary** Introduced the [Travel Rule Widget](/widgets/travel-rule/introduction), a fully Uphold-managed, embeddable solution that enables compliant collection and exchange of originator and beneficiary information for crypto transactions — ensuring adherence to global Travel Rule regulations. ### FPS support in Payment Widget **Summary** Introduced support for Faster Payments Service (FPS) in the [Payment Widget](/widgets/payment/introduction), supporting instant push bank deposits and bank withdrawals for users in the United Kingdom and EU. **Documentation** * [FPS deposit](/developer-guides/bank-transfers/deposit/via-payment-widget/fps) * [FPS withdrawal](/developer-guides/bank-transfers/withdrawal/via-payment-widget/fps) ### Card support in Payment Widget **Summary** Introduced the [Payment Widget](/widgets/payment/introduction), a fully Uphold-managed, embeddable solution that allows partners to support payment methods without implementing the API endpoints. The Payment Widget is launched with support for debit and credit cards. **Documentation** * [SDK reference](/widgets/payment/sdk-reference) * [Card deposit](/developer-guides/card-transfers/deposit/via-payment-widget) * [Card withdrawal](/developer-guides/card-transfers/withdrawal/via-payment-widget) # Uphold Widgets introduction Source: https://developer.uphold.com/widgets/introduction Embed turnkey Uphold widgets — Payment, Travel Rule, KYC — to add payment methods, transactions, and compliance flows to your applications. To reduce the time to market, we offer turnkey solutions in the form of widgets. Widgets are designed to be easily integrated into your existing applications, allowing you to offer a seamless experience to your users with some degree of flexibility and customization. Facilitate payments from and to your users. Ensure compliance with Travel Rule regulations for crypto transfers. Simplify collecting KYC from your users. Offer crypto onramp and offramp to your users. # KYC Widget installation and setup Source: https://developer.uphold.com/widgets/kyc/installation-and-setup Install the Uphold KYC Widget SDK — or integrate directly against the Widget's message protocol — in web and native applications through a WebView. By the end of this guide the KYC Widget will be running in your app, mounted to a container, and emitting events you can react to. ## Before you start Before you can test the KYC Widget in Sandbox or Production, Uphold must complete a one-time internal setup to enable identity verification for your account. Contact your Account Manager to have this provisioned ahead of your integration. You'll need: * Access to [Widgets API](/rest-apis/widgets-api/kyc/create-session) to create widget sessions. Manage your access in [Enterprise Portal](https://portal.enterprise.uphold.com/). * A **backend** that can call the Widgets API to create sessions on behalf of your users. * A **frontend** — a web app, or a native app with a WebView — to embed the Widget. The Widget runs from one of two hosts depending on environment: | Environment | Widget host | | ----------- | -------------------------------------------------- | | Sandbox | `https://kyc-widget.enterprise.sandbox.uphold.com` | | Production | `https://kyc-widget.enterprise.uphold.com` | ## Choose an integration approach We recommend **integrating without the SDK** for native apps — it's simpler to set up. For web-only integrations, the SDK is a solid default. There are two ways to embed the Widget: * **Web SDK** — Install `@uphold/enterprise-kyc-widget-web-sdk` for a better developer experience — typed events and a more streamlined integration on web. For native apps, the SDK must be bundled into the WebView's HTML page. * **JavaScript** — Listen for the Widget's messages over the iframe or WebView and respond to them directly. This is also the simpler option for native apps: there's no SDK to bundle into the WebView's HTML page. Both approaches use the same backend step — creating a session via the Widgets API — and emit the same four lifecycle outcomes (`ready`, `complete`, `cancel`, `error`). ## Shared setup These two steps are identical whichever approach you choose above — do them once, then jump to the matching section below. ### 1. Create a session on your backend The KYC Widget runs against a session — a short-lived, server-side authorization scoped to one flow and one user. Create it server-side using your OAuth credentials. To create a session, you must have the `KYC Widget` scope. Never create session directly from the client. Your client secret must not leave your backend. Call [`Create session`](/rest-apis/widgets-api/kyc/create-session) with the `verify` flow and the processes you want the user to complete: ```bash theme={null} curl -X POST https://api.sandbox.uphold.com/widgets/kyc/sessions \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "X-On-Behalf-Of: user $USER_ID" \ -H "Content-Type: application/json" \ -d '{ "flow": "verify", "processes": ["identity", "profile", "proof-of-address"] }' ``` The response wraps the session object: ```json theme={null} { "session": { "flow": "verify", "data": { "processes": ["identity", "profile", "proof-of-address"] }, "url": "https://kyc-widget.enterprise.sandbox.uphold.com/...", "token": "..." } } ``` Pass `response.session` to your frontend (e.g. as part of your page response or via your own API endpoint): * With the SDK, it's the single `session` argument to the `KycWidget` constructor — see [Setup Web SDK](#setup-with-web-sdk). * Without the SDK, `session.url` is what you load directly into your iframe or WebView, and the whole object is what you send back as `init` — see [Setup with JavaScript](#setup-with-javascript). ### 2. Allow the Widget domain in your CSP If your web app embeds the Widget in an iframe and enforces a Content Security Policy, allow the Widget host for your environment(s) under `frame-src`. ```html theme={null} ``` If your app does not use CSP, skip this step. ## Setup with Web SDK ### Install the SDK Install the SDK in the frontend that will host the Widget — your web app, or the JS bundle loaded by your native WebView. ```bash theme={null} npm install @uphold/enterprise-kyc-widget-web-sdk ``` ### Initialize and mount the Widget On the frontend, instantiate `KycWidget` with the session from [Create a session on your backend](#1-create-a-session-on-your-backend), then mount it into a container element. ```javascript theme={null} import { KycWidget } from '@uphold/enterprise-kyc-widget-web-sdk'; // session is `response.session` from your backend — see Shared setup const widget = new KycWidget(session, { theme: { appearance: 'dark' }, // omit to follow system preference debug: true // verbose logging during development }); widget.mountIframe(document.getElementById('kyc-container')); ``` The container must have explicit CSS width and height — the iframe fills its bounds. Minimum recommended size is **400px × 600px**. See the [SDK reference](./sdk-reference#constructor) for full constructor details. ### Handle Widget events The Widget emits four events during its lifecycle. Wire up handlers **before** calling `mountIframe`. | Event | Fires when | What to do | | ---------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `ready` | The Widget has finished loading | Hide your loading state | | `complete` | The user submitted all KYC processes | Call `widget.unmount()`, then monitor outcomes via [KYC webhooks](/rest-apis/core-api/kyc/introduction) | | `cancel` | The user dismissed the Widget | Call `widget.unmount()`, return them to your flow | | `error` | The Widget hit an unrecoverable error | Read `event.detail.error`, call `widget.unmount()`, retry | The Widget does not unmount itself. You must call `widget.unmount()` from `complete`, `cancel`, and `error` handlers. ```javascript [expandable] theme={null} widget.on('ready', () => { console.log('KYC Widget is ready'); }); widget.on('complete', () => { console.log('KYC processes submitted'); widget.unmount(); }); widget.on('cancel', () => { console.log('KYC cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('KYC error:', event.detail.error); widget.unmount(); }); ``` The `complete` event signals that the user has submitted all required processes — not that verification was approved. Final verification outcomes (e.g. identity approved or rejected) are delivered asynchronously via [KYC webhooks](/rest-apis/core-api/kyc/introduction). Monitor those server-side to update your user's status. ### Native apps with the SDK Native mobile apps can also use the SDK to embed the Widget through a WebView, building on the [Install the SDK](#install-the-sdk) and [Initialize and mount the Widget](#initialize-and-mount-the-widget) steps above. To do so, it requires you to bundle the SDK into the WebView's HTML page, and forward events to native code through a bridge. We recommend integrating [without the SDK](#setup-with-javascript) for native apps as it has a simpler setup, but if you choose to use the SDK, follow the steps below. Create a JS bundle that includes the SDK. This bundle will be loaded in the WebView. You can use a tool like [Webpack](https://webpack.js.org/) or [Rollup](https://rollupjs.org/) to bundle the SDK and your custom code into a single JS file. Loading the Web SDK from a CDN is not supported. Then create an HTML page that includes the JS bundle and mounts the Widget. This page will be loaded in the WebView. Here is an example you can use — the `sendToNativeApp` helper at the bottom forwards events to whichever bridge is available (iOS, Android, or React Native). ```html [expandable] theme={null} KYC Widget
``` ### Platform setup Here is a sample of how to create a WebView in your native app and load the HTML page that contains the SDK and mounts the Widget. The WebView should be configured to allow JavaScript execution and to forward messages to your native code. ```swift [expandable] theme={null} import WebKit class KycViewController: UIViewController, WKScriptMessageHandler { @IBOutlet weak var webView: WKWebView! override func viewDidLoad() { super.viewDidLoad() // Register a single message handler for all Widget events let contentController = webView.configuration.userContentController contentController.add(self, name: "kycWidgetMessage") if let url = Bundle.main.url(forResource: "kyc-widget", withExtension: "html") { webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) } } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "kycWidgetMessage", let messageDict = message.body as? [String: Any], let type = messageDict["type"] as? String else { return } let data = messageDict["data"] switch type { case "complete": handleKycComplete() case "cancel": handleKycCancel() case "error": handleKycError(error: data) default: print("Unknown KYC Widget message type: \(type)") } } private func handleKycComplete() { /* navigate to success */ } private func handleKycCancel() { /* return user to previous screen */ } private func handleKycError(error: Any?) { /* show error UI */ } deinit { webView?.configuration.userContentController.removeScriptMessageHandler(forName: "kycWidgetMessage") } } ``` ```java [expandable] theme={null} import android.webkit.WebView; import android.webkit.WebSettings; import android.webkit.JavascriptInterface; import android.util.Log; import org.json.JSONObject; WebView webView = findViewById(R.id.webview); WebSettings webSettings = webView.getSettings(); webSettings.setJavaScriptEnabled(true); webView.addJavascriptInterface(new KycBridge(), "KycBridge"); webView.loadUrl("file:///android_asset/kyc-widget.html"); public class KycBridge { @JavascriptInterface public void onMessage(String messageJson) { try { JSONObject message = new JSONObject(messageJson); String type = message.getString("type"); Object data = message.opt("data"); runOnUiThread(() -> { switch (type) { case "complete": handleKycComplete(); break; case "cancel": handleKycCancel(); break; case "error": handleKycError(data != null ? data.toString() : null); break; default: Log.w("Kyc", "Unknown KYC Widget message type: " + type); } }); } catch (Exception e) { Log.e("Kyc", "Error parsing KYC Widget message", e); } } private void handleKycComplete() { /* navigate to success */ } private void handleKycCancel() { /* return user to previous screen */ } private void handleKycError(String error) { /* show error UI */ } } ``` If you load local assets, ensure the WebView allows file access according to your security requirements (e.g. `setAllowFileAccess(true)` when needed). ```javascript [expandable] theme={null} import { WebView } from 'react-native-webview'; import { Alert } from 'react-native'; const KycScreen = () => { const handleMessage = (event) => { try { const message = JSON.parse(event.nativeEvent.data); switch (message.type) { case 'complete': handleKycComplete(); break; case 'cancel': handleKycCancel(); break; case 'error': handleKycError(message.data); break; default: console.log('Unknown message type:', message.type); } } catch (error) { console.error('Error parsing WebView message:', error); } }; const handleKycComplete = () => { /* navigate to success */ }; const handleKycCancel = () => { /* return user to previous screen */ }; const handleKycError = (error) => { /* show error UI */ }; return ( ); }; ``` For iOS, `file://` URLs may be restricted. Consider `source={{ html: '...' }}` or a bundled asset, adjusted per platform.
## Setup with JavaScript Instead of installing the SDK, you can load the Widget's session `url` directly — as an iframe you create yourself on web, or as your WebView's top-level page on native — and speak its underlying message protocol directly. This is useful for hosts that can't ship a JS bundle to their WebView, or want a fully native shell around the Widget. The [Shared setup](#shared-setup) steps still apply here — you just don't install anything. Only how you load the Widget and exchange messages changes. ### The message protocol These are the message types the Widget speaks. The transport carrying them differs per platform — see the tabs below. **From the Widget to your host:** | Type | Payload | Fires when | What to do | | --------------- | ---------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `load` | — | The Widget's script has booted and needs its session | Reply with an `init` message (see below) | | `ready` | — | The Widget is ready for interaction | Hide your loading state | | `complete` | — | The user submitted all KYC processes | Tear down the iframe/WebView, then monitor outcomes via [KYC webhooks](/rest-apis/core-api/kyc/introduction) | | `cancel` | — | The user dismissed the Widget | Tear down the iframe/WebView, return them to your flow | | `error` | `error` (string) | The Widget hit an unrecoverable error | Read `error`, tear down the iframe/WebView, retry | | `force_repaint` | — | Works around a redraw issue in mobile Safari only | Briefly toggle the WebView's opacity to force a repaint | **From your host to the Widget:** | Type | Payload | Send when | | ------ | ------------------------- | --------------------------------------- | | `init` | `{ ...session, options }` | Replying to the Widget's `load` message | The `init` payload spreads the session object from [Create a session on your backend](#1-create-a-session-on-your-backend) (`url`, `token`, `flow`, `data`) alongside an `options` object with the same shape as the SDK's [`KycWidgetOptions`](./sdk-reference#options) — omit `options` or pass `{}` to use defaults. ### Specifying the theme appearance By default, the Widget matches the browser or OS `prefers-color-scheme` until it receives your `init` reply. To control the initial appearance yourself — and avoid a flash if it won't match what you send in `init` — append a `theme_appearance` query parameter (`dark` or `light`) to `session.url` before loading it: ```javascript theme={null} const url = new URL(session.url); url.searchParams.set('theme_appearance', 'dark'); // or 'light' ``` This works the same whether you load the result into a web iframe or as your native WebView's top-level page. ### Platform implementation Mount the session `url` in an iframe you create yourself, and exchange messages over the standard `window.postMessage` API. Make sure the iframe's container has explicit CSS width and height (minimum recommended size is **400px × 600px**). ```html [expandable] theme={null}
```
Instead of bundling an HTML page, point the WebView directly at the session `url`. The Widget sends messages to native code through the `uphdKycWidget` message handler; to reply, call `window.KycWidgetBridge.sendMessageToWidget(...)` via `evaluateJavaScript`, passing the session object alongside an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `KycViewController` below ties this together into a complete example, forwarding the Widget's messages to native callbacks. ```swift [expandable] theme={null} import WebKit final class KycViewController: UIViewController, WKScriptMessageHandler { var onComplete: (() -> Void)? var onCancel: (() -> Void)? var onError: ((Any?) -> Void)? // The session dict from your backend (url, token, flow, data, ...), kept around so it can be // echoed back to the Widget in the "init" reply to its "load" message. private var session: [String: Any]? private let webView: WKWebView = { let webView = WKWebView(frame: .zero) webView.translatesAutoresizingMaskIntoConstraints = false return webView }() override func viewDidLoad() { super.viewDidLoad() view.addSubview(webView) NSLayoutConstraint.activate([ webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) // Registers the exact handler name the Widget looks for webView.configuration.userContentController.add(self, name: "uphdKycWidget") Task { do { let session = try await createKycWidgetSession() // calls your backend self.session = session guard let urlString = session["url"] as? String, let url = URL(string: urlString) else { onError?(["message": "KYC Widget session response is missing a valid 'url'"]) return } webView.load(URLRequest(url: url)) } catch { onError?(["message": "\(error)"]) } } } // Replies to the Widget's "load" message with an "init" command carrying the session // (url, token, flow, data, ...) plus an options object — same shape as the SDK's // KycWidgetOptions. Empty here; see Configuration reference to customize it. private func sendInitMessageToWidget() { guard var initMessage = session, let data = try? JSONSerialization.data(withJSONObject: { initMessage["type"] = "init" initMessage["options"] = [:] return initMessage }()), let json = String(data: data, encoding: .utf8) else { return } webView.evaluateJavaScript("window.KycWidgetBridge.sendMessageToWidget(\(json));") } // Nudges the page's opacity to force a repaint — needed to work around a WKWebView // redraw bug when the Widget uses the View Transitions API. private func forceRepaintWidget() { webView.evaluateJavaScript(""" document.documentElement.style.opacity = '0.99'; setTimeout(() => { document.documentElement.style.opacity = '1'; }, 0); """) } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { // The Widget JSON.stringifies its messages before posting them, so the body // arrives as a string rather than an already-decoded dictionary. guard message.name == "uphdKycWidget", let jsonString = message.body as? String, let data = jsonString.data(using: .utf8), let messageDict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let type = messageDict["type"] as? String else { return } switch type { case "load": sendInitMessageToWidget() case "force_repaint": forceRepaintWidget() case "ready": print("KYC Widget is ready") case "complete": onComplete?() case "cancel": onCancel?() case "error": onError?(messageDict["error"]) default: print("Unknown KYC Widget message type: \(type)") } } deinit { webView.configuration.userContentController.removeScriptMessageHandler(forName: "uphdKycWidget") } } ``` Instead of bundling an HTML page, point the WebView directly at the session `url`. The Widget sends messages to native code through the `uphdKycWidget` object registered below; to reply, call `replyProxy.postMessage(...)`, passing the session object alongside an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `KycActivity` below ties this together into a complete example, forwarding the Widget's messages to native callbacks. There is no bundled test app reference for this path yet. Validate the message names and payload shapes below against the version of the Widget you're integrating. ```kotlin [expandable] theme={null} import android.webkit.WebView import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import org.json.JSONObject class KycActivity : AppCompatActivity() { private lateinit var webView: WebView private var session: JSONObject? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_kyc) webView = findViewById(R.id.webview) webView.settings.javaScriptEnabled = true if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { WebViewCompat.addWebMessageListener( webView, "uphdKycWidget", setOf( "https://kyc-widget.enterprise.sandbox.uphold.com", "https://kyc-widget.enterprise.uphold.com" ) ) { _, message, _, _, replyProxy -> val body = JSONObject(message.data ?: return@addWebMessageListener) when (body.getString("type")) { "load" -> sendInitMessageToWidget(replyProxy) "ready" -> Log.d("Kyc", "KYC Widget is ready") "complete" -> handleKycComplete() "cancel" -> handleKycCancel() "error" -> handleKycError(body.opt("error")) else -> Log.w("Kyc", "Unknown KYC Widget message type: ${body.getString("type")}") } } } else { // Fall back to Setup Web SDK on WebViews that don't support WEB_MESSAGE_LISTENER. } lifecycleScope.launch { try { val session = createKycWidgetSession() // calls your backend this@KycActivity.session = session webView.loadUrl(session.getString("url")) } catch (e: Exception) { handleKycError(e.message) } } } // Replies to the Widget's "load" message with an "init" command carrying the session // (url, token, flow, data, ...) plus an options object — same shape as the SDK's // KycWidgetOptions. Empty here; see Configuration reference to customize it. private fun sendInitMessageToWidget(replyProxy: JavaScriptReplyProxy) { val session = session ?: return val initMessage = JSONObject(session.toString()) .put("type", "init") .put("options", JSONObject()) replyProxy.postMessage(initMessage.toString()) } private fun handleKycComplete() { /* navigate to success */ } private fun handleKycCancel() { /* return user to previous screen */ } private fun handleKycError(error: Any?) { /* show error UI */ } } ``` `force_repaint` is a WKWebView-specific workaround and isn't expected on Android — the listener above doesn't need to handle it. Instead of bundling an HTML page, point the WebView directly at the session `url`. The Widget sends messages to native code through the WebView's `onMessage` prop, once it detects `window.ReactNativeWebView` (injected automatically by `react-native-webview`); to reply, call `window.KycWidgetBridge.sendMessageToWidget(...)` via `injectJavaScript`, passing the session object alongside an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `KycScreen` below ties this together into a complete example, forwarding the Widget's messages to native callbacks. ```jsx [expandable] theme={null} import React, { useRef } from 'react'; import { WebView } from 'react-native-webview'; const KycScreen = ({ session }) => { const webViewRef = useRef(null); // Replies to the Widget's "load" message with an "init" command carrying the session // (url, token, flow, data, ...) plus any options. const sendInitMessageToWidget = () => { const initMessage = JSON.stringify({ ...session, options: {}, type: 'init' }); webViewRef.current?.injectJavaScript(`window.KycWidgetBridge.sendMessageToWidget(${initMessage}); true;`); }; const handleMessage = (event) => { const message = JSON.parse(event.nativeEvent.data); switch (message.type) { case 'load': sendInitMessageToWidget(); break; case 'ready': console.log('KYC Widget is ready'); break; case 'complete': handleKycComplete(); break; case 'cancel': handleKycCancel(); break; case 'error': handleKycError(message.error); break; default: console.log('Unknown KYC Widget message type:', message.type); } }; const handleKycComplete = () => { /* navigate to success */ }; const handleKycCancel = () => { /* return user to previous screen */ }; const handleKycError = (error) => { /* show error UI */ }; return ( ); }; ``` `force_repaint` is a WKWebView-specific workaround. If you see repaint glitches on iOS, forward it the same way as `load`, toggling the WebView's `opacity` briefly via `injectJavaScript`. Check the iOS example for details.
## Test in Sandbox With your Sandbox credentials and the Sandbox Widget host configured, run through this checklist — it applies whichever approach you integrated with: * The Widget mounts and `ready` fires. * Completing the verification flow fires `complete`. * Closing or dismissing the Widget fires `cancel`. * If your app uses a CSP (see [Shared setup](#shared-setup)), the browser console shows no violations (look for "Refused to frame"). Once Sandbox is green, swap your OAuth credentials to Production. The Widget host is selected automatically by the session `url` returned from your backend — no client-side environment switching is needed. ## Configuration reference The most common SDK options. See the [SDK reference](./sdk-reference#options) for the full schema and all event types. If you're integrating without the SDK, these map directly to the `options` object you send in your `init` reply. | Option | Purpose | | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `theme` | Customize the Widget's appearance: force `light` or `dark` mode, set brand colors, typography, and per-component border radii. See [WidgetThemeOption](/widgets/kyc/sdk-reference#widgetthemeoption) for all options. | | `layout` | Control how the Widget is laid out on larger viewports — a centered, framed `'boxed'` card (default) or a `'fluid'` fill of its container. | | `debug` | Verbose console logging. Use during development. | ## Troubleshooting **Widget not displaying** Confirm the Widget host for your environment is in the `frame-src` directive of your CSP (see [Shared setup](#shared-setup)). Open DevTools → Console and look for `Refused to frame` violations. **Container is empty after mount** The iframe fills its container — the container must have explicit CSS width and height. Minimum recommended size is 400px × 600px. **Events not firing in native apps (with the SDK)** Verify that: * JavaScript is enabled in the WebView. * The message bridge is registered **before** the HTML page loads. * Event handler names match the platform-specific bridge contract used in `sendToNativeApp`. **Events not firing (without the SDK)** Verify that: * Your message-handler / listener name is exactly `uphdKycWidget` — the name the Widget checks for on both iOS and Android — and is registered **before** the WebView loads the session `url`. * You're replying to `load` with `init` — the Widget won't render anything until it receives it. * On web, you're filtering incoming `message` events by origin (`event.origin === sessionOrigin`) — messages from other origins should be ignored, not treated as Widget events. **Widget never unmounts.** The SDK does not auto-unmount, and neither does the Widget itself when integrating without it. Call `widget.unmount()` (SDK) or tear down your iframe/WebView (no SDK) from each terminal message (`complete`, `cancel`, `error`). ## Next steps * Review the complete [SDK Reference](./sdk-reference) for all available methods and events. * Follow the [user onboarding guide](/developer-guides/user-onboarding/individual/via-kyc-widget) for a step-by-step walkthrough of verifying users with the KYC Widget. # KYC Widget introduction Source: https://developer.uphold.com/widgets/kyc/introduction An embeddable UI component for collecting KYC data from your users — fully managed by Uphold, customizable to match your app. The KYC Widget is a fully Uphold-managed, embeddable solution for collecting KYC data from individual users across web and native apps. Instead of building and maintaining a custom verification interface, you embed the Widget and Uphold handles forms, document uploads, and process state on your behalf. It's fully customizable — themes, fonts, and brand colors — so it can match your app's look and feel. *** ## Features Fully managed UI and verification logic with automated state handling. Go live quickly with minimal frontend effort. Run one or more KYC processes in a single flow. Control which processes users go through via the SDK configuration. Embed seamlessly into web apps via iframe and native apps via WebView. Consistent user experience across all platforms. SDK emits lifecycle events such as completion, error, and cancellation. Integrate deeply with your app's flow and UI feedback. ## Next Steps Install the SDK, configure CSP, and set up native app integration. End-to-end walkthrough for onboarding individual users with the Widget. Full reference for the `KycWidget` class, options, methods, and events. # KYC Widget SDK reference Source: https://developer.uphold.com/widgets/kyc/sdk-reference Reference for the @uphold/enterprise-kyc-widget-web-sdk package, including the KycWidget class, constructor options, methods, and lifecycle events. Complete reference documentation for the `@uphold/enterprise-kyc-widget-web-sdk` package. The main class for creating and managing KYC Widget instances is `KycWidget`. It requires a `KycWidgetSession` object that must be created through the API before instantiating the Widget. ## Constructor ```typescript theme={null} new KycWidget( session: KycWidgetSession, options?: KycWidgetOptions ) ``` **Parameters:** | Parameter | Type | Required | Description | | --------- | ------------------ | -------- | --------------------------------------------------------------------------------------------------------- | | `session` | `KycWidgetSession` | Yes | KYC session object obtained from the [Create session](/rest-apis/widgets-api/kyc/create-session) endpoint | | `options` | `KycWidgetOptions` | No | Configuration options for the Widget | ### Options ```typescript theme={null} type KycWidgetOptions = { debug?: boolean; layout?: WidgetLayout; theme?: WidgetThemeOption; }; ``` | Property | Type | Default | Description | | -------- | ------------------- | ----------------- | ----------------------------------------------------------------------------------------------------- | | `debug` | `boolean` | `false` | Enable debug mode for additional logging | | `layout` | `WidgetLayout` | `'boxed'` | Control how the Widget is laid out on larger viewports. See [WidgetLayout](#widgetlayout) for details | | `theme` | `WidgetThemeOption` | System preference | Control the visual appearance of the Widget. See [WidgetThemeOption](#widgetthemeoption) for details | **layout option:** The `layout` option controls how the Widget is laid out when there is enough room to frame it — on viewports that are both at least `md` wide (≥768px) and at least 600px tall, i.e. tablets and up. On smaller viewports — narrower or shorter, including phones in landscape — the Widget always fills its container regardless of this option. * `'boxed'` (default): on viewports that are ≥768px wide and ≥600px tall, centers the Widget and frames its content as a fixed-size card; on smaller viewports it fills its container. Use this when the Widget takes up the whole page (e.g. mounted into a full-page container). * `'fluid'`: renders the Widget so it fills its container, with no frame or centering, so your own container (e.g. a modal or a sized panel) acts as the frame. Most integrations should rely on the default `'boxed'` and only set `layout: 'fluid'` when embedding the Widget in your own framed container. ```typescript theme={null} const widget = new KycWidget(session, { layout: 'fluid' }); ``` Use `'fluid'` when you already provide a centered container or modal around the Widget, to avoid a card-within-a-card appearance on larger viewports. **theme option:** The `theme` option lets you control the visual appearance of the Widget. By default, the Widget automatically detects and matches the user's browser or OS appearance preference (`light` or `dark`). Use this option when you want to enforce a specific theme regardless of the user's system settings. ```typescript theme={null} const widget = new KycWidget(session, { theme: { appearance: 'dark', primary: { light: '#6200EA', dark: '#BB86FC' }, // Alternatively, a single color string applies to both appearances, e.g. primary: '#6200EA' // primary: '#6200EA', fontFamily: 'Inter, sans-serif', components: { button: { borderRadius: '8px' } } } }); ``` ## Methods ### `mountIframe()` Mounts the KYC Widget iframe to the specified DOM element. **Parameters:** * `element`: The HTML element where the Widget should be mounted **Example:** ```javascript theme={null} // Ensure the container has appropriate sizing const container = document.getElementById('kyc-container'); widget.mountIframe(container); ``` The container element should have explicit dimensions set via CSS. The Widget will fill the entire container. Minimum recommended size is 400px width × 600px height for optimal user experience. ### `unmount()` Unmounts and cleans up the KYC Widget iframe. **Example:** ```javascript theme={null} widget.unmount(); ``` The Widget will not unmount itself when a flow is finished, either by successful completion, user cancellation, or unrecoverable error, so it must always be called manually. ### `on()` Registers an event listener for Widget events. **Parameters:** * `event`: The event name to listen for * `callback`: Function to execute when the event is triggered **Example:** ```javascript theme={null} widget.on('complete', () => { console.log('KYC completed'); }); ``` ### `off()` Removes an event listener for KYC Widget events. **Parameters:** * `event`: The event name to stop listening for * `callback`: The specific function to remove (must be the same reference as used in `on()`) **Example:** ```javascript theme={null} const handleComplete = () => { console.log('KYC completed'); }; // Register the listener widget.on('complete', handleComplete); // Remove the listener widget.off('complete', handleComplete); ``` ## Events The KYC Widget emits several events during its lifecycle that you can listen to using the `on()` method. ### `complete` Fired when all KYC processes have been submitted. ```typescript theme={null} type KycWidgetCompleteEvent = { detail: {}; }; ``` **Example:** ```javascript theme={null} widget.on('complete', () => { console.log('KYC processes submitted'); widget.unmount(); }); ``` The `complete` event signals submission, not approval. Final verification outcomes are delivered asynchronously via [KYC webhooks](/rest-apis/core-api/kyc/introduction). Monitor those server-side to update your user's status. ### `cancel` Fired when the user cancels the KYC flow. ```typescript theme={null} type KycWidgetCancelEvent = { detail: {}; }; ``` **Example:** ```javascript theme={null} widget.on('cancel', () => { console.log('KYC cancelled by user'); widget.unmount(); }); ``` ### `error` Fired when an unrecoverable error occurs during the KYC flow. ```typescript theme={null} type KycWidgetErrorEvent = { detail: { error: string; }; }; ``` **Example:** ```javascript theme={null} widget.on('error', (event) => { console.error('KYC error:', event.detail.error); widget.unmount(); }); ``` ### `ready` Fired when the KYC Widget has finished loading. ```typescript theme={null} type KycWidgetReadyEvent = { detail: {}; }; ``` **Example:** ```javascript theme={null} widget.on('ready', () => { console.log('KYC Widget is ready'); }); ``` ## Types ### KycWidgetSession The session object returned by the [Create session](/rest-apis/widgets-api/kyc/create-session) endpoint. ```typescript theme={null} type KycWidgetSession = { url: string; token: string; data: { processes: 'identity' | 'profile' | 'proof-of-address'; } }; ``` ### WidgetLayout Controls how the Widget is laid out on larger viewports. ```typescript theme={null} type WidgetLayout = 'boxed' | 'fluid'; ``` | Value | Description | | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `'boxed'` | Default. On viewports that are ≥768px wide and ≥600px tall (tablets and up), centers the Widget and frames its content as a fixed-size card; on smaller viewports it fills its container | | `'fluid'` | Renders the Widget so it fills its container, with no frame or centering, so your own container acts as the frame | ### WidgetThemeOption Controls the visual appearance of the Widget. ```typescript theme={null} type WidgetThemeOption = { appearance?: 'light' | 'dark'; primary?: ColorTokenValue; primaryForeground?: ColorTokenValue; foreground?: ColorTokenValue; emphasisForeground?: ColorTokenValue; background?: ColorTokenValue; fontFamily?: FontFamily; components?: { button?: { borderRadius?: string }; input?: { borderRadius?: string }; card?: { borderRadius?: string }; }; }; ``` | Property | Type | Description | | -------------------- | ------------------- | ------------------------------------------------------------------------------------------------------ | | `appearance` | `'light' \| 'dark'` | Forces light or dark mode, overriding the user's system preference | | `primary` | `ColorTokenValue` | Primary brand color, used for buttons and key interactive elements | | `primaryForeground` | `ColorTokenValue` | Text color rendered on top of the primary color | | `foreground` | `ColorTokenValue` | Main text color | | `emphasisForeground` | `ColorTokenValue` | Emphasized text color | | `background` | `ColorTokenValue` | Widget background color | | `fontFamily` | `FontFamily` | Font family applied across the Widget. See [FontFamily](#fontfamily) for details | | `components` | `object` | Per-component style overrides for `button`, `input`, and `card` (each accepts `borderRadius?: string`) | ### ColorTokenValue A color value that can be a single string applied to both light and dark modes, or separate values per mode. ```typescript theme={null} type ColorTokenValue = string | { light: string; dark: string }; ``` ### FontFamily A font family that can be a single string applied to all slots, or separate values per slot (`mono`, `sans`, `serif`). ```typescript theme={null} type FontFamily = string | Partial>; ``` ## Complete usage example Here's an end-to-end example showing how to use the KYC Widget with all events: ```typescript theme={null} import { KycWidget } from '@uphold/enterprise-kyc-widget-web-sdk'; // Create a KYC Widget session from your backend const session = await createKycWidgetSession(); // Initialize the Widget const widget = new KycWidget(session, { debug: true }); // Handle ready event widget.on('ready', () => { console.log('KYC Widget is ready'); }); // Handle completion (monitor final outcomes via webhooks) widget.on('complete', () => { console.log('KYC processes submitted'); widget.unmount(); }); // Handle cancellation widget.on('cancel', () => { console.log('KYC cancelled by user'); widget.unmount(); }); // Handle errors widget.on('error', (event) => { console.error('KYC error:', event.detail.error); widget.unmount(); }); // Mount the Widget's iframe to a DOM element widget.mountIframe(document.getElementById('kyc-container')); ``` # Payment Widget installation and setup Source: https://developer.uphold.com/widgets/payment/installation-and-setup Install the Uphold Payment Widget SDK — or integrate directly against the Widget's message protocol — in web and native applications through a WebView. By the end of this guide the Payment Widget will be running in your app, mounted to a container, and emitting events you can react to. ## Before you start You'll need: * Access to [Widgets API](/rest-apis/widgets-api/payment/create-session) to create widget sessions. Manage your access in [Enterprise Portal](https://portal.enterprise.uphold.com/). * A **backend** that can call the Widgets API to create sessions on behalf of your users. * A **frontend** — a web app, or a native app with a WebView — to embed the Widget. The Widget runs from one of two hosts depending on environment: | Environment | Widget host | | ----------- | ------------------------------------------------------ | | Sandbox | `https://payment-widget.enterprise.sandbox.uphold.com` | | Production | `https://payment-widget.enterprise.uphold.com` | ## Choose an integration approach We recommend **integrating without the SDK** for native apps — it's simpler to set up. For web-only integrations, the SDK is a solid default. There are two ways to embed the Widget: * **Web SDK** — Install `@uphold/enterprise-payment-widget-web-sdk` for a better developer experience — typed events and a more streamlined integration on web. For native apps, the SDK must be bundled into the WebView's HTML page, and if your flow needs Apple Pay or Google Pay, that page must be served from a real HTTPS origin rather than bundled locally. * **JavaScript** — Listen for the Widget's messages over the iframe or WebView and respond to them directly. This is also the simpler option for native apps: there's no SDK to bundle into the WebView's HTML page, so payment methods like Apple Pay and Google Pay work without any extra setup. Both approaches use the same backend step — creating a session via the Widgets API — and emit the same four lifecycle outcomes (`ready`, `complete`, `cancel`, `error`). ## Shared setup These two steps are identical whichever approach you choose above — do them once, then jump to the matching section below. ### 1. Create a session on your backend The Payment Widget runs against a session — a short-lived, server-side authorization scoped to one flow and one user. Create it server-side using your OAuth credentials. To create a session, you must have the `Payment Widget` scope. Never create session directly from the client. Your client secret must not leave your backend. Call [`Create session`](/rest-apis/widgets-api/payment/create-session) with the desired `flow` (`select-for-deposit`, `select-for-withdrawal`, or `authorize`) and the user the session is for: ```bash theme={null} curl -X POST https://api.sandbox.uphold.com/widgets/payment/sessions \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "X-On-Behalf-Of: user $USER_ID" \ -H "Content-Type: application/json" \ -d '{ "flow": "select-for-deposit" }' ``` The response wraps the session object: ```json theme={null} { "session": { "flow": "select-for-deposit", "url": "https://payment-widget.enterprise.sandbox.uphold.com/...", "token": "..." } } ``` Pass `response.session` to your frontend (e.g. as part of your page response or via your own API endpoint): * With the SDK, it's the single `session` argument to the `PaymentWidget` constructor — see [Setup Web SDK](#setup-with-web-sdk). * Without the SDK, `session.url` is what you load directly into your iframe or WebView, and the whole object is what you send back as `init` — see [Setup with JavaScript](#setup-with-javascript). ### 2. Allow the Widget domain in your CSP If your web app embeds the Widget in an iframe and enforces a Content Security Policy, allow the Widget host for your environment(s) under `frame-src`. ```html theme={null} ``` If your app does not use CSP, skip this step. ## Setup with Web SDK ### Install the SDK Install the SDK in the frontend that will host the Widget — your web app, or the JS bundle loaded by your native WebView. ```bash theme={null} npm install @uphold/enterprise-payment-widget-web-sdk ``` ### Initialize and mount the Widget On the frontend, instantiate `PaymentWidget` with the session from [Create a session on your backend](#1-create-a-session-on-your-backend), then mount it into a container element. ```javascript [expandable] theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; // session is `response.session` from your backend — see Shared setup const widget = new PaymentWidget(session, { paymentMethods: [ { type: 'card' }, { type: 'bank' }, { type: 'crypto', assets: { include: ['BTC', 'ETH', 'XRP'] } }, { type: 'paypal' }, { type: 'apple-pay'}, { type: 'google-pay'} ], theme: { appearance: 'dark' }, // omit to follow system preference debug: true // verbose logging during development }); widget.mountIframe(document.getElementById('payment-container')); ``` The container must have explicit CSS width and height — the iframe fills its bounds. Minimum recommended size is **400px × 600px**. For type inference on the `complete` event, pass the flow as a generic: `new PaymentWidget<'select-for-deposit'>(session)`. See the [SDK reference](./sdk-reference#constructor) for full constructor details. ### Handle Widget events The Widget emits four events during its lifecycle. Wire up handlers **before** calling `mountIframe`. | Event | Fires when | What to do | | ---------- | -------------------------------------------- | --------------------------------------------------------- | | `ready` | The Widget has finished loading | Hide your loading state | | `complete` | The user finished selection or authorization | Read `event.detail.value`, then call `widget.unmount()` | | `cancel` | The user dismissed the Widget | Call `widget.unmount()`, return them to your flow | | `error` | The Widget hit an unrecoverable error | Read `event.detail.error`, call `widget.unmount()`, retry | The Widget does not unmount itself. You must call `widget.unmount()` from `complete`, `cancel`, and `error` handlers. ```javascript [expandable] theme={null} widget.on('ready', () => { console.log('Payment Widget is ready'); }); widget.on('complete', (event) => { console.log('Selection:', event.detail.value); widget.unmount(); }); widget.on('cancel', () => { console.log('Payment cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Payment error:', event.detail.error); widget.unmount(); }); ``` The shape of `event.detail.value` varies by flow. See [Events](./sdk-reference#events) in the SDK reference for the full type definitions. ### Native apps with the SDK Native mobile apps can also use the SDK to embed the Widget through a WebView, building on the [Install the SDK](#install-the-sdk) and [Initialize and mount the Widget](#initialize-and-mount-the-widget) steps above. To do so, it requires you to bundle the SDK into the WebView's HTML page, and forward events to native code through a bridge. We recommend integrating [without the SDK](#setup-with-javascript) for native apps as it has a simpler setup, but if you choose to use the SDK, follow the steps below. Create a JS bundle that includes the SDK. This bundle will be loaded in the WebView. You can use a tool like [Webpack](https://webpack.js.org/) or [Rollup](https://rollupjs.org/) to bundle the SDK and your custom code into a single JS file. Loading the Web SDK from a CDN is not supported. Then create an HTML page that includes the JS bundle and mounts the Widget. This page will be loaded in the WebView. Here is an example you can use — the `sendToNativeApp` helper at the bottom forwards events to whichever bridge is available (iOS, Android, or React Native). ```html [expandable] theme={null} Payment Widget
``` Here is a sample of how to create a WebView in your native app and load the HTML page that contains the SDK and mounts the Widget. The WebView should be configured to allow JavaScript execution and to forward messages to your native code. The following example will load the HTML from the app's bundle but if you intend to use certain payment methods (e.g. Apple Pay or Google Pay), the HTML page must be served from a real HTTPS origin rather than bundled locally. ### Platform setup ```swift [expandable] theme={null} import WebKit class PaymentViewController: UIViewController, WKScriptMessageHandler { @IBOutlet weak var webView: WKWebView! override func viewDidLoad() { super.viewDidLoad() // Register a single message handler for all Widget events let contentController = webView.configuration.userContentController contentController.add(self, name: "paymentWidgetMessage") if let url = Bundle.main.url(forResource: "payment-widget", withExtension: "html") { webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) } } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "paymentWidgetMessage", let messageDict = message.body as? [String: Any], let type = messageDict["type"] as? String else { return } let data = messageDict["data"] switch type { case "complete": handlePaymentComplete(data: data) case "cancel": handlePaymentCancel() case "error": handlePaymentError(error: data) default: print("Unknown Payment Widget message type: \(type)") } } private func handlePaymentComplete(data: Any?) { /* navigate to success */ } private func handlePaymentCancel() { /* return user to previous screen */ } private func handlePaymentError(error: Any?) { /* show error UI */ } deinit { webView?.configuration.userContentController.removeScriptMessageHandler(forName: "paymentWidgetMessage") } } ``` ```java [expandable] theme={null} import android.webkit.WebView; import android.webkit.WebSettings; import android.webkit.JavascriptInterface; import android.util.Log; import org.json.JSONObject; WebView webView = findViewById(R.id.webview); WebSettings webSettings = webView.getSettings(); webSettings.setJavaScriptEnabled(true); webView.addJavascriptInterface(new PaymentBridge(), "PaymentBridge"); webView.loadUrl("file:///android_asset/payment-widget.html"); public class PaymentBridge { @JavascriptInterface public void onMessage(String messageJson) { try { JSONObject message = new JSONObject(messageJson); String type = message.getString("type"); Object data = message.opt("data"); runOnUiThread(() -> { switch (type) { case "complete": handlePaymentComplete(data != null ? data.toString() : null); break; case "cancel": handlePaymentCancel(); break; case "error": handlePaymentError(data != null ? data.toString() : null); break; default: Log.w("Payment", "Unknown Payment Widget message type: " + type); } }); } catch (Exception e) { Log.e("Payment", "Error parsing Payment Widget message", e); } } private void handlePaymentComplete(String data) { /* navigate to success */ } private void handlePaymentCancel() { /* return user to previous screen */ } private void handlePaymentError(String error) { /* show error UI */ } } ``` If you load local assets, ensure the WebView allows file access according to your security requirements (e.g. `setAllowFileAccess(true)` when needed). ```javascript [expandable] theme={null} import { WebView } from 'react-native-webview'; import { Alert } from 'react-native'; const PaymentScreen = () => { const handleMessage = (event) => { try { const message = JSON.parse(event.nativeEvent.data); switch (message.type) { case 'complete': handlePaymentComplete(message.data); break; case 'cancel': handlePaymentCancel(); break; case 'error': handlePaymentError(message.data); break; default: console.log('Unknown message type:', message.type); } } catch (error) { console.error('Error parsing WebView message:', error); } }; const handlePaymentComplete = (data) => { /* navigate to success */ }; const handlePaymentCancel = () => { /* return user to previous screen */ }; const handlePaymentError = (error) => { /* show error UI */ }; return ( ); }; ``` For iOS, `file:` URLs may be restricted. Consider `source={{ html: '...' }}` or a bundled asset, adjusted per platform.
## Setup with JavaScript Instead of installing the SDK, you can load the Widget's session `url` directly — as an iframe you create yourself on web, or as your WebView's top-level page on native — and speak its underlying message protocol. This approach is recommended for native apps as there is no need for bundling the SDK into the WebView's HTML page and serving that page from a real HTTPS origin to avoid issues with Apple Pay/Google Pay. The [Shared setup](#shared-setup) steps still apply here — you just don't install anything. Only how you load the Widget and exchange messages changes. ### The message protocol These are the message types the Widget speaks. The transport carrying them differs per platform — see the tabs below. **From the Widget to your host:** | Type | Payload | Fires when | What to do | | --------------- | ------- | ---------------------------------------------------- | ------------------------------------------------------- | | `load` | — | The Widget's script has booted and needs its session | Reply with an `init` message (see below) | | `ready` | — | The Widget is ready for interaction | Hide your loading state | | `complete` | `value` | The user finished selection or authorization | Read `value`, then tear down the iframe/WebView | | `cancel` | — | The user dismissed the Widget | Tear down the iframe/WebView, return them to your flow | | `error` | `error` | The Widget hit an unrecoverable error | Read `error`, tear down the iframe/WebView, retry | | `force_repaint` | — | Works around a redraw issue in mobile Safari only | Briefly toggle the WebView's opacity to force a repaint | **From your host to the Widget:** | Type | Payload | Send when | | ------ | ------------------------- | --------------------------------------- | | `init` | `{ ...session, options }` | Replying to the Widget's `load` message | The `init` payload spreads the session object from [Create a session on your backend](#1-create-a-session-on-your-backend) (`url`, `token`, `flow`) alongside an `options` object with the same shape as the SDK's [`PaymentWidgetOptions`](./sdk-reference#options) — omit `options` or pass `{}` to use defaults. ### Specifying the theme appearance By default, the Widget matches the browser or OS `prefers-color-scheme` until it receives your `init` reply. To control the initial appearance yourself — and avoid a flash if it won't match what you send in `init` — append a `theme_appearance` query parameter (`dark` or `light`) to `session.url` before loading it: ```javascript theme={null} const url = new URL(session.url); url.searchParams.set('theme_appearance', 'dark'); // or 'light' ``` This works the same whether you load the result into a web iframe or as your native WebView's top-level page. ### Platform implementation Mount the session `url` in an iframe you create yourself, and exchange messages over the standard `window.postMessage` API. Make sure the iframe's `allow` attribute includes clipboard permissions (and payment permissions, for Apple Pay and Google Pay), and that its container has explicit CSS width and height (minimum recommended size is **400px × 600px**). ```html [expandable] theme={null}
```
Instead of bundling an HTML page, point the WebView directly at the session `url`. The Widget sends messages to native code through the `uphdPaymentWidget` message handler; to reply, call `window.PaymentWidgetBridge.sendMessageToWidget(...)` via `evaluateJavaScript`, passing the session object alongside an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `PaymentViewController` below ties this together into a complete example, forwarding the Widget's messages to native callbacks. ```swift [expandable] theme={null} import WebKit final class PaymentViewController: UIViewController, WKScriptMessageHandler { var onComplete: ((Any?) -> Void)? var onCancel: (() -> Void)? var onError: ((Any?) -> Void)? // The session dict from your backend (url, token, flow, ...), kept around so it can be // echoed back to the Widget in the "init" reply to its "load" message. private var session: [String: Any]? private let webView: WKWebView = { let webView = WKWebView(frame: .zero) webView.translatesAutoresizingMaskIntoConstraints = false return webView }() override func viewDidLoad() { super.viewDidLoad() view.addSubview(webView) NSLayoutConstraint.activate([ webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) // Registers the exact handler name the Widget looks for webView.configuration.userContentController.add(self, name: "uphdPaymentWidget") Task { do { let session = try await createPaymentWidgetSession() // calls your backend self.session = session guard let urlString = session["url"] as? String, let url = URL(string: urlString) else { onError?(["message": "Payment Widget session response is missing a valid 'url'"]) return } webView.load(URLRequest(url: url)) } catch { onError?(["message": "\(error)"]) } } } // Replies to the Widget's "load" message with an "init" command carrying the session // (url, token, flow, ...) plus an options object — same shape as the SDK's // PaymentWidgetOptions. Empty here; see Configuration reference to customize it. private func sendInitMessageToWidget() { guard var initMessage = session, let data = try? JSONSerialization.data(withJSONObject: { initMessage["type"] = "init" initMessage["options"] = [:] return initMessage }()), let json = String(data: data, encoding: .utf8) else { return } webView.evaluateJavaScript("window.PaymentWidgetBridge.sendMessageToWidget(\(json));") } // Nudges the page's opacity to force a repaint — needed to work around a WKWebView // redraw bug when the Widget uses the View Transitions API. private func forceRepaintWidget() { webView.evaluateJavaScript(""" document.documentElement.style.opacity = '0.99'; setTimeout(() => { document.documentElement.style.opacity = '1'; }, 0); """) } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { // The Widget JSON.stringifies its messages before posting them, so the body // arrives as a string rather than an already-decoded dictionary. guard message.name == "uphdPaymentWidget", let jsonString = message.body as? String, let data = jsonString.data(using: .utf8), let messageDict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let type = messageDict["type"] as? String else { return } switch type { case "load": sendInitMessageToWidget() case "force_repaint": forceRepaintWidget() case "ready": print("Payment Widget is ready") case "complete": onComplete?(messageDict["value"]) case "cancel": onCancel?() case "error": onError?(messageDict["error"]) default: print("Unknown Payment Widget message type: \(type)") } } deinit { webView.configuration.userContentController.removeScriptMessageHandler(forName: "uphdPaymentWidget") } } ``` Some flows (e.g. PayPal's authorization step) open a popup via `window.open()`. WKWebView never opens real OS windows for that — without a `WKUIDelegate` implementing `webView(_:createWebViewWith:for:windowFeatures:)` to host the popup's own `WKWebView`, `window.open()` silently fails and the page waits indefinitely. Similarly, app-switch redirects to non-http(s) schemes (e.g. `venmo://...`) need a `WKNavigationDelegate` that hands them off to `UIApplication.shared.open(url)` instead of letting the load fail. Both are one-time additions to a production integration; omitted above for clarity. Instead of bundling an HTML page, point the WebView directly at the session `url`. The Widget sends messages to native code through the `uphdPaymentWidget` object registered below; to reply, call `replyProxy.postMessage(...)`, passing the session object alongside an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `PaymentActivity` below ties this together into a complete example, forwarding the Widget's messages to native callbacks. ```kotlin [expandable] theme={null} import android.webkit.WebView import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import org.json.JSONObject class PaymentActivity : AppCompatActivity() { private lateinit var webView: WebView private var session: JSONObject? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_payment) webView = findViewById(R.id.webview) webView.settings.javaScriptEnabled = true if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { WebViewCompat.addWebMessageListener( webView, "uphdPaymentWidget", setOf( "https://payment-widget.enterprise.sandbox.uphold.com", "https://payment-widget.enterprise.uphold.com" ) ) { _, message, _, _, replyProxy -> val body = JSONObject(message.data ?: return@addWebMessageListener) when (body.getString("type")) { "load" -> sendInitMessageToWidget(replyProxy) "ready" -> Log.d("Payment", "Payment Widget is ready") "complete" -> handlePaymentComplete(body.opt("value")) "cancel" -> handlePaymentCancel() "error" -> handlePaymentError(body.opt("error")) else -> Log.w("Payment", "Unknown Payment Widget message type: ${body.getString("type")}") } } } else { // Fall back to Setup Web SDK on WebViews that don't support WEB_MESSAGE_LISTENER. } lifecycleScope.launch { try { val session = createPaymentWidgetSession() // calls your backend this@PaymentActivity.session = session webView.loadUrl(session.getString("url")) } catch (e: Exception) { handlePaymentError(e.message) } } } // Replies to the Widget's "load" message with an "init" command carrying the session // (url, token, flow, ...) plus an options object — same shape as the SDK's // PaymentWidgetOptions. Empty here; see Configuration reference to customize it. private fun sendInitMessageToWidget(replyProxy: JavaScriptReplyProxy) { val session = session ?: return val initMessage = JSONObject(session.toString()) .put("type", "init") .put("options", JSONObject()) replyProxy.postMessage(initMessage.toString()) } private fun handlePaymentComplete(value: Any?) { /* navigate to success */ } private fun handlePaymentCancel() { /* return user to previous screen */ } private fun handlePaymentError(error: Any?) { /* show error UI */ } } ``` `force_repaint` is a WKWebView-specific workaround and isn't expected on Android — the listener above doesn't need to handle it. Instead of bundling an HTML page, point the WebView directly at the session `url`. The Widget sends messages to native code through the WebView's `onMessage` prop, once it detects `window.ReactNativeWebView` (injected automatically by `react-native-webview`); to reply, call `window.PaymentWidgetBridge.sendMessageToWidget(...)` via `injectJavaScript`, passing the session object alongside an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `PaymentScreen` below ties this together into a complete example, forwarding the Widget's messages to native callbacks. ```jsx [expandable] theme={null} import React, { useRef } from 'react'; import { WebView } from 'react-native-webview'; const PaymentScreen = ({ session }) => { const webViewRef = useRef(null); // Replies to the Widget's "load" message with an "init" command carrying the session // (url, token, flow, ...) plus any options. const sendInitMessageToWidget = () => { const initMessage = JSON.stringify({ ...session, options: {}, type: 'init' }); webViewRef.current?.injectJavaScript(`window.PaymentWidgetBridge.sendMessageToWidget(${initMessage}); true;`); }; const handleMessage = (event) => { const message = JSON.parse(event.nativeEvent.data); switch (message.type) { case 'load': sendInitMessageToWidget(); break; case 'ready': console.log('Payment Widget is ready'); break; case 'complete': handlePaymentComplete(message.value); break; case 'cancel': handlePaymentCancel(); break; case 'error': handlePaymentError(message.error); break; default: console.log('Unknown Payment Widget message type:', message.type); } }; const handlePaymentComplete = (value) => { /* navigate to success */ }; const handlePaymentCancel = () => { /* return user to previous screen */ }; const handlePaymentError = (error) => { /* show error UI */ }; return ( ); }; ``` `force_repaint` is a WKWebView-specific workaround. If you see repaint glitches on iOS, forward it the same way as `load`, toggling the WebView's `opacity` briefly via `injectJavaScript`. Check the iOS example for details.
## Test in Sandbox With your Sandbox credentials and the Sandbox Widget host configured, run through this checklist — it applies whichever approach you integrated with: * The Widget mounts and `ready` fires. * Selecting a payment method fires `complete` with the expected value for your flow — `event.detail.value` with the SDK, or the `value` payload of the `complete` message without it. * Closing or dismissing the Widget fires `cancel`. * If your app uses a CSP (see [Shared setup](#shared-setup)), the browser console shows no violations (look for "Refused to frame"). Once Sandbox is green, swap your OAuth credentials to Production. The Widget host is selected automatically by the session `url` returned from your backend — no client-side environment switching is needed. ## Configuration reference The most common SDK options. See the [SDK reference](./sdk-reference#options) for the full schema and all event types. If you're integrating without the SDK, these map directly to the `options` object you send in your `init` reply. | Option | Purpose | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `authorize` | Configure the `authorize` flow — set `mode` to control how authorization is handled. Applies only to sessions created with the `authorize` flow. | | `paymentMethods` | Filter which payment methods (and assets) appear. Omit to show all. | | `theme` | Customize the Widget's appearance: force `light` or `dark` mode, set brand colors, typography, and per-component border radii. See [WidgetThemeOption](/widgets/payment/sdk-reference#widgetthemeoption) for all options. | | `debug` | Verbose console logging. Use during development. | | `maxAccountsPerAsset` | Cap accounts created per asset for crypto deposit flows (max effective: `100`). | ## Troubleshooting **Widget not displaying** Confirm the Widget host for your environment is in the `frame-src` directive of your CSP (see [Shared setup](#shared-setup)). Open DevTools → Console and look for `Refused to frame` violations. **Container is empty after mount** The iframe fills its container — the container must have explicit CSS width and height. Minimum recommended size is 400px × 600px. **Events not firing in native apps (with the SDK)** Verify that: * JavaScript is enabled in the WebView. * The message bridge is registered **before** the HTML page loads. * Event handler names match the platform-specific bridge contract used in `sendToNativeApp`. **Events not firing (without the SDK)** Verify that: * Your message-handler / listener name is exactly `uphdPaymentWidget` — the name the Widget checks for on both iOS and Android — and is registered **before** the WebView loads the session `url`. * You're replying to `load` with `init` — the Widget won't render anything until it receives it. * On web, you're filtering incoming `message` events by origin (`event.origin === sessionOrigin`) — messages from other origins should be ignored, not treated as Widget events. **Widget never unmounts.** The SDK does not auto-unmount, and neither does the Widget itself when integrating without it. Call `widget.unmount()` (SDK) or tear down your iframe/WebView (no SDK) from each terminal message (`complete`, `cancel`, `error`). **Apple Pay option not shown to the user** Confirm the device supports Apple Pay through the [Payment Request API](https://developer.apple.com/documentation/applepayontheweb#Apple-Pay-availability-by-region-and-platform) in case of a deposit or for the [Disbursement Request API](https://applepaydemo.apple.com/disbursement-request-api) in case of a withdrawal and that the user has the required capabilities enabled. If you mounted the iframe yourself instead of using `mountIframe()`, confirm its `allow` attribute includes `payment 'src'` — without it, the Widget can't detect Apple Pay support and hides the option. Also confirm your domain is registered with Apple Pay (see [Apple Pay](/developer-guides/apm-transfers/overview#apple-pay)); an unverified domain makes Apple Pay silently unavailable. **Apple Pay sheet doesn't open when the button is clicked (authorize flow)** This is usually the device, not the integration: Apple Pay requires the device to be able to authenticate the user at the moment of the click. For example, on a MacBook with the lid closed, Touch ID is unreachable and the sheet won't open; the same applies if the device has no Touch ID/Face ID or passcode configured. Ask the user to check their device's authentication method is available, then try again. **Apple Pay sheet opens, then is immediately dismissed** This is typically a merchant validation failure — check that your domain, and any ancestor frame domains, are registered with Apple Pay under Uphold's merchant ID (see [Apple Pay](/developer-guides/apm-transfers/overview#apple-pay)). ## Next steps * Review the complete [SDK Reference](./sdk-reference) for all available methods and events. * Read our Developer Guides for step-by-step instructions on implementing specific payment methods with the Widget: - [Bank deposits](/developer-guides/bank-transfers/deposit/via-payment-widget) - [Bank withdrawals](/developer-guides/bank-transfers/withdrawal/via-payment-widget) * [Card deposits](/developer-guides/card-transfers/deposit/via-payment-widget) * [Card withdrawals](/developer-guides/card-transfers/withdrawal/via-payment-widget) * [Crypto deposits](/developer-guides/crypto-transfers/deposit/via-payment-widget) * [Crypto withdrawals](/developer-guides/crypto-transfers/withdrawal/via-payment-widget) * [APM deposits](/developer-guides/apm-transfers/deposit/via-payment-widget) * [APM withdrawals](/developer-guides/apm-transfers/withdrawal/via-payment-widget) # Payment Widget introduction Source: https://developer.uphold.com/widgets/payment/introduction The Uphold Payment Widget is an embeddable, managed solution that lets users securely add payment methods and run transactions across web and native apps.

The Payment Widget is a fully Uphold-managed, embeddable solution that allows users to securely add pay-in and payout methods.

It's built with developer efficiency and global readiness in mind, with support for multi-platform integration and a growing list of payment methods.

*** Fully managed UI with automated state handling and lightweight SDK to instantiate and control the Widget. Go live quickly with minimal frontend effort. Accept cards, bank, and crypto transfers out of the box. Control which methods users see, with more options on the way. Embed seamlessly into web apps via iframe and native apps via WebView. Consistent user experience across all platforms. SDK emits lifecycle events such as success, error, and cancellation, with minimal error handling required. Integrate deeply with your app's flow and UI feedback. ## Payment methods Read our Developer Guides for step-by-step instructions on implementing specific payment methods with the Widget. * [Bank deposits](/developer-guides/bank-transfers/deposit/via-payment-widget) * [Bank withdrawals](/developer-guides/bank-transfers/withdrawal/via-payment-widget) * [Card deposits](/developer-guides/card-transfers/deposit/via-payment-widget) * [Card withdrawals](/developer-guides/card-transfers/withdrawal/via-payment-widget) * [Crypto deposits](/developer-guides/crypto-transfers/deposit/via-payment-widget) * [Crypto withdrawals](/developer-guides/crypto-transfers/withdrawal/via-payment-widget) # Payment Widget SDK reference Source: https://developer.uphold.com/widgets/payment/sdk-reference Reference for the @uphold/enterprise-payment-widget-web-sdk package, including the PaymentWidget class, constructor options, methods, and lifecycle events. Complete reference documentation for the `@uphold/enterprise-payment-widget-web-sdk` package. The main class for creating and managing Payment Widget instances is `PaymentWidget`. It requires a `PaymentWidgetSession` object that must be created through the API before instantiating the Widget. ## Constructor ```typescript theme={null} new PaymentWidget( session: PaymentWidgetSession, options?: PaymentWidgetOptions ) ``` **Parameters:** | Parameter | Type | Required | Description | | --------- | ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- | | `session` | `PaymentWidgetSession` | Yes | Payment session object obtained from the [Create session](/rest-apis/widgets-api/payment/create-session) endpoint | | `options` | `PaymentWidgetOptions` | No | Configuration options for the Widget | **Generic type parameter:** The constructor accepts an optional generic type parameter that specifies the flow type. When provided, it enables better type inference for event handlers, particularly for the `complete` event. **Examples:** ```typescript theme={null} // Generic flow type (default) const widget = new PaymentWidget(session); // Specific flow type for better type inference const depositWidget = new PaymentWidget<'select-for-deposit'>(session); const withdrawWidget = new PaymentWidget<'select-for-withdrawal'>(session); const authorizeWidget = new PaymentWidget<'authorize'>(session); ``` ### Options ```typescript theme={null} type PaymentWidgetOptions = { authorize?: AuthorizeFlowOptions; debug?: boolean; layout?: WidgetLayout; maxAccountsPerAsset?: number; paymentMethods?: PaymentMethodOption[]; theme?: WidgetThemeOption; }; ``` | Property | Type | Default | Description | | --------------------- | ----------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `authorize` | `AuthorizeFlowOptions` | `{ mode: 'default' }` | Configuration for the `authorize` flow. Ignored for other flows. See [AuthorizeFlowOptions](#authorizeflowoptions) for details | | `debug` | `boolean` | `false` | Enable debug mode for additional logging | | `layout` | `WidgetLayout` | `'boxed'` | Control how the Widget is laid out on larger viewports. See [WidgetLayout](#widgetlayout) for details | | `maxAccountsPerAsset` | `number` | No limit | Limit the number of accounts that can be created per asset. When the limit is reached, the Widget reuses the most recent account instead of creating a new one. Must be greater than `0`. Maximum effective value is `100` | | `paymentMethods` | `PaymentMethodOption[]` | All available methods | Restrict which payment methods are available to users. See [PaymentMethodOption](#paymentmethodoption) for details | | `theme` | `WidgetThemeOption` | System preference | Control the visual appearance of the Widget. See [WidgetThemeOption](#widgetthemeoption) for details | **paymentMethods option:** The `paymentMethods` option allows you to control which payment methods the Widget displays to users. When not specified, all supported payment methods are available. **Basic usage:** ```typescript theme={null} const widget = new PaymentWidget(session, { paymentMethods: [ { type: 'card' }, { type: 'bank' }, { type: 'crypto' }, { type: 'paypal' }, { type: 'apple-pay'}, { type: 'google-pay'} ] }); ``` **Filtering assets:** For `bank` and `crypto` payment methods, you can filter which assets are available using the `assets` property. This works the same way for both methods: ```typescript theme={null} const widget = new PaymentWidget(session, { paymentMethods: [ { type: 'card' }, { type: 'bank', assets: { include: ['GBP', 'EUR', 'ETH', 'BTC'] // Only these assets will be available } }, { type: 'crypto', assets: { include: ['BTC', 'ETH', 'XRP'] // Only these assets will be available } } ] }); ``` You can also exclude specific assets while allowing all others: ```typescript theme={null} const widget = new PaymentWidget(session, { paymentMethods: [ { type: 'bank', assets: { exclude: ['BTC'] // All assets except BTC } }, { type: 'crypto', assets: { exclude: ['DOGE', 'SHIB'] // All assets except these } } ] }); ``` If no `assets` filter is provided, all supported assets for that payment method will be available. Use either `include` or `exclude`, not both. **theme option:** The `theme` option lets you control the visual appearance of the Widget. By default, the Widget automatically detects and matches the user's browser or OS appearance preference (`light` or `dark`). Use this option when you want to enforce a specific theme regardless of the user's system settings. ```typescript theme={null} const widget = new PaymentWidget(session, { theme: { appearance: 'dark', primary: { light: '#6200EA', dark: '#BB86FC' }, // Alternatively, a single color string applies to both appearances, e.g. primary: '#6200EA' // primary: '#6200EA', fontFamily: 'Inter, sans-serif', components: { button: { borderRadius: '8px' } } } }); ``` **layout option:** The `layout` option controls how the Widget is laid out when there is enough room to frame it — on viewports that are both at least `md` wide (≥768px) and at least 600px tall, i.e. tablets and up. On smaller viewports — narrower or shorter, including phones in landscape — the Widget always fills its container regardless of this option. * `'boxed'` (default): on viewports that are ≥768px wide and ≥600px tall, centers the Widget and frames its content as a fixed-size card; on smaller viewports it fills its container. Use this when the Widget takes up the whole page (e.g. mounted into a full-page container). * `'fluid'`: renders the Widget so it fills its container, with no frame or centering, so your own container (e.g. a modal or a sized panel) acts as the frame. Most integrations should rely on the default `'boxed'` and only set `layout: 'fluid'` when embedding the Widget in your own framed container. ```typescript theme={null} const widget = new PaymentWidget(session, { layout: 'fluid' }); ``` Use `'fluid'` when you already provide a centered container or modal around the Widget, to avoid a card-within-a-card appearance on larger viewports. **maxAccountsPerAsset option:** When generating deposit details, depending on the rail's constraints, the Widget allows users to select a target account for the deposit. The `maxAccountsPerAsset` option controls how many accounts can be created per asset. For example, setting it to 1 ensures only a single account exists per asset, while setting it to 5 allows up to five accounts for the same asset (e.g. one USD account for general use, another for a vacation fund, and so on). Once the limit is reached, the Widget automatically reuses the most recent account instead of creating a new one. ```typescript theme={null} const widget = new PaymentWidget(session, { maxAccountsPerAsset: 5 }); ``` Values above `100` are capped at `100`. **authorize option:** The `authorize` option configures the `authorize` flow. It is only relevant for Widgets created with an `authorize` session; it is ignored for `select-for-deposit` and `select-for-withdrawal` flows. Use `mode` to control how the authorization is presented: * `'default'` (default): the Widget renders its standard authorization UI. * `'headless'`: the Widget renders only the payment method's button, with no surrounding UI, so you can embed it directly into your own layout. Supported for the Apple Pay authorization flow, and for Google Pay on Android devices that support `CRYPTOGRAM_3DS`. ```typescript theme={null} const authorizeWidget = new PaymentWidget<'authorize'>(session, { authorize: { mode: 'headless' } }); ``` See [AuthorizeFlowOptions](#authorizeflowoptions) for the full type definition. Apple Pay inside the Widget is driven by [Apple Pay on the Web](https://developer.apple.com/documentation/apple_pay_on_the_web) via the browser [Payment Request API](https://developer.mozilla.org/en-US/docs/Web/API/Payment_Request_API). In a native app this runs inside the WebView, which imposes requirements beyond the event bridge above. If they are not met, the Widget hides Apple Pay and the user never sees the button. Because the Widget runs inside an iframe, the Payment Request API only works if the parent page delegates the `payment` [permission](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Permissions_Policy) to that iframe. `mountIframe()` sets this automatically. If you mount the Widget iframe yourself, include `payment` in the `allow` attribute alongside the permissions the Widget already needs: ```javascript theme={null} iframe.setAttribute('allow', "clipboard-write 'src'; clipboard-read 'src'; payment 'src';"); ``` ## Methods ### `mountIframe()` Mounts the Payment Widget iframe to the specified DOM element. **Parameters:** * `element`: The HTML element where the Widget should be mounted **Example:** ```javascript theme={null} // Ensure the container has appropriate sizing const container = document.getElementById('payment-container'); widget.mountIframe(container); ``` The container element should have explicit dimensions set via CSS. The Widget will fill the entire container. Minimum recommended size is 400px width × 600px height for optimal user experience. ### `unmount()` Unmounts and cleans up the Payment Widget iframe. **Example:** ```javascript theme={null} widget.unmount(); ``` The Widget will not unmount itself when a flow is finished, either by successful completion, user cancellation, or unrecoverable error, so it must always be called manually. ### `on()` Registers an event listener for Widget events. **Parameters:** * `event`: The event name to listen for * `callback`: Function to execute when the event is triggered **Example:** ```javascript theme={null} widget.on('complete', (event) => { console.log('Payment completed:', event.detail.value); }); ``` ### `off()` Removes an event listener for Payment Widget events. **Parameters:** * `event`: The event name to stop listening for * `callback`: The specific function to remove (must be the same reference as used in `on()`) **Example:** ```javascript theme={null} const handleComplete = (event) => { console.log('Payment flow completed:', event.detail.value); }; // Register the listener widget.on('complete', handleComplete); // Remove the listener widget.off('complete', handleComplete); ``` ## Events The Payment Widget emits several events during its lifecycle that you can listen to using the `on()` method. ### `complete` Fired when the Payment flow is completed. ```typescript theme={null} type PaymentWidgetCompleteValue = T extends 'select-for-deposit' ? DepositSelection : T extends 'select-for-withdrawal' ? WithdrawalSelection : T extends 'authorize' ? AuthorizeResult : never; type PaymentWidgetCompleteEvent = { detail: { value: PaymentWidgetCompleteValue; }; }; ``` The `event.detail.value` contains the payment flow result. The structure depends on the flow type. **Example:** ```typescript theme={null} widget.on('complete', (event: PaymentWidgetCompleteEvent) => { const result = event.detail.value; console.log('Payment flow completed:', result); // Process the result based on your flow type // See flow-specific examples below widget.unmount(); }); ``` **Usage in different flows:** For `select-for-deposit` flows: ```typescript theme={null} const depositWidget = new PaymentWidget<'select-for-deposit'>(session); depositWidget.on('complete', (event) => { const result = event.detail.value; if (result.via === 'external-account') { // User selected a saved payment method (e.g., card) console.log('Selected external account:', result.selection); } else if (result.via === 'deposit-method') { // User selected a bank or crypto transfer method const { depositMethod, account } = result.selection; if (depositMethod.type === 'bank') { console.log('Bank transfer details:', depositMethod.details); } else if (depositMethod.type === 'crypto') { console.log('Crypto transfer details:', depositMethod.details); } console.log('Target account:', account); } depositWidget.unmount(); }); ``` For `select-for-withdrawal` flows: ```typescript theme={null} const withdrawWidget = new PaymentWidget<'select-for-withdrawal'>(session); withdrawWidget.on('complete', (event) => { const result = event.detail.value; if (result.via === 'external-account') { // User selected a saved payment method (e.g., card, bank account) console.log('Selected external account:', result.selection); } else if (result.via === 'crypto-network') { // User provided a crypto withdrawal address console.log('Crypto withdrawal details:', { asset: result.selection.asset, network: result.selection.network, address: result.selection.address, reference: result.selection.reference // Destination tag for XRP, memo for XLM, etc. }); } withdrawWidget.unmount(); }); ``` For `authorize` flows: ```typescript theme={null} const authorizeWidget = new PaymentWidget<'authorize'>(session); authorizeWidget.on('complete', (event) => { const result = event.detail.value; if (result.trigger.reason === 'transaction-status-changed') { // Check transaction status - it may have succeeded or failed if (result.transaction.status === 'completed') { console.log('Transaction successful:', result.transaction); } else if (result.transaction.status === 'failed') { console.error('Transaction failed:', result.transaction); // Handle failure case } } else if (result.trigger.reason === 'max-retries-reached') { console.log('Polling timeout reached, transaction may still be processing: ', result.transaction); // Implement additional polling or show a message indicating that the transaction is still processing and to try again later } authorizeWidget.unmount(); }); ``` ### `cancel` Fired when the user cancels the payment flow. ```typescript theme={null} type PaymentWidgetCancelEvent = { detail: {}; } ``` **Example:** ```javascript theme={null} widget.on('cancel', (event: PaymentWidgetCancelEvent) => { console.log('Payment cancelled by user'); widget.unmount(); }); ``` ### `error` Fired when an unrecoverable error occurs during the payment flow. ```typescript theme={null} type PaymentWidgetError = { name: string; code: string; message: string; details?: Record; cause?: PaymentWidgetError; // The original underlying error, when this error wraps one httpStatusCode?: number; } type PaymentWidgetErrorEvent = { detail: { error: PaymentWidgetError; }; }; ``` **Example:** ```javascript theme={null} widget.on('error', (event: PaymentWidgetErrorEvent) => { const { error } = event.detail; console.error('Payment error:', error); if (error.cause) { // Some flow-specific codes wrap the real reason in `cause` instead of exposing it as the // top-level `code` — see the flow-specific guide you're integrating against for details console.error('Underlying cause:', error.cause); } showGenericError(error.message); widget.unmount(); }); ``` Any error can carry a `cause`: the original, lower-level error that led to it. Always check `error.cause` in your handler, not just `error.code` and `error.message` — some flow-specific codes exist only to wrap another error rather than to describe the failure themselves. In the `authorize` flow, for example, `code: 'authorize_transaction_failed'` means a transaction could not be created, and the actual reason (e.g. `entity_not_found`, `insufficient_balance`) is nested at `error.cause?.code` — it never appears as the top-level `error.code`. Check the flow-specific authorize guide you're integrating against for which codes are wrapped this way. ### `ready` Fired when the Payment Widget has finished loading. ```typescript theme={null} type PaymentWidgetReadyEvent = { detail: {}; } ``` **Example:** ```typescript theme={null} widget.on('ready', (event: PaymentWidgetReadyEvent) => { console.log('Payment Widget is ready'); }); ``` ## Types ### PaymentWidgetSession The session object returned by the [Create session](/rest-apis/widgets-api/payment/create-session) endpoint. ```typescript theme={null} type PaymentWidgetSession = { url: string; token: string; flow: PaymentWidgetFlow; } ``` ### `PaymentWidgetFlow` Represents the different flows supported by the Payment Widget. ```typescript theme={null} type PaymentWidgetFlow = 'select-for-deposit' | 'select-for-withdrawal' | 'authorize'; ``` ### AuthorizeFlowOptions Configuration for the `authorize` flow, passed via the `authorize` option. ```typescript theme={null} type AuthorizeFlowOptions = { mode?: AuthorizeMode; }; ``` | Property | Type | Default | Description | | -------- | --------------- | ----------- | ---------------------------------------------------------------------------------------------------------------- | | `mode` | `AuthorizeMode` | `'default'` | Selects how much UI the Widget renders around the authorization. See [AuthorizeMode](#authorizemode) for details | ### AuthorizeMode Selects how much UI the Widget renders around the authorization. Both modes handle creating the transaction, and polling it to a final status. What differs is the UI the Widget shows — and therefore what your app is responsible for providing. ```typescript theme={null} type AuthorizeMode = 'default' | 'headless'; ``` | Value | Description | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `'default'` | The Widget renders the full authorization experience: a short "what to expect" walkthrough, the authorization button, back and close controls, and a processing screen while the transaction is confirmed. Use this when the Widget owns the authorization screen. | | `'headless'` | The Widget renders **only** the payment method's button — no walkthrough, navigation, or processing screen. After authorization, it creates and polls the transaction in the background and hands control back to your app, which owns the surrounding context and the post-authorization experience (progress and success feedback, and a way to cancel). Supported for the Apple Pay authorization flow, and for Google Pay on Android devices that support `CRYPTOGRAM_3DS`. | Regardless of mode, the Apple Pay button only appears in [supported environments](https://support.apple.com/en-us/102896), and the Google Pay button only appears on Android devices that support `CRYPTOGRAM_3DS` when `'headless'` mode is used. In `'headless'` mode there is no surrounding UI, so make sure your layout handles the case where the button is not rendered. ### PaymentMethodOption Defines the payment methods that can be configured in the Widget options. ```typescript theme={null} type PaymentMethodOption = | { type: 'card' } | { type: 'bank'; assets?: PaymentAssetOptions } | { type: 'crypto'; assets?: PaymentAssetOptions } | { type: 'paypal' } | { type: 'apple-pay' } | { type: 'google-pay' }; ``` | Type | Description | | ------------ | ----------------------------------------------------------------- | | `card` | Credit and debit card payments | | `bank` | Push bank deposit methods. Supports optional `assets` filtering | | `crypto` | Crypto deposits/withdrawals. Supports optional `assets` filtering | | `paypal` | PayPal deposits/withdrawals | | `apple-pay` | ApplePay deposits/withdrawals | | `google-pay` | GooglePay deposits | ### PaymentAssetOptions Allows filtering which assets are available when using the `bank` or `crypto` payment methods. ```typescript theme={null} type PaymentAssetOptions = { include?: string[]; exclude?: string[]; }; ``` | Property | Type | Description | | --------- | ---------- | ----------------------------------------------------------------------------------------- | | `include` | `string[]` | List of asset codes to include. When specified, only these assets will be available | | `exclude` | `string[]` | List of asset codes to exclude. When specified, all assets except these will be available | Use either `include` or `exclude`, not both. Asset codes should be uppercase (e.g., `'BTC'`, `'ETH'`, `'XRP'`). ### Complete event result types #### DepositSelection Result structure for `select-for-deposit` flow: ```typescript theme={null} type ExternalAccountSelection = { via: 'external-account'; selection: ExternalAccount; // See external accounts API documentation }; type AccountDepositMethodSelection = { via: 'deposit-method'; selection: { depositMethod: AccountDepositMethod; // See account deposit methods API documentation account: Account; // See accounts API documentation }; }; type DepositSelection = ExternalAccountSelection | AccountDepositMethodSelection; ``` For complete type definitions, see the API documentation: * `ExternalAccount`: [External accounts](/rest-apis/core-api/external-accounts/introduction) * `AccountDepositMethod`: [Account deposit methods](/rest-apis/core-api/accounts/get-account-deposit-method) * `Account`: [Accounts](/rest-apis/core-api/accounts/introduction) #### WithdrawalSelection Result structure for `select-for-withdrawal` flow: ```typescript theme={null} type ExternalAccountSelection = { via: 'external-account'; selection: ExternalAccount; // See external accounts API documentation }; type CryptoNetworkSelection = { via: 'crypto-network'; selection: { asset: string; // e.g., 'BTC', 'ETH', 'XRP' network: string; // e.g., 'bitcoin', 'ethereum', 'xrp-ledger' address: string; // The destination crypto address reference?: string; // Destination tag for XRP, memo for XLM, etc. }; }; type WithdrawalSelection = ExternalAccountSelection | CryptoNetworkSelection; ``` For complete `ExternalAccount` type definition, see the [External accounts](/rest-apis/core-api/external-accounts/introduction) API documentation. #### AuthorizeResult Result structure for `authorize` flow: ```typescript theme={null} type AuthorizeResult = { transaction: Transaction; // See Transactions API documentation trigger: { reason: 'transaction-status-changed' | 'max-retries-reached'; }; } ``` For complete `Transaction` type definition, see the [Transactions](/rest-apis/core-api/transactions/introduction) API documentation. ### WidgetLayout Controls how the Widget is laid out on larger viewports. ```typescript theme={null} type WidgetLayout = 'boxed' | 'fluid'; ``` | Value | Description | | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `'boxed'` | Default. On viewports that are ≥768px wide and ≥600px tall (tablets and up), centers the Widget and frames its content as a fixed-size card; on smaller viewports it fills its container | | `'fluid'` | Renders the Widget so it fills its container, with no frame or centering, so your own container acts as the frame | ### WidgetThemeOption Controls the visual appearance of the Widget. ```typescript theme={null} type WidgetThemeOption = { appearance?: 'light' | 'dark'; primary?: ColorTokenValue; primaryForeground?: ColorTokenValue; foreground?: ColorTokenValue; emphasisForeground?: ColorTokenValue; background?: ColorTokenValue; fontFamily?: FontFamily; components?: { button?: { borderRadius?: string }; input?: { borderRadius?: string }; card?: { borderRadius?: string }; }; }; ``` | Property | Type | Description | | -------------------- | ------------------- | ------------------------------------------------------------------------------------------------------ | | `appearance` | `'light' \| 'dark'` | Forces the Widget to render in light or dark mode, overriding the user's system preference | | `primary` | `ColorTokenValue` | Primary brand color, used for buttons and key interactive elements | | `primaryForeground` | `ColorTokenValue` | Text color rendered on top of the primary color | | `foreground` | `ColorTokenValue` | Main text color | | `emphasisForeground` | `ColorTokenValue` | Emphasized text color | | `background` | `ColorTokenValue` | Widget background color | | `fontFamily` | `FontFamily` | Font family applied across the Widget. See [FontFamily](#fontfamily) for details | | `components` | `object` | Per-component style overrides for `button`, `input`, and `card` (each accepts `borderRadius?: string`) | ### ColorTokenValue A color value that can be a single string applied to both light and dark modes, or separate values per mode. ```typescript theme={null} type ColorTokenValue = string | { light: string; dark: string }; ``` ### FontFamily A font family that can be a single string applied to all slots, or separate values per slot (`mono`, `sans`, `serif`). ```typescript theme={null} type FontFamily = string | Partial>; ``` ## Complete usage example Here's an end-to-end example showing how to use the Payment Widget with all events and type safety: ```typescript theme={null} import { PaymentWidget } from '@uphold/enterprise-payment-widget-web-sdk'; // Create a Payment Widget session from your backend const session = await createPaymentWidgetSession(); // Initialize the Widget with type inference and configuration const widget = new PaymentWidget<'select-for-deposit'>(session, { debug: true, paymentMethods: [ { type: 'card' }, { type: 'bank' }, { type: 'crypto', assets: { include: ['BTC', 'ETH', 'XRP'] } }, { type: 'paypal' }, { type: 'apple-pay'}, { type: 'google-pay'} ] }); // Handle ready event widget.on('ready', () => { console.log('Payment Widget is ready'); }); // Handle completion with flow-specific type-safe result widget.on('complete', (event) => { const result = event.detail.value; if (result.via === 'external-account') { console.log('User selected saved payment method:', result.selection); } else if (result.via === 'deposit-method') { console.log('User selected deposit method:', result.selection.depositMethod); console.log('Target account:', result.selection.account); } widget.unmount(); }); // Handle cancellation widget.on('cancel', () => { console.log('Payment cancelled by user'); widget.unmount(); }); // Handle errors widget.on('error', (event) => { const { error } = event.detail; console.error('Payment error:', error); if (error.cause) { // Some flow-specific codes wrap the real reason in `cause` instead of exposing it as the // top-level `code` — see the flow-specific guide you're integrating against for details console.error('Underlying cause:', error.cause); } showGenericError(error.message); widget.unmount(); }); // Mount the Widget to a DOM element widget.mountIframe(document.getElementById('payment-container')); ``` # Topper Widget introduction Source: https://developer.uphold.com/widgets/topper/introduction The Topper Widget has its own dedicated documentation site. Visit the Topper API documentation for installation, configuration, and integration details. The Topper Widget has a dedicated documentation site. Please refer to the [Topper API documentation](https://docs.topperpay.com/) for more information. # Travel Rule Widget installation and setup Source: https://developer.uphold.com/widgets/travel-rule/installation-and-setup Install the Uphold Travel Rule Widget SDK — or integrate directly against the Widget's message protocol — in web and native applications through a WebView. By the end of this guide the Travel Rule Widget will be running in your app, mounted to a container, and emitting events you can react to. ## Before you start You'll need: * Access to [Widgets API](/rest-apis/widgets-api/travel-rule/create-session) to create widget sessions. Manage your access in [Enterprise Portal](https://portal.enterprise.uphold.com/). * A **backend** that can call the Widgets API to create sessions on behalf of your users. * A **frontend** — a web app, or a native app with a WebView — to embed the Widget. * A quote or transaction with the **travel-rule** requirement — the Widget resolves either a pending request for information on an on-hold deposit (`deposit-form` flow) or a travel-rule requirement surfaced on a withdrawal quote (`withdrawal-form` flow). See [Travel Rule — deposit flow](/developer-guides/travel-rule/deposit) and [Travel Rule — withdrawal flow](/developer-guides/travel-rule/withdrawal) for how each requirement arises. The Widget runs from one of two hosts depending on environment: | Environment | Widget host | | ----------- | ---------------------------------------------------------- | | Sandbox | `https://travel-rule-widget.enterprise.sandbox.uphold.com` | | Production | `https://travel-rule-widget.enterprise.uphold.com` | ## Choose an integration approach We recommend **integrating without the SDK** for native apps — it's simpler to set up. For web-only integrations, the SDK is a solid default. There are two ways to embed the Widget: * **Web SDK** — Install `@uphold/enterprise-travel-rule-widget-web-sdk` for a better developer experience — typed events and a more streamlined integration on web. For native apps, the SDK must be bundled into the WebView's HTML page. * **JavaScript** — Listen for the Widget's messages over the iframe or WebView and respond to them directly. This is also the simpler option for native apps: there's no SDK to bundle into the WebView's HTML page. Both approaches use the same backend step — creating a session via the Widgets API — and emit the same four lifecycle outcomes (`ready`, `complete`, `cancel`, `error`). ## Shared setup These two steps are identical whichever approach you choose above — do them once, then jump to the matching section below. ### 1. Create a session on your backend The Travel Rule Widget runs against a session — a short-lived, server-side authorization scoped to one flow and one user. Create it server-side using your OAuth credentials. To create a session, you must have the `Travel Rule Widget` scope. Never create session directly from the client. Your client secret must not leave your backend. Call [`Create session`](/rest-apis/widgets-api/travel-rule/create-session) with the flow that matches what you're resolving: * `deposit-form` — resolves a pending request for information on an on-hold deposit. Pass `data.requestForInformationId`. * `withdrawal-form` — resolves a travel-rule requirement on a withdrawal quote. Pass `data.quoteId`. The example below creates a `deposit-form` session: ```bash theme={null} curl -X POST https://api.sandbox.uphold.com/widgets/travel-rule/sessions \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "X-On-Behalf-Of: user $USER_ID" \ -H "Content-Type: application/json" \ -d '{ "flow": "deposit-form", "data": { "requestForInformationId": "3f6d0c1e-a1bf-4b25-9802-2a3ee492d3c8" } }' ``` The response wraps the session object: ```json [expandable] theme={null} { "session": { "flow": "deposit-form", "url": "https://travel-rule-widget.enterprise.sandbox.uphold.com/...", "token": "...", "data": { "provider": "notabene", "parameters": { "init": { "authToken": "...", "nodeUrl": "https://api.notabene.id" }, "options": {}, "transaction": { "amountDecimal": 0.05, "asset": "BTC", "source": ["bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"], "customer": { "name": "John Doe", "type": "natural" } } } } } } ``` A `withdrawal-form` session's `data.parameters.transaction` is shaped around the destination address instead — see [Travel Rule — deposit flow](/developer-guides/travel-rule/deposit) and [Travel Rule — withdrawal flow](/developer-guides/travel-rule/withdrawal) for the end-to-end flow each session type is used in, including how to detect the requirement and where to send the resulting data. Pass `response.session` to your frontend (e.g. as part of your page response or via your own API endpoint): * With the SDK, it's the single `session` argument to the `TravelRuleWidget` constructor — see [Setup Web SDK](#setup-with-web-sdk). * Without the SDK, `session.url` is what you load directly into your iframe or WebView, and the whole object is what you send back as `init` — see [Setup with JavaScript](#setup-with-javascript). ### 2. Allow the Widget domain in your CSP If your web app embeds the Widget in an iframe and enforces a Content Security Policy, allow the Widget host for your environment(s) under `frame-src`. ```html theme={null} ``` If your app does not use CSP, skip this step. ## Setup with Web SDK ### Install the SDK Install the SDK in the frontend that will host the Widget — your web app, or the JS bundle loaded by your native WebView. ```bash theme={null} npm install @uphold/enterprise-travel-rule-widget-web-sdk ``` ### Initialize and mount the Widget On the frontend, instantiate `TravelRuleWidget` with the session from [Create a session on your backend](#1-create-a-session-on-your-backend), then mount it into a container element. ```javascript theme={null} import { TravelRuleWidget } from '@uphold/enterprise-travel-rule-widget-web-sdk'; // session is `response.session` from your backend — see Shared setup const widget = new TravelRuleWidget(session, { theme: { appearance: 'dark' }, // omit to follow system preference debug: true // verbose logging during development }); widget.mountIframe(document.getElementById('travel-rule-container')); ``` The container must have explicit CSS width and height — the iframe fills its bounds. Minimum recommended size is **400px × 600px**. For type inference on the `complete` event, pass the flow as a generic: `new TravelRuleWidget<'deposit-form'>(session)` (or `'withdrawal-form'`). See the [SDK reference](./sdk-reference#constructor) for full constructor details. ### Handle Widget events The Widget emits four events during its lifecycle. Wire up handlers **before** calling `mountIframe`. | Event | Fires when | What to do | | ---------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `ready` | The Widget has finished loading | Hide your loading state | | `complete` | The user finished the compliance form | Read `event.detail.value`, send it to your backend to resolve the RFI or create the transaction, then call `widget.unmount()` | | `cancel` | The user dismissed the Widget | Call `widget.unmount()`, return them to your flow | | `error` | The Widget hit an unrecoverable error | Read `event.detail.error`, call `widget.unmount()`, retry | The Widget does not unmount itself. You must call `widget.unmount()` from `complete`, `cancel`, and `error` handlers. ```javascript [expandable] theme={null} widget.on('ready', () => { console.log('Travel Rule Widget is ready'); }); widget.on('complete', async (event) => { console.log('Travel Rule data:', event.detail.value); // Send event.detail.value to your backend to resolve the RFI or create the transaction await submitTravelRuleData(event.detail.value); widget.unmount(); }); widget.on('cancel', () => { console.log('Travel Rule form cancelled'); widget.unmount(); }); widget.on('error', (event) => { console.error('Travel Rule error:', event.detail.error); widget.unmount(); }); ``` `event.detail.value` is an opaque compliance data object — pass it through unchanged. For `deposit-form` sessions, send it as the `data` field of [Update request for information](/rest-apis/core-api/requests-for-information/update-request-for-information); for `withdrawal-form` sessions, send it as `params.travelRule` of [Create transaction](/rest-apis/core-api/transactions/create-transaction). See [Travel Rule — deposit flow](/developer-guides/travel-rule/deposit#handle-the-complete-event) and [Travel Rule — withdrawal flow](/developer-guides/travel-rule/withdrawal#handle-complete-event) for full examples. The `error.code` property (e.g. `entity_not_found`, `validation_failed`) lets you distinguish an expired session from a form validation issue — see [Events](./sdk-reference#events) in the SDK reference for the full error shape. ### Native apps with the SDK Native mobile apps can also use the SDK to embed the Widget through a WebView, building on the [Install the SDK](#install-the-sdk) and [Initialize and mount the Widget](#initialize-and-mount-the-widget) steps above. To do so, it requires you to bundle the SDK into the WebView's HTML page, and forward events to native code through a bridge. We recommend integrating [without the SDK](#setup-with-javascript) for native apps as it has a simpler setup, but if you choose to use the SDK, follow the steps below. Create a JS bundle that includes the SDK. This bundle will be loaded in the WebView. You can use a tool like [Webpack](https://webpack.js.org/) or [Rollup](https://rollupjs.org/) to bundle the SDK and your custom code into a single JS file. Loading the Web SDK from a CDN is not supported. Then create an HTML page that includes the JS bundle and mounts the Widget. This page will be loaded in the WebView. Here is an example you can use — the `sendToNativeApp` helper at the bottom forwards events to whichever bridge is available (iOS, Android, or React Native). ```html [expandable] theme={null} Travel Rule Widget
``` ### Platform setup Here is a sample of how to create a WebView in your native app and load the HTML page that contains the SDK and mounts the Widget. The WebView should be configured to allow JavaScript execution and to forward messages to your native code. ```swift [expandable] theme={null} import WebKit class TravelRuleViewController: UIViewController, WKScriptMessageHandler { @IBOutlet weak var webView: WKWebView! override func viewDidLoad() { super.viewDidLoad() // Register a single message handler for all Widget events let contentController = webView.configuration.userContentController contentController.add(self, name: "travelRuleWidgetMessage") if let url = Bundle.main.url(forResource: "travel-rule-widget", withExtension: "html") { webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) } } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "travelRuleWidgetMessage", let messageDict = message.body as? [String: Any], let type = messageDict["type"] as? String else { return } let data = messageDict["data"] switch type { case "complete": handleTravelRuleComplete(data: data) case "cancel": handleTravelRuleCancel() case "error": handleTravelRuleError(error: data) default: print("Unknown Travel Rule Widget message type: \(type)") } } private func handleTravelRuleComplete(data: Any?) { /* send data to your backend to resolve the RFI or create the transaction */ } private func handleTravelRuleCancel() { /* return user to previous screen */ } private func handleTravelRuleError(error: Any?) { /* show error UI */ } deinit { webView?.configuration.userContentController.removeScriptMessageHandler(forName: "travelRuleWidgetMessage") } } ``` ```java [expandable] theme={null} import android.webkit.WebView; import android.webkit.WebSettings; import android.webkit.JavascriptInterface; import android.util.Log; import org.json.JSONObject; WebView webView = findViewById(R.id.webview); WebSettings webSettings = webView.getSettings(); webSettings.setJavaScriptEnabled(true); webView.addJavascriptInterface(new TravelRuleBridge(), "TravelRuleBridge"); webView.loadUrl("file:///android_asset/travel-rule-widget.html"); public class TravelRuleBridge { @JavascriptInterface public void onMessage(String messageJson) { try { JSONObject message = new JSONObject(messageJson); String type = message.getString("type"); Object data = message.opt("data"); runOnUiThread(() -> { switch (type) { case "complete": handleTravelRuleComplete(data != null ? data.toString() : null); break; case "cancel": handleTravelRuleCancel(); break; case "error": handleTravelRuleError(data != null ? data.toString() : null); break; default: Log.w("TravelRule", "Unknown Travel Rule Widget message type: " + type); } }); } catch (Exception e) { Log.e("TravelRule", "Error parsing Travel Rule Widget message", e); } } private void handleTravelRuleComplete(String data) { /* send data to your backend to resolve the RFI or create the transaction */ } private void handleTravelRuleCancel() { /* return user to previous screen */ } private void handleTravelRuleError(String error) { /* show error UI */ } } ``` If you load local assets, ensure the WebView allows file access according to your security requirements (e.g. `setAllowFileAccess(true)` when needed). ```javascript [expandable] theme={null} import { WebView } from 'react-native-webview'; import { Alert } from 'react-native'; const TravelRuleScreen = () => { const handleMessage = (event) => { try { const message = JSON.parse(event.nativeEvent.data); switch (message.type) { case 'complete': handleTravelRuleComplete(message.data); break; case 'cancel': handleTravelRuleCancel(); break; case 'error': handleTravelRuleError(message.data); break; default: console.log('Unknown message type:', message.type); } } catch (error) { console.error('Error parsing WebView message:', error); } }; const handleTravelRuleComplete = (data) => { /* send data to your backend to resolve the RFI or create the transaction */ }; const handleTravelRuleCancel = () => { /* return user to previous screen */ }; const handleTravelRuleError = (error) => { /* show error UI */ }; return ( ); }; ``` For iOS, `file:` URLs may be restricted. Consider using `source={{ html: '...' }}` or loading a bundled asset and adjusting the URI per platform.
## Setup with JavaScript Instead of installing the SDK, you can load the Widget's session `url` directly — as an iframe you create yourself on web, or as your WebView's top-level page on native — and speak its underlying message protocol directly. This is useful for hosts that can't ship a JS bundle to their WebView, or want a fully native shell around the Widget. The [Shared setup](#shared-setup) steps still apply here — you just don't install anything. Only how you load the Widget and exchange messages changes. ### The message protocol These are the message types the Widget speaks. The transport carrying them differs per platform — see the tabs below. **From the Widget to your host:** | Type | Payload | Fires when | What to do | | --------------- | ------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `load` | — | The Widget's script has booted and needs its session | Reply with an `init` message (see below) | | `ready` | — | The Widget is ready for interaction | Hide your loading state | | `complete` | `value` | The user finished the compliance form | Read `value`, send it to your backend to resolve the RFI or create the transaction, then tear down the iframe/WebView | | `cancel` | — | The user dismissed the Widget | Tear down the iframe/WebView, return them to your flow | | `error` | `error` | The Widget hit an unrecoverable error | Read `error`, tear down the iframe/WebView, retry | | `force_repaint` | — | Works around a redraw issue in mobile Safari only | Briefly toggle the WebView's opacity to force a repaint | **From your host to the Widget:** | Type | Payload | Send when | | ------ | ------------------------- | --------------------------------------- | | `init` | `{ ...session, options }` | Replying to the Widget's `load` message | The `init` payload spreads the session object from [Create a session on your backend](#1-create-a-session-on-your-backend) (`url`, `token`, `flow`, `data`) alongside an `options` object with the same shape as the SDK's [`TravelRuleWidgetOptions`](./sdk-reference#options) — omit `options` or pass `{}` to use defaults. ### Specifying the theme appearance By default, the Widget matches the browser or OS `prefers-color-scheme` until it receives your `init` reply. To control the initial appearance yourself — and avoid a flash if it won't match what you send in `init` — append a `theme_appearance` query parameter (`dark` or `light`) to `session.url` before loading it: ```javascript theme={null} const url = new URL(session.url); url.searchParams.set('theme_appearance', 'dark'); // or 'light' ``` This works the same whether you load the result into a web iframe or as your native WebView's top-level page. ### Platform implementation Mount the session `url` in an iframe you create yourself, and exchange messages over the standard `window.postMessage` API. Make sure the iframe's container has explicit CSS width and height (minimum recommended size is **400px × 600px**). ```html [expandable] theme={null}
```
Instead of bundling an HTML page, point the WebView directly at the session `url`. The Widget sends messages to native code through the `uphdTravelRuleWidget` message handler; to reply, call `window.TravelRuleWidgetBridge.sendMessageToWidget(...)` via `evaluateJavaScript`, passing the session object alongside an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `TravelRuleViewController` below ties this together into a complete example, forwarding the Widget's messages to native callbacks. ```swift [expandable] theme={null} import WebKit final class TravelRuleViewController: UIViewController, WKScriptMessageHandler { var onComplete: ((Any?) -> Void)? var onCancel: (() -> Void)? var onError: ((Any?) -> Void)? // The session dict from your backend (url, token, flow, data, ...), kept around so it can be // echoed back to the Widget in the "init" reply to its "load" message. private var session: [String: Any]? private let webView: WKWebView = { let webView = WKWebView(frame: .zero) webView.translatesAutoresizingMaskIntoConstraints = false return webView }() override func viewDidLoad() { super.viewDidLoad() view.addSubview(webView) NSLayoutConstraint.activate([ webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) // Registers the exact handler name the Widget looks for webView.configuration.userContentController.add(self, name: "uphdTravelRuleWidget") Task { do { let session = try await createTravelRuleWidgetSession() // calls your backend self.session = session guard let urlString = session["url"] as? String, let url = URL(string: urlString) else { onError?(["message": "Travel Rule Widget session response is missing a valid 'url'"]) return } webView.load(URLRequest(url: url)) } catch { onError?(["message": "\(error)"]) } } } // Replies to the Widget's "load" message with an "init" command carrying the session // (url, token, flow, data, ...) plus an options object — same shape as the SDK's // TravelRuleWidgetOptions. Empty here; see Configuration reference to customize it. private func sendInitMessageToWidget() { guard var initMessage = session, let data = try? JSONSerialization.data(withJSONObject: { initMessage["type"] = "init" initMessage["options"] = [:] return initMessage }()), let json = String(data: data, encoding: .utf8) else { return } webView.evaluateJavaScript("window.TravelRuleWidgetBridge.sendMessageToWidget(\(json));") } // Nudges the page's opacity to force a repaint — needed to work around a WKWebView // redraw bug when the Widget uses the View Transitions API. private func forceRepaintWidget() { webView.evaluateJavaScript(""" document.documentElement.style.opacity = '0.99'; setTimeout(() => { document.documentElement.style.opacity = '1'; }, 0); """) } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { // The Widget JSON.stringifies its messages before posting them, so the body // arrives as a string rather than an already-decoded dictionary. guard message.name == "uphdTravelRuleWidget", let jsonString = message.body as? String, let data = jsonString.data(using: .utf8), let messageDict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let type = messageDict["type"] as? String else { return } switch type { case "load": sendInitMessageToWidget() case "force_repaint": forceRepaintWidget() case "ready": print("Travel Rule Widget is ready") case "complete": onComplete?(messageDict["value"]) case "cancel": onCancel?() case "error": onError?(messageDict["error"]) default: print("Unknown Travel Rule Widget message type: \(type)") } } deinit { webView.configuration.userContentController.removeScriptMessageHandler(forName: "uphdTravelRuleWidget") } } ``` Instead of bundling an HTML page, point the WebView directly at the session `url`. The Widget sends messages to native code through the `uphdTravelRuleWidget` object registered below; to reply, call `replyProxy.postMessage(...)`, passing the session object alongside an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `TravelRuleActivity` below ties this together into a complete example, forwarding the Widget's messages to native callbacks. There is no bundled test app reference for this path yet. Validate the message names and payload shapes below against the version of the Widget you're integrating. ```kotlin [expandable] theme={null} import android.webkit.WebView import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import org.json.JSONObject class TravelRuleActivity : AppCompatActivity() { private lateinit var webView: WebView private var session: JSONObject? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_travel_rule) webView = findViewById(R.id.webview) webView.settings.javaScriptEnabled = true if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { WebViewCompat.addWebMessageListener( webView, "uphdTravelRuleWidget", setOf( "https://travel-rule-widget.enterprise.sandbox.uphold.com", "https://travel-rule-widget.enterprise.uphold.com" ) ) { _, message, _, _, replyProxy -> val body = JSONObject(message.data ?: return@addWebMessageListener) when (body.getString("type")) { "load" -> sendInitMessageToWidget(replyProxy) "ready" -> Log.d("TravelRule", "Travel Rule Widget is ready") "complete" -> handleTravelRuleComplete(body.opt("value")) "cancel" -> handleTravelRuleCancel() "error" -> handleTravelRuleError(body.opt("error")) else -> Log.w("TravelRule", "Unknown Travel Rule Widget message type: ${body.getString("type")}") } } } else { // Fall back to Setup Web SDK on WebViews that don't support WEB_MESSAGE_LISTENER. } lifecycleScope.launch { try { val session = createTravelRuleWidgetSession() // calls your backend this@TravelRuleActivity.session = session webView.loadUrl(session.getString("url")) } catch (e: Exception) { handleTravelRuleError(e.message) } } } // Replies to the Widget's "load" message with an "init" command carrying the session // (url, token, flow, data, ...) plus an options object — same shape as the SDK's // TravelRuleWidgetOptions. Empty here; see Configuration reference to customize it. private fun sendInitMessageToWidget(replyProxy: JavaScriptReplyProxy) { val session = session ?: return val initMessage = JSONObject(session.toString()) .put("type", "init") .put("options", JSONObject()) replyProxy.postMessage(initMessage.toString()) } private fun handleTravelRuleComplete(value: Any?) { /* send value to your backend to resolve the RFI or create the transaction */ } private fun handleTravelRuleCancel() { /* return user to previous screen */ } private fun handleTravelRuleError(error: Any?) { /* show error UI */ } } ``` `force_repaint` is a WKWebView-specific workaround and isn't expected on Android — the listener above doesn't need to handle it. Instead of bundling an HTML page, point the WebView directly at the session `url`. The Widget sends messages to native code through the WebView's `onMessage` prop, once it detects `window.ReactNativeWebView` (injected automatically by `react-native-webview`); to reply, call `window.TravelRuleWidgetBridge.sendMessageToWidget(...)` via `injectJavaScript`, passing the session object alongside an `options` object (empty below — see [Configuration reference](#configuration-reference) to customize it). The `TravelRuleScreen` below ties this together into a complete example, forwarding the Widget's messages to native callbacks. ```jsx [expandable] theme={null} import React, { useRef } from 'react'; import { WebView } from 'react-native-webview'; const TravelRuleScreen = ({ session }) => { const webViewRef = useRef(null); // Replies to the Widget's "load" message with an "init" command carrying the session // (url, token, flow, data, ...) plus any options. const sendInitMessageToWidget = () => { const initMessage = JSON.stringify({ ...session, options: {}, type: 'init' }); webViewRef.current?.injectJavaScript(`window.TravelRuleWidgetBridge.sendMessageToWidget(${initMessage}); true;`); }; const handleMessage = (event) => { const message = JSON.parse(event.nativeEvent.data); switch (message.type) { case 'load': sendInitMessageToWidget(); break; case 'ready': console.log('Travel Rule Widget is ready'); break; case 'complete': handleTravelRuleComplete(message.value); break; case 'cancel': handleTravelRuleCancel(); break; case 'error': handleTravelRuleError(message.error); break; default: console.log('Unknown Travel Rule Widget message type:', message.type); } }; const handleTravelRuleComplete = (value) => { /* send value to your backend to resolve the RFI or create the transaction */ }; const handleTravelRuleCancel = () => { /* return user to previous screen */ }; const handleTravelRuleError = (error) => { /* show error UI */ }; return ( ); }; ``` `force_repaint` is a WKWebView-specific workaround. If you see repaint glitches on iOS, forward it the same way as `load`, toggling the WebView's `opacity` briefly via `injectJavaScript`. Check the iOS example for details.
## Test in Sandbox With your Sandbox credentials and the Sandbox Widget host configured, run through this checklist — it applies whichever approach you integrated with: * The Widget mounts and `ready` fires. * Completing the compliance form fires `complete` with the expected `value` for your flow — `event.detail.value` with the SDK, or the `value` payload of the `complete` message without it. * Closing or dismissing the Widget fires `cancel`. * If your app uses a CSP (see [Shared setup](#shared-setup)), the browser console shows no violations (look for "Refused to frame"). Once Sandbox is green, swap your OAuth credentials to Production. The Widget host is selected automatically by the session `url` returned from your backend — no client-side environment switching is needed. ## Configuration reference The most common SDK options. See the [SDK reference](./sdk-reference#options) for the full schema and all event types. If you're integrating without the SDK, these map directly to the `options` object you send in your `init` reply. | Option | Purpose | | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `theme` | Customize the Widget's appearance: force `light` or `dark` mode, set brand colors, typography, and per-component border radii. See [WidgetThemeOption](/widgets/travel-rule/sdk-reference#widgetthemeoption) for all options. | | `layout` | Control how the Widget is laid out on larger viewports — a centered, framed `'boxed'` card (default) or a `'fluid'` fill of its container. See [WidgetLayout](/widgets/travel-rule/sdk-reference#widgetlayout) for details. | | `debug` | Verbose console logging. Use during development. | ## Troubleshooting **Widget not displaying** Confirm the Widget host for your environment is in the `frame-src` directive of your CSP (see [Shared setup](#shared-setup)). Open DevTools → Console and look for `Refused to frame` violations. **Container is empty after mount** The iframe fills its container — the container must have explicit CSS width and height. Minimum recommended size is 400px × 600px. **Events not firing in native apps (with the SDK)** Verify that: * JavaScript is enabled in the WebView. * The message bridge is registered **before** the HTML page loads. * Event handler names match the platform-specific bridge contract used in `sendToNativeApp`. **Events not firing (without the SDK)** Verify that: * Your message-handler / listener name is exactly `uphdTravelRuleWidget` — the name the Widget checks for on both iOS and Android — and is registered **before** the WebView loads the session `url`. * You're replying to `load` with `init` — the Widget won't render anything until it receives it. * On web, you're filtering incoming `message` events by origin (`event.origin === sessionOrigin`) — messages from other origins should be ignored, not treated as Widget events. **Widget never unmounts.** The SDK does not auto-unmount, and neither does the Widget itself when integrating without it. Call `widget.unmount()` (SDK) or tear down your iframe/WebView (no SDK) from each terminal message (`complete`, `cancel`, `error`). ## Next steps * Review the complete [SDK Reference](./sdk-reference) for all available methods and events. * See [Handle quote requirements](/developer-guides/crypto-transfers/withdrawal/via-rest-api#handle-quote-requirements) and [Handle on-hold transactions](/developer-guides/crypto-transfers/deposit/via-rest-api#handle-on-hold-transactions) for practical examples of using the Travel Rule Widget in transactions. # Travel Rule Widget introduction Source: https://developer.uphold.com/widgets/travel-rule/introduction The Uphold Travel Rule Widget is an embeddable solution for FATF-compliant collection and exchange of originator and beneficiary info on crypto transactions. The Travel Rule Widget is a fully Uphold-managed, embeddable solution that enables compliant collection and exchange of originator and beneficiary information for crypto transactions — ensuring adherence to FATF Recommendation 16 and global Travel Rule regulations. ## Features Automates collection and exchange of originator and beneficiary information. Embeds seamlessly into web and native apps. Emits lifecycle events to integrate with your application flow. Lightweight SDK to instantiate and control the widget. ## Using the widget The Travel Rule Widget is designed to be embedded into your application to collect required information from users during crypto transactions. ### Installation The widget is available as an npm package for web applications. For detailed installation and setup instructions, see [Installation and Setup](./installation-and-setup). ### Integration To integrate the widget into your application: 1. Create a widget session using the [Create session](/rest-apis/widgets-api/travel-rule/create-session) endpoint 2. Initialize the widget with the session data 3. Handle lifecycle events (complete, cancel, error) 4. Submit the collected data For a complete API reference, see [SDK Reference](./sdk-reference). The widget supports both web applications (via iframe) and native applications (via WebView). See [Native App Integration](./installation-and-setup#native-app-integration) for platform-specific guidance. ## When to use the widget The widget addresses Travel Rule compliance requirements during crypto transactions. For context on what Travel Rule is, when it applies, and the types of checks involved, see the [Travel Rule overview](/developer-guides/travel-rule/overview). ### Quote requirements When creating a quote for a crypto withdrawal, the response may include a `travel-rule` requirement indicating that Travel Rule information must be collected before the transaction can be executed. See the [withdrawal flow guide](/developer-guides/travel-rule/withdrawal) for the full implementation. ### Transaction RFIs When a crypto deposit transaction is placed on hold with a `travel-rule` request for information (RFI), the widget must be used to collect the required information to allow the transaction to proceed. See the [deposit flow guide](/developer-guides/travel-rule/deposit) for the full implementation. # Travel Rule Widget SDK reference Source: https://developer.uphold.com/widgets/travel-rule/sdk-reference Reference for the @uphold/enterprise-travel-rule-widget-web-sdk package, with the TravelRuleWidget class, constructor options, methods, and event handlers. Complete reference documentation for the `@uphold/enterprise-travel-rule-widget-web-sdk` package. The main class for creating and managing Travel Rule Widget instances is `TravelRuleWidget`. It requires a `TravelRuleWidgetSession` object that must be created through the API before instantiating the Widget. ## Constructor ```typescript theme={null} new TravelRuleWidget( session: TravelRuleWidgetSession, options?: TravelRuleWidgetOptions ) ``` **Parameters:** | Parameter | Type | Required | Description | | --------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- | | `session` | `TravelRuleWidgetSession` | Yes | Travel rule session object obtained from the [Create session](/rest-apis/widgets-api/travel-rule/create-session) endpoint | | `options` | `TravelRuleWidgetOptions` | No | Configuration options for the Widget | **Generic type parameter:** The constructor accepts an optional generic type parameter that specifies the flow type. When provided, it enables better type inference for event handlers, particularly for the `complete` event. **Examples:** ```typescript theme={null} // Generic flow type (default) const widget = new TravelRuleWidget(session); // Specific flow type for better type inference const depositWidget = new TravelRuleWidget<'deposit-form'>(session); const withdrawalWidget = new TravelRuleWidget<'withdrawal-form'>(session); ``` ### Options ```typescript theme={null} type TravelRuleWidgetOptions = { debug?: boolean; layout?: WidgetLayout; theme?: WidgetThemeOption; }; ``` | Property | Type | Default | Description | | -------- | ------------------- | ----------------- | ----------------------------------------------------------------------------------------------------- | | `debug` | `boolean` | `false` | Enable debug mode for additional logging | | `layout` | `WidgetLayout` | `'boxed'` | Control how the Widget is laid out on larger viewports. See [WidgetLayout](#widgetlayout) for details | | `theme` | `WidgetThemeOption` | System preference | Control the visual appearance of the Widget. See [WidgetThemeOption](#widgetthemeoption) for details | **layout option:** The `layout` option controls how the Widget is laid out when there is enough room to frame it — on viewports that are both at least `md` wide (≥768px) and at least 600px tall, i.e. tablets and up. On smaller viewports — narrower or shorter, including phones in landscape — the Widget always fills its container regardless of this option. * `'boxed'` (default): on viewports that are ≥768px wide and ≥600px tall, centers the Widget and frames its content as a fixed-size card; on smaller viewports it fills its container. Use this when the Widget takes up the whole page (e.g. mounted into a full-page container). * `'fluid'`: renders the Widget so it fills its container, with no frame or centering, so your own container (e.g. a modal or a sized panel) acts as the frame. Most integrations should rely on the default `'boxed'` and only set `layout: 'fluid'` when embedding the Widget in your own framed container. ```typescript theme={null} const widget = new TravelRuleWidget(session, { layout: 'fluid' }); ``` Use `'fluid'` when you already provide a centered container or modal around the Widget, to avoid a card-within-a-card appearance on larger viewports. **theme option:** The `theme` option lets you control the visual appearance of the Widget. By default, the Widget automatically detects and matches the user's browser or OS appearance preference (`light` or `dark`). Use this option when you want to enforce a specific theme regardless of the user's system settings. ```typescript theme={null} const widget = new TravelRuleWidget(session, { theme: { appearance: 'dark', primary: { light: '#6200EA', dark: '#BB86FC' }, // Alternatively, a single color string applies to both appearances, e.g. primary: '#6200EA' // primary: '#6200EA', fontFamily: 'Inter, sans-serif', components: { button: { borderRadius: '8px' } } } }); ``` ## Methods ### `mountIframe()` Mounts the Travel Rule Widget iframe to the specified DOM element. **Parameters:** * `element`: The HTML element where the Widget should be mounted **Example:** ```javascript theme={null} // Ensure the container has appropriate sizing const container = document.getElementById('tr-container'); widget.mountIframe(container); ``` The container element should have explicit dimensions set via CSS. The Widget will fill the entire container. Minimum recommended size is 400px width × 600px height for optimal user experience. ### `unmount()` Unmounts and cleans up the Travel Rule Widget iframe. **Example:** ```javascript theme={null} widget.unmount(); ``` The Widget will not unmount itself when a flow is finished, either by successful completion, user cancellation, or unrecoverable error, so it must always be called manually. ### `on()` Registers an event listener for Widget events. **Parameters:** * `event`: The event name to listen for * `callback`: Function to execute when the event is triggered **Example:** ```javascript theme={null} widget.on('complete', (event) => { console.log('Travel Rule form completed:', event.detail.value); }); ``` ### `off()` Removes an event listener for Travel Rule Widget events. **Parameters:** * `event`: The event name to stop listening for * `callback`: The specific function to remove (must be the same reference as used in `on()`) **Example:** ```javascript theme={null} const handleComplete = (event) => { console.log('Travel Rule form completed:', event.detail.value); }; // Register the listener widget.on('complete', handleComplete); // Remove the listener widget.off('complete', handleComplete); ``` ## Events The Travel Rule Widget emits several events during its lifecycle that you can listen to using the `on()` method. ### `complete` Fired when the Travel Rule form is completed successfully. ```typescript theme={null} type TravelRuleWidgetCompleteEvent = { detail: { value: T; }; }; ``` The `event.detail.value` contains the Travel Rule compliance data collected from the user. This is an opaque data structure that should be passed to your backend and used when resolving RFIs or creating transactions. **Example:** ```typescript theme={null} widget.on('complete', (event: TravelRuleWidgetCompleteEvent) => { const travelRuleData = event.detail.value; console.log('Travel Rule form completed:', travelRuleData); // Send the data to your backend to resolve the RFI or create the transaction await submitTravelRuleData(travelRuleData); widget.unmount(); }); ``` **Usage in different flows:** For deposit flows: ```typescript theme={null} const depositWidget = new TravelRuleWidget<'deposit-form'>(session); depositWidget.on('complete', async (event) => { // Use the travel rule data to resolve the quote requirement await resolveQuoteRequirement(quoteId, event.detail.value); depositWidget.unmount(); }); ``` For withdrawal flows: ```typescript theme={null} const withdrawalWidget = new TravelRuleWidget<'withdrawal-form'>(session); withdrawalWidget.on('complete', async (event) => { // Use the travel rule data when creating the transaction await createTransaction({ ...transactionParams, travelRuleData: event.detail.value }); withdrawalWidget.unmount(); }); ``` ### `cancel` Fired when the user cancels the Travel Rule form. ```typescript theme={null} type TravelRuleWidgetCancelEvent = { detail: {}; } ``` **Example:** ```javascript theme={null} widget.on('cancel', (event: TravelRuleWidgetCancelEvent) => { console.log('Travel Rule form cancelled by user'); widget.unmount(); }); ``` ### `error` Fired when an unrecoverable error occurs during the Travel Rule flow. ```typescript theme={null} type TravelRuleWidgetError = { name: string; code: string; message: string; details?: Record; stack?: string; cause?: TravelRuleWidgetError; httpStatusCode?: number; } type TravelRuleWidgetErrorEvent = { detail: { error: TravelRuleWidgetError; }; }; ``` **Example:** ```typescript theme={null} widget.on('error', (event: TravelRuleWidgetErrorEvent) => { const { error } = event.detail; console.error('Travel Rule form error:', error); // Handle specific error types if (error.code === 'entity_not_found') { // Quote or transaction not found - may have expired handleExpiredSession(); } else if (error.code === 'validation_failed') { // Form validation error (shouldn't happen in normal flow) console.error('Validation error:', error.details); } else { // Generic error handling showGenericError(error.message); } widget.unmount(); }); ``` ### `ready` Fired when the Travel Rule Widget has finished loading and is ready for user interaction. ```typescript theme={null} type TravelRuleWidgetReadyEvent = { detail: {}; } ``` **Example:** ```typescript theme={null} widget.on('ready', (event: TravelRuleWidgetReadyEvent) => { console.log('Travel Rule form is ready'); }); ``` ## Types ### TravelRuleWidgetSession The session object obtained from the [Create session](/rest-apis/widgets-api/travel-rule/create-session) endpoint. ```typescript theme={null} type TravelRuleWidgetSession = { url: string; token: string; flow: TravelRuleWidgetFlow; data: TravelRuleWidgetData; } ``` ### TravelRuleWidgetFlow Represents the different flows supported by the Travel Rule Widget. ```typescript theme={null} type TravelRuleWidgetFlow = 'deposit-form' | 'withdrawal-form'; ``` ### TravelRuleWidgetData The data object included in the session, containing context for the flow. ```typescript theme={null} type TravelRuleWidgetData = { provider: 'notabene'; parameters: object; }; ``` ### TravelRuleResult The result object returned upon successful completion of the Travel Rule form. ```typescript theme={null} type TravelRuleResult = Record; ``` The `event.detail.value` object is an opaque data structure that should be used as-is when resolving RFIs or creating transactions. ### WidgetLayout Controls how the Widget is laid out on larger viewports. ```typescript theme={null} type WidgetLayout = 'boxed' | 'fluid'; ``` | Value | Description | | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `'boxed'` | Default. On viewports that are ≥768px wide and ≥600px tall (tablets and up), centers the Widget and frames its content as a fixed-size card; on smaller viewports it fills its container | | `'fluid'` | Renders the Widget so it fills its container, with no frame or centering, so your own container acts as the frame | ### WidgetThemeOption Controls the visual appearance of the Widget. ```typescript theme={null} type WidgetThemeOption = { appearance?: 'light' | 'dark'; primary?: ColorTokenValue; primaryForeground?: ColorTokenValue; foreground?: ColorTokenValue; emphasisForeground?: ColorTokenValue; background?: ColorTokenValue; fontFamily?: FontFamily; components?: { button?: { borderRadius?: string }; input?: { borderRadius?: string }; card?: { borderRadius?: string }; }; }; ``` | Property | Type | Description | | -------------------- | ------------------- | ------------------------------------------------------------------------------------------------------ | | `appearance` | `'light' \| 'dark'` | Forces the Widget to render in light or dark mode, overriding the user's system preference | | `primary` | `ColorTokenValue` | Primary brand color, used for buttons and key interactive elements | | `primaryForeground` | `ColorTokenValue` | Text color rendered on top of the primary color | | `foreground` | `ColorTokenValue` | Main text color | | `emphasisForeground` | `ColorTokenValue` | Emphasized text color | | `background` | `ColorTokenValue` | Widget background color | | `fontFamily` | `FontFamily` | Font family applied across the Widget. See [FontFamily](#fontfamily) for details | | `components` | `object` | Per-component style overrides for `button`, `input`, and `card` (each accepts `borderRadius?: string`) | ### ColorTokenValue A color value that can be a single string applied to both light and dark modes, or separate values per mode. ```typescript theme={null} type ColorTokenValue = string | { light: string; dark: string }; ``` ### FontFamily A font family that can be a single string applied to all slots, or separate values per slot (`mono`, `sans`, `serif`). ```typescript theme={null} type FontFamily = string | Partial>; ``` ## Complete usage example Here's an end-to-end example showing how to use the Travel Rule Widget with all events and type safety: ```typescript theme={null} import { TravelRuleWidget } from '@uphold/enterprise-travel-rule-widget-web-sdk'; // Create a Travel Rule Widget session from your backend const session = await createTravelRuleWidgetSession(quoteId); // Initialize the Widget with type inference const widget = new TravelRuleWidget<'deposit-form'>(session, { debug: true }); // Handle ready event widget.on('ready', () => { console.log('Travel Rule form is ready'); }); // Handle completion with the Travel Rule data widget.on('complete', async (event) => { const travelRuleData = event.detail.value; console.log('Travel Rule form completed:', travelRuleData); try { // Send the data to your backend to resolve the quote requirement await resolveQuoteRequirement(quoteId, travelRuleData); // Proceed with the transaction flow showSuccessMessage('Travel Rule compliance completed'); } catch (error) { console.error('Failed to submit Travel Rule data:', error); showErrorMessage('Failed to submit compliance information'); } widget.unmount(); }); // Handle cancellation widget.on('cancel', () => { console.log('Travel Rule form cancelled by user'); showCancellationMessage(); widget.unmount(); }); // Handle errors with specific error handling widget.on('error', (event) => { const { error } = event.detail; console.error('Travel Rule form error:', error); if (error.code === 'entity_not_found') { showErrorMessage('Session expired. Please start over.'); } else { showErrorMessage(error.message); } widget.unmount(); }); // Mount the Widget to a DOM element widget.mountIframe(document.getElementById('tr-container')); ```