Your user has signed in and your app has created a Turnkey wallet. Their funds are still on an exchange. A deposit button needs to give them withdrawal instructions, route a supported deposit to their wallet, and show when settlement has completed.

This walkthrough adds that flow to an existing React and Next.js application. Your server resolves the user's Turnkey account, creates a Canopy intent with that address as its destination, and saves the relationship before opening checkout. The exchange sends to the address shown by checkout. Canopy's signed settlement webhook updates your application.

The destination example is an EVM account on Base. You must confirm the source asset, exchange withdrawal network and output token with Canopy before accepting funds. The provider snippets compile against the versions below. Local fixture tests and browser checks passed; real Turnkey account lookup, hosted Canopy checkout and funded settlement remain unverified.

What you need to implement

This recipe assumes Turnkey authentication and wallet creation already work in your app. It adds exchange deposits to that existing flow. The backend appendix contains the shared types, Canopy request helper and checkout component. The webhook appendix contains the signature-verifying receiver.

Your application must supply the remaining adapters:

Application codeResponsibility
requireUser and getBindingValidate the server session and load the account selected during verified onboarding.
lookupAccountMake the authenticated Turnkey account query with access to the correct organization.
checksumAddressNormalize an EVM address and reject the zero address.
Deposit store and API routePersist reservations, serialize creation, enforce ownership and return a saved intent.
Webhook store, worker and status endpointSave verified deliveries, reconcile settlement once and expose only the signed-in user's record.

These are application contracts. The appendices describe their behavior; they do not supply a production database or authentication system.

Versions checked

The reader-test application used @turnkey/sdk-server 8.6.0, @canopypay/checkout-sdk 0.7.2, viem 2.56.9 and standardwebhooks 1.1.1 with Next.js 16.3.6 and React 19.3.0. Its production build, 12 fixture tests, browser flow and restart-persistence check passed on Node 20.19.0. Keep your application's supported Node version consistent between installation and execution, and commit the resolved dependency lockfile.

The browser exercise used synthetic accounts and Canopy responses. It tested the application's deposit handling, not a real withdrawal. Use the Turnkey quickstart for the provider setup and Canopy quickstart for account credentials.

Choose the funding path

A direct withdrawal to the Turnkey account is appropriate when the exchange supports the exact token and network your application needs. Turnkey's embedded wallet quickstart includes sending and receiving funds. Its API also documents fiat onramp initiation and same-chain and cross-chain swap status.

Compare those options with your actual deposit requirement. LI.FI's Smart Deposit Addresses also support transfer-to-address funding with routing and asset conversion. That overlaps with this tutorial's user journey; a Turnkey application may choose to integrate it. Its existence does not establish that every Turnkey app already exposes that flow.

The Canopy path below manages deposit instructions and settlement reporting across its supported routes. It leaves your existing Turnkey authentication and subsequent transaction signing in place. See the funding guide for a comparison of direct transfers, bridges and routed deposits.

Prepare the destination and checkout

You need an existing authenticated Turnkey integration, a server database mapping application users to their Turnkey organizations and accounts, and a confirmed Canopy route. Start from the current Turnkey integration guide if wallet creation is still missing.

Configure the following server and browser values, using your existing secret manager:

dotenv
CANOPY_SECRET_KEY=<server-secret>
CANOPY_WEBHOOK_SECRET=<webhook-signing-secret>
NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=<publishable-key>
CANOPY_BASE_OUTPUT_TOKEN=<account-confirmed-token-address-or-native>

Enable per-intent destinations on your Canopy account and confirm that Canopy has provisioned the route's confirmedPayoutMode as Dynamic. This is a payout configuration, unrelated to the company named Dynamic. Omitting the destination sends settlement to the merchant account's payout wallet. A successful intent response alone does not prove that Base delivery is configured. Canopy destination requirements.

Register the exact HTTPS origin of your frontend for embedded checkout. Add the hosted SDK to the app and retain its resolved version in your lockfile:

