DEV Community

boring
boring

Posted on Originally published at canopypay.io

Add cross-chain deposits to Dfns wallets and reconcile settlement

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 your product uses. You need to route that deposit into the correct customer wallet and keep the resulting settlement separate from other incoming transfers.

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.

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.

Keep Dfns deposit automation where it fits

Dfns already documents incoming-deposit detection, duplicate handling, reconciliation and treasury sweeping. Its deposit automation guide 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.

Use this Canopy path when you have selected a supported route that converts or delivers an incoming deposit to a different configured destination. The funding guide explains how this differs from direct transfers and connected-wallet bridges.

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. Dfns wallet and vault model.

Prepare the two services

Start with a Dfns organization, an existing Base wallet assigned to each customer, and a backend credential with Wallets:Read permission. Get Wallet 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.

Keep credentials in server environment variables. Add the Canopy values from the shared backend guide, plus your Dfns read credential:

DFNS_AUTH_TOKEN=<server-only-read-credential>
CANOPY_SECRET_KEY=<server-only-secret>
CANOPY_WEBHOOK_SECRET=<server-only-webhook-secret>
NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=<publishable-key>
Enter fullscreen mode Exit fullscreen mode

Enable per-intent destinations and have Canopy provision the route in Dynamic 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. Canopy destinations.

Install viem@2.56.3 for EVM address validation and @canopypay/checkout-sdk@0.7.2 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.

The token contract and API origin are public configuration. Put them in lib/deposits/config.ts, separate from credentials:

// 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";
Enter fullscreen mode Exit fullscreen mode

Circle lists this USDC contract on Base. 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.

Dfns organizations and wallets belong to a specific API region. 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. Dfns authentication.

Retrieve the approved customer wallet

Create lib/deposits/dfns.ts with the documented GET /wallets/{walletId} request. The function takes a wallet ID from your verified database mapping, never directly from the checkout request.

// 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<DfnsWallet> {
  if (!walletId || walletId.length > 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<string, unknown>)[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 };
}
Enter fullscreen mode Exit fullscreen mode

Now connect that request to your existing session and ownership model:

// 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<{ userId: string; tenantId: string }>;
  findApprovedWallet(customer: { userId: string; tenantId: string }): Promise<{
    walletId: string;
    address: string;
  } | null>;
};

