<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Canopy]]></title><description><![CDATA[Canopy]]></description><link>https://canopypay.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Canopy</title><link>https://canopypay.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 09 Oct 2026 09:44:22 GMT</lastBuildDate><atom:link href="https://canopypay.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Add cross-chain deposits to Dfns wallets and reconcile settlement]]></title><description><![CDATA[Originally published on Canopy.
Your application already assigns Dfns wallets to customers and detects incoming transfers. A customer now wants to deposit an asset on a network different from the one ]]></description><link>https://canopypay.hashnode.dev/add-cross-chain-deposits-to-dfns-wallets-and-reconcile-settlement</link><guid isPermaLink="true">https://canopypay.hashnode.dev/add-cross-chain-deposits-to-dfns-wallets-and-reconcile-settlement</guid><category><![CDATA[webdev]]></category><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Blockchain]]></category><dc:creator><![CDATA[jack]]></dc:creator><pubDate>Tue, 06 Oct 2026 15:40:39 GMT</pubDate><content:encoded><![CDATA[<p>Originally published on <a href="https://www.canopypay.io/articles/dfns-cross-chain-deposits">Canopy</a>.</p>
<p>Your application already assigns Dfns wallets to customers and detects incoming transfers. A customer now wants to deposit an asset on a network different from the one your product uses. You need to route that deposit into the correct customer wallet and keep the resulting settlement separate from other incoming transfers.</p>
<p>This guide adds Canopy's deposit checkout to an existing Dfns backend. It retrieves the customer's approved Base wallet, selects USDC on Base as the output, and records settlement against that wallet. Dfns continues to manage your wallets, signing permissions and transaction observations.</p>
<p>The code fits into an application with existing authentication and persistence. You supply the customer-to-wallet mapping and durable storage adapters described below. The verification section records the local checks and the live steps still needed.</p>
<h2>Keep Dfns deposit automation where it fits</h2>
<p>Dfns already documents incoming-deposit detection, duplicate handling, reconciliation and treasury sweeping. Its <a href="https://docs.dfns.co/solutions/automate-deposits">deposit automation guide</a> distinguishes an early included transfer from one that has passed the network's confirmation delay. If the customer can send the desired token directly to your wallet on the desired network, that flow may cover the entire task.</p>
<p>Use this Canopy path when you have selected a supported route that converts or delivers an incoming deposit to a different configured destination. The <a href="https://www.canopypay.io/guides/multi-network-deposits">funding guide</a> explains how this differs from direct transfers and connected-wallet bridges.</p>
<p>This tutorial uses a dedicated customer wallet. An omnibus treasury wallet needs a different attribution design. Dfns also offers vaults with incoming and quarantined holdings; a normal wallet balance and a vault's available balance should not be treated as interchangeable. <a href="https://docs.dfns.co/core-concepts/vaults-wallets-and-keys">Dfns wallet and vault model</a>.</p>
<h2>Prepare the two services</h2>
<p>Start with a Dfns organization, an existing Base wallet assigned to each customer, and a backend credential with <code>Wallets:Read</code> permission. <a href="https://docs.dfns.co/api-reference/wallets/get-wallet">Get Wallet</a> allows organization users, delegated users and service accounts. The application still needs to authorize the customer-to-wallet relationship; a service account's broad read access is not customer authorization.</p>
<p>Keep credentials in server environment variables. Add the Canopy values from the <a href="https://www.canopypay.io/articles/dfns-cross-chain-deposits#canopy-backend">shared backend guide</a>, plus your Dfns read credential:</p>
<pre><code class="language-dotenv">DFNS_AUTH_TOKEN=&lt;server-only-read-credential&gt;
CANOPY_SECRET_KEY=&lt;server-only-secret&gt;
CANOPY_WEBHOOK_SECRET=&lt;server-only-webhook-secret&gt;
NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=&lt;publishable-key&gt;
</code></pre>
<p>Enable per-intent destinations and have Canopy provision the route in <code>Dynamic</code> payout mode. Confirm Base delivery and the output token before exposing checkout. This payout mode has no relation to the Dynamic wallet company. Register the exact frontend HTTPS origin. <a href="https://www.canopypay.io/concepts/destinations">Canopy destinations</a>.</p>
<p>Install <code>viem@2.56.3</code> for EVM address validation and <code>@canopypay/checkout-sdk@0.7.2</code> for checkout. Retain the lockfile in your existing app. The Dfns lookup below uses the documented REST API directly, so it needs no Dfns SDK package.</p>
<p>The token contract and API origin are public configuration. Put them in <code>lib/deposits/config.ts</code>, separate from credentials:</p>
<pre><code class="language-ts">// lib/deposits/config.ts
// Default Europe region. Use https://api.uae.dfns.io for a UAE organization.
export const DFNS_API_ORIGIN = "https://api.dfns.io";
export const BASE_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
</code></pre>
<p>Circle lists this <a href="https://developers.circle.com/stablecoins/usdc-contract-addresses">USDC contract on Base</a>. Pinning it here selects the example's output asset; it does not establish that your Canopy account has that route configured. Confirm USDC on Base delivery, a supported source network and token, fees and the minimum deposit with Canopy before accepting funds.</p>
<p>Dfns organizations and wallets belong to a specific <a href="https://docs.dfns.co/api-reference/regions">API region</a>. Select that region in your server configuration. Read-only calls use a bearer token; state-changing operations have additional user-action signing requirements. This tutorial reads the destination wallet and does not execute a Dfns transfer. <a href="https://docs.dfns.co/api-reference/auth">Dfns authentication</a>.</p>
<h2>Retrieve the approved customer wallet</h2>
<p>Create <code>lib/deposits/dfns.ts</code> with the documented <code>GET /wallets/{walletId}</code> request. The function takes a wallet ID from your verified database mapping, never directly from the checkout request.</p>
<pre><code class="language-ts">// lib/deposits/dfns.ts
import "server-only";
import { getAddress, zeroAddress } from "viem";
import { DFNS_API_ORIGIN } from "./config";

type DfnsWallet = {
  id: string;
  network: string;
  address: string;
  status: string;
};

export async function getDfnsDepositWallet(walletId: string): Promise&lt;DfnsWallet&gt; {
  if (!walletId || walletId.length &gt; 64) throw new Error("Invalid Dfns wallet ID");
  const token = process.env.DFNS_AUTH_TOKEN;
  if (!token) throw new Error("DFNS_AUTH_TOKEN is missing");
  const response = await fetch(
    `${DFNS_API_ORIGIN}/wallets/${encodeURIComponent(walletId)}`,
    {
      headers: { Authorization: `Bearer ${token}` },
      cache: "no-store",
    },
  );
  if (!response.ok) throw new Error(`Dfns wallet lookup failed: ${response.status}`);
  const value: unknown = await response.json();
  if (!value || typeof value !== "object") throw new Error("Invalid Dfns wallet");
  for (const key of ["id", "network", "address", "status"] as const) {
    if (!(key in value) || typeof (value as Record&lt;string, unknown&gt;)[key] !== "string") {
      throw new Error("Incomplete Dfns wallet response");
    }
  }
  const wallet = value as DfnsWallet;
  if (wallet.id !== walletId || wallet.network !== "Base" || wallet.status !== "Active") {
    throw new Error("Wallet is not an active Base deposit destination");
  }
  const address = getAddress(wallet.address);
  if (address === zeroAddress) throw new Error("Invalid deposit address");
  return { id: wallet.id, network: wallet.network, status: wallet.status, address };
}
</code></pre>
<p>Now connect that request to your existing session and ownership model:</p>
<pre><code class="language-ts">// lib/deposits/resolve-dfns-owner.ts
import "server-only";
import { getAddress } from "viem";
import { BASE_USDC } from "./config";
import { getDfnsDepositWallet } from "./dfns";
import type { DepositOwner } from "./types";

// Application adapters: implement with your existing authentication and database.
type Adapters = {
  requireCustomer(request: Request): Promise&lt;{ userId: string; tenantId: string }&gt;;
  findApprovedWallet(customer: { userId: string; tenantId: string }): Promise&lt;{
    walletId: string;
    address: string;
  } | null&gt;;
};