sh
npm install @canopypay/checkout-sdk

Use the shared backend guide for lib/deposits/canopy.ts, the persistence contracts and components/DepositCheckout.tsx. The next step supplies its Turnkey-specific destination resolver.

Resolve the Turnkey account on the server

Turnkey distinguishes a wallet from the accounts derived from it. Save the particular account selected for deposits, including its organization ID, wallet ID and wallet-account ID. Selecting the first wallet or first EVM address returned by a list call can silently change the destination when the user adds another account.

The documented Get wallet account request is a signed POST to https://api.turnkey.com/public/v1/query/get_wallet_account, with organizationId, walletId and an address or path selector. The returned account includes walletAccountId, organizationId, walletId, address and addressFormat. List wallet accounts can populate the initial selection.

Implement the following application adapter in lib/deposits/turnkey.ts. lookupAccount is your authenticated Turnkey lookup, not a new SDK method. Satisfy its contract using the documented query through your authorized server integration. Turnkey's server SDK constructs and authenticates API requests. Confirm that your credential can read the user's sub-organization; do not assume a parent credential can access every account.

lib/deposits/turnkey.tsts
// lib/deposits/turnkey.ts
import "server-only";
import type { DepositOwner } from "./types";

type Binding = {
  userId: string;
  organizationId: string;
  walletId: string;
  walletAccountId: string;
  address: string;
};

type Account = {
  organizationId: string;
  walletId: string;
  walletAccountId: string;
  address: string;
  addressFormat: string;
};

type TurnkeyDepositAdapters = {
  // Validate the server session; reject missing or expired sessions.
  requireUser(request: Request): Promise<{ id: string }>;
  // Read the selected, previously verified account from your database.
  getBinding(userId: string): Promise<Binding | null>;
  // Authenticated get_wallet_account query; return response.account.
  lookupAccount(input: {
    organizationId: string;
    walletId: string;
    address: string;
  }): Promise<Account>;
  // Validate EVM address and return its checksum form; reject zero address.
  checksumAddress(address: string): string;
};

export function makeTurnkeyDepositResolver(a: TurnkeyDepositAdapters) {
  return async function resolveDepositOwner(
    request: Request,
  ): Promise<DepositOwner> {
    const user = await a.requireUser(request);
    const binding = await a.getBinding(user.id);
    if (!binding || binding.userId !== user.id) {
      throw new Error("No approved Turnkey deposit account");
    }
    const account = await a.lookupAccount({
      organizationId: binding.organizationId,
      walletId: binding.walletId,
      address: binding.address,
    });
    if (
      account.organizationId !== binding.organizationId ||
      account.walletId !== binding.walletId ||
      account.walletAccountId !== binding.walletAccountId ||
      account.address.toLowerCase() !== binding.address.toLowerCase() ||
      account.addressFormat !== "ADDRESS_FORMAT_ETHEREUM"
    )
      throw new Error("Turnkey account does not match the deposit binding");

    const tokenAddress = process.env.CANOPY_BASE_OUTPUT_TOKEN;
    if (!tokenAddress) throw new Error("Base output token is not configured");
    return {
      userId: user.id,
      providerWalletId: account.walletAccountId,
      destination: {
        wallet: a.checksumAddress(account.address),
        namespace: "eip155",
        chainReference: "8453",
        tokenAddress,
      },
    };
  };
}

The database mapping must originate from your verified onboarding or account-selection flow. A client-supplied organization ID is no more trustworthy than a client-supplied address. If your integration has no authorized server lookup, establish a verified account mapping during onboarding before enabling deposits; do not weaken ownership checks to make this function pass.

For an app using a smart account, this resolver needs a different destination mapping. Funding the Turnkey signer address will not fund a separate smart-account address. This walkthrough uses the selected Turnkey EVM account directly.