export function makeDfnsOwnerResolver(a: Adapters) {
  return async (request: Request): Promise<DepositOwner> => {
    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,
      },
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

requireCustomer verifies your application session and returns a globally unique application user ID and the authorized tenant. findApprovedWallet 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.

The reader validates the returned address with viem, 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.

Checkpoint: a customer resolves to an active Dfns wallet whose network is Base. 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.

Save a Canopy intent before opening checkout

Use the shared guide's authenticated POST /api/deposits handler and durable destination lock. After it has checked for an existing active record, the provider-specific handoff is:

// 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.
Enter fullscreen mode Exit fullscreen mode

Create resolveDepositOwner with makeDfnsOwnerResolver({ requireCustomer, findApprovedWallet }). The shared handler calls it before taking its destination lock. owner is that result, and pending is the saved reservation inside the lock. createCanopyIntent is the shared server helper. It posts to https://www.canopypay.io/api/v1/intents with the secret bearer key, JSON content type and Canopy-Version: 2026-09-01. It supplies all destination fields, including the Base USDC contract as payoutTokenAddress.

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. merchantReference is for correlation, and there is no request Idempotency-Key. Payment intents.

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 201 response establishes creation, not completed delivery.

Add checkout to the customer funding page

Use components/DepositCheckout.tsx from the shared guide in your existing funding page. Request the saved intent ID from your authenticated POST /api/deposits endpoint and pass it as <DepositCheckout intentId={intentId} />. The component mounts with your publishable key, canopyOrigin: "https://www.canopypay.io" and surface: "auto"; its cleanup calls handle.destroy().

Use your actual cnpy_pk_live_ 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.

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.

Treat paid and deposit_detected as pending UI notifications. Refresh your own backend record when they fire, and continue server processing when the customer closes the page. Canopy SDK events.

Reconcile two different event streams

Dfns documents wallet.blockchain_event.transfer.included for early detection on supported networks, with status: "Included". Its wallet.blockchainevent.detected notification follows the network confirmation delay with status: "Confirmed". An included transfer can still be affected by a reorganization. Dfns deposit automation.

Canopy's signed payment.settled confirms the Canopy operation. Verify its raw body using Standard Webhooks and branch on state === "settled"; the body is flat, without an event-type wrapper. Match the saved intent and destination configuration before updating your records. Use the webhook reconciliation implementation.

Keep Dfns's webhook receiver separate and use its documented signature verification. The Canopy Standard Webhooks handler below does not verify Dfns deliveries. This guide does not implement or test a Dfns event receiver.

Keep the records distinct:

Record What it establishes Application action
Canopy settled operation The routed deposit's settlement result Record the operation once under its saved customer and wallet binding
Dfns included transfer A destination-wallet transfer is observed in a block Show pending destination activity where supported
Dfns confirmed transfer The wallet transfer passed Dfns confirmation requirements Reconcile destination receipt and any permitted downstream sweep

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

After settlement, refresh assets through Dfns's documented Get Wallet Assets endpoint, GET /wallets/{walletId}/assets. 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.

Exercise the failure paths

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.

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.

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.

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 no_intent_destination and ambiguous_intent_destination against the saved active-intent records. If a Dfns event is missing, use wallet history reconciliation before treating the deposit as lost.

What has been checked

The provider snippets and linked backend, React checkout and Canopy webhook snippets type-check with TypeScript 5.9.3, viem 2.56.3, React 19.2.8, @canopypay/checkout-sdk 0.7.2 and standardwebhooks 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.

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.

Canopy backend and checkout

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

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

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

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

Configure a route before accepting deposits

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

CANOPY_SECRET_KEY=<server-secret>
NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=<publishable-key>
CANOPY_WEBHOOK_SECRET=<endpoint-signing-secret>
Enter fullscreen mode Exit fullscreen mode

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

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

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

Resolve the destination on your server

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

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

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

export type DepositOwner = {
  userId: string;
  providerWalletId: string;
  destination: DepositDestination;
};
Enter fullscreen mode Exit fullscreen mode

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

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

Create the Canopy intent

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

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

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

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

  return {
    intentId: result.intentId,
    inboxAddress: result.inboxAddress,
    created: result.created,
  };
}
Enter fullscreen mode Exit fullscreen mode

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

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

Save and reuse the intent

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

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

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

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

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:

npm install @canopypay/checkout-sdk
Enter fullscreen mode Exit fullscreen mode

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.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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

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:

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

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:

npm install standardwebhooks
Enter fullscreen mode Exit fullscreen mode

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.ts
export interface WebhookStore {
  recordAndEnqueue(input: {
    endpointKey: string;
    eventId: string;
    rawBody: string;
    payload: unknown;
  }): Promise<void>;
}
Enter fullscreen mode Exit fullscreen mode

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.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 });
}
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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.

Exercise Expected result
Correctly signed settlement for a saved intent One delivery and one reconciliation job
Same delivery sent twice One stored delivery; no second business effect
Payload changed after signing HTTP 400; no queued job
Missing or stale timestamp Rejected by header or signature checks
Database unavailable during receipt Non-2xx response; no acknowledgement of durability
Process exits after receipt commits Pending job survives and resumes
Valid event with an unknown intent Durable receipt, pending investigation, no credit
Two legitimate deposits on a repeatable intent Both reconciled using distinct operation identities
Duplicate business operation with a different delivery ID One business effect
Non-settled or out-of-order outcome No 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.

Top comments (0)