A user has a Portal wallet in your app and funds on an exchange. To fund the app, they need withdrawal instructions that lead to the asset and chain your app accepts. This guide adds Canopy's hosted deposit checkout to an existing Portal web application and uses the user's Portal EVM address as the destination on Base.
The integration has two parts: your server binds a Canopy intent to the correct Portal client, and your frontend shows the deposit flow. A signed settlement webhook updates your app after the transfer completes. The code fits into an application with existing authentication and persistence; the application adapters below need implementations before you can run it.
The Portal and checkout snippets compile unchanged with @portal-hq/web 4.0.0 and @canopypay/checkout-sdk 0.7.2 on Node 26.8.1, and local fixture tests passed against those installed packages. A browser run, real Portal login, hosted Canopy checkout and funded settlement remain unverified.
Choose the funding flow
Portal already has a LI.FI integration for quotes, swaps, bridges and transaction tracking. Its documented flow starts with assets in a source wallet and signs the transactions needed to move them. That is useful when the user already has funds in a wallet your application can transact from.
Here, the user sends a manual transfer from an exchange or another wallet to the address shown by Canopy checkout. Keep Portal's native options when they fit your requirements. Compare the actual source asset, network, destination and fees before choosing a funding route; support for LI.FI or for an EVM chain does not establish the availability of every deposit route.
LI.FI also advertises enterprise Smart Deposit Addresses. Check their availability for your integration; that announcement does not establish that Portal exposes them through its SDK.
The embedded-wallet funding guide compares address deposits with direct transfers, bridges and fiat onramps.
This walkthrough uses a browser page. For a native Portal application, the same backend mapping applies, but checkout presentation and return-to-app behavior need a separate platform integration. Do not paste this browser SDK into an Android activity or React Native view.
Prepare the wallet and destination
You need an existing Portal web integration, an authenticated application session and a server record linking that user to their Portal client. Portal's web setup uses @portal-hq/web; its current default RPC configuration includes Base. Retain your installed, working Portal version and lockfile while adding the funding flow.
Configure these Canopy requirements before showing checkout:
- A server secret, a publishable frontend key and a webhook signing secret.
- Your exact HTTPS frontend origin registered for embedding.
- Per-intent destinations enabled on the account and the relevant route provisioned by Canopy in Dynamic payout mode.
- An agreed output token on Base and a supported input asset/network, including its minimum deposit amount.
Base is the example destination, identified by namespace eip155 and chain reference 8453. Confirm the output token and the full route with Canopy; the code obtains the token from server configuration. An intent without destination fields pays your merchant account's payout wallet. Canopy destination requirements.
Read the Portal address after the SDK is ready
Portal loads wallet resources asynchronously. Use its documented onReady callback before reading the address. In your existing wallet screen, add:
// lib/portal/show-wallet-address.ts
import type Portal from "@portal-hq/web";
export function showWalletAddress(portal: Portal, output: HTMLElement) {
portal.onReady(async () => {
try {
if (!(await portal.doesWalletExist("eip155:8453"))) {
output.textContent = "Create your wallet before adding funds.";
return;
}
const address = await portal.getEip155Address();
if (!address) {
output.textContent = "Create your wallet before adding funds.";
return;
}
output.textContent = address;
} catch {
output.textContent = "Your wallet could not be loaded. Sign in again.";
}
});
}Pass the Portal instance already initialized for the current user. These methods come from Portal's wallet creation guide. At this checkpoint, the wallet screen should display the intended EVM address. getEip155Address() returns an empty string when the client has no EVM wallet, so the snippet checks the Base chain explicitly and treats an empty result as a missing wallet.
An address can exist even when this device cannot sign. Portal documents isWalletOnDevice and recovery for lost local shares. Keep that recovery flow available before offering later spending actions. Receiving a deposit does not restore a missing signing share.
Resolve the same wallet on your server
The displayed address is a useful check, but the deposit endpoint must use your server's authenticated Portal mapping. Portal distinguishes a server-side Portal API key, which can fetch client information, from a Client Session Token used by its SDK. Portal API keys.
Use the following application-owned adapter with the types in the shared backend guide:
// lib/deposits/portal-owner.ts
import "server-only";
import type { DepositOwner } from "./types";
type Dependencies = {
requireUser(request: Request): Promise<{ id: string }>;
loadVerifiedPortalWallet(userId: string): Promise<{
clientId: string;
eip155Address: string;
} | null>;
outputTokenOnBase: string;
};
export async function resolvePortalOwner(
request: Request,
deps: Dependencies,
): Promise<DepositOwner> {
const user = await deps.requireUser(request);
const wallet = await deps.loadVerifiedPortalWallet(user.id);
if (!wallet || !deps.outputTokenOnBase) {
throw new Error("Portal deposit destination is not configured");
}
return {
userId: user.id,
providerWalletId: wallet.clientId,
destination: {
wallet: wallet.eip155Address,
namespace: "eip155",
chainReference: "8453",
tokenAddress: deps.outputTokenOnBase,
},
};
}requireUser and loadVerifiedPortalWallet are your application functions. The lookup must use a Portal client ID already bound to the signed-in user, verify the address through your authenticated Portal integration, and return a validated checksummed EVM address. Reject missing, ambiguous or differently owned records. A browser POST containing clientId and address does not satisfy that contract.
This example assumes your app already controls the login and Portal client mapping. If you use Portal's newer Client Auth, its web session token stays inside Portal's iframe, and restoreSession() returns an identity rather than a credential. Establish a server-verifiable identity mapping for that mode before enabling this endpoint; do not treat a browser-provided endUserId as authentication.
Create and save the Canopy intent
Wire resolvePortalOwner into your authenticated POST /api/deposits handler. Inside the durable reservation and destination lock described in the shared guide, the Canopy handoff is:
// At the top of your deposit route module:
import { createCanopyIntent } from "@/lib/deposits/canopy";
// Inside your server's serialized deposit operation:
const intent = await createCanopyIntent({
reference: reservation.reference,
destination: owner.destination,
});owner is the verified result above. reservation is your persisted application deposit record. The shared helper sends a server-authenticated POST https://www.canopypay.io/api/v1/intents with API version 2026-09-01 and explicit destination fields, including the configured output token. Save the returned intent against the Portal client before returning { intentId } to the browser.
Reuse the saved active intent when the user reopens the page. Canopy deduplicates active intents by merchant, destination namespace, chain, wallet and token; another create rotates the widget token. merchantReference provides correlation, not request idempotency. Follow the shared guide's reservation and retry handling so two simultaneous clicks do not create conflicting local records. Payment intent behavior.
A successful create confirms that Canopy accepted the intent. It does not prove settlement. Keep the deposit address and the destination Portal address as separate fields in your application.
Show checkout in the browser
Install @canopypay/checkout-sdk in the existing frontend and add <div id="canopy-deposit"></div> to the funding screen. After /api/deposits returns the saved intent ID, mount checkout:
// lib/deposits/mount-checkout.ts
import { mount } from "@canopypay/checkout-sdk";
export function mountPortalDeposit(
intentId: string,
publishableKey: string,
message: HTMLElement,
) {
return mount({
target: "#canopy-deposit",
intentId,
merchant: publishableKey,
canopyOrigin: "https://www.canopypay.io",
surface: "auto",
onEvent(event) {
if (event.type === "paid" || event.type === "deposit_detected") {
message.textContent = "Checking deposit settlement.";
}
if (event.type === "error") {
message.textContent =
"Checkout needs attention. Reopen the funding page.";
}
},
});
}Keep the returned handle and call handle.destroy() when the view unmounts or the user signs out. Mount one instance in this container at a time. The SDK accepts only publishable keys beginning cnpy_pk_live_ and refuses to mount otherwise, reporting an error event. surface: "auto" attempts inline checkout and offers a popup fallback if the frame handshake fails. Canopy embedding guide.
The user selects an available source in checkout and follows its address, asset and network instructions. Do not replace those instructions with the Portal destination address or a hardcoded list of Portal-supported chains.
Confirm settlement and refresh the wallet
Implement the webhook reconciliation guide before accepting deposits. Verify the raw body with Standard Webhooks using webhook-id, webhook-timestamp and webhook-signature. The verified payload is flat; branch on payload.state === "settled". The event's label, payment.settled, is not an event.type field in that body. Canopy webhooks.
Correlate the intent to your saved Portal client and user, then durably record the settlement. Deduplicate deliveries and the business operation so retries cannot credit the same transfer twice. Keep amounts in integer units. If your app only displays the on-chain wallet balance, refresh that balance after confirmation. If the product needs an internal credit, define and deduplicate it separately.
When a mobile browser resumes after the user visits an exchange, reload the deposit record from your authenticated backend. Closing checkout must not prevent the webhook handler from processing the deposit. A paid callback can show progress while the authoritative record is still pending.
Verify the flow
Before a funded run, check that the frontend and server resolve the same Portal wallet, a second click reuses the intent, and another user's session cannot retrieve it. Confirm that the configured token and Base destination match the route provisioned for your account.
For an authorized deposit test, record the source asset/network and output token first. Send only through the instructions displayed for that route. Observe the webhook's verified settlement state, the saved intent-to-client mapping and the resulting destination balance. Close the browser during a separate exercise and confirm that processing still completes. Replay a valid delivery in your test harness and check that it creates no second credit.
| Symptom | Check |
|---|---|
| Portal address differs from the server record | Stop checkout and inspect the user-to-client mapping and current SDK session. |
| Address loads but spending fails | Check whether the signing shares are on the device and follow Portal recovery. |
| Per-intent destination rejected | Check the account setting; route Dynamic mode requires Canopy provisioning. |
| Inline checkout fails | Check the exact registered HTTPS origin and your iframe/CSP configuration. |
| UI reports a deposit but balance has not changed | Inspect the verified settlement record and destination-chain balance; do not credit from the UI event. |
ambiguous_intent_destination | Reconcile the active intent mapping before another funding attempt. |
Keep the Portal client ID, application deposit reference and Canopy intent ID together in support records. That gives you the link from a user's wallet screen to the settlement your backend processed.
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:
CANOPY_SECRET_KEY=<server-secret>
NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=<publishable-key>
CANOPY_WEBHOOK_SECRET=<endpoint-signing-secret>To settle into a user's wallet, enable per-intent destinations on your account and have Canopy confirm that the intended route uses Dynamic payout mode. Canopy controls route provisioning. This mode has no connection to the wallet provider named Dynamic. A request that omits a destination uses the account's own payout wallet. Canopy payout destinations.
Pick one destination chain and token for the first integration. The examples below use Base, whose chain reference is 8453, and expect a configured token address from your server settings. Confirm that token, the source assets, minimum amounts and the complete route with Canopy. An address being valid on Base does not establish that a particular deposit route is available.
Record the provider wallet ID, destination address, namespace, chain reference and token alongside the user who owns the wallet. If you support both a signer account and a smart account, choose the account whose balance the product displays.
Resolve the destination on your server
The browser can request a deposit for its signed-in user. Your backend decides where that deposit goes. A wallet address submitted in a request body is not proof of ownership.
Each provider tutorial describes how to populate this application-owned result:
// lib/deposits/types.ts
export type DepositDestination = {
wallet: string;
namespace: "eip155" | "solana";
chainReference: string;
tokenAddress: string;
};
export type DepositOwner = {
userId: string;
providerWalletId: string;
destination: DepositDestination;
};Your resolveDepositOwner(request) adapter must verify the session or provider token, fetch the wallet through an authenticated provider lookup or a previously verified database mapping, and check that the user may fund it. Check the destination against your server's supported route configuration. Reject ambiguous wallet selections. For custodial or organization wallets, verify the application's tenant and account permissions too.
Keep a unique ownership constraint on the destination you use for individual wallets. A shared treasury address needs a separate attribution design; the same destination cannot safely stand in for several users in this walkthrough.
Create the Canopy intent
Add the following server helper. Its input comes from your verified wallet mapping and a persisted local deposit reference.
// lib/deposits/canopy.ts
import "server-only";
import type { DepositDestination } from "./types";
export async function createCanopyIntent(input: {
reference: string;
destination: DepositDestination;
}): Promise<{
intentId: string;
inboxAddress: string;
created: boolean;
}> {
const key = process.env.CANOPY_SECRET_KEY;
if (!key) throw new Error("CANOPY_SECRET_KEY is missing");
if (!input.reference || input.reference.length > 128) {
throw new Error("Invalid deposit reference");
}
const { destination } = input;
const response = await fetch("https://www.canopypay.io/api/v1/intents", {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
"Canopy-Version": "2026-09-01",
"Content-Type": "application/json",
},
body: JSON.stringify({
merchantReference: input.reference,
payoutWallet: destination.wallet,
payoutNamespace: destination.namespace,
payoutChainReference: destination.chainReference,
payoutTokenAddress: destination.tokenAddress,
}),
cache: "no-store",
});
if (!response.ok) {
// Record a redacted error code/request ID in your server logs.
// Do not forward provider error bodies or credentials to the browser.
throw new Error(`Canopy create failed with HTTP ${response.status}`);
}
const result: unknown = await response.json();
if (
!result ||
typeof result !== "object" ||
!("intentId" in result) ||
typeof result.intentId !== "string" ||
!("inboxAddress" in result) ||
typeof result.inboxAddress !== "string" ||
!("created" in result) ||
typeof result.created !== "boolean"
)
throw new Error("Unexpected Canopy create response");
return {
intentId: result.intentId,
inboxAddress: result.inboxAddress,
created: result.created,
};
}The output token is pinned with payoutTokenAddress. Use a token configured for your account and route. Leaving it out uses the matching account currency or the configured chain default. The destination fields become fixed when the intent is created. Canopy payment intents.
Canopy deduplicates active intents by merchant account, namespace, chain, wallet and token. A repeat create with matching terms returns the existing intent and rotates its widget token. merchantReference is a correlation value; it does not deduplicate requests. There is no request idempotency key. These rules matter when a user double-clicks Deposit or reloads the page.
Save and reuse the intent
Wrap intent creation in a durable application operation. For example, an authenticated POST /api/deposits handler can use this flow:
// Application pseudocode: implement these adapters in your app.
const owner = await resolveDepositOwner(request);
const result = await depositStore.withDestinationLock(owner, async () => {
const existing = await depositStore.findActive(owner);
if (existing?.intentId) return existing;
// Durable get-or-create: reuse any pending reservation and its reference.
const pending = await depositStore.reserve(owner);
const intent = await createCanopyIntent({
reference: pending.reference,
destination: owner.destination,
});
return depositStore.attachIntent(pending.id, intent);
});
return Response.json(
{ intentId: result.intentId },
{ headers: { "Cache-Control": "no-store" } },
);depositStore and resolveDepositOwner are your application code, not Canopy SDK APIs. The store must persist the reservation before the external request, serialize operations across server instances, enforce ownership of existing records, and save the returned intent before checkout opens. An in-memory mutex cannot coordinate separate instances.
Give the store methods precise contracts. findActive returns a reusable record with an attached intentId, or null; an unfinished reservation cannot be returned to checkout. reserve is a durable get-or-create operation for the same owner and destination terms. It returns an existing pending reservation with its original reference when one exists. Enforce that uniqueness in the database, including when an earlier request timed out. attachIntent must complete that same reservation and reject a conflicting user or destination binding.
Retain a reservation after a timeout so you can reconcile the uncertain result using the same destination and terms. Do not create another local deposit reference on each retry. If a retry returns an intent already associated with a different user or operation, stop and investigate the mapping.
Use your framework's CSRF protection or validate the request origin for cookie-authenticated writes. Rate-limit intent creation per user. Return a controlled error when authentication fails, a wallet is unavailable or the route has not been configured. Avoid putting a stack trace in the response.
At this checkpoint, one authenticated user should have one saved active intent for the chosen destination. A second click should reuse it. Another user must not be able to retrieve it.
Mount checkout in React
Install the hosted SDK in your existing app:
npm install @canopypay/checkout-sdkThis 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>
);
}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:
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:
npm install standardwebhooksConfigure 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>;
}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 });
}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
commitThis 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.
Continue reading
Turnkey wallet funding: add exchange deposits with Canopy