Checkpoint: signing in as two different users resolves two different approved accounts. Changing an address in the browser request has no effect. The server pins Base independently of whichever network the frontend wallet currently displays.

Create and save the intent

Wire the resolver into the authenticated POST /api/deposits flow from the shared guide. Inside the store's destination lock, the Canopy handoff is:

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);

These calls belong inside the full save-and-reuse operation, which first returns an existing saved active intent when available. reserveOrResume and attachIntentToOwner are application adapters, not Canopy functions. Save the intent ID, deposit inbox and immutable destination with the Turnkey account ID and user ID before returning an intent ID to the browser.

createCanopyIntent sends the server-only request to POST https://www.canopypay.io/api/v1/intents with bearer authentication, Content-Type: application/json and Canopy-Version: 2026-09-01. It maps the destination to payoutWallet, payoutNamespace, payoutChainReference and payoutTokenAddress. Canopy payment intents.

Canopy deduplicates active intents by merchant, namespace, chain, wallet and token. Repeating creation with matching terms returns the existing intent and rotates the widget token. Serialize creation across application instances and reuse your saved result. merchantReference provides correlation, not request idempotency. Keep an uncertain reservation after a timeout for reconciliation.

A 201 means the intent was created. It does not mean funds arrived. Also keep the Canopy deposit inbox separate from the Turnkey destination address in storage and UI.

Show the exchange withdrawal instructions

Mount DepositCheckout from the shared guide with the saved intentId. That component calls mount from @canopypay/checkout-sdk, sets canopyOrigin: "https://www.canopypay.io" and surface: "auto", and calls the returned handle's destroy() method when the view unmounts. Canopy embedding guide.

Place the destination wallet and "Base" above checkout so the user can see which account they are funding. Within checkout, they select a supported source asset and network, then copy the deposit address into their exchange withdrawal form. They must match the displayed network exactly. An EVM address's shape cannot distinguish Base from another EVM network.

Keep the flow pending after deposit_detected or paid. Those browser events can update the message to "Checking settlement" and request a refresh from your backend. Closing the tab must not cancel server reconciliation. Do not advertise the displayed inbox as a permanent universal deposit address.

Confirm settlement and refresh the balance

Install the handler from Crypto deposit webhook reconciliation. Verify the raw body with Standard Webhooks using webhook-id, webhook-timestamp and webhook-signature, then branch on the verified body's state === "settled". The event label is payment.settled; the payload is flat and has no event.type wrapper. Canopy webhooks.

Correlate the intent to the saved Turnkey account, record the settlement durably, and deduplicate both deliveries and the business operation. Keep netUnits and feeUnits as integer strings. The authenticated app can then refresh its own deposit status and the destination token balance on Base.

A wallet balance refresh and an internal ledger credit are separate operations. If this app only displays assets held in the user's wallet, record the funding history without inventing a second spendable balance. Any later transfer, swap or vault deposit uses your existing Turnkey authorization and signing flow.

Verify the integration

Before enabling withdrawals, run the following exercise with fixtures and then an explicitly approved funded route:

  1. Sign in as two users. Check the complete destination tuple and verify that each user can access only their own intent.
  2. Click Deposit twice and reload. Confirm that the app reuses the saved active intent without issuing concurrent create calls.
  3. 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.
  4. 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.
  5. Close checkout after withdrawal. Confirm server processing continues, then reopen the app and compare its recorded settlement with the destination wallet's token balance.

If lookup fails, check the sub-organization binding and read permissions first. If checkout opens but creation rejects destinations, distinguish the account's disabled setting from a route that Canopy has not provisioned. no_intent_destination and ambiguous_intent_destination require investigation of active-intent lifecycle and routing. A Base delivery failure can also require Canopy to inspect egress configuration. Preserve the saved intent and support reference while investigating; repeatedly creating deposits obscures the original attempt.

Turnkey wallet funding questions

Can a user withdraw directly from an exchange to a Turnkey wallet?

