Originally published on Canopy.
Your user has signed in and your app has created a Turnkey wallet. Their funds are still on an exchange. A deposit button needs to give them withdrawal instructions, route a supported deposit to their wallet, and show when settlement has completed.
This walkthrough adds that flow to an existing React and Next.js application. Your server resolves the user's Turnkey account, creates a Canopy intent with that address as its destination, and saves the relationship before opening checkout. The exchange sends to the address shown by checkout. Canopy's signed settlement webhook updates your application.
The destination example is an EVM account on Base. You must confirm the source asset, exchange withdrawal network and output token with Canopy before accepting funds. The provider snippets compile against the versions below. Local fixture tests and browser checks passed; real Turnkey account lookup, hosted Canopy checkout and funded settlement remain unverified.
What you need to implement
This recipe assumes Turnkey authentication and wallet creation already work in your app. It adds exchange deposits to that existing flow. The backend appendix contains the shared types, Canopy request helper and checkout component. The webhook appendix contains the signature-verifying receiver.
Your application must supply the remaining adapters:
| Application code | Responsibility |
|---|---|
requireUser and getBinding | Validate the server session and load the account selected during verified onboarding. |
lookupAccount | Make the authenticated Turnkey account query with access to the correct organization. |
checksumAddress | Normalize an EVM address and reject the zero address. |
| Deposit store and API route | Persist reservations, serialize creation, enforce ownership and return a saved intent. |
| Webhook store, worker and status endpoint | Save verified deliveries, reconcile settlement once and expose only the signed-in user's record. |
These are application contracts. The appendices describe their behavior; they do not supply a production database or authentication system.
Versions checked
The reader-test application used @turnkey/sdk-server 8.6.0, @canopypay/checkout-sdk 0.7.2, viem 2.56.9 and standardwebhooks 1.1.1 with Next.js 16.3.6 and React 19.3.0. Its production build, 12 fixture tests, browser flow and restart-persistence check passed on Node 20.19.0. Keep your application's supported Node version consistent between installation and execution, and commit the resolved dependency lockfile.
The browser exercise used synthetic accounts and Canopy responses. It tested the application's deposit handling, not a real withdrawal. Use the Turnkey quickstart for the provider setup and Canopy quickstart for account credentials.
Choose the funding path
A direct withdrawal to the Turnkey account is appropriate when the exchange supports the exact token and network your application needs. Turnkey's embedded wallet quickstart includes sending and receiving funds. Its API also documents fiat onramp initiation and same-chain and cross-chain swap status.
Compare those options with your actual deposit requirement. LI.FI's Smart Deposit Addresses also support transfer-to-address funding with routing and asset conversion. That overlaps with this tutorial's user journey; a Turnkey application may choose to integrate it. Its existence does not establish that every Turnkey app already exposes that flow.
The Canopy path below manages deposit instructions and settlement reporting across its supported routes. It leaves your existing Turnkey authentication and subsequent transaction signing in place. See the funding guide for a comparison of direct transfers, bridges and routed deposits.
Prepare the destination and checkout
You need an existing authenticated Turnkey integration, a server database mapping application users to their Turnkey organizations and accounts, and a confirmed Canopy route. Start from the current Turnkey integration guide if wallet creation is still missing.
Configure the following server and browser values, using your existing secret manager:
CANOPY_SECRET_KEY=<server-secret> CANOPY_WEBHOOK_SECRET=<webhook-signing-secret> NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=<publishable-key> CANOPY_BASE_OUTPUT_TOKEN=<account-confirmed-token-address-or-native> Enable per-intent destinations on your Canopy account and confirm that Canopy has provisioned the route's confirmedPayoutMode as Dynamic. This is a payout configuration, unrelated to the company named Dynamic. Omitting the destination sends settlement to the merchant account's payout wallet. A successful intent response alone does not prove that Base delivery is configured. Canopy destination requirements.
Register the exact HTTPS origin of your frontend for embedded checkout. Add the hosted SDK to the app and retain its resolved version in your lockfile:
npm install @canopypay/checkout-sdk Use the shared backend guide for lib/deposits/canopy.ts, the persistence contracts and components/DepositCheckout.tsx. The next step supplies its Turnkey-specific destination resolver.
Resolve the Turnkey account on the server
Turnkey distinguishes a wallet from the accounts derived from it. Save the particular account selected for deposits, including its organization ID, wallet ID and wallet-account ID. Selecting the first wallet or first EVM address returned by a list call can silently change the destination when the user adds another account.
The documented Get wallet account request is a signed POST to https://api.turnkey.com/public/v1/query/get_wallet_account, with organizationId, walletId and an address or path selector. The returned account includes walletAccountId, organizationId, walletId, address and addressFormat. List wallet accounts can populate the initial selection.
Implement the following application adapter in lib/deposits/turnkey.ts. lookupAccount is your authenticated Turnkey lookup, not a new SDK method. Satisfy its contract using the documented query through your authorized server integration. Turnkey's server SDK constructs and authenticates API requests. Confirm that your credential can read the user's sub-organization; do not assume a parent credential can access every account.
// lib/deposits/turnkey.ts import "server-only"; import type { DepositOwner } from "./types"; type Binding = { userId: string; organizationId: string; walletId: string; walletAccountId: string; address: string; }; type Account = { organizationId: string; walletId: string; walletAccountId: string; address: string; addressFormat: string; }; type TurnkeyDepositAdapters = { // Validate the server session; reject missing or expired sessions. requireUser(request: Request): Promise<{ id: string }>; // Read the selected, previously verified account from your database. getBinding(userId: string): Promise<Binding | null>; // Authenticated get_wallet_account query; return response.account. lookupAccount(input: { organizationId: string; walletId: string; address: string; }): Promise<Account>; // Validate EVM address and return its checksum form; reject zero address. checksumAddress(address: string): string; }; export function makeTurnkeyDepositResolver(a: TurnkeyDepositAdapters) { return async function resolveDepositOwner( request: Request, ): Promise<DepositOwner> { const user = await a.requireUser(request); const binding = await a.getBinding(user.id); if (!binding || binding.userId !== user.id) { throw new Error("No approved Turnkey deposit account"); } const account = await a.lookupAccount({ organizationId: binding.organizationId, walletId: binding.walletId, address: binding.address, }); if ( account.organizationId !== binding.organizationId || account.walletId !== binding.walletId || account.walletAccountId !== binding.walletAccountId || account.address.toLowerCase() !== binding.address.toLowerCase() || account.addressFormat !== "ADDRESS_FORMAT_ETHEREUM" ) throw new Error("Turnkey account does not match the deposit binding"); const tokenAddress = process.env.CANOPY_BASE_OUTPUT_TOKEN; if (!tokenAddress) throw new Error("Base output token is not configured"); return { userId: user.id, providerWalletId: account.walletAccountId, destination: { wallet: a.checksumAddress(account.address), namespace: "eip155", chainReference: "8453", tokenAddress, }, }; }; } The database mapping must originate from your verified onboarding or account-selection flow. A client-supplied organization ID is no more trustworthy than a client-supplied address. If your integration has no authorized server lookup, establish a verified account mapping during onboarding before enabling deposits; do not weaken ownership checks to make this function pass.
For an app using a smart account, this resolver needs a different destination mapping. Funding the Turnkey signer address will not fund a separate smart-account address. This walkthrough uses the selected Turnkey EVM account directly.
Checkpoint: signing in as two different users resolves two different approved accounts. Changing an address in the browser request has no effect. The server pins Base independently of whichever network the frontend wallet currently displays.
Create and save the intent
Wire the resolver into the authenticated POST /api/deposits flow from the shared guide. Inside the store's destination lock, the Canopy handoff is:
// Inside your application-owned deposit creation operation. const owner = await resolveDepositOwner(request); // reserveOrResume is your durable store adapter, described in the shared guide. const pending = await reserveOrResume(owner); const intent = await createCanopyIntent({ reference: pending.reference, destination: owner.destination, }); await attachIntentToOwner(pending, owner, intent); These calls belong inside the full save-and-reuse operation, which first returns an existing saved active intent when available. reserveOrResume and attachIntentToOwner are application adapters, not Canopy functions. Save the intent ID, deposit inbox and immutable destination with the Turnkey account ID and user ID before returning an intent ID to the browser.
createCanopyIntent sends the server-only request to POST https://www.canopypay.io/api/v1/intents with bearer authentication, Content-Type: application/json and Canopy-Version: 2026-09-01. It maps the destination to payoutWallet, payoutNamespace, payoutChainReference and payoutTokenAddress. Canopy payment intents.
Canopy deduplicates active intents by merchant, namespace, chain, wallet and token. Repeating creation with matching terms returns the existing intent and rotates the widget token. Serialize creation across application instances and reuse your saved result. merchantReference provides correlation, not request idempotency. Keep an uncertain reservation after a timeout for reconciliation.
A 201 means the intent was created. It does not mean funds arrived. Also keep the Canopy deposit inbox separate from the Turnkey destination address in storage and UI.
Show the exchange withdrawal instructions
Mount DepositCheckout from the shared guide with the saved intentId. That component calls mount from @canopypay/checkout-sdk, sets canopyOrigin: "https://www.canopypay.io" and surface: "auto", and calls the returned handle's destroy() method when the view unmounts. Canopy embedding guide.
Place the destination wallet and "Base" above checkout so the user can see which account they are funding. Within checkout, they select a supported source asset and network, then copy the deposit address into their exchange withdrawal form. They must match the displayed network exactly. An EVM address's shape cannot distinguish Base from another EVM network.
Keep the flow pending after deposit_detected or paid. Those browser events can update the message to "Checking settlement" and request a refresh from your backend. Closing the tab must not cancel server reconciliation. Do not advertise the displayed inbox as a permanent universal deposit address.
Confirm settlement and refresh the balance
Install the handler from Crypto deposit webhook reconciliation. Verify the raw body with Standard Webhooks using webhook-id, webhook-timestamp and webhook-signature, then branch on the verified body's state === "settled". The event label is payment.settled; the payload is flat and has no event.type wrapper. Canopy webhooks.
Correlate the intent to the saved Turnkey account, record the settlement durably, and deduplicate both deliveries and the business operation. Keep netUnits and feeUnits as integer strings. The authenticated app can then refresh its own deposit status and the destination token balance on Base.
A wallet balance refresh and an internal ledger credit are separate operations. If this app only displays assets held in the user's wallet, record the funding history without inventing a second spendable balance. Any later transfer, swap or vault deposit uses your existing Turnkey authorization and signing flow.
Verify the integration
Before enabling withdrawals, run the following exercise with fixtures and then an explicitly approved funded route:
- Sign in as two users. Check the complete destination tuple and verify that each user can access only their own intent.
- Click Deposit twice and reload. Confirm that the app reuses the saved active intent without issuing concurrent create calls.
- Send an invalid-signature webhook and a valid settlement fixture twice. The invalid delivery changes nothing; the duplicate produces no second funding record or ledger credit.
- For the approved route, copy the source instructions into the exchange exactly, including network and minimum amount. Record the actual source token and output token; no route is assumed from this Base example.
- Close checkout after withdrawal. Confirm server processing continues, then reopen the app and compare its recorded settlement with the destination wallet's token balance.
If lookup fails, check the sub-organization binding and read permissions first. If checkout opens but creation rejects destinations, distinguish the account's disabled setting from a route that Canopy has not provisioned. no_intent_destination and ambiguous_intent_destination require investigation of active-intent lifecycle and routing. A Base delivery failure can also require Canopy to inspect egress configuration. Preserve the saved intent and support reference while investigating; repeatedly creating deposits obscures the original attempt.
Turnkey wallet funding questions
Can a user withdraw directly from an exchange to a Turnkey wallet?
Yes, when the exchange supports the destination account's exact network and token. That direct transfer does not need a Canopy intent. Use the routed deposit flow when its confirmed source and settlement options fit the funding experience your app needs.
Does funding a Turnkey signer fund a smart account?
Only if that signer address is the intended receiving account. A separate smart account has its own address and balance. Resolve and verify that address before choosing the payout destination.
What confirms that the deposit settled?
Your backend verifies Canopy's signed webhook and reconciles it with the saved intent. A browser paid event or successful intent creation is insufficient. Refresh the destination token balance after recording the verified settlement.
For the equivalent React flow with Para identity tokens, see Para wallet funding. The Canopy documentation covers checkout configuration and API details.
Canopy backend and checkout
Your app has created a wallet for a user. To add a deposit button, your backend needs to identify that wallet, create a Canopy payment intent with it as the payout destination, and return the intent ID to checkout. Once a deposit settles, a signed webhook updates your app's record.
This guide builds the common Canopy part of the provider tutorials. It assumes an existing Next.js application with server authentication and a database. The wallet provider supplies the destination address through an authenticated server integration. Your application supplies the session and database adapters described below.
The code is an integration pattern, not a standalone starter app. You must connect those adapters to your own authentication and persistence before running it.
The sequence is: authenticate the user, resolve the destination, create and save the intent, open checkout, then reconcile the signed settlement webhook.
Configure a route before accepting deposits
Create a Canopy account and obtain a server secret key and a frontend publishable key. Register your application's exact HTTPS origin for inline checkout. Keep the secret in a server environment variable:
CANOPY_SECRET_KEY=<server-secret> NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=<publishable-key> CANOPY_WEBHOOK_SECRET=<endpoint-signing-secret> To settle into a user's wallet, enable per-intent destinations on your account and have Canopy confirm that the intended route uses Dynamic payout mode. Canopy controls route provisioning. This mode has no connection to the wallet provider named Dynamic. A request that omits a destination uses the account's own payout wallet. Canopy payout destinations.
Pick one destination chain and token for the first integration. The examples below use Base, whose chain reference is 8453, and expect a configured token address from your server settings. Confirm that token, the source assets, minimum amounts and the complete route with Canopy. An address being valid on Base does not establish that a particular deposit route is available.
Record the provider wallet ID, destination address, namespace, chain reference and token alongside the user who owns the wallet. If you support both a signer account and a smart account, choose the account whose balance the product displays.
Resolve the destination on your server
The browser can request a deposit for its signed-in user. Your backend decides where that deposit goes. A wallet address submitted in a request body is not proof of ownership.
Each provider tutorial describes how to populate this application-owned result:
// lib/deposits/types.ts export type DepositDestination = { wallet: string; namespace: "eip155" | "solana"; chainReference: string; tokenAddress: string; }; export type DepositOwner = { userId: string; providerWalletId: string; destination: DepositDestination; }; Your resolveDepositOwner(request) adapter must verify the session or provider token, fetch the wallet through an authenticated provider lookup or a previously verified database mapping, and check that the user may fund it. Check the destination against your server's supported route configuration. Reject ambiguous wallet selections. For custodial or organization wallets, verify the application's tenant and account permissions too.
Keep a unique ownership constraint on the destination you use for individual wallets. A shared treasury address needs a separate attribution design; the same destination cannot safely stand in for several users in this walkthrough.
Create the Canopy intent
Add the following server helper. Its input comes from your verified wallet mapping and a persisted local deposit reference.
// lib/deposits/canopy.ts import "server-only"; import type { DepositDestination } from "./types"; export async function createCanopyIntent(input: { reference: string; destination: DepositDestination; }): Promise<{ intentId: string; inboxAddress: string; created: boolean; }> { const key = process.env.CANOPY_SECRET_KEY; if (!key) throw new Error("CANOPY_SECRET_KEY is missing"); if (!input.reference || input.reference.length > 128) { throw new Error("Invalid deposit reference"); } const { destination } = input; const response = await fetch("https://www.canopypay.io/api/v1/intents", { method: "POST", headers: { Authorization: `Bearer ${key}`, "Canopy-Version": "2026-09-01", "Content-Type": "application/json", }, body: JSON.stringify({ merchantReference: input.reference, payoutWallet: destination.wallet, payoutNamespace: destination.namespace, payoutChainReference: destination.chainReference, payoutTokenAddress: destination.tokenAddress, }), cache: "no-store", }); if (!response.ok) { // Record a redacted error code/request ID in your server logs. // Do not forward provider error bodies or credentials to the browser. throw new Error(`Canopy create failed with HTTP ${response.status}`); } const result: unknown = await response.json(); if ( !result || typeof result !== "object" || !("intentId" in result) || typeof result.intentId !== "string" || !("inboxAddress" in result) || typeof result.inboxAddress !== "string" || !("created" in result) || typeof result.created !== "boolean" ) throw new Error("Unexpected Canopy create response"); return { intentId: result.intentId, inboxAddress: result.inboxAddress, created: result.created, }; } The output token is pinned with payoutTokenAddress. Use a token configured for your account and route. Leaving it out uses the matching account currency or the configured chain default. The destination fields become fixed when the intent is created. Canopy payment intents.
Canopy deduplicates active intents by merchant account, namespace, chain, wallet and token. A repeat create with matching terms returns the existing intent and rotates its widget token. merchantReference is a correlation value; it does not deduplicate requests. There is no request idempotency key. These rules matter when a user double-clicks Deposit or reloads the page.
Save and reuse the intent
Wrap intent creation in a durable application operation. For example, an authenticated POST /api/deposits handler can use this flow:
// Application pseudocode: implement these adapters in your app. const owner = await resolveDepositOwner(request); const result = await depositStore.withDestinationLock(owner, async () => { const existing = await depositStore.findActive(owner); if (existing?.intentId) return existing; // Durable get-or-create: reuse any pending reservation and its reference. const pending = await depositStore.reserve(owner); const intent = await createCanopyIntent({ reference: pending.reference, destination: owner.destination, }); return depositStore.attachIntent(pending.id, intent); }); return Response.json( { intentId: result.intentId }, { headers: { "Cache-Control": "no-store" } }, ); depositStore and resolveDepositOwner are your application code, not Canopy SDK APIs. The store must persist the reservation before the external request, serialize operations across server instances, enforce ownership of existing records, and save the returned intent before checkout opens. An in-memory mutex cannot coordinate separate instances.
Give the store methods precise contracts. findActive returns a reusable record with an attached intentId, or null; an unfinished reservation cannot be returned to checkout. reserve is a durable get-or-create operation for the same owner and destination terms. It returns an existing pending reservation with its original reference when one exists. Enforce that uniqueness in the database, including when an earlier request timed out. attachIntent must complete that same reservation and reject a conflicting user or destination binding.
Retain a reservation after a timeout so you can reconcile the uncertain result using the same destination and terms. Do not create another local deposit reference on each retry. If a retry returns an intent already associated with a different user or operation, stop and investigate the mapping.
Use your framework's CSRF protection or validate the request origin for cookie-authenticated writes. Rate-limit intent creation per user. Return a controlled error when authentication fails, a wallet is unavailable or the route has not been configured. Avoid putting a stack trace in the response.
At this checkpoint, one authenticated user should have one saved active intent for the chosen destination. A second click should reuse it. Another user must not be able to retrieve it.
Mount checkout in React
Install the hosted SDK in your existing app:
Related posts
DeepSeek's Open-Source "Super-Agent" Codes Without Being Asked
All-in-one platform or a stack you assemble? Where do coaches and educators land?
I Wanted to Live on Coruscant. PAT Scared Me. Now My Alexa Lives on Mute.
Detect What You Can Name: PaliGemma 2 Object Detection with Transformers
When Should an LLM Router Stop Being Sticky? llm-d 1.0 Cache-Affinity Routing, Break-Even Math, and a Simulation