export function makeDfnsOwnerResolver(a: Adapters) {
  return async (request: Request): Promise&lt;DepositOwner&gt; =&gt; {
    const customer = await a.requireCustomer(request);
    const binding = await a.findApprovedWallet(customer);
    if (!binding) throw new Error("Customer has no approved deposit wallet");
    const wallet = await getDfnsDepositWallet(binding.walletId);
    if (wallet.address !== getAddress(binding.address)) {
      throw new Error("Dfns wallet address differs from the approved binding");
    }
    return {
      userId: customer.userId,
      providerWalletId: wallet.id,
      destination: {
        wallet: wallet.address,
        namespace: "eip155",
        chainReference: "8453",
        tokenAddress: BASE_USDC,
      },
    };
  };
}
</code></pre>
<p><code>requireCustomer</code> verifies your application session and returns a globally unique application user ID and the authorized tenant. <code>findApprovedWallet</code> must filter by both values, reject ambiguous assignments, and return a wallet binding previously approved through your authenticated Dfns integration. If your database uses tenant-local user IDs, convert them to a globally unique owner ID before using the shared store. These two functions are application contracts, not Dfns SDK methods.</p>
<p>The reader validates the returned address with <code>viem</code>, rejects the zero address, and checks its checksum form against the approved binding. Dfns lookup failures stop intent creation. The browser request cannot select another wallet ID or payout address.</p>
<p>Checkpoint: a customer resolves to an active Dfns wallet whose network is <code>Base</code>. A BaseSepolia or Ethereum wallet fails this path even if its address has the same shape. A request attempting another tenant's wallet cannot alter the server mapping.</p>
<h2>Save a Canopy intent before opening checkout</h2>
<p>Use the shared guide's authenticated <code>POST /api/deposits</code> handler and durable destination lock. After it has checked for an existing active record, the provider-specific handoff is:</p>
<pre><code class="language-ts">// Inside the shared guide's serialized operation, after owner resolution:
const intent = await createCanopyIntent({
  reference: pending.reference, // existing durable reservation under the lock
  destination: owner.destination,
});
// Persist intent + owner + reservation before returning intent.intentId.
</code></pre>
<p>Create <code>resolveDepositOwner</code> with <code>makeDfnsOwnerResolver({ requireCustomer, findApprovedWallet })</code>. The shared handler calls it before taking its destination lock. <code>owner</code> is that result, and <code>pending</code> is the saved reservation inside the lock. <code>createCanopyIntent</code> is the shared server helper. It posts to <code>https://www.canopypay.io/api/v1/intents</code> with the secret bearer key, JSON content type and <code>Canopy-Version: 2026-09-01</code>. It supplies all destination fields, including the Base USDC contract as <code>payoutTokenAddress</code>.</p>
<p>An omitted destination uses the merchant's payout wallet. The destination becomes fixed at creation. Canopy deduplicates active intents by merchant, namespace, chain, wallet and token; a repeated matching create rotates the widget token. Save and reuse the existing intent, serialize concurrent requests and retain uncertain reservations after timeouts. <code>merchantReference</code> is for correlation, and there is no request <code>Idempotency-Key</code>. <a href="https://www.canopypay.io/concepts/payment-intents">Payment intents</a>.</p>
<p>Keep the Dfns wallet ID alongside the Canopy intent ID. The Canopy inbox is the address the customer funds; the Dfns address is the destination. A <code>201</code> response establishes creation, not completed delivery.</p>
<h2>Add checkout to the customer funding page</h2>
<p>Use <code>components/DepositCheckout.tsx</code> from the shared guide in your existing funding page. Request the saved intent ID from your authenticated <code>POST /api/deposits</code> endpoint and pass it as <code>&lt;DepositCheckout intentId={intentId} /&gt;</code>. The component mounts with your publishable key, <code>canopyOrigin: "https://www.canopypay.io"</code> and <code>surface: "auto"</code>; its cleanup calls <code>handle.destroy()</code>.</p>
<p>Use your actual <code>cnpy_pk_live_</code> publishable key. A secret key does not belong in checkout. Key the parent funding screen by the authenticated user ID, clear its intent on logout, and discard responses from an earlier session. A user switch must destroy the old checkout before another user's funding flow opens.</p>
<p>Show the destination wallet label and Base network outside checkout. Let checkout supply the supported source assets, networks and withdrawal instructions. A user funding from an exchange must choose exactly the network shown for the selected source asset. Do not present the inbox as a permanent universal address.</p>
<p>Treat <code>paid</code> and <code>deposit_detected</code> as pending UI notifications. Refresh your own backend record when they fire, and continue server processing when the customer closes the page. <a href="https://www.canopypay.io/sdk/events">Canopy SDK events</a>.</p>
<h2>Reconcile two different event streams</h2>
<p>Dfns documents <code>wallet.blockchain_event.transfer.included</code> for early detection on supported networks, with <code>status: "Included"</code>. Its <code>wallet.blockchainevent.detected</code> notification follows the network confirmation delay with <code>status: "Confirmed"</code>. An included transfer can still be affected by a reorganization. <a href="https://docs.dfns.co/solutions/automate-deposits">Dfns deposit automation</a>.</p>
<p>Canopy's signed <code>payment.settled</code> confirms the Canopy operation. Verify its raw body using Standard Webhooks and branch on <code>state === "settled"</code>; the body is flat, without an event-type wrapper. Match the saved intent and destination configuration before updating your records. Use the <a href="https://www.canopypay.io/articles/dfns-cross-chain-deposits#settlement-handler">webhook reconciliation implementation</a>.</p>
<p>Keep Dfns's webhook receiver separate and use its <a href="https://docs.dfns.co/guides/developers/webhooks">documented signature verification</a>. The Canopy Standard Webhooks handler below does not verify Dfns deliveries. This guide does not implement or test a Dfns event receiver.</p>
<p>Keep the records distinct:</p>
<table>
<thead>
<tr>
<th>Record</th>
<th>What it establishes</th>
<th>Application action</th>
</tr>
</thead>
<tbody><tr>
<td>Canopy settled operation</td>
<td>The routed deposit's settlement result</td>
<td>Record the operation once under its saved customer and wallet binding</td>
</tr>
<tr>
<td>Dfns included transfer</td>
<td>A destination-wallet transfer is observed in a block</td>
<td>Show pending destination activity where supported</td>
</tr>
<tr>
<td>Dfns confirmed transfer</td>
<td>The wallet transfer passed Dfns confirmation requirements</td>
<td>Reconcile destination receipt and any permitted downstream sweep</td>
</tr>
</tbody></table>
<p>Do not credit once for each stream. Correlate destination receipts using the actual chain, token, recipient and transaction identity available for the route. Do not assume Canopy's <code>txHash</code> always identifies the final egress transfer on a different chain; verify the route's transaction semantics first. Quarantine unmatched events for investigation instead of matching on amount alone.</p>
<p>After settlement, refresh assets through Dfns's documented <a href="https://docs.dfns.co/api-reference/wallets/get-wallet-assets">Get Wallet Assets</a> endpoint, <code>GET /wallets/{walletId}/assets</code>. Preserve integer token units. Wallet-balance display, internal customer credit and a treasury sweep have separate effects. A sweep needs its own Dfns authorization and signing policy.</p>
<h2>Exercise the failure paths</h2>
<p>Use two customers in separate tenants. Confirm wallet resolution rejects cross-tenant access and the wrong network. Open checkout twice and verify that both views reference the saved intent. A failed Dfns lookup should stop intent creation.</p>
<p>With Canopy signed fixtures, repeat each delivery, replay the same settlement under a new delivery ID, and include an invalid signature. The result should be one routed-deposit record. Simulate a database failure before webhook persistence and confirm the endpoint does not acknowledge success. Separately exercise your existing Dfns receiver with its own verified events, including out-of-order included and confirmed notifications.</p>
<p>For a separately approved funded test, record the exact source network/token, withdrawal amount, output token and destination wallet. Close the browser during processing. Compare the saved Canopy settlement with the confirmed Dfns receipt and wallet assets after reopening the app.</p>
<p>If Canopy reports disabled per-intent destinations, fix the merchant setting. If the route rejects dynamic destinations or lacks Base egress configuration, resolve it with Canopy. Investigate <code>no_intent_destination</code> and <code>ambiguous_intent_destination</code> against the saved active-intent records. If a Dfns event is missing, use wallet history reconciliation before treating the deposit as lost.</p>
<h2>What has been checked</h2>
<p>The provider snippets and linked backend, React checkout and Canopy webhook snippets type-check with TypeScript 5.9.3, <code>viem</code> 2.56.3, React 19.2.8, <code>@canopypay/checkout-sdk</code> 0.7.2 and <code>standardwebhooks</code> 1.1.1. Local fixtures passed 22 tests covering the Dfns response checks, tenant-bound wallet selection, intent reuse, failure recovery and signed Canopy webhook replay handling. The fixture intercepts every provider request and uses in-memory stores.</p>
<p>These checks used existing installed packages on Node 22.23.2. A clean dependency installation, live Dfns lookup, browser checkout, production database locking, Dfns event verification and a funded route remain unverified. Complete those checks in your approved integration environment before exposing deposits to customers.</p>
<h2>Canopy backend and checkout</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>The sequence is: authenticate the user, resolve the destination, create and save the intent, open checkout, then reconcile the signed settlement webhook.</p>
<h3>Configure a route before accepting deposits</h3>
<p>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:</p>
<pre><code class="language-dotenv">CANOPY_SECRET_KEY=&lt;server-secret&gt;
NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=&lt;publishable-key&gt;
CANOPY_WEBHOOK_SECRET=&lt;endpoint-signing-secret&gt;
</code></pre>
<p>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. <a href="https://www.canopypay.io/concepts/destinations">Canopy payout destinations</a>.</p>
<p>Pick one destination chain and token for the first integration. The examples below use Base, whose chain reference is <code>8453</code>, 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.</p>
<p>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.</p>
<h3>Resolve the destination on your server</h3>
<p>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.</p>
<p>Each provider tutorial describes how to populate this application-owned result:</p>
<pre><code class="language-ts">// 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;
};
</code></pre>
<p>Your <code>resolveDepositOwner(request)</code> 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.</p>
<p>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.</p>
<h3>Create the Canopy intent</h3>
<p>Add the following server helper. Its input comes from your verified wallet mapping and a persisted local deposit reference.</p>
<pre><code class="language-ts">// lib/deposits/canopy.ts
import "server-only";
import type { DepositDestination } from "./types";