Yes, when the exchange supports the destination account's exact network and token. That direct transfer does not need a Canopy intent. Use the routed deposit flow when its confirmed source and settlement options fit the funding experience your app needs.

Does funding a Turnkey signer fund a smart account?

Only if that signer address is the intended receiving account. A separate smart account has its own address and balance. Resolve and verify that address before choosing the payout destination.

What confirms that the deposit settled?

Your backend verifies Canopy's signed webhook and reconciles it with the saved intent. A browser paid event or successful intent creation is insufficient. Refresh the destination token balance after recording the verified settlement.

For the equivalent React flow with Para identity tokens, see Para wallet funding. The Canopy documentation covers checkout configuration and API details.

Code reference

Canopy backend and checkout

Your app has created a wallet for a user. To add a deposit button, your backend needs to identify that wallet, create a Canopy payment intent with it as the payout destination, and return the intent ID to checkout. Once a deposit settles, a signed webhook updates your app's record.

This guide builds the common Canopy part of the provider tutorials. It assumes an existing Next.js application with server authentication and a database. The wallet provider supplies the destination address through an authenticated server integration. Your application supplies the session and database adapters described below.

The code is an integration pattern, not a standalone starter app. You must connect those adapters to your own authentication and persistence before running it.

The sequence is: authenticate the user, resolve the destination, create and save the intent, open checkout, then reconcile the signed settlement webhook.

Configure a route before accepting deposits

Create a Canopy account and obtain a server secret key and a frontend publishable key. Register your application's exact HTTPS origin for inline checkout. Keep the secret in a server environment variable:

dotenv
CANOPY_SECRET_KEY=<server-secret>
NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=<publishable-key>
CANOPY_WEBHOOK_SECRET=<endpoint-signing-secret>

To settle into a user's wallet, enable per-intent destinations on your account and have Canopy confirm that the intended route uses Dynamic payout mode. Canopy controls route provisioning. This mode has no connection to the wallet provider named Dynamic. A request that omits a destination uses the account's own payout wallet. Canopy payout destinations.

Pick one destination chain and token for the first integration. The examples below use Base, whose chain reference is 8453, and expect a configured token address from your server settings. Confirm that token, the source assets, minimum amounts and the complete route with Canopy. An address being valid on Base does not establish that a particular deposit route is available.

Record the provider wallet ID, destination address, namespace, chain reference and token alongside the user who owns the wallet. If you support both a signer account and a smart account, choose the account whose balance the product displays.

Resolve the destination on your server

The browser can request a deposit for its signed-in user. Your backend decides where that deposit goes. A wallet address submitted in a request body is not proof of ownership.

Each provider tutorial describes how to populate this application-owned result:

lib/deposits/types.tsts
// lib/deposits/types.ts
export type DepositDestination = {
  wallet: string;
  namespace: "eip155" | "solana";
  chainReference: string;
  tokenAddress: string;
};

export type DepositOwner = {
  userId: string;
  providerWalletId: string;
  destination: DepositDestination;
};

Your resolveDepositOwner(request) adapter must verify the session or provider token, fetch the wallet through an authenticated provider lookup or a previously verified database mapping, and check that the user may fund it. Check the destination against your server's supported route configuration. Reject ambiguous wallet selections. For custodial or organization wallets, verify the application's tenant and account permissions too.

Keep a unique ownership constraint on the destination you use for individual wallets. A shared treasury address needs a separate attribution design; the same destination cannot safely stand in for several users in this walkthrough.

Create the Canopy intent

Add the following server helper. Its input comes from your verified wallet mapping and a persisted local deposit reference.

lib/deposits/canopy.tsts
// lib/deposits/canopy.ts
import "server-only";
import type { DepositDestination } from "./types";

