Guide to Crossmint Webhooks: Features and Best Practices
Crossmint covers several blockchain products under one API: wallets, a checkout for NFTs and other digital items, minting, verifiable credentials and stablecoin offramp to bank accounts. These operations run asynchronously, often waiting on a blockchain, so they finish after the API call has returned. Crossmint's webhooks tell your server when they do: a wallet received or sent tokens, an order was paid or delivered, a mint succeeded or failed, a credential was issued, or a bank payment method changed status.
What are Crossmint webhooks?
There is one webhook system per Crossmint project, and every product publishes into it. You add endpoints in the console and choose which event types each receives. Delivery runs on Svix, so each request has svix-id, svix-timestamp and svix-signature headers and is signed with the endpoint's whsec_ secret.
Every body names its event in a top-level type, but each product shapes the rest differently. A handler that expects data.id on every event will break on the first Checkout V2 or minting event, so route on type before reading anything else.
Crossmint webhook features
| Feature | Details |
|---|---|
| Configuration | Console, Webhooks > Add Endpoint. Staging and production are separate |
| Event selection | Per endpoint. No filter means every event from every product |
| Event list | Full list in the console's Event Catalog tab |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | svix-signature, with svix-id and svix-timestamp |
| Secret | Per endpoint, whsec_..., under Signing Secret in the endpoint details |
| Body | JSON, envelope varies by product |
| Event routing | Body field type |
| Response window | 2xx within 15 seconds |
| Retries | 8 attempts over about 28 hours, then resend from the console |
| Ordering | At least once. Offramp events can arrive out of order |
| Source IPs | Not published |
Events and payloads
The docs list the events each product guide covers. They include wallets.transfer.in, wallets.transfer.out and wallets.signer.exported for wallets; orders.quote.created, orders.payment.succeeded, orders.delivery.completed and the related quote, payment and delivery events for Checkout V3; purchase.succeeded for Checkout V2; collections.create.succeeded, nfts.create.succeeded, nfts.create.failed and update events for minting; credential.creation.succeeded and credential.creation.failed; and payment_ for offramp. Crossmint points to the console's Event Catalog tab for the full list.
The envelopes differ by product:
| Product | Envelope |
|---|---|
| Wallets | { id: "evt_; data has status, token (symbol, amount, decimals, locator, chain), sender, recipient, and on failures error |
| Checkout V3 | { actionId, type, data }; data has orderId, phase, locale, lineItems |
| Checkout V2 | Flat { type, status, walletAddress, projectId, collectionId, clientId, txId, contractAddress, tokenIds, passThroughArgs } (EVM) or mintAddress (Solana); no data |
| NFT minting | { actionId, startedAt, completedAt, type, data, resource } |
| Credentials | { type, data }, with data.credential or data.error |
| Offramp | { id: "pm-status-...", type, data, timestamp } |
This is the collections.create.succeeded sample from the webhook samples library:
{
"actionId": "897eadae-ee2d-43f9-a97b-1a9d9c682d6f",
"startedAt": "2023-10-04T15:48:20.000Z",
"completedAt": "2023-10-04T15:48:42.000Z",
"type": "collections.create.succeeded",
"data": {
"chain": "polygon",
"txId": "0x919ec33c1ceffd292d1d0cdd9675ffcc0af31a1f34622edd4865a9ca9fa82aa1",
"collection": {
"id": "897eadae-ee2d-43f9-a97b-1a9d9c682d6f",
"contractAddress": "0x59195995f248450267AD40CAc1d79fAAba290467"
}
},
"resource": "https://crossmint.com/api/2022-06-09/collections/897eadae-ee2d-43f9-a97b-1a9d9c682d6f"
}
Minting events add resource, a link to the API object, and startedAt/completedAt. The NFT minting guide's setup step mentions a mint.succeeded event that no example uses; subscribe to the nfts.* and collections.* names instead.
Setting up Crossmint webhooks
- Open the Webhooks page in the Crossmint console for the right environment. Staging and production each need their own endpoint.
- Click Add Endpoint and enter your HTTPS URL.
- Choose event types. Leaving the filter empty subscribes the endpoint to every event Crossmint has.
- Click Create, open the endpoint's details, and copy the value in Signing Secret into
CROSSMINT_WEBHOOK_SECRET,whsec_included.
The console also has a "with Svix Play" option that captures payloads without a server, which is a quick way to see each product's body shape.
To use Hookdeck, create a source with the Crossmint type, paste the signing secret, and add the source URL as the endpoint. Forward it locally with the Hookdeck CLI:
npx hookdeck-cli listen 3000 crossmint --path /webhooks/crossmint
Securing Crossmint webhooks
Crossmint's guide verifies with the Svix library, and its manual section HMACs <svix-id>.<svix-timestamp>.<raw body> with "the base64 portion of your signing secret", the part after whsec_. The function below does the same, checks each v1, entry in the header, and reads webhook-* names if svix-id is missing:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['svix-id'] ? 'svix' : 'webhook';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Use it instead of the Svix library if events reach you through Hookdeck. The library rejects a svix-timestamp more than five minutes old, Hookdeck resends the original timestamp when it retries or replays, and Hookdeck itself applies no timestamp tolerance.
Since the shape depends on the product, the receiver keys deduplication on the header and leaves parsing to product-specific workers:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
// outbox.append(record) stores the record and resolves once it is durable.
function createCrossmintReceiver(outbox) {
const app = express();
app.post('/webhooks/crossmint', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.CROSSMINT_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const event = parseJsonObject(req.body);
if (!event) return res.sendStatus(400); // signed, but not a JSON object
if (typeof event.type !== 'string') return res.sendStatus(400);
const product = event.type.split('.')[0]; // wallets, orders, nfts, credential, payment_methods ...
try {
await outbox.append({ svixId: req.headers['svix-id'], product, event });
} catch {
return res.sendStatus(503);
}
return res.sendStatus(200);
});
return app;
}
A Hookdeck Crossmint source behaves differently on a bad signature: it answers 200, records the request as failed verification and doesn't pass it on.
Make Crossmint webhooks production-ready. Hookdeck Event Gateway verifies the Svix signature on wallet, checkout and minting events, and can route each product's events to the service that understands that body shape.
Crossmint webhook limitations and pain points
Different shapes behind one type field
The Problem: A shared handler crashes or silently misreads fields for some products.
Why It Happens: Wallets wrap data in data with an evt_ ID, Checkout V3 uses actionId, Checkout V2 sends a flat body with no data, and offramp adds its own id and timestamp.
Workarounds:
- Switch on the
typeprefix first and parse each product's envelope separately. - Deduplicate on
svix-id, which every delivery has, rather than a body ID.
How Hookdeck Can Help: Hookdeck filters on type can send each product's events to its own destination, and a transformation can map them to one internal shape.
The event list isn't in the docs
The Problem: You can't see every event you might receive without logging in.
Why It Happens: Crossmint's docs list only the events each guide covers, and the full catalogue is a tab in the console. Some example payloads also use placeholders or comments rather than valid JSON.
Workarounds:
- Read the Event Catalog tab for both environments, and filter endpoints to the events you handle.
- Capture real bodies with Svix Play or a test endpoint before writing parsers.
How Hookdeck Can Help: A Hookdeck source stores every delivery in full, so real payloads for each product build up as soon as staging traffic starts.
Offramp events can arrive out of order
The Problem: A payment method shows an old status after a newer one has arrived.
Why It Happens: Delivery is at least once, and Crossmint notes that payment_ events can arrive out of order.
Workarounds:
- Compare the body's
timestampwith the one you stored and ignore older updates.
How Hookdeck Can Help: Hookdeck's event history shows the arrival order of each delivery, which makes these cases easy to confirm.
Retries stop after eight attempts
The Problem: Events from an outage longer than about a day never arrive.
Why It Happens: Crossmint tries eight times, immediately and then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. After that the message is marked failed.
Workarounds:
- Resend failed messages from the console once your endpoint is healthy.
- Reconcile orders and wallet balances through the API after an outage.
How Hookdeck Can Help: Hookdeck takes each delivery when Crossmint first sends it, so failures on your side never use up Crossmint's attempts, and you can replay events to your service at any time.
Best practices
Wait for delivery, not payment
For checkout, orders.payment.succeeded means the buyer paid. The item is only in their wallet after orders.delivery.completed. Handle orders.delivery.failed separately, because the payment has already gone through.
Treat wallets.signer.exported as a security event
It fires the first time a wallet's private key is exported. Alert on it if your product doesn't expect users to export keys.
Keep staging and production apart
The two consoles are separate, with separate endpoints and secrets. Use different URLs or Hookdeck sources for each so a staging event never reaches production code.
Answer within 15 seconds
Store the event and return 2xx. Chain lookups and fulfilment belong in a worker. See why to process webhooks asynchronously.
Conclusion
Crossmint sends one Svix-signed stream for very different products, so routing on type is the first thing your handler does. Use the console's Event Catalog, not the docs, for the full event list.
Hookdeck Event Gateway can split that stream by product and hold events through your outages. Get started free.