After initiating a token transfer, you can monitor its status and receive real-time notifications using webhooks. This guide shows you how to set up webhook listeners to track when transfers complete or fail.
Prerequisites
- A Crossmint project with API keys configured
- A wallet with tokens to transfer (see Get Staging Tokens)
- The
svix package installed for webhook signature verification:
Choose your setup path
There are two ways to test and integrate webhooks:
- Option 1: Test using Svix Play - Quick testing without setting up a local server
- Option 2: Integrate with your local app - Full integration with your development environment
Option 1: Test using Svix Play
Use Svix Play to inspect webhook payloads without setting up a local server. This is useful for understanding the event structure before building your integration.
Step 1: Create a webhook endpoint with Svix Play
- Navigate to the Webhooks page in the Crossmint Console
- Click Add Endpoint
- In the dialog that appears, click the “with Svix Play” button (instead of entering a custom URL)
- Select the following event types:
wallets.transfer.in - Receive notifications when tokens arrive in your wallets
wallets.transfer.out - Receive notifications when tokens leave your wallets
- Click Create
This automatically configures your endpoint to use Svix Play, which opens with a pre-filled URL like:
https://play.svix.com/in/e_0izvs3VwiAo8hvKf6Ygh2tujXre/
Step 2: Trigger a transfer
From your app, call the wallet.send() method to create an outgoing transfer. Any transfer made with the same project API key will trigger a webhook event on this endpoint.
const { hash, explorerLink } = await wallet.send(
"<RECIPIENT_ADDRESS>",
"solana:usdc",
"1.00"
);
As soon as the transfer is created, a wallets.transfer.out event will appear in Svix Play, where you can inspect the full payload structure.
For the complete transfer setup, see the Transfer Tokens guide.
Option 2: Integrate with your local app
For full integration, set up a local server to receive and process webhook events.
Step 1: Expose your local server
Before registering a webhook endpoint, you need a publicly accessible URL. If your app runs locally on http://localhost:3000, use ngrok to create a public URL:
This gives you a public URL like https://abc123.ngrok.io that forwards to your local server.
Alternatively, if you have a staging or development server with a public URL, you can register that directly.
Step 2: Register your webhook endpoint
- Navigate to the Webhooks page in the Crossmint Console
- Click Add Endpoint
- Enter your public URL with the webhook path (e.g.,
https://abc123.ngrok.io/api/webhooks/transfers)
- Select the following event types:
wallets.transfer.in - Receive notifications when tokens arrive in your wallets
wallets.transfer.out - Receive notifications when tokens leave your wallets
- Click Create
- Copy the signing secret from the endpoint details - you will need this to verify webhook signatures
For detailed setup instructions, see Add an Endpoint.
Your webhook endpoint must return a 2xx HTTP status code within 15 seconds to acknowledge receipt. This is critical for webhook delivery confirmation.
Step 3: Handle transfer events
Create an endpoint to receive and process webhook events. The following example shows how to handle both incoming and outgoing transfer events.
Node.js (Express)
Next.js (App Router)
Python (Flask)
Replace <YOUR_WEBHOOK_SECRET> with the signing secret from Step 2.import express from "express";
import { Webhook } from "svix";
const app = express();
app.use("/api/webhooks", express.raw({ type: "application/json" }));
const WEBHOOK_SECRET = <YOUR_WEBHOOK_SECRET>; // String
app.post("/api/webhooks/transfers", async (req, res) => {
const wh = new Webhook(WEBHOOK_SECRET);
let payload;
try {
payload = wh.verify(req.body, {
"svix-id": req.headers["svix-id"],
"svix-timestamp": req.headers["svix-timestamp"],
"svix-signature": req.headers["svix-signature"],
});
} catch (err) {
console.error("Webhook verification failed:", err.message);
return res.status(400).json({ error: "Invalid signature" });
}
res.status(200).json({ received: true });
await processTransferEvent(payload);
});
async function processTransferEvent(event) {
const { type, data } = event;
if (type === "wallets.transfer.in") {
console.log("Incoming transfer received:");
console.log(` From: ${data.sender.address}`);
console.log(` To: ${data.recipient.address}`);
console.log(` Amount: ${data.token.amount} ${data.token.symbol}`);
console.log(` Explorer: ${data.onChain.explorerLink}`);
}
if (type === "wallets.transfer.out") {
if (data.status === "succeeded") {
console.log("Outgoing transfer succeeded:");
console.log(` To: ${data.recipient.address}`);
console.log(` Amount: ${data.token.amount} ${data.token.symbol}`);
console.log(` Explorer: ${data.onChain.explorerLink}`);
} else {
console.error("Outgoing transfer failed:");
console.error(` Error: ${data.error.message}`);
console.error(` Reason: ${data.error.reason}`);
}
}
}
app.listen(3000, () => console.log("Webhook server running on port 3000"));
Replace <YOUR_WEBHOOK_SECRET> with the signing secret from Step 2.// app/api/webhooks/transfers/route.ts
import { Webhook } from "svix";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
const WEBHOOK_SECRET = <YOUR_WEBHOOK_SECRET>; // String
export async function POST(req: Request) {
const body = await req.text();
const headerPayload = await headers();
const svixId = headerPayload.get("svix-id");
const svixTimestamp = headerPayload.get("svix-timestamp");
const svixSignature = headerPayload.get("svix-signature");
if (!svixId || !svixTimestamp || !svixSignature) {
return NextResponse.json({ error: "Missing headers" }, { status: 400 });
}
const wh = new Webhook(WEBHOOK_SECRET);
let payload: any;
try {
payload = wh.verify(body, {
"svix-id": svixId,
"svix-timestamp": svixTimestamp,
"svix-signature": svixSignature,
});
} catch (err) {
console.error("Webhook verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const { type, data } = payload;
if (type === "wallets.transfer.in") {
console.log(`Received ${data.token.amount} ${data.token.symbol}`);
console.log(`From: ${data.sender.address}`);
console.log(`Explorer: ${data.onChain.explorerLink}`);
}
if (type === "wallets.transfer.out") {
if (data.status === "succeeded") {
console.log(`Sent ${data.token.amount} ${data.token.symbol}`);
console.log(`To: ${data.recipient.address}`);
} else {
console.error(`Transfer failed: ${data.error.message}`);
}
}
return NextResponse.json({ received: true });
}
Replace <YOUR_WEBHOOK_SECRET> with the signing secret from Step 2.from flask import Flask, request, jsonify
from svix.webhooks import Webhook
app = Flask(__name__)
WEBHOOK_SECRET = <YOUR_WEBHOOK_SECRET> # String
@app.route("/api/webhooks/transfers", methods=["POST"])
def handle_transfer_webhook():
headers = {
"svix-id": request.headers.get("svix-id"),
"svix-timestamp": request.headers.get("svix-timestamp"),
"svix-signature": request.headers.get("svix-signature"),
}
wh = Webhook(WEBHOOK_SECRET)
try:
payload = wh.verify(request.get_data(as_text=True), headers)
except Exception as e:
print(f"Webhook verification failed: {e}")
return jsonify({"error": "Invalid signature"}), 400
event_type = payload.get("type")
data = payload.get("data")
if event_type == "wallets.transfer.in":
print(f"Received {data['token']['amount']} {data['token']['symbol']}")
print(f"From: {data['sender']['address']}")
print(f"Explorer: {data['onChain']['explorerLink']}")
if event_type == "wallets.transfer.out":
if data["status"] == "succeeded":
print(f"Sent {data['token']['amount']} {data['token']['symbol']}")
print(f"To: {data['recipient']['address']}")
else:
print(f"Transfer failed: {data['error']['message']}")
return jsonify({"received": True})
if __name__ == "__main__":
app.run(port=3000)
Always use the raw request body when verifying webhooks. Parsing and re-stringifying JSON will alter the content and cause verification failure.
For more details on securing and verifying webhook requests, see Verify Webhooks.
Step 4: Test your endpoint
Execute a transfer using the Transfer Tokens guide and observe the webhook event in your server logs. For a successful outgoing transfer, you should see output like:
Sent 0.40 USDC
To: 0xdf8b5f9c19e187f1ea00730a1e46180152244315
Best practices
- Respond quickly: Return a 2xx status within 15 seconds to prevent retries
- Verify signatures: Always verify webhook signatures before processing. For comprehensive guidance, see Webhook Best Practices