export async function createCanopyIntent(input: {
  reference: string;
  destination: DepositDestination;
}): Promise<{
  intentId: string;
  inboxAddress: string;
  created: boolean;
}> {
  const key = process.env.CANOPY_SECRET_KEY;
  if (!key) throw new Error("CANOPY_SECRET_KEY is missing");
  if (!input.reference || input.reference.length > 128) {
    throw new Error("Invalid deposit reference");
  }
  const { destination } = input;
  const response = await fetch("https://www.canopypay.io/api/v1/intents", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${key}`,
      "Canopy-Version": "2026-09-01",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      merchantReference: input.reference,
      payoutWallet: destination.wallet,
      payoutNamespace: destination.namespace,
      payoutChainReference: destination.chainReference,
      payoutTokenAddress: destination.tokenAddress,
    }),
    cache: "no-store",
  });

  if (!response.ok) {
    // Record a redacted error code/request ID in your server logs.
    // Do not forward provider error bodies or credentials to the browser.
    throw new Error(`Canopy create failed with HTTP ${response.status}`);
  }
  const result: unknown = await response.json();
  if (
    !result ||
    typeof result !== "object" ||
    !("intentId" in result) ||
    typeof result.intentId !== "string" ||
    !("inboxAddress" in result) ||
    typeof result.inboxAddress !== "string" ||
    !("created" in result) ||
    typeof result.created !== "boolean"
  )
    throw new Error("Unexpected Canopy create response");

  return {
    intentId: result.intentId,
    inboxAddress: result.inboxAddress,
    created: result.created,
  };
}

The output token is pinned with payoutTokenAddress. Use a token configured for your account and route. Leaving it out uses the matching account currency or the configured chain default. The destination fields become fixed when the intent is created. Canopy payment intents.

Canopy deduplicates active intents by merchant account, namespace, chain, wallet and token. A repeat create with matching terms returns the existing intent and rotates its widget token. merchantReference is a correlation value; it does not deduplicate requests. There is no request idempotency key. These rules matter when a user double-clicks Deposit or reloads the page.

Save and reuse the intent

Wrap intent creation in a durable application operation. For example, an authenticated POST /api/deposits handler can use this flow:

ts
// Application pseudocode: implement these adapters in your app.
const owner = await resolveDepositOwner(request);
const result = await depositStore.withDestinationLock(owner, async () => {
  const existing = await depositStore.findActive(owner);
  if (existing?.intentId) return existing;

  // Durable get-or-create: reuse any pending reservation and its reference.
  const pending = await depositStore.reserve(owner);
  const intent = await createCanopyIntent({
    reference: pending.reference,
    destination: owner.destination,
  });

  return depositStore.attachIntent(pending.id, intent);
});
return Response.json(
  { intentId: result.intentId },
  { headers: { "Cache-Control": "no-store" } },
);

depositStore and resolveDepositOwner are your application code, not Canopy SDK APIs. The store must persist the reservation before the external request, serialize operations across server instances, enforce ownership of existing records, and save the returned intent before checkout opens. An in-memory mutex cannot coordinate separate instances.

Give the store methods precise contracts. findActive returns a reusable record with an attached intentId, or null; an unfinished reservation cannot be returned to checkout. reserve is a durable get-or-create operation for the same owner and destination terms. It returns an existing pending reservation with its original reference when one exists. Enforce that uniqueness in the database, including when an earlier request timed out. attachIntent must complete that same reservation and reject a conflicting user or destination binding.

Retain a reservation after a timeout so you can reconcile the uncertain result using the same destination and terms. Do not create another local deposit reference on each retry. If a retry returns an intent already associated with a different user or operation, stop and investigate the mapping.

Use your framework's CSRF protection or validate the request origin for cookie-authenticated writes. Rate-limit intent creation per user. Return a controlled error when authentication fails, a wallet is unavailable or the route has not been configured. Avoid putting a stack trace in the response.

At this checkpoint, one authenticated user should have one saved active intent for the chosen destination. A second click should reuse it. Another user must not be able to retrieve it.

Mount checkout in React

Install the hosted SDK in your existing app:

sh
npm install @canopypay/checkout-sdk

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.

components/DepositCheckout.tsxtsx
// 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(() => {
    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 () => handle.destroy();
  }, [intentId, id]);

  return (
    <section aria-label="Fund your wallet">
      <div id={`deposit-${id}`} />
      <p role="status">{message}</p>
    </section>
  );
}

The SDK's surface: "auto" attempts inline checkout and offers a popup fallback if the frame cannot complete its handshake. The npm build needs canopyOrigin. See the embedding guide and SDK events.

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.

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.

Confirm settlement and refresh the wallet

Connect the webhook reconciliation handler 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.

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.

Check the complete flow

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.

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.

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.

Code reference

Signed webhook reconciliation

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.

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 embedded wallet deposit backend and works independently of the wallet provider.

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.

Understand the event body

Canopy's public webhook contract uses a flat JSON object:

ts
type SettlementPayload = {
  intentId: string | null;
  inboxAddress: string;
  created: false;
  state: string;
  feeUnits: string;
  netUnits: string;
  txHash: string;
  chainId: number;
  merchantReference: string | null;
};

The documentation calls the success event payment.settled. Its body discriminator is state: "settled"; there is no outer event.type or data wrapper. payment.failed is documented but is not currently emitted. A handler should still refuse to fund on any state other than settled. Delivery order is not guaranteed. Canopy webhook contract.

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.

Keep the raw request body

Install the package used by Canopy's documentation:

sh
npm install standardwebhooks

Configure the endpoint's signing secret in CANOPY_WEBHOOK_SECRET on your server. A publishable key or Canopy API secret is not the webhook signing secret.

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. Standard Webhooks specification.

The request must include webhook-id, webhook-timestamp and webhook-signature. The official JavaScript verifier checks both the signature and timestamp tolerance. Keep the host clock synchronized. JavaScript verifier source.

Receive and store the delivery

Create a persistence adapter with this contract:

lib/deposits/webhook-store.tsts
// lib/deposits/webhook-store.ts
export interface WebhookStore {
  recordAndEnqueue(input: {
    endpointKey: string;
    eventId: string;
    rawBody: string;
    payload: unknown;
  }): Promise<void>;
}

recordAndEnqueue must atomically insert a delivery and create a pending reconciliation job. A unique database constraint on (endpointKey, eventId) 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. endpointKey is your stable local endpoint identifier, especially useful when several Canopy accounts share one service.

Export your implementation as webhookStore and use it from the route:

app/api/canopy/webhook/route.tsts
// 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 });
}

A 204 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. Canopy delivery behavior.

Register this HTTPS endpoint in Canopy and save its signing secret. Use a separate secret and local endpoint identifier for each environment.

Reconcile against the original intent

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.

Find the local record using intentId 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.

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.

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.

Deduplicate deliveries and business effects separately

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.

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 intentId globally unique in a credit table would also discard legitimate later deposits.

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.

Once resolved, apply the effect in one database transaction:

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

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 netUnits from every full-state delivery without establishing whether it is an incremental amount for your route.

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.

Test the failure cases

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.

ExerciseExpected result
Correctly signed settlement for a saved intentOne delivery and one reconciliation job
Same delivery sent twiceOne stored delivery; no second business effect
Payload changed after signingHTTP 400; no queued job
Missing or stale timestampRejected by header or signature checks
Database unavailable during receiptNon-2xx response; no acknowledgement of durability
Process exits after receipt commitsPending job survives and resumes
Valid event with an unknown intentDurable receipt, pending investigation, no credit
Two legitimate deposits on a repeatable intentBoth reconciled using distinct operation identities
Duplicate business operation with a different delivery IDOne business effect
Non-settled or out-of-order outcomeNo new credit and no downgrade of confirmed settlement

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.

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.

Continue reading

Para wallet funding: add crypto deposits in React