export async function createCanopyIntent(input: {
  reference: string;
  destination: DepositDestination;
}): Promise&lt;{
  intentId: string;
  inboxAddress: string;
  created: boolean;
}&gt; {
  const key = process.env.CANOPY_SECRET_KEY;
  if (!key) throw new Error("CANOPY_SECRET_KEY is missing");
  if (!input.reference || input.reference.length &gt; 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,
  };
}
</code></pre>
<p>The output token is pinned with <code>payoutTokenAddress</code>. 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. <a href="https://www.canopypay.io/concepts/payment-intents">Canopy payment intents</a>.</p>
<p>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. <code>merchantReference</code> 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.</p>
<h3>Save and reuse the intent</h3>
<p>Wrap intent creation in a durable application operation. For example, an authenticated <code>POST /api/deposits</code> handler can use this flow:</p>
<pre><code class="language-ts">// Application pseudocode: implement these adapters in your app.
const owner = await resolveDepositOwner(request);
const result = await depositStore.withDestinationLock(owner, async () =&gt; {
  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" } },
);
</code></pre>
<p><code>depositStore</code> and <code>resolveDepositOwner</code> 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.</p>
<p>Give the store methods precise contracts. <code>findActive</code> returns a reusable record with an attached <code>intentId</code>, or <code>null</code>; an unfinished reservation cannot be returned to checkout. <code>reserve</code> 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. <code>attachIntent</code> must complete that same reservation and reject a conflicting user or destination binding.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h3>Mount checkout in React</h3>
<p>Install the hosted SDK in your existing app:</p>
<pre><code class="language-sh">npm install @canopypay/checkout-sdk
</code></pre>
<p>This component receives the intent ID returned by your endpoint. It mounts the hosted checkout after the container exists and destroys the instance when that view leaves the page.</p>
<pre><code class="language-tsx">// components/DepositCheckout.tsx
"use client";

import { useEffect, useId, useState } from "react";
import { mount } from "@canopypay/checkout-sdk";

export function DepositCheckout({ intentId }: { intentId: string }) {
  const id = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const [message, setMessage] = useState("");

  useEffect(() =&gt; {
    const merchant = process.env.NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY;
    if (!merchant) {
      setMessage("Deposit checkout is not configured.");
      return;
    }
    const handle = mount({
      target: `#deposit-${id}`,
      intentId,
      merchant,
      canopyOrigin: "https://www.canopypay.io",
      surface: "auto",
      onEvent(event) {
        if (event.type === "error") {
          setMessage("Checkout could not open. Please try again.");
        }
        if (event.type === "paid" || event.type === "deposit_detected") {
          setMessage("Deposit received. Checking settlement.");
        }
      },
    });
    return () =&gt; handle.destroy();
  }, [intentId, id]);

  return (
    &lt;section aria-label="Fund your wallet"&gt;
      &lt;div id={`deposit-${id}`} /&gt;
      &lt;p role="status"&gt;{message}&lt;/p&gt;
    &lt;/section&gt;
  );
}
</code></pre>
<p>The SDK's <code>surface: "auto"</code> attempts inline checkout and offers a popup fallback if the frame cannot complete its handshake. The npm build needs <code>canopyOrigin</code>. See the <a href="https://www.canopypay.io/guides/embedding">embedding guide</a> and <a href="https://www.canopypay.io/sdk/events">SDK events</a>.</p>
<p>Reset the parent funding screen when the authenticated user changes. In React, key that screen by your application user ID and clear any pending request result on logout. The component's cleanup then destroys the old checkout. An asynchronous response from a previous session must not reopen that user's intent in the next session.</p>
<p>Let checkout show the supported source networks, asset and deposit instructions. A user withdrawing from an exchange must select the exact network and asset shown. An EVM-shaped address alone cannot tell them which withdrawal network to choose.</p>
<h3>Confirm settlement and refresh the wallet</h3>
<p>Connect the <a href="https://www.canopypay.io/articles/dfns-cross-chain-deposits#settlement-handler">webhook reconciliation handler</a> before enabling deposits. Your backend should store the verified settlement against the saved intent, and an authenticated status endpoint should expose only that user's local record. The UI can poll that endpoint or subscribe to your application's updates.</p>
<p>When settlement is confirmed, refresh the provider wallet's balance on the destination chain. A deposit into a user's wallet does not authorize a swap or deposit into a vault. Those actions require their own transaction flow. If your app has an internal balance ledger, define separately whether any credit is appropriate; do not count both wallet ownership and an application liability as the same deposit twice.</p>
<h3>Check the complete flow</h3>
<p>Start with two test users. Verify that each resolves to their own wallet and that altering a browser-submitted address cannot change the backend destination. Open the deposit view twice and confirm it reuses the saved intent. Close and reopen checkout, then confirm the same record remains associated with the user.</p>
<p>Exercise the webhook with a correctly signed fixture, a duplicate delivery, an invalid signature and a simulated database failure. The first valid settlement should create one record; the duplicate should have no additional effect. A database failure before durable receipt must cause a non-2xx response so delivery can be retried.</p>
<p>Before production, verify a funded route in an approved integration environment and record the actual source asset, destination token, fees, transaction hash and resulting wallet balance. Keep this record with your pinned SDK versions. The examples in this article have not been run as a funded end-to-end integration.</p>
<h2>Signed webhook reconciliation</h2>
<p>A user closes the deposit window before settlement finishes. Your backend still needs to record the payment. Another user leaves the window open and receives a browser success event twice. Neither browser session should decide whether funds arrived.</p>
<p>Canopy sends settlement outcomes to your registered server endpoint. This tutorial adds a receiver that verifies the original request body, saves each delivery once and leaves a durable job for reconciliation. It fits the <a href="https://www.canopypay.io/articles/dfns-cross-chain-deposits#canopy-backend">embedded wallet deposit backend</a> and works independently of the wallet provider.</p>
<p>The example assumes Next.js route handlers and a transactional database. The persistence interface is application code that you must implement; the article defines its required behavior. The endpoint records evidence and schedules work. Wallet balance refresh and any internal ledger credit happen in the worker.</p>
<h3>Understand the event body</h3>
<p>Canopy's public webhook contract uses a flat JSON object:</p>
<pre><code class="language-ts">type SettlementPayload = {
  intentId: string | null;
  inboxAddress: string;
  created: false;
  state: string;
  feeUnits: string;
  netUnits: string;
  txHash: string;
  chainId: number;
  merchantReference: string | null;
};
</code></pre>
<p>The documentation calls the success event <code>payment.settled</code>. Its body discriminator is <code>state: "settled"</code>; there is no outer <code>event.type</code> or <code>data</code> wrapper. <code>payment.failed</code> is documented but is not currently emitted. A handler should still refuse to fund on any state other than <code>settled</code>. Delivery order is not guaranteed. <a href="https://www.canopypay.io/webhooks">Canopy webhook contract</a>.</p>
<p>The minimal body does not identify a token contract or decimals. Keep the configured destination asset with the original intent and reconcile the amount's meaning for your route before doing ledger arithmetic. A transaction hash also needs its chain context. Do not assume the top-level hash is the final payout transaction on every route.</p>
<h3>Keep the raw request body</h3>
<p>Install the package used by Canopy's documentation:</p>
<pre><code class="language-sh">npm install standardwebhooks
</code></pre>
<p>Configure the endpoint's signing secret in <code>CANOPY_WEBHOOK_SECRET</code> on your server. A publishable key or Canopy API secret is not the webhook signing secret.</p>
<p>Standard Webhooks signs the payload together with the message ID and timestamp. Parsing JSON and serializing it again can change the bytes and break verification. Read the body as text once and pass that string directly to the verifier. <a href="https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md">Standard Webhooks specification</a>.</p>
<p>The request must include <code>webhook-id</code>, <code>webhook-timestamp</code> and <code>webhook-signature</code>. The official JavaScript verifier checks both the signature and timestamp tolerance. Keep the host clock synchronized. <a href="https://github.com/standard-webhooks/standard-webhooks/blob/main/libraries/javascript/src/index.ts">JavaScript verifier source</a>.</p>
<h3>Receive and store the delivery</h3>
<p>Create a persistence adapter with this contract:</p>
<pre><code class="language-ts">// lib/deposits/webhook-store.ts
export interface WebhookStore {
  recordAndEnqueue(input: {
    endpointKey: string;
    eventId: string;
    rawBody: string;
    payload: unknown;
  }): Promise&lt;void&gt;;
}
</code></pre>
<p><code>recordAndEnqueue</code> must atomically insert a delivery and create a pending reconciliation job. A unique database constraint on <code>(endpointKey, eventId)</code> makes retries a no-op. It must resolve only after the transaction commits. If the same ID arrives with a different body, preserve the original and raise an operational alert instead of overwriting it. <code>endpointKey</code> is your stable local endpoint identifier, especially useful when several Canopy accounts share one service.</p>
<p>Export your implementation as <code>webhookStore</code> and use it from the route:</p>
<pre><code class="language-ts">// app/api/canopy/webhook/route.ts
import { Webhook } from "standardwebhooks";
import { webhookStore } from "@/lib/deposits/webhook-store";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const secret = process.env.CANOPY_WEBHOOK_SECRET;
  if (!secret) return new Response("Endpoint unavailable", { status: 503 });

  const eventId = request.headers.get("webhook-id");
  const timestamp = request.headers.get("webhook-timestamp");
  const signature = request.headers.get("webhook-signature");
  if (!eventId || !timestamp || !signature) {
    return new Response("Missing signature headers", { status: 400 });
  }

  // Also configure a request body size limit at your ingress.
  const rawBody = await request.text();
  let payload: unknown;
  try {
    payload = new Webhook(secret).verify(rawBody, {
      "webhook-id": eventId,
      "webhook-timestamp": timestamp,
      "webhook-signature": signature,
    });
  } catch {
    return new Response("Invalid webhook", { status: 400 });
  }

  try {
    await webhookStore.recordAndEnqueue({
      endpointKey: "canopy-primary",
      eventId,
      rawBody,
      payload,
    });
  } catch {
    return new Response("Receipt unavailable", { status: 503 });
  }
  return new Response(null, { status: 204 });
}
</code></pre>
<p>A <code>204</code> now means the event has been saved and queued. It does not mean the worker has updated the user interface. Canopy retries non-2xx responses and timeouts, so acknowledging before the durable write would leave a gap if the process crashes. <a href="https://www.canopypay.io/webhooks">Canopy delivery behavior</a>.</p>
<p>Register this HTTPS endpoint in Canopy and save its signing secret. Use a separate secret and local endpoint identifier for each environment.</p>
<h3>Reconcile against the original intent</h3>
<p>The worker starts with a verified but otherwise untrusted shape: a valid signature identifies the sender, while schema and business checks decide what the payload means to your app. Validate string fields, nullable identifiers, the state discriminator and integer amount strings before using them.</p>
<p>Find the local record using <code>intentId</code> within the Canopy account associated with the endpoint. Compare the inbox and merchant reference with the saved record where available. If the intent is null, unknown, associated with another account, or its reference conflicts, mark the job for reconciliation. Do not guess the user from an address sent by a browser.</p>
<p>For a settled event, save its chain and transaction references with the intent. Then refresh or schedule a refresh of the user's wallet balance using the destination chain's provider or RPC. Indexing can lag, so a stale balance read should leave a visible pending refresh instead of causing another credit.</p>
<p>For non-settled states, save the outcome and stop before any crediting step. Because events can arrive out of order, a later delivery with an older state must not overwrite a settlement you have already confirmed.</p>
<h3>Deduplicate deliveries and business effects separately</h3>
<p>The delivery ID solves retries of one webhook. Your business record needs its own uniqueness rule because the same deposit could reach your system through an operator replay, another endpoint or a later reconciliation job.</p>
<p>For a one-time purchase, a unique fulfillment row for the local purchase can stop duplicate fulfillment. A repeatable wallet funding intent can receive more than one deposit, so making <code>intentId</code> globally unique in a credit table would also discard legitimate later deposits.</p>
<p>Resolve a canonical settlement operation from your supported route's evidence. That may require a chain transaction and transfer/log identity, the asset and recipient, or another documented unique settlement identifier. Confirm its granularity before choosing a database key: a transaction can contain several transfers. If the public webhook fields do not distinguish the operations your ledger needs, leave the event pending and obtain the missing evidence through your supported reconciliation process.</p>
<p>Once resolved, apply the effect in one database transaction:</p>
<pre><code class="language-text">begin transaction
  lock the local deposit record
  insert the canonical settlement operation under a unique constraint
  if it already exists: check it agrees with the saved evidence and stop
  record the confirmed outcome
  if this product has an internal ledger:
    write balanced ledger entries for the verified asset and amount
  mark the reconciliation job complete
