Your user has signed in and Para has created an embedded wallet. They still need a way to move crypto from an exchange or another wallet into it. This guide adds a Deposit button that opens Canopy checkout and sends settlement to that user's verified EVM wallet on Base.
The receiving wallet stays with Para, formerly Capsule. Your server binds a Canopy intent to the authenticated user; Canopy provides the deposit instructions and reports settlement to your webhook. The source asset and network must be part of your configured Canopy route.
This is an integration recipe for an existing React/Next.js app. It includes the Para-specific code and uses the shared Canopy backend for persistence and checkout. The application adapters need your authentication and database implementation. The provider snippets compile against the versions below. Local fixture tests and browser checks passed; real Para login, hosted Canopy checkout and funded settlement remain unverified.
What you need to implement
The backend appendix includes the shared types, Canopy intent helper and checkout component. The webhook appendix includes the signature-verifying receiver. Your application must supply:
| Application code | Responsibility |
|---|---|
| Para provider and server session | Complete wallet onboarding and bind the authenticated application user to a verified Para identity. |
| Deposit API route | Validate the session and bearer token, select the attested wallet and enforce request-origin checks and rate limits. |
| Durable deposit store | Reserve and reuse the intent, serialize creation and prevent conflicting ownership. |
| Webhook store and reconciliation worker | Save deliveries atomically and deduplicate each confirmed business operation. |
| Status endpoint and balance refresh | Expose only the user's saved result, then refresh the configured destination token. |
The store and session interfaces require implementation in your existing app. They are not additional Para or Canopy SDK methods.
Versions checked and dependency setup
The reader-test application compiled the unchanged Para snippets with @getpara/react-sdk 3.20.0, @canopypay/checkout-sdk 0.7.2, jose 6.2.12, viem 2.56.9 and standardwebhooks 1.1.1. It used Next.js 16.3.6, React 19.3.0 and Node 26.8.1. Its production build and 14 fixture cases passed, and the browser exercise confirmed signed-settlement handling and replay deduplication.
For a new app, follow Para's React quickstart and Next.js troubleshooting guide, including the provider wrapper and peer dependencies. In our test scaffold, the full SDK bundle also needed wagmi 2.19.5, ox 0.8.9, ethers 6.17.0 and @x402/core, @x402/evm and @x402/svm 2.27.0 to resolve build errors. These versions describe that tested scaffold, not a minimum dependency list for every Para app. Optional-module warnings and dependency audit findings still require review before deploying that scaffold.
Pin the dependency set that builds in your application and retain its lockfile. Use the same Node runtime when installing and running native dependencies. The local browser exercise substituted Para and Canopy responses; it did not establish live provider compatibility or a funded route.
Check the funding path and prerequisites
Para already has a Buy/Withdraw flow in its modal. Its useInitiateFiatRamp hook can launch that flow from your own UI, with providers and supported assets configured in the Developer Portal. For buying crypto with fiat, evaluate that existing path first. Para also publishes Relay and Squid examples. Choose between those options and manual crypto deposits according to the user's source of funds; the funding guide explains the tradeoffs. Para fiat ramps, Para example index.
Start with the following in place:
- A working Para React integration under
ParaProvider, with an authenticated embedded EVM wallet. Keep all@getpara/*packages on the same pinned release. Use the React quickstart if wallet creation is unfinished. - A server session that maps your application user to a verified Para user ID. Guest wallets are outside this example.
- A Canopy secret key on the server, a publishable key in the browser and the exact HTTPS checkout origin registered for embedding.
- Canopy per-intent destinations enabled, plus a route that Canopy has provisioned with
confirmedPayoutMode: Dynamic. This route mode is unrelated to the wallet company Dynamic. - A confirmed Base output token and supported source asset/network. Store the output token in server configuration. Base's
eip155:8453destination identifier alone does not establish route availability.
Without the destination fields, Canopy settles to the merchant account's payout wallet. An accepted create request also does not guarantee later delivery. Confirm these details before showing a deposit address to users. Canopy payout destinations.
Configure these values in the appropriate environment. PARA_APP_KEY_ID is the audience ID for the API key, not the API key itself. Keep secrets on the server.
# Browser: pass this to your existing ParaProvider configuration.
NEXT_PUBLIC_PARA_API_KEY=<para-api-key>
NEXT_PUBLIC_CANOPY_PUBLISHABLE_KEY=<canopy-publishable-key>
# Server only
PARA_APP_KEY_ID=<para-api-key-audience-id>
CANOPY_SECRET_KEY=<canopy-server-secret>
CANOPY_WEBHOOK_SECRET=<webhook-signing-secret>
CANOPY_BASE_OUTPUT_TOKEN=<account-confirmed-token-address-or-native>The resolver below selects the production JWKS URL. If your Para app uses BETA, replace it with the documented BETA URL before testing; keep the provider and verifier in the same environment.
Select an embedded EVM wallet
Para's useAccount() distinguishes embedded.wallets from connected external wallets. Its top-level isConnected can mean either kind of wallet is connected, so check the embedded account explicitly. useAccount reference.
Add components/ParaDepositButton.tsx. This version requires exactly one embedded EVM wallet; an app with several should add a wallet selector and keep the same server ownership checks.
"use client";
import { useState } from "react";
import { useAccount, useIssueJwt } from "@getpara/react-sdk";
import { DepositCheckout } from "./DepositCheckout";
export function ParaDepositButton() {
const account = useAccount();
const { issueJwtAsync } = useIssueJwt();
const [intentId, setIntentId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const wallets =
account.embedded.wallets?.filter((w) => w.type === "EVM") ?? [];
const wallet = wallets.length === 1 ? wallets[0] : undefined;
async function startDeposit() {
if (!wallet) return;
setBusy(true);
setError("");
try {
const { token } = await issueJwtAsync();
const response = await fetch("/api/deposits/para", {
method: "POST",
credentials: "same-origin",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ walletId: wallet.id }),
});
if (!response.ok) throw new Error("Could not prepare the deposit.");
const data = await response.json();
if (typeof data.intentId !== "string")
throw new Error("Invalid response.");
setIntentId(data.intentId);
} catch {
setError(
"Could not prepare the deposit. Check your session and try again.",
);
} finally {
setBusy(false);
}
}
if (account.isLoading) return <p>Loading your wallet...</p>;
if (!account.embedded.isConnected || account.embedded.isGuestMode) {
return <p>Sign in to your Para account to make a deposit.</p>;
}
if (!wallet) return <p>Select one embedded EVM wallet before depositing.</p>;
return (
<section>
<p>Destination: {wallet.address} on Base</p>
<button disabled={busy || !!intentId} onClick={startDeposit}>
{busy ? "Preparing deposit..." : "Deposit crypto"}
</button>
<p role="alert">{error}</p>
{intentId && <DepositCheckout intentId={intentId} />}
</section>
);
}Copy DepositCheckout from the shared backend guide. It calls mount from @canopypay/checkout-sdk inside an effect and destroys the handle on unmount. Mount this entire screen with a React key tied to your authenticated application user, so switching accounts destroys the previous user's checkout.
The JWT hook is useIssueJwt, with issueJwtAsync() returning { token, keyId }. Keep the token in memory and send it over HTTPS; the browser's wallet ID is only a selection hint. useIssueJwt reference.
At this checkpoint, an external wallet connection alone should not enable the button. An authenticated embedded account should show the intended EVM address, even when a separate external wallet is also connected.
Verify ownership on the server
Para's signed JWT includes sub, an application-specific aud, and wallet attestations in data.wallets. Verify the signature using the JWKS for your Para environment, require a valid expiry and the expected audience, then compare the subject with your authenticated user's stored Para identity. The audience is the API key's unique ID, not a value supplied by the browser. Para JWT management.
Add lib/deposits/para.ts. This uses jose for JWT verification and viem for address normalization. Pin those dependencies in your application lockfile.
import "server-only";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { getAddress, zeroAddress } from "viem";
import type { DepositOwner } from "./types";
// Use the BETA URL only when your existing Para app uses BETA.
const jwks = createRemoteJWKSet(
new URL("https://api.getpara.com/.well-known/jwks.json"),
);
export async function resolveParaOwner(input: {
token: string;
walletId: string;
user: { id: string; paraUserId: string };
}): Promise<DepositOwner> {
const audience = process.env.PARA_APP_KEY_ID;
const outputToken = process.env.CANOPY_BASE_OUTPUT_TOKEN;
if (!audience || !outputToken)
throw new Error("Missing server configuration");
const { payload } = await jwtVerify(input.token, jwks, {
audience,
requiredClaims: ["exp", "sub", "aud"],
});
if (payload.sub !== input.user.paraUserId)
throw new Error("Identity mismatch");
const data = payload.data as
| {
userId?: unknown;
wallets?: Array<{ id?: unknown; type?: unknown; address?: unknown }>;
}
| undefined;
if (data?.userId !== payload.sub || !Array.isArray(data.wallets)) {
throw new Error("Missing wallet attestation");
}
const matches = data.wallets.filter(
(w) =>
w &&
w.id === input.walletId &&
w.type === "EVM" &&
typeof w.address === "string",
);
if (matches.length !== 1) throw new Error("Wallet not authorized");
const wallet = getAddress(matches[0].address as string);
if (wallet === zeroAddress) throw new Error("Invalid destination");
return {
userId: input.user.id,
providerWalletId: input.walletId,
destination: {
wallet,
namespace: "eip155",
chainReference: "8453",
tokenAddress: outputToken,
},
};
}For Para BETA, the documented JWKS URL is https://api.beta.getpara.com/.well-known/jwks.json. Choose the environment on the server. Never accept a JWKS URL from the request. Configure any additional issuer or algorithm restrictions against the token profile confirmed for your deployed Para version.
This recipe deposits to the attested EVM wallet address. If your product displays an ERC-4337 smart-account balance, resolve that account through your existing account-abstraction integration and verify its relationship to this signer. Substituting the signer address would fund a different balance.
Create and save the intent
In app/api/deposits/para/route.ts, authenticate your existing application session, extract the bearer token and validate that walletId is a string. Call resolveParaOwner with those values. Apply your framework's CSRF protection and rate limits before creating an intent.
Use the durable store operation from the shared backend guide:
// Inside the authenticated route; application adapters, not Para SDK methods.
const owner = await resolveParaOwner({ token, walletId, user });
const saved = await depositStore.withDestinationLock(owner, async () => {
const existing = await depositStore.findActive(owner);
if (existing?.intentId) return existing;
const pending = await depositStore.reserve(owner);
const intent = await createCanopyIntent({
reference: pending.reference,
destination: owner.destination,
});
return depositStore.attachIntent(pending.id, intent);
});
return Response.json(
{ intentId: saved.intentId },
{
headers: { "Cache-Control": "no-store" },
},
);user, depositStore and the route's authentication code belong to your application. The store must enforce unique ownership of the destination, serialize creates across instances and save the intent before returning it. Its reserve operation must get or create the same durable reservation and reference after a timeout. Import createCanopyIntent from lib/deposits/canopy.ts in the shared guide.
That helper calls POST https://www.canopypay.io/api/v1/intents with the server secret, Canopy-Version: 2026-09-01, and the verified payout destination. Reuse the saved intent when the user reopens checkout. Canopy deduplicates by merchant and destination, including token; repeated creates rotate the widget token. merchantReference is correlation data, not a request idempotency key. Canopy payment intents.
Confirm settlement and update the screen
Checkout displays the deposit inbox and supported source instructions. The inbox is distinct from the user's Para destination. An exchange withdrawal must use the network and asset checkout specifies.
Implement the webhook reconciliation guide before enabling transfers. Verify the raw request body with Standard Webhooks using webhook-id, webhook-timestamp and webhook-signature. The verified body is flat: branch on state === "settled"; do not expect an event.type wrapper. Match the saved intent and persist settlement idempotently. Canopy webhooks.
Expose that saved result through an authenticated application status endpoint. Browser paid and deposit_detected events can show a pending message, but they cannot authorize a credit. Once the backend confirms settlement, refresh the configured token's balance on Base. If the app also has an internal ledger, define and deduplicate that credit separately using integer token units. Receiving funds does not execute a subsequent swap or contract call.
Verify the integration
Use two application users with different Para identities. First, send one user's JWT with the other's application session: the endpoint should reject it. Then submit an unrelated wallet ID and confirm that no Canopy intent is created. A modified browser address should have no effect because the endpoint never consumes it.
Open checkout twice for the same user. Both requests should resolve to one saved active intent. Close the tab after a simulated deposit notification and deliver a correctly signed settlement fixture to the backend. Reopening the app should show the saved result. Redeliver the fixture and confirm that the settlement or ledger operation is recorded once.
For an authorized funded verification, record the exact source asset/network and destination token before sending. Compare the final wallet balance with the settlement record. A fixture exercise proves your application logic; it does not prove the live route.
| Symptom | Check |
|---|---|
| Only an external wallet is connected | Require embedded.isConnected and a provisioned EVM wallet. |
| JWT verification fails | Match Para environment, application audience and expiry; request a fresh token. |
| Funds would reach the signer rather than the app balance | Check whether the product uses an EOA or a separate smart account. |
| Per-intent destination create is refused | Check the account setting and Canopy's Dynamic route provisioning separately. |
no_intent_destination or ambiguous_intent_destination | Inspect the active-intent mapping and concurrent creates; do not generate another address as a retry. |
| Checkout reports activity but the app remains pending | Inspect verified webhook receipt and the saved settlement record. |
Para wallet funding questions
Is this the same as Para's fiat onramp?
No. Para's Buy/Withdraw flow covers its configured fiat-ramp providers. This tutorial handles a crypto transfer from an exchange or another wallet through a confirmed Canopy route. Choose the path that matches the user's source of funds.
Does this apply to Capsule wallets?
Para was formerly called Capsule. This example uses the current @getpara/react-sdk API. An older Capsule integration should follow Para's current setup and migration guidance before copying these hooks.
Can an external wallet connection enable deposits?
This example requires an authenticated embedded EVM wallet. An external connection alone does not enable the button, and the server verifies the selected wallet against the signed Para token.
For an account-query approach, see Turnkey wallet funding. The Canopy quickstart covers the underlying payment-intent flow.
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