commit
</code></pre>
<p>This is application logic, not an extra Canopy API operation. Use integer base units and an asset identifier that includes the chain. JavaScript floating-point numbers are unsuitable for token balances. Do not add <code>netUnits</code> from every full-state delivery without establishing whether it is an incremental amount for your route.</p>
<p>If the money lands in a self-custodial user wallet, your product may only need a settlement record and balance refresh. Crediting a separate spendable internal balance would create another obligation. Make that product decision explicitly before writing the ledger branch.</p>
<h3>Test the failure cases</h3>
<p>Use the verifier package to sign a synthetic fixture with a test secret. Keep test deliveries out of production accounting. Your first test should prove the body survives your HTTP framework unchanged.</p>
<table>
<thead>
<tr>
<th>Exercise</th>
<th>Expected result</th>
</tr>
</thead>
<tbody><tr>
<td>Correctly signed settlement for a saved intent</td>
<td>One delivery and one reconciliation job</td>
</tr>
<tr>
<td>Same delivery sent twice</td>
<td>One stored delivery; no second business effect</td>
</tr>
<tr>
<td>Payload changed after signing</td>
<td>HTTP 400; no queued job</td>
</tr>
<tr>
<td>Missing or stale timestamp</td>
<td>Rejected by header or signature checks</td>
</tr>
<tr>
<td>Database unavailable during receipt</td>
<td>Non-2xx response; no acknowledgement of durability</td>
</tr>
<tr>
<td>Process exits after receipt commits</td>
<td>Pending job survives and resumes</td>
</tr>
<tr>
<td>Valid event with an unknown intent</td>
<td>Durable receipt, pending investigation, no credit</td>
</tr>
<tr>
<td>Two legitimate deposits on a repeatable intent</td>
<td>Both reconciled using distinct operation identities</td>
</tr>
<tr>
<td>Duplicate business operation with a different delivery ID</td>
<td>One business effect</td>
</tr>
<tr>
<td>Non-settled or out-of-order outcome</td>
<td>No new credit and no downgrade of confirmed settlement</td>
</tr>
</tbody></table>
<p>Run a funded integration exercise only after the route and environment are approved. Compare the signed event, destination transaction and resulting wallet balance. The code here has not been tested against a funded Canopy deposit.</p>
<p>During signing-secret rotation, deploy verification with the new secret before revoking the retiring one. Canopy can sign with both during the transition. Keep receipt failures and pending reconciliation jobs visible to the team operating deposits, along with enough redacted context to find the associated intent.</p>
]]></content:encoded></item><item><title><![CDATA[Para wallet funding: add crypto deposits in React]]></title><description><![CDATA[Originally published on Canopy.
Your user has signed in and Para has created an embedded wallet. They still need a way to move crypto from an exchange or another wallet into it. This guide adds a Depo]]></description><link>https://canopypay.hashnode.dev/para-wallet-funding-add-crypto-deposits-in-react</link><guid isPermaLink="true">https://canopypay.hashnode.dev/para-wallet-funding-add-crypto-deposits-in-react</guid><dc:creator><![CDATA[jack]]></dc:creator><pubDate>Sat, 26 Sep 2026 17:11:56 GMT</pubDate><content:encoded><![CDATA[<p>Originally published on <a href="https://www.canopypay.io/articles/para-wallet-crypto-deposits-react">Canopy</a>.</p>
<p>Your user has signed in and Para has created an embedded wallet. They still need a way to move crypto from an exchange or another wallet into it. This guide adds a Deposit button that opens Canopy checkout and sends settlement to that user's verified EVM wallet on Base.</p>
<p>The receiving wallet stays with Para, formerly Capsule. Your server binds a Canopy intent to the authenticated user; Canopy provides the deposit instructions and reports settlement to your webhook. The source asset and network must be part of your configured Canopy route.</p>
<p>This is an integration recipe for an existing React/Next.js app. It includes the Para-specific code and uses the <a href="https://www.canopypay.io/articles/para-wallet-crypto-deposits-react#canopy-backend">shared Canopy backend</a> for persistence and checkout. The application adapters need your authentication and database implementation. The provider snippets compile against the versions below. Local fixture tests and browser checks passed; real Para login, hosted Canopy checkout and funded settlement remain unverified.</p>
<h2>What you need to implement</h2>
<p>The <a href="https://www.canopypay.io/articles/para-wallet-crypto-deposits-react#canopy-backend">backend appendix</a> includes the shared types, Canopy intent helper and checkout component. The <a href="https://www.canopypay.io/articles/para-wallet-crypto-deposits-react#settlement-handler">webhook appendix</a> includes the signature-verifying receiver. Your application must supply:</p>
<table>
<thead>
<tr>
<th>Application code</th>
<th>Responsibility</th>
</tr>
</thead>
<tbody><tr>
<td>Para provider and server session</td>
<td>Complete wallet onboarding and bind the authenticated application user to a verified Para identity.</td>
</tr>
<tr>
<td>Deposit API route</td>
<td>Validate the session and bearer token, select the attested wallet and enforce request-origin checks and rate limits.</td>
</tr>
<tr>
<td>Durable deposit store</td>
<td>Reserve and reuse the intent, serialize creation and prevent conflicting ownership.</td>
</tr>
<tr>
<td>Webhook store and reconciliation worker</td>
<td>Save deliveries atomically and deduplicate each confirmed business operation.</td>
</tr>
<tr>
<td>Status endpoint and balance refresh</td>
<td>Expose only the user's saved result, then refresh the configured destination token.</td>
</tr>
</tbody></table>
<p>The store and session interfaces require implementation in your existing app. They are not additional Para or Canopy SDK methods.</p>
<h2>Versions checked and dependency setup</h2>
<p>The reader-test application compiled the unchanged Para snippets with <code>@getpara/react-sdk</code> 3.20.0, <code>@canopypay/checkout-sdk</code> 0.7.2, <code>jose</code> 6.2.12, <code>viem</code> 2.56.9 and <code>standardwebhooks</code> 1.1.1. It used Next.js 16.3.6, React 19.3.0 and Node 26.8.1. Its production build and 14 fixture cases passed, and the browser exercise confirmed signed-settlement handling and replay deduplication.</p>
<p>For a new app, follow Para's <a href="https://docs.getpara.com/v3/react/quickstart">React quickstart</a> and <a href="https://docs.getpara.com/v3/react/troubleshooting/nextjs">Next.js troubleshooting guide</a>, including the provider wrapper and peer dependencies. In our test scaffold, the full SDK bundle also needed <code>wagmi</code> 2.19.5, <code>ox</code> 0.8.9, <code>ethers</code> 6.17.0 and <code>@x402/core</code>, <code>@x402/evm</code> and <code>@x402/svm</code> 2.27.0 to resolve build errors. These versions describe that tested scaffold, not a minimum dependency list for every Para app. Optional-module warnings and dependency audit findings still require review before deploying that scaffold.</p>
<p>Pin the dependency set that builds in your application and retain its lockfile. Use the same Node runtime when installing and running native dependencies. The local browser exercise substituted Para and Canopy responses; it did not establish live provider compatibility or a funded route.</p>
<h2>Check the funding path and prerequisites</h2>
<p>Para already has a Buy/Withdraw flow in its modal. Its <code>useInitiateFiatRamp</code> hook can launch that flow from your own UI, with providers and supported assets configured in the Developer Portal. For buying crypto with fiat, evaluate that existing path first. Para also publishes Relay and Squid examples. Choose between those options and manual crypto deposits according to the user's source of funds; the <a href="https://www.canopypay.io/guides/multi-network-deposits">funding guide</a> explains the tradeoffs. <a href="https://docs.getpara.com/v3/react/guides/customization/fiat-ramps">Para fiat ramps</a>, <a href="https://docs.getpara.com/llms.txt">Para example index</a>.</p>
<p>Start with the following in place:</p>
<ul>
<li>A working Para React integration under <code>ParaProvider</code>, with an authenticated embedded EVM wallet. Keep all <code>@getpara/*</code> packages on the same pinned release. Use the <a href="https://docs.getpara.com/v3/react/quickstart">React quickstart</a> if wallet creation is unfinished.</li>
<li>A server session that maps your application user to a verified Para user ID. Guest wallets are outside this example.</li>
<li>A Canopy secret key on the server, a publishable key in the browser and the exact HTTPS checkout origin registered for embedding.</li>
<li>Canopy per-intent destinations enabled, plus a route that Canopy has provisioned with <code>confirmedPayoutMode: Dynamic</code>. This route mode is unrelated to the wallet company Dynamic.</li>
<li>A confirmed Base output token and supported source asset/network. Store the output token in server configuration. Base's <code>eip155:8453</code> destination identifier alone does not establish route availability.</li>
</ul>
<p>Without the destination fields, Canopy settles to the merchant account's payout wallet. An accepted create request also does not guarantee later delivery. Confirm these details before showing a deposit address to users. <a href="https://www.canopypay.io/concepts/destinations">Canopy payout destinations</a>.</p>
<p>Configure these values in the appropriate environment. <code>PARA_APP_KEY_ID</code> is the audience ID for the API key, not the API key itself. Keep secrets on the server.</p>
<pre><code class="language-dotenv"># Browser: pass this to your existing ParaProvider configuration.
NEXT_PUBLIC_PARA_API_KEY=&lt;para-api-key&gt;
NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=&lt;canopy-publishable-key&gt;
# Server only
PARA_APP_KEY_ID=&lt;para-api-key-audience-id&gt;
CANOPY_SECRET_KEY=&lt;canopy-server-secret&gt;
CANOPY_WEBHOOK_SECRET=&lt;webhook-signing-secret&gt;
CANOPY_BASE_OUTPUT_TOKEN=&lt;account-confirmed-token-address-or-native&gt;
</code></pre>
<p>The resolver below selects the production JWKS URL. If your Para app uses BETA, replace it with the documented BETA URL before testing; keep the provider and verifier in the same environment.</p>
<h2>Select an embedded EVM wallet</h2>
<p>Para's <code>useAccount()</code> distinguishes <code>embedded.wallets</code> from connected external wallets. Its top-level <code>isConnected</code> can mean either kind of wallet is connected, so check the embedded account explicitly. <a href="https://docs.getpara.com/v3/react/guides/hooks/use-account">useAccount reference</a>.</p>
<p>Add <code>components/ParaDepositButton.tsx</code>. This version requires exactly one embedded EVM wallet; an app with several should add a wallet selector and keep the same server ownership checks.</p>
<pre><code class="language-tsx">"use client";

import { useState } from "react";
import { useAccount, useIssueJwt } from "@getpara/react-sdk";
import { DepositCheckout } from "./DepositCheckout";

export function ParaDepositButton() {
  const account = useAccount();
  const { issueJwtAsync } = useIssueJwt();
  const [intentId, setIntentId] = useState&lt;string | null&gt;(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");
  const wallets =
    account.embedded.wallets?.filter((w) =&gt; w.type === "EVM") ?? [];
  const wallet = wallets.length === 1 ? wallets[0] : undefined;

  async function startDeposit() {
    if (!wallet) return;
    setBusy(true);
    setError("");
    try {
      const { token } = await issueJwtAsync();
      const response = await fetch("/api/deposits/para", {
        method: "POST",
        credentials: "same-origin",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ walletId: wallet.id }),
      });
      if (!response.ok) throw new Error("Could not prepare the deposit.");
      const data = await response.json();
      if (typeof data.intentId !== "string")
        throw new Error("Invalid response.");
      setIntentId(data.intentId);
    } catch {
      setError(
        "Could not prepare the deposit. Check your session and try again.",
      );
    } finally {
      setBusy(false);
    }
  }

  if (account.isLoading) return &lt;p&gt;Loading your wallet...&lt;/p&gt;;
  if (!account.embedded.isConnected || account.embedded.isGuestMode) {
    return &lt;p&gt;Sign in to your Para account to make a deposit.&lt;/p&gt;;
  }
  if (!wallet) return &lt;p&gt;Select one embedded EVM wallet before depositing.&lt;/p&gt;;

  return (
    &lt;section&gt;
      &lt;p&gt;Destination: {wallet.address} on Base&lt;/p&gt;
      &lt;button disabled={busy || !!intentId} onClick={startDeposit}&gt;
        {busy ? "Preparing deposit..." : "Deposit crypto"}
      &lt;/button&gt;
      &lt;p role="alert"&gt;{error}&lt;/p&gt;
      {intentId &amp;&amp; &lt;DepositCheckout intentId={intentId} /&gt;}
    &lt;/section&gt;
  );
}
</code></pre>
<p>Copy <code>DepositCheckout</code> from the shared backend guide. It calls <code>mount</code> from <code>@canopypay/checkout-sdk</code> inside an effect and destroys the handle on unmount. Mount this entire screen with a React key tied to your authenticated application user, so switching accounts destroys the previous user's checkout.</p>
<p>The JWT hook is <code>useIssueJwt</code>, with <code>issueJwtAsync()</code> returning <code>{ token, keyId }</code>. Keep the token in memory and send it over HTTPS; the browser's wallet ID is only a selection hint. <a href="https://docs.getpara.com/v3/react/guides/hooks/use-issue-jwt">useIssueJwt reference</a>.</p>
<p>At this checkpoint, an external wallet connection alone should not enable the button. An authenticated embedded account should show the intended EVM address, even when a separate external wallet is also connected.</p>
<h2>Verify ownership on the server</h2>
<p>Para's signed JWT includes <code>sub</code>, an application-specific <code>aud</code>, and wallet attestations in <code>data.wallets</code>. Verify the signature using the JWKS for your Para environment, require a valid expiry and the expected audience, then compare the subject with your authenticated user's stored Para identity. The audience is the API key's unique ID, not a value supplied by the browser. <a href="https://docs.getpara.com/v3/react/guides/sessions-jwt">Para JWT management</a>.</p>
<p>Add <code>lib/deposits/para.ts</code>. This uses <code>jose</code> for JWT verification and <code>viem</code> for address normalization. Pin those dependencies in your application lockfile.</p>
<pre><code class="language-ts">import "server-only";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { getAddress, zeroAddress } from "viem";
import type { DepositOwner } from "./types";

// Use the BETA URL only when your existing Para app uses BETA.
const jwks = createRemoteJWKSet(
  new URL("https://api.getpara.com/.well-known/jwks.json"),
);

export async function resolveParaOwner(input: {
  token: string;
  walletId: string;
  user: { id: string; paraUserId: string };
}): Promise&lt;DepositOwner&gt; {
  const audience = process.env.PARA_APP_KEY_ID;
  const outputToken = process.env.CANOPY_BASE_OUTPUT_TOKEN;
  if (!audience || !outputToken)
    throw new Error("Missing server configuration");

  const { payload } = await jwtVerify(input.token, jwks, {
    audience,
    requiredClaims: ["exp", "sub", "aud"],
  });
  if (payload.sub !== input.user.paraUserId)
    throw new Error("Identity mismatch");

  const data = payload.data as
    | {
        userId?: unknown;
        wallets?: Array&lt;{ id?: unknown; type?: unknown; address?: unknown }&gt;;
      }
    | undefined;
  if (data?.userId !== payload.sub || !Array.isArray(data.wallets)) {
    throw new Error("Missing wallet attestation");
  }
  const matches = data.wallets.filter(
    (w) =&gt;
      w &amp;&amp;
      w.id === input.walletId &amp;&amp;
      w.type === "EVM" &amp;&amp;
      typeof w.address === "string",
  );
  if (matches.length !== 1) throw new Error("Wallet not authorized");
  const wallet = getAddress(matches[0].address as string);
  if (wallet === zeroAddress) throw new Error("Invalid destination");

  return {
    userId: input.user.id,
    providerWalletId: input.walletId,
    destination: {
      wallet,
      namespace: "eip155",
      chainReference: "8453",
      tokenAddress: outputToken,
    },
  };
}
</code></pre>
<p>For Para BETA, the documented JWKS URL is <code>https://api.beta.getpara.com/.well-known/jwks.json</code>. Choose the environment on the server. Never accept a JWKS URL from the request. Configure any additional issuer or algorithm restrictions against the token profile confirmed for your deployed Para version.</p>
<p>This recipe deposits to the attested EVM wallet address. If your product displays an ERC-4337 smart-account balance, resolve that account through your existing account-abstraction integration and verify its relationship to this signer. Substituting the signer address would fund a different balance.</p>
<h2>Create and save the intent</h2>
<p>In <code>app/api/deposits/para/route.ts</code>, authenticate your existing application session, extract the bearer token and validate that <code>walletId</code> is a string. Call <code>resolveParaOwner</code> with those values. Apply your framework's CSRF protection and rate limits before creating an intent.</p>
<p>Use the durable store operation from the <a href="https://www.canopypay.io/articles/para-wallet-crypto-deposits-react#canopy-backend">shared backend guide</a>:</p>
<pre><code class="language-ts">// Inside the authenticated route; application adapters, not Para SDK methods.
const owner = await resolveParaOwner({ token, walletId, user });
const saved = await depositStore.withDestinationLock(owner, async () =&gt; {
  const existing = await depositStore.findActive(owner);
  if (existing?.intentId) return existing;
  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: saved.intentId },
  {
    headers: { "Cache-Control": "no-store" },
  },
);
</code></pre>
<p><code>user</code>, <code>depositStore</code> and the route's authentication code belong to your application. The store must enforce unique ownership of the destination, serialize creates across instances and save the intent before returning it. Its <code>reserve</code> operation must get or create the same durable reservation and reference after a timeout. Import <code>createCanopyIntent</code> from <code>lib/deposits/canopy.ts</code> in the shared guide.</p>
<p>That helper calls <code>POST https://www.canopypay.io/api/v1/intents</code> with the server secret, <code>Canopy-Version: 2026-09-01</code>, and the verified payout destination. Reuse the saved intent when the user reopens checkout. Canopy deduplicates by merchant and destination, including token; repeated creates rotate the widget token. <code>merchantReference</code> is correlation data, not a request idempotency key. <a href="https://www.canopypay.io/concepts/payment-intents">Canopy payment intents</a>.</p>
<h2>Confirm settlement and update the screen</h2>
<p>Checkout displays the deposit inbox and supported source instructions. The inbox is distinct from the user's Para destination. An exchange withdrawal must use the network and asset checkout specifies.</p>
<p>Implement the <a href="https://www.canopypay.io/articles/para-wallet-crypto-deposits-react#settlement-handler">webhook reconciliation guide</a> before enabling transfers. Verify the raw request body with Standard Webhooks using <code>webhook-id</code>, <code>webhook-timestamp</code> and <code>webhook-signature</code>. The verified body is flat: branch on <code>state === "settled"</code>; do not expect an <code>event.type</code> wrapper. Match the saved intent and persist settlement idempotently. <a href="https://www.canopypay.io/webhooks">Canopy webhooks</a>.</p>
<p>Expose that saved result through an authenticated application status endpoint. Browser <code>paid</code> and <code>deposit_detected</code> events can show a pending message, but they cannot authorize a credit. Once the backend confirms settlement, refresh the configured token's balance on Base. If the app also has an internal ledger, define and deduplicate that credit separately using integer token units. Receiving funds does not execute a subsequent swap or contract call.</p>
<h2>Verify the integration</h2>
<p>Use two application users with different Para identities. First, send one user's JWT with the other's application session: the endpoint should reject it. Then submit an unrelated wallet ID and confirm that no Canopy intent is created. A modified browser address should have no effect because the endpoint never consumes it.</p>
<p>Open checkout twice for the same user. Both requests should resolve to one saved active intent. Close the tab after a simulated deposit notification and deliver a correctly signed settlement fixture to the backend. Reopening the app should show the saved result. Redeliver the fixture and confirm that the settlement or ledger operation is recorded once.</p>
<p>For an authorized funded verification, record the exact source asset/network and destination token before sending. Compare the final wallet balance with the settlement record. A fixture exercise proves your application logic; it does not prove the live route.</p>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Check</th>
</tr>
</thead>
<tbody><tr>
<td>Only an external wallet is connected</td>
<td>Require <code>embedded.isConnected</code> and a provisioned EVM wallet.</td>
</tr>
<tr>
<td>JWT verification fails</td>
<td>Match Para environment, application audience and expiry; request a fresh token.</td>
</tr>
<tr>
<td>Funds would reach the signer rather than the app balance</td>
<td>Check whether the product uses an EOA or a separate smart account.</td>
</tr>
<tr>
<td>Per-intent destination create is refused</td>
<td>Check the account setting and Canopy's Dynamic route provisioning separately.</td>
</tr>
<tr>
<td><code>no_intent_destination</code> or <code>ambiguous_intent_destination</code></td>
<td>Inspect the active-intent mapping and concurrent creates; do not generate another address as a retry.</td>
</tr>
<tr>
<td>Checkout reports activity but the app remains pending</td>
<td>Inspect verified webhook receipt and the saved settlement record.</td>
</tr>
</tbody></table>
<h2>Para wallet funding questions</h2>
<h3>Is this the same as Para's fiat onramp?</h3>
<p>No. Para's Buy/Withdraw flow covers its configured fiat-ramp providers. This tutorial handles a crypto transfer from an exchange or another wallet through a confirmed Canopy route. Choose the path that matches the user's source of funds.</p>
<h3>Does this apply to Capsule wallets?</h3>
<p>Para was formerly called Capsule. This example uses the current <code>@getpara/react-sdk</code> API. An older Capsule integration should follow Para's current setup and migration guidance before copying these hooks.</p>
<h3>Can an external wallet connection enable deposits?</h3>
<p>This example requires an authenticated embedded EVM wallet. An external connection alone does not enable the button, and the server verifies the selected wallet against the signed Para token.</p>
<p>For an account-query approach, see <a href="https://www.canopypay.io/articles/turnkey-wallet-crypto-deposits">Turnkey wallet funding</a>. The <a href="https://www.canopypay.io/quickstart">Canopy quickstart</a> covers the underlying payment-intent flow.</p>
<h2>Canopy backend and checkout</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>The sequence is: authenticate the user, resolve the destination, create and save the intent, open checkout, then reconcile the signed settlement webhook.</p>
<h3>Configure a route before accepting deposits</h3>
<p>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:</p>
<pre><code class="language-dotenv">CANOPY_SECRET_KEY=&lt;server-secret&gt;
NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=&lt;publishable-key&gt;
CANOPY_WEBHOOK_SECRET=&lt;endpoint-signing-secret&gt;
</code></pre>
<p>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. <a href="https://www.canopypay.io/concepts/destinations">Canopy payout destinations</a>.</p>
<p>Pick one destination chain and token for the first integration. The examples below use Base, whose chain reference is <code>8453</code>, 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.</p>
<p>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.</p>
<h3>Resolve the destination on your server</h3>
<p>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.</p>
<p>Each provider tutorial describes how to populate this application-owned result:</p>
<pre><code class="language-ts">// 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;
};
</code></pre>
<p>Your <code>resolveDepositOwner(request)</code> 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.</p>
<p>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.</p>
<h3>Create the Canopy intent</h3>
<p>Add the following server helper. Its input comes from your verified wallet mapping and a persisted local deposit reference.</p>
<pre><code class="language-ts">// lib/deposits/canopy.ts
import "server-only";
import type { DepositDestination } from "./types";

export async function createCanopyIntent(input: {
  reference: string;
  destination: DepositDestination;
}): Promise&lt;{
  intentId: string;
  inboxAddress: string;
  created: boolean;
}&gt; {
  const key = process.env.CANOPY_SECRET_KEY;
  if (!key) throw new Error("CANOPY_SECRET_KEY is missing");
  if (!input.reference || input.reference.length &gt; 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,
  };
}
</code></pre>
<p>The output token is pinned with <code>payoutTokenAddress</code>. 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. <a href="https://www.canopypay.io/concepts/payment-intents">Canopy payment intents</a>.</p>
<p>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. <code>merchantReference</code> 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.</p>
<h3>Save and reuse the intent</h3>
<p>Wrap intent creation in a durable application operation. For example, an authenticated <code>POST /api/deposits</code> handler can use this flow:</p>
<pre><code class="language-ts">// Application pseudocode: implement these adapters in your app.
const owner = await resolveDepositOwner(request);
const result = await depositStore.withDestinationLock(owner, async () =&gt; {
  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" } },
);
</code></pre>
<p><code>depositStore</code> and <code>resolveDepositOwner</code> 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.</p>
<p>Give the store methods precise contracts. <code>findActive</code> returns a reusable record with an attached <code>intentId</code>, or <code>null</code>; an unfinished reservation cannot be returned to checkout. <code>reserve</code> 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. <code>attachIntent</code> must complete that same reservation and reject a conflicting user or destination binding.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h3>Mount checkout in React</h3>
<p>Install the hosted SDK in your existing app:</p>
<pre><code class="language-sh">npm install @canopypay/checkout-sdk
</code></pre>
<p>This component receives the intent ID returned by your endpoint. It mounts the hosted checkout after the container exists and destroys the instance when that view leaves the page.</p>
<pre><code class="language-tsx">// components/DepositCheckout.tsx
"use client";

import { useEffect, useId, useState } from "react";
import { mount } from "@canopypay/checkout-sdk";

export function DepositCheckout({ intentId }: { intentId: string }) {
  const id = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const [message, setMessage] = useState("");

  useEffect(() =&gt; {
    const merchant = process.env.NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY;
    if (!merchant) {
      setMessage("Deposit checkout is not configured.");
      return;
    }
    const handle = mount({
      target: `#deposit-${id}`,
      intentId,
      merchant,
      canopyOrigin: "https://www.canopypay.io",
      surface: "auto",
      onEvent(event) {
        if (event.type === "error") {
          setMessage("Checkout could not open. Please try again.");
        }
        if (event.type === "paid" || event.type === "deposit_detected") {
          setMessage("Deposit received. Checking settlement.");
        }
      },
    });
    return () =&gt; handle.destroy();
  }, [intentId, id]);

  return (
    &lt;section aria-label="Fund your wallet"&gt;
      &lt;div id={`deposit-${id}`} /&gt;
      &lt;p role="status"&gt;{message}&lt;/p&gt;
    &lt;/section&gt;
  );
}
</code></pre>
<p>The SDK's <code>surface: "auto"</code> attempts inline checkout and offers a popup fallback if the frame cannot complete its handshake. The npm build needs <code>canopyOrigin</code>. See the <a href="https://www.canopypay.io/guides/embedding">embedding guide</a> and <a href="https://www.canopypay.io/sdk/events">SDK events</a>.</p>
<p>Reset the parent funding screen when the authenticated user changes. In React, key that screen by your application user ID and clear any pending request result on logout. The component's cleanup then destroys the old checkout. An asynchronous response from a previous session must not reopen that user's intent in the next session.</p>
<p>Let checkout show the supported source networks, asset and deposit instructions. A user withdrawing from an exchange must select the exact network and asset shown. An EVM-shaped address alone cannot tell them which withdrawal network to choose.</p>
<h3>Confirm settlement and refresh the wallet</h3>
<p>Connect the <a href="https://www.canopypay.io/articles/para-wallet-crypto-deposits-react#settlement-handler">webhook reconciliation handler</a> before enabling deposits. Your backend should store the verified settlement against the saved intent, and an authenticated status endpoint should expose only that user's local record. The UI can poll that endpoint or subscribe to your application's updates.</p>
<p>When settlement is confirmed, refresh the provider wallet's balance on the destination chain. A deposit into a user's wallet does not authorize a swap or deposit into a vault. Those actions require their own transaction flow. If your app has an internal balance ledger, define separately whether any credit is appropriate; do not count both wallet ownership and an application liability as the same deposit twice.</p>
<h3>Check the complete flow</h3>
<p>Start with two test users. Verify that each resolves to their own wallet and that altering a browser-submitted address cannot change the backend destination. Open the deposit view twice and confirm it reuses the saved intent. Close and reopen checkout, then confirm the same record remains associated with the user.</p>
<p>Exercise the webhook with a correctly signed fixture, a duplicate delivery, an invalid signature and a simulated database failure. The first valid settlement should create one record; the duplicate should have no additional effect. A database failure before durable receipt must cause a non-2xx response so delivery can be retried.</p>
<p>Before production, verify a funded route in an approved integration environment and record the actual source asset, destination token, fees, transaction hash and resulting wallet balance. Keep this record with your pinned SDK versions. The examples in this article have not been run as a funded end-to-end integration.</p>
<h2>Signed webhook reconciliation</h2>
<p>A user closes the deposit window before settlement finishes. Your backend still needs to record the payment. Another user leaves the window open and receives a browser success event twice. Neither browser session should decide whether funds arrived.</p>
<p>Canopy sends settlement outcomes to your registered server endpoint. This tutorial adds a receiver that verifies the original request body, saves each delivery once and leaves a durable job for reconciliation. It fits the <a href="https://www.canopypay.io/articles/para-wallet-crypto-deposits-react#canopy-backend">embedded wallet deposit backend</a> and works independently of the wallet provider.</p>
<p>The example assumes Next.js route handlers and a transactional database. The persistence interface is application code that you must implement; the article defines its required behavior. The endpoint records evidence and schedules work. Wallet balance refresh and any internal ledger credit happen in the worker.</p>
<h3>Understand the event body</h3>
<p>Canopy's public webhook contract uses a flat JSON object:</p>
<pre><code class="language-ts">type SettlementPayload = {
  intentId: string | null;
  inboxAddress: string;
  created: false;
  state: string;
  feeUnits: string;
  netUnits: string;
  txHash: string;
  chainId: number;
  merchantReference: string | null;
};
</code></pre>
<p>The documentation calls the success event <code>payment.settled</code>. Its body discriminator is <code>state: "settled"</code>; there is no outer <code>event.type</code> or <code>data</code> wrapper. <code>payment.failed</code> is documented but is not currently emitted. A handler should still refuse to fund on any state other than <code>settled</code>. Delivery order is not guaranteed. <a href="https://www.canopypay.io/webhooks">Canopy webhook contract</a>.</p>
<p>The minimal body does not identify a token contract or decimals. Keep the configured destination asset with the original intent and reconcile the amount's meaning for your route before doing ledger arithmetic. A transaction hash also needs its chain context. Do not assume the top-level hash is the final payout transaction on every route.</p>
<h3>Keep the raw request body</h3>
<p>Install the package used by Canopy's documentation:</p>
<pre><code class="language-sh">npm install standardwebhooks
</code></pre>
<p>Configure the endpoint's signing secret in <code>CANOPY_WEBHOOK_SECRET</code> on your server. A publishable key or Canopy API secret is not the webhook signing secret.</p>
<p>Standard Webhooks signs the payload together with the message ID and timestamp. Parsing JSON and serializing it again can change the bytes and break verification. Read the body as text once and pass that string directly to the verifier. <a href="https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md">Standard Webhooks specification</a>.</p>
<p>The request must include <code>webhook-id</code>, <code>webhook-timestamp</code> and <code>webhook-signature</code>. The official JavaScript verifier checks both the signature and timestamp tolerance. Keep the host clock synchronized. <a href="https://github.com/standard-webhooks/standard-webhooks/blob/main/libraries/javascript/src/index.ts">JavaScript verifier source</a>.</p>
<h3>Receive and store the delivery</h3>
<p>Create a persistence adapter with this contract:</p>
<pre><code class="language-ts">// lib/deposits/webhook-store.ts
export interface WebhookStore {
  recordAndEnqueue(input: {
    endpointKey: string;
    eventId: string;
    rawBody: string;
    payload: unknown;
  }): Promise&lt;void&gt;;
}
</code></pre>
<p><code>recordAndEnqueue</code> must atomically insert a delivery and create a pending reconciliation job. A unique database constraint on <code>(endpointKey, eventId)</code> makes retries a no-op. It must resolve only after the transaction commits. If the same ID arrives with a different body, preserve the original and raise an operational alert instead of overwriting it. <code>endpointKey</code> is your stable local endpoint identifier, especially useful when several Canopy accounts share one service.</p>
<p>Export your implementation as <code>webhookStore</code> and use it from the route:</p>
<pre><code class="language-ts">// app/api/canopy/webhook/route.ts
import { Webhook } from "standardwebhooks";
import { webhookStore } from "@/lib/deposits/webhook-store";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const secret = process.env.CANOPY_WEBHOOK_SECRET;
  if (!secret) return new Response("Endpoint unavailable", { status: 503 });

  const eventId = request.headers.get("webhook-id");
  const timestamp = request.headers.get("webhook-timestamp");
  const signature = request.headers.get("webhook-signature");
  if (!eventId || !timestamp || !signature) {
    return new Response("Missing signature headers", { status: 400 });
  }

  // Also configure a request body size limit at your ingress.
  const rawBody = await request.text();
  let payload: unknown;
  try {
    payload = new Webhook(secret).verify(rawBody, {
      "webhook-id": eventId,
      "webhook-timestamp": timestamp,
      "webhook-signature": signature,
    });
  } catch {
    return new Response("Invalid webhook", { status: 400 });
  }

  try {
    await webhookStore.recordAndEnqueue({
      endpointKey: "canopy-primary",
      eventId,
      rawBody,
      payload,
    });
  } catch {
    return new Response("Receipt unavailable", { status: 503 });
  }
  return new Response(null, { status: 204 });
}
</code></pre>
<p>A <code>204</code> now means the event has been saved and queued. It does not mean the worker has updated the user interface. Canopy retries non-2xx responses and timeouts, so acknowledging before the durable write would leave a gap if the process crashes. <a href="https://www.canopypay.io/webhooks">Canopy delivery behavior</a>.</p>
<p>Register this HTTPS endpoint in Canopy and save its signing secret. Use a separate secret and local endpoint identifier for each environment.</p>
<h3>Reconcile against the original intent</h3>
<p>The worker starts with a verified but otherwise untrusted shape: a valid signature identifies the sender, while schema and business checks decide what the payload means to your app. Validate string fields, nullable identifiers, the state discriminator and integer amount strings before using them.</p>
<p>Find the local record using <code>intentId</code> within the Canopy account associated with the endpoint. Compare the inbox and merchant reference with the saved record where available. If the intent is null, unknown, associated with another account, or its reference conflicts, mark the job for reconciliation. Do not guess the user from an address sent by a browser.</p>
<p>For a settled event, save its chain and transaction references with the intent. Then refresh or schedule a refresh of the user's wallet balance using the destination chain's provider or RPC. Indexing can lag, so a stale balance read should leave a visible pending refresh instead of causing another credit.</p>
<p>For non-settled states, save the outcome and stop before any crediting step. Because events can arrive out of order, a later delivery with an older state must not overwrite a settlement you have already confirmed.</p>
<h3>Deduplicate deliveries and business effects separately</h3>
<p>The delivery ID solves retries of one webhook. Your business record needs its own uniqueness rule because the same deposit could reach your system through an operator replay, another endpoint or a later reconciliation job.</p>
<p>For a one-time purchase, a unique fulfillment row for the local purchase can stop duplicate fulfillment. A repeatable wallet funding intent can receive more than one deposit, so making <code>intentId</code> globally unique in a credit table would also discard legitimate later deposits.</p>
<p>Resolve a canonical settlement operation from your supported route's evidence. That may require a chain transaction and transfer/log identity, the asset and recipient, or another documented unique settlement identifier. Confirm its granularity before choosing a database key: a transaction can contain several transfers. If the public webhook fields do not distinguish the operations your ledger needs, leave the event pending and obtain the missing evidence through your supported reconciliation process.</p>
<p>Once resolved, apply the effect in one database transaction:</p>
<pre><code class="language-text">begin transaction
  lock the local deposit record
  insert the canonical settlement operation under a unique constraint
  if it already exists: check it agrees with the saved evidence and stop
  record the confirmed outcome
  if this product has an internal ledger:
    write balanced ledger entries for the verified asset and amount
  mark the reconciliation job complete
commit
</code></pre>
<p>This is application logic, not an extra Canopy API operation. Use integer base units and an asset identifier that includes the chain. JavaScript floating-point numbers are unsuitable for token balances. Do not add <code>netUnits</code> from every full-state delivery without establishing whether it is an incremental amount for your route.</p>
<p>If the money lands in a self-custodial user wallet, your product may only need a settlement record and balance refresh. Crediting a separate spendable internal balance would create another obligation. Make that product decision explicitly before writing the ledger branch.</p>
<h3>Test the failure cases</h3>
<p>Use the verifier package to sign a synthetic fixture with a test secret. Keep test deliveries out of production accounting. Your first test should prove the body survives your HTTP framework unchanged.</p>
<table>
<thead>
<tr>
<th>Exercise</th>
<th>Expected result</th>
</tr>
</thead>
<tbody><tr>
<td>Correctly signed settlement for a saved intent</td>
<td>One delivery and one reconciliation job</td>
</tr>
<tr>
<td>Same delivery sent twice</td>
<td>One stored delivery; no second business effect</td>
</tr>
<tr>
<td>Payload changed after signing</td>
<td>HTTP 400; no queued job</td>
</tr>
<tr>
<td>Missing or stale timestamp</td>
<td>Rejected by header or signature checks</td>
</tr>
<tr>
<td>Database unavailable during receipt</td>
<td>Non-2xx response; no acknowledgement of durability</td>
</tr>
<tr>
<td>Process exits after receipt commits</td>
<td>Pending job survives and resumes</td>
</tr>
<tr>
<td>Valid event with an unknown intent</td>
<td>Durable receipt, pending investigation, no credit</td>
</tr>
<tr>
<td>Two legitimate deposits on a repeatable intent</td>
<td>Both reconciled using distinct operation identities</td>
</tr>
<tr>
<td>Duplicate business operation with a different delivery ID</td>
<td>One business effect</td>
</tr>
<tr>
<td>Non-settled or out-of-order outcome</td>
<td>No new credit and no downgrade of confirmed settlement</td>
</tr>
</tbody></table>
<p>Run a funded integration exercise only after the route and environment are approved. Compare the signed event, destination transaction and resulting wallet balance. The code here has not been tested against a funded Canopy deposit.</p>
<p>During signing-secret rotation, deploy verification with the new secret before revoking the retiring one. Canopy can sign with both during the transition. Keep receipt failures and pending reconciliation jobs visible to the team operating deposits, along with enough redacted context to find the associated intent.</p>
]]></content:encoded></item><item><title><![CDATA[Turnkey wallet funding: add exchange deposits with Canopy]]></title><description><![CDATA[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]]></description><link>https://canopypay.hashnode.dev/turnkey-wallet-funding-add-exchange-deposits-with-canopy</link><guid isPermaLink="true">https://canopypay.hashnode.dev/turnkey-wallet-funding-add-exchange-deposits-with-canopy</guid><dc:creator><![CDATA[jack]]></dc:creator><pubDate>Sat, 26 Sep 2026 17:00:52 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>What you need to implement</h2>
<p>This recipe assumes Turnkey authentication and wallet creation already work in your app. It adds exchange deposits to that existing flow. The <a href="https://www.canopypay.io/articles/turnkey-wallet-crypto-deposits#canopy-backend">backend appendix</a> contains the shared types, Canopy request helper and checkout component. The <a href="https://www.canopypay.io/articles/turnkey-wallet-crypto-deposits#settlement-handler">webhook appendix</a> contains the signature-verifying receiver.</p>
<p>Your application must supply the remaining adapters:</p>
<table>
<thead>
<tr>
<th>Application code</th>
<th>Responsibility</th>
</tr>
</thead>
<tbody><tr>
<td><code>requireUser</code> and <code>getBinding</code></td>
<td>Validate the server session and load the account selected during verified onboarding.</td>
</tr>
<tr>
<td><code>lookupAccount</code></td>
<td>Make the authenticated Turnkey account query with access to the correct organization.</td>
</tr>
<tr>
<td><code>checksumAddress</code></td>
<td>Normalize an EVM address and reject the zero address.</td>
</tr>
<tr>
<td>Deposit store and API route</td>
<td>Persist reservations, serialize creation, enforce ownership and return a saved intent.</td>
</tr>
<tr>
<td>Webhook store, worker and status endpoint</td>
<td>Save verified deliveries, reconcile settlement once and expose only the signed-in user's record.</td>
</tr>
</tbody></table>
<p>These are application contracts. The appendices describe their behavior; they do not supply a production database or authentication system.</p>
<h2>Versions checked</h2>
<p>The reader-test application used <code>@turnkey/sdk-server</code> 8.6.0, <code>@canopypay/checkout-sdk</code> 0.7.2, <code>viem</code> 2.56.9 and <code>standardwebhooks</code> 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.</p>
<p>The browser exercise used synthetic accounts and Canopy responses. It tested the application's deposit handling, not a real withdrawal. Use the <a href="https://docs.turnkey.com/solutions/embedded-wallets/quickstart">Turnkey quickstart</a> for the provider setup and <a href="https://www.canopypay.io/quickstart">Canopy quickstart</a> for account credentials.</p>
<h2>Choose the funding path</h2>
<p>A direct withdrawal to the Turnkey account is appropriate when the exchange supports the exact token and network your application needs. Turnkey's <a href="https://docs.turnkey.com/solutions/embedded-wallets/quickstart">embedded wallet quickstart</a> includes sending and receiving funds. Its API also documents <a href="https://docs.turnkey.com/api-reference/activities/init-fiat-on-ramp">fiat onramp initiation</a> and <a href="https://docs.turnkey.com/api-reference/queries/get-swap-status">same-chain and cross-chain swap status</a>.</p>
<p>Compare those options with your actual deposit requirement. LI.FI's <a href="https://li.fi/knowledge-hub/introducing-smart-deposit-addresses">Smart Deposit Addresses</a> 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.</p>
<p>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 <a href="https://www.canopypay.io/guides/multi-network-deposits">funding guide</a> for a comparison of direct transfers, bridges and routed deposits.</p>
<h2>Prepare the destination and checkout</h2>
<p>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 <a href="https://docs.turnkey.com/solutions/embedded-wallets/quickstart">Turnkey integration guide</a> if wallet creation is still missing.</p>
<p>Configure the following server and browser values, using your existing secret manager:</p>
<pre><code class="language-dotenv">CANOPY_SECRET_KEY=&lt;server-secret&gt;
CANOPY_WEBHOOK_SECRET=&lt;webhook-signing-secret&gt;
NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=&lt;publishable-key&gt;
CANOPY_BASE_OUTPUT_TOKEN=&lt;account-confirmed-token-address-or-native&gt;
</code></pre>
<p>Enable per-intent destinations on your Canopy account and confirm that Canopy has provisioned the route's <code>confirmedPayoutMode</code> as <code>Dynamic</code>. 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. <a href="https://www.canopypay.io/concepts/destinations">Canopy destination requirements</a>.</p>
<p>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:</p>
<pre><code class="language-sh">npm install @canopypay/checkout-sdk
</code></pre>
<p>Use the <a href="https://www.canopypay.io/articles/turnkey-wallet-crypto-deposits#canopy-backend">shared backend guide</a> for <code>lib/deposits/canopy.ts</code>, the persistence contracts and <code>components/DepositCheckout.tsx</code>. The next step supplies its Turnkey-specific destination resolver.</p>
<h2>Resolve the Turnkey account on the server</h2>
<p>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.</p>
<p>The documented <a href="https://docs.turnkey.com/api-reference/queries/get-wallet-account">Get wallet account</a> request is a signed <code>POST</code> to <code>https://api.turnkey.com/public/v1/query/get_wallet_account</code>, with <code>organizationId</code>, <code>walletId</code> and an <code>address</code> or <code>path</code> selector. The returned <code>account</code> includes <code>walletAccountId</code>, <code>organizationId</code>, <code>walletId</code>, <code>address</code> and <code>addressFormat</code>. <a href="https://docs.turnkey.com/api-reference/queries/list-wallet-accounts">List wallet accounts</a> can populate the initial selection.</p>
<p>Implement the following application adapter in <code>lib/deposits/turnkey.ts</code>. <code>lookupAccount</code> is your authenticated Turnkey lookup, not a new SDK method. Satisfy its contract using the documented query through your authorized server integration. Turnkey's <a href="https://docs.turnkey.com/solutions/company-wallets/integration-guide/javascript-server">server SDK</a> 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.</p>
<pre><code class="language-ts">// 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&lt;{ id: string }&gt;;
  // Read the selected, previously verified account from your database.
  getBinding(userId: string): Promise&lt;Binding | null&gt;;
  // Authenticated get_wallet_account query; return response.account.
  lookupAccount(input: {
    organizationId: string;
    walletId: string;
    address: string;
  }): Promise&lt;Account&gt;;
  // 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&lt;DepositOwner&gt; {
    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,
      },
    };
  };
}
</code></pre>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h2>Create and save the intent</h2>
<p>Wire the resolver into the authenticated <code>POST /api/deposits</code> flow from the shared guide. Inside the store's destination lock, the Canopy handoff is:</p>
<pre><code class="language-ts">// 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);
</code></pre>
<p>These calls belong inside the full save-and-reuse operation, which first returns an existing saved active intent when available. <code>reserveOrResume</code> and <code>attachIntentToOwner</code> 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.</p>
<p><code>createCanopyIntent</code> sends the server-only request to <code>POST https://www.canopypay.io/api/v1/intents</code> with bearer authentication, <code>Content-Type: application/json</code> and <code>Canopy-Version: 2026-09-01</code>. It maps the destination to <code>payoutWallet</code>, <code>payoutNamespace</code>, <code>payoutChainReference</code> and <code>payoutTokenAddress</code>. <a href="https://www.canopypay.io/concepts/payment-intents">Canopy payment intents</a>.</p>
<p>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. <code>merchantReference</code> provides correlation, not request idempotency. Keep an uncertain reservation after a timeout for reconciliation.</p>
<p>A <code>201</code> 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.</p>
<h2>Show the exchange withdrawal instructions</h2>
<p>Mount <code>DepositCheckout</code> from the shared guide with the saved <code>intentId</code>. That component calls <code>mount</code> from <code>@canopypay/checkout-sdk</code>, sets <code>canopyOrigin: "https://www.canopypay.io"</code> and <code>surface: "auto"</code>, and calls the returned handle's <code>destroy()</code> method when the view unmounts. <a href="https://www.canopypay.io/guides/embedding">Canopy embedding guide</a>.</p>
<p>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.</p>
<p>Keep the flow pending after <code>deposit_detected</code> or <code>paid</code>. 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.</p>
<h2>Confirm settlement and refresh the balance</h2>
<p>Install the handler from <a href="https://www.canopypay.io/articles/turnkey-wallet-crypto-deposits#settlement-handler">Crypto deposit webhook reconciliation</a>. Verify the raw body with Standard Webhooks using <code>webhook-id</code>, <code>webhook-timestamp</code> and <code>webhook-signature</code>, then branch on the verified body's <code>state === "settled"</code>. The event label is <code>payment.settled</code>; the payload is flat and has no <code>event.type</code> wrapper. <a href="https://www.canopypay.io/webhooks">Canopy webhooks</a>.</p>
<p>Correlate the intent to the saved Turnkey account, record the settlement durably, and deduplicate both deliveries and the business operation. Keep <code>netUnits</code> and <code>feeUnits</code> as integer strings. The authenticated app can then refresh its own deposit status and the destination token balance on Base.</p>
<p>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.</p>
<h2>Verify the integration</h2>
<p>Before enabling withdrawals, run the following exercise with fixtures and then an explicitly approved funded route:</p>
<ol>
<li>Sign in as two users. Check the complete destination tuple and verify that each user can access only their own intent.</li>
<li>Click Deposit twice and reload. Confirm that the app reuses the saved active intent without issuing concurrent create calls.</li>
<li>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.</li>
<li>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.</li>
<li>Close checkout after withdrawal. Confirm server processing continues, then reopen the app and compare its recorded settlement with the destination wallet's token balance.</li>
</ol>
<p>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. <code>no_intent_destination</code> and <code>ambiguous_intent_destination</code> 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.</p>
<h2>Turnkey wallet funding questions</h2>
<h3>Can a user withdraw directly from an exchange to a Turnkey wallet?</h3>
<p>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.</p>
<h3>Does funding a Turnkey signer fund a smart account?</h3>
<p>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.</p>
<h3>What confirms that the deposit settled?</h3>
<p>Your backend verifies Canopy's signed webhook and reconciles it with the saved intent. A browser <code>paid</code> event or successful intent creation is insufficient. Refresh the destination token balance after recording the verified settlement.</p>
<p>For the equivalent React flow with Para identity tokens, see <a href="https://www.canopypay.io/articles/para-wallet-crypto-deposits-react">Para wallet funding</a>. The <a href="https://www.canopypay.io/docs">Canopy documentation</a> covers checkout configuration and API details.</p>
<h2>Canopy backend and checkout</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>The sequence is: authenticate the user, resolve the destination, create and save the intent, open checkout, then reconcile the signed settlement webhook.</p>
<h3>Configure a route before accepting deposits</h3>
<p>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:</p>
<pre><code class="language-dotenv">CANOPY_SECRET_KEY=&lt;server-secret&gt;
NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=&lt;publishable-key&gt;
CANOPY_WEBHOOK_SECRET=&lt;endpoint-signing-secret&gt;
</code></pre>
<p>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. <a href="https://www.canopypay.io/concepts/destinations">Canopy payout destinations</a>.</p>
<p>Pick one destination chain and token for the first integration. The examples below use Base, whose chain reference is <code>8453</code>, 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.</p>
<p>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.</p>
<h3>Resolve the destination on your server</h3>
<p>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.</p>
<p>Each provider tutorial describes how to populate this application-owned result:</p>
<pre><code class="language-ts">// 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;
};
</code></pre>
<p>Your <code>resolveDepositOwner(request)</code> 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.</p>
<p>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.</p>
<h3>Create the Canopy intent</h3>
<p>Add the following server helper. Its input comes from your verified wallet mapping and a persisted local deposit reference.</p>
<pre><code class="language-ts">// lib/deposits/canopy.ts
import "server-only";
import type { DepositDestination } from "./types";

export async function createCanopyIntent(input: {
  reference: string;
  destination: DepositDestination;
}): Promise&lt;{
  intentId: string;
  inboxAddress: string;
  created: boolean;
}&gt; {
  const key = process.env.CANOPY_SECRET_KEY;
  if (!key) throw new Error("CANOPY_SECRET_KEY is missing");
  if (!input.reference || input.reference.length &gt; 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,
  };
}
</code></pre>
<p>The output token is pinned with <code>payoutTokenAddress</code>. 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. <a href="https://www.canopypay.io/concepts/payment-intents">Canopy payment intents</a>.</p>
<p>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. <code>merchantReference</code> 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.</p>
<h3>Save and reuse the intent</h3>
<p>Wrap intent creation in a durable application operation. For example, an authenticated <code>POST /api/deposits</code> handler can use this flow:</p>
<pre><code class="language-ts">// Application pseudocode: implement these adapters in your app.
const owner = await resolveDepositOwner(request);
const result = await depositStore.withDestinationLock(owner, async () =&gt; {
  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" } },
);
</code></pre>
<p><code>depositStore</code> and <code>resolveDepositOwner</code> 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.</p>
<p>Give the store methods precise contracts. <code>findActive</code> returns a reusable record with an attached <code>intentId</code>, or <code>null</code>; an unfinished reservation cannot be returned to checkout. <code>reserve</code> 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. <code>attachIntent</code> must complete that same reservation and reject a conflicting user or destination binding.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<h3>Mount checkout in React</h3>
<p>Install the hosted SDK in your existing app:</p>
<pre><code class="language-sh">npm install @canopypay/checkout-sdk
</code></pre>
<p>This component receives the intent ID returned by your endpoint. It mounts the hosted checkout after the container exists and destroys the instance when that view leaves the page.</p>
<pre><code class="language-tsx">// components/DepositCheckout.tsx
"use client";

import { useEffect, useId, useState } from "react";
import { mount } from "@canopypay/checkout-sdk";

export function DepositCheckout({ intentId }: { intentId: string }) {
  const id = useId().replace(/[^a-zA-Z0-9_-]/g, "");
  const [message, setMessage] = useState("");

  useEffect(() =&gt; {
    const merchant = process.env.NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY;
    if (!merchant) {
      setMessage("Deposit checkout is not configured.");
      return;
    }
    const handle = mount({
      target: `#deposit-${id}`,
      intentId,
      merchant,
      canopyOrigin: "https://www.canopypay.io",
      surface: "auto",
      onEvent(event) {
        if (event.type === "error") {
          setMessage("Checkout could not open. Please try again.");
        }
        if (event.type === "paid" || event.type === "deposit_detected") {
          setMessage("Deposit received. Checking settlement.");
        }
      },
    });
    return () =&gt; handle.destroy();
  }, [intentId, id]);

  return (
    &lt;section aria-label="Fund your wallet"&gt;
      &lt;div id={`deposit-${id}`} /&gt;
      &lt;p role="status"&gt;{message}&lt;/p&gt;
    &lt;/section&gt;
  );
}
</code></pre>
<p>The SDK's <code>surface: "auto"</code> attempts inline checkout and offers a popup fallback if the frame cannot complete its handshake. The npm build needs <code>canopyOrigin</code>. See the <a href="https://www.canopypay.io/guides/embedding">embedding guide</a> and <a href="https://www.canopypay.io/sdk/events">SDK events</a>.</p>
<p>Reset the parent funding screen when the authenticated user changes. In React, key that screen by your application user ID and clear any pending request result on logout. The component's cleanup then destroys the old checkout. An asynchronous response from a previous session must not reopen that user's intent in the next session.</p>
<p>Let checkout show the supported source networks, asset and deposit instructions. A user withdrawing from an exchange must select the exact network and asset shown. An EVM-shaped address alone cannot tell them which withdrawal network to choose.</p>
<h3>Confirm settlement and refresh the wallet</h3>
<p>Connect the <a href="https://www.canopypay.io/articles/turnkey-wallet-crypto-deposits#settlement-handler">webhook reconciliation handler</a> before enabling deposits. Your backend should store the verified settlement against the saved intent, and an authenticated status endpoint should expose only that user's local record. The UI can poll that endpoint or subscribe to your application's updates.</p>
<p>When settlement is confirmed, refresh the provider wallet's balance on the destination chain. A deposit into a user's wallet does not authorize a swap or deposit into a vault. Those actions require their own transaction flow. If your app has an internal balance ledger, define separately whether any credit is appropriate; do not count both wallet ownership and an application liability as the same deposit twice.</p>
<h3>Check the complete flow</h3>
<p>Start with two test users. Verify that each resolves to their own wallet and that altering a browser-submitted address cannot change the backend destination. Open the deposit view twice and confirm it reuses the saved intent. Close and reopen checkout, then confirm the same record remains associated with the user.</p>
<p>Exercise the webhook with a correctly signed fixture, a duplicate delivery, an invalid signature and a simulated database failure. The first valid settlement should create one record; the duplicate should have no additional effect. A database failure before durable receipt must cause a non-2xx response so delivery can be retried.</p>
<p>Before production, verify a funded route in an approved integration environment and record the actual source asset, destination token, fees, transaction hash and resulting wallet balance. Keep this record with your pinned SDK versions. The examples in this article have not been run as a funded end-to-end integration.</p>
<h2>Signed webhook reconciliation</h2>
<p>A user closes the deposit window before settlement finishes. Your backend still needs to record the payment. Another user leaves the window open and receives a browser success event twice. Neither browser session should decide whether funds arrived.</p>
<p>Canopy sends settlement outcomes to your registered server endpoint. This tutorial adds a receiver that verifies the original request body, saves each delivery once and leaves a durable job for reconciliation. It fits the <a href="https://www.canopypay.io/articles/turnkey-wallet-crypto-deposits#canopy-backend">embedded wallet deposit backend</a> and works independently of the wallet provider.</p>
<p>The example assumes Next.js route handlers and a transactional database. The persistence interface is application code that you must implement; the article defines its required behavior. The endpoint records evidence and schedules work. Wallet balance refresh and any internal ledger credit happen in the worker.</p>
<h3>Understand the event body</h3>
<p>Canopy's public webhook contract uses a flat JSON object:</p>
<pre><code class="language-ts">type SettlementPayload = {
  intentId: string | null;
  inboxAddress: string;
  created: false;
  state: string;
  feeUnits: string;
  netUnits: string;
  txHash: string;
  chainId: number;
  merchantReference: string | null;
};
</code></pre>
<p>The documentation calls the success event <code>payment.settled</code>. Its body discriminator is <code>state: "settled"</code>; there is no outer <code>event.type</code> or <code>data</code> wrapper. <code>payment.failed</code> is documented but is not currently emitted. A handler should still refuse to fund on any state other than <code>settled</code>. Delivery order is not guaranteed. <a href="https://www.canopypay.io/webhooks">Canopy webhook contract</a>.</p>
<p>The minimal body does not identify a token contract or decimals. Keep the configured destination asset with the original intent and reconcile the amount's meaning for your route before doing ledger arithmetic. A transaction hash also needs its chain context. Do not assume the top-level hash is the final payout transaction on every route.</p>
<h3>Keep the raw request body</h3>
<p>Install the package used by Canopy's documentation:</p>
<pre><code class="language-sh">npm install standardwebhooks
</code></pre>
<p>Configure the endpoint's signing secret in <code>CANOPY_WEBHOOK_SECRET</code> on your server. A publishable key or Canopy API secret is not the webhook signing secret.</p>
<p>Standard Webhooks signs the payload together with the message ID and timestamp. Parsing JSON and serializing it again can change the bytes and break verification. Read the body as text once and pass that string directly to the verifier. <a href="https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md">Standard Webhooks specification</a>.</p>
<p>The request must include <code>webhook-id</code>, <code>webhook-timestamp</code> and <code>webhook-signature</code>. The official JavaScript verifier checks both the signature and timestamp tolerance. Keep the host clock synchronized. <a href="https://github.com/standard-webhooks/standard-webhooks/blob/main/libraries/javascript/src/index.ts">JavaScript verifier source</a>.</p>
<h3>Receive and store the delivery</h3>
<p>Create a persistence adapter with this contract:</p>
<pre><code class="language-ts">// lib/deposits/webhook-store.ts
export interface WebhookStore {
  recordAndEnqueue(input: {
    endpointKey: string;
    eventId: string;
    rawBody: string;
    payload: unknown;
  }): Promise&lt;void&gt;;
}
</code></pre>
<p><code>recordAndEnqueue</code> must atomically insert a delivery and create a pending reconciliation job. A unique database constraint on <code>(endpointKey, eventId)</code> makes retries a no-op. It must resolve only after the transaction commits. If the same ID arrives with a different body, preserve the original and raise an operational alert instead of overwriting it. <code>endpointKey</code> is your stable local endpoint identifier, especially useful when several Canopy accounts share one service.</p>
<p>Export your implementation as <code>webhookStore</code> and use it from the route:</p>
<pre><code class="language-ts">// app/api/canopy/webhook/route.ts
import { Webhook } from "standardwebhooks";
import { webhookStore } from "@/lib/deposits/webhook-store";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const secret = process.env.CANOPY_WEBHOOK_SECRET;
  if (!secret) return new Response("Endpoint unavailable", { status: 503 });

  const eventId = request.headers.get("webhook-id");
  const timestamp = request.headers.get("webhook-timestamp");
  const signature = request.headers.get("webhook-signature");
  if (!eventId || !timestamp || !signature) {
    return new Response("Missing signature headers", { status: 400 });
  }

  // Also configure a request body size limit at your ingress.
  const rawBody = await request.text();
  let payload: unknown;
  try {
    payload = new Webhook(secret).verify(rawBody, {
      "webhook-id": eventId,
      "webhook-timestamp": timestamp,
      "webhook-signature": signature,
    });
  } catch {
    return new Response("Invalid webhook", { status: 400 });
  }

  try {
    await webhookStore.recordAndEnqueue({
      endpointKey: "canopy-primary",
      eventId,
      rawBody,
      payload,
    });
  } catch {
    return new Response("Receipt unavailable", { status: 503 });
  }
  return new Response(null, { status: 204 });
}
</code></pre>
<p>A <code>204</code> now means the event has been saved and queued. It does not mean the worker has updated the user interface. Canopy retries non-2xx responses and timeouts, so acknowledging before the durable write would leave a gap if the process crashes. <a href="https://www.canopypay.io/webhooks">Canopy delivery behavior</a>.</p>
<p>Register this HTTPS endpoint in Canopy and save its signing secret. Use a separate secret and local endpoint identifier for each environment.</p>
<h3>Reconcile against the original intent</h3>
<p>The worker starts with a verified but otherwise untrusted shape: a valid signature identifies the sender, while schema and business checks decide what the payload means to your app. Validate string fields, nullable identifiers, the state discriminator and integer amount strings before using them.</p>
<p>Find the local record using <code>intentId</code> within the Canopy account associated with the endpoint. Compare the inbox and merchant reference with the saved record where available. If the intent is null, unknown, associated with another account, or its reference conflicts, mark the job for reconciliation. Do not guess the user from an address sent by a browser.</p>
<p>For a settled event, save its chain and transaction references with the intent. Then refresh or schedule a refresh of the user's wallet balance using the destination chain's provider or RPC. Indexing can lag, so a stale balance read should leave a visible pending refresh instead of causing another credit.</p>
<p>For non-settled states, save the outcome and stop before any crediting step. Because events can arrive out of order, a later delivery with an older state must not overwrite a settlement you have already confirmed.</p>
<h3>Deduplicate deliveries and business effects separately</h3>
<p>The delivery ID solves retries of one webhook. Your business record needs its own uniqueness rule because the same deposit could reach your system through an operator replay, another endpoint or a later reconciliation job.</p>
<p>For a one-time purchase, a unique fulfillment row for the local purchase can stop duplicate fulfillment. A repeatable wallet funding intent can receive more than one deposit, so making <code>intentId</code> globally unique in a credit table would also discard legitimate later deposits.</p>
<p>Resolve a canonical settlement operation from your supported route's evidence. That may require a chain transaction and transfer/log identity, the asset and recipient, or another documented unique settlement identifier. Confirm its granularity before choosing a database key: a transaction can contain several transfers. If the public webhook fields do not distinguish the operations your ledger needs, leave the event pending and obtain the missing evidence through your supported reconciliation process.</p>
<p>Once resolved, apply the effect in one database transaction:</p>
<pre><code class="language-text">begin transaction
  lock the local deposit record
  insert the canonical settlement operation under a unique constraint
  if it already exists: check it agrees with the saved evidence and stop
  record the confirmed outcome
  if this product has an internal ledger:
    write balanced ledger entries for the verified asset and amount
  mark the reconciliation job complete
commit
</code></pre>
<p>This is application logic, not an extra Canopy API operation. Use integer base units and an asset identifier that includes the chain. JavaScript floating-point numbers are unsuitable for token balances. Do not add <code>netUnits</code> from every full-state delivery without establishing whether it is an incremental amount for your route.</p>
<p>If the money lands in a self-custodial user wallet, your product may only need a settlement record and balance refresh. Crediting a separate spendable internal balance would create another obligation. Make that product decision explicitly before writing the ledger branch.</p>
<h3>Test the failure cases</h3>
<p>Use the verifier package to sign a synthetic fixture with a test secret. Keep test deliveries out of production accounting. Your first test should prove the body survives your HTTP framework unchanged.</p>
<table>
<thead>
<tr>
<th>Exercise</th>
<th>Expected result</th>
</tr>
</thead>
<tbody><tr>
<td>Correctly signed settlement for a saved intent</td>
<td>One delivery and one reconciliation job</td>
</tr>
<tr>
<td>Same delivery sent twice</td>
<td>One stored delivery; no second business effect</td>
</tr>
<tr>
<td>Payload changed after signing</td>
<td>HTTP 400; no queued job</td>
</tr>
<tr>
<td>Missing or stale timestamp</td>
<td>Rejected by header or signature checks</td>
</tr>
<tr>
<td>Database unavailable during receipt</td>
<td>Non-2xx response; no acknowledgement of durability</td>
</tr>
<tr>
<td>Process exits after receipt commits</td>
<td>Pending job survives and resumes</td>
</tr>
<tr>
<td>Valid event with an unknown intent</td>
<td>Durable receipt, pending investigation, no credit</td>
</tr>
<tr>
<td>Two legitimate deposits on a repeatable intent</td>
<td>Both reconciled using distinct operation identities</td>
</tr>
<tr>
<td>Duplicate business operation with a different delivery ID</td>
<td>One business effect</td>
</tr>
<tr>
<td>Non-settled or out-of-order outcome</td>
<td>No new credit and no downgrade of confirmed settlement</td>
</tr>
</tbody></table>
<p>Run a funded integration exercise only after the route and environment are approved. Compare the signed event, destination transaction and resulting wallet balance. The code here has not been tested against a funded Canopy deposit.</p>
<p>During signing-secret rotation, deploy verification with the new secret before revoking the retiring one. Canopy can sign with both during the transition. Keep receipt failures and pending reconciliation jobs visible to the team operating deposits, along with enough redacted context to find the associated intent.</p>
]]></content:encoded></item></channel></rss>