Agent skill
Crossmint Webhooks Skill
Receive and verify Crossmint webhooks, which are delivered by Svix. Use when setting up a Crossmint webhook endpoint, getting the whsec_ signing secret, debugging svix-id / svix-timestamp / svix-signature verification, or handling wallet, checkout, NFT minting, credential and offramp events like wallets.transfer.in, orders.delivery.completed, nfts.create.succeeded and purchase.succeeded.
Install this skill
npx skills add hookdeck/webhook-skills --skill crossmint-webhooks
Crossmint is a wallets, checkout, NFT-minting, verifiable-credential and stablecoin offramp platform. One project-level webhook system carries events from every product: you add an endpoint in the Crossmint console and receive signed JSON POSTs. Deliveries are sent through Svix, so they carry svix-id, svix-timestamp and svix-signature headers and the endpoint's signing secret starts with whsec_.
When to Use This Skill
- How do I receive Crossmint webhooks?
- How do I verify a Crossmint webhook signature (
svix-signature)? - Where do I find my Crossmint webhook signing secret (
whsec_...)? - How do I handle
wallets.transfer.in,orders.delivery.completedornfts.create.succeededevents? - Why do Crossmint webhook bodies have different shapes (
idvsactionId)? - Why is my Crossmint webhook verification failing?
Verification (core)
| Header | Value |
|---|---|
svix-id | Identifies the message; retries reuse it |
svix-timestamp | Unix seconds |
svix-signature | One or more space-separated v1,<base64> entries |
Signed content is {svix-id}.{svix-timestamp}.{raw body}, HMAC-SHA256, base64 output. The key is the base64-decoded part of the whsec_... secret after the prefix. Compare against every v1, entry; ignore other versions. If svix-id is absent, read webhook-id, webhook-timestamp and webhook-signature instead, as Hookdeck does. The examples use the svix library (npm and PyPI):
import { Webhook } from 'svix';
// rawBody: exact request body (Buffer or string), NOT re-serialised JSON
function verifyCrossmint(rawBody, headers, secret = process.env.CROSSMINT_WEBHOOK_SECRET) {
const p = headers['svix-id'] ? 'svix' : 'webhook'; // fall back to the Standard Webhooks names
const [id, timestamp, signature] = ['id', 'timestamp', 'signature'].map((k) => headers[`${p}-${k}`]);
new Webhook(secret).verify(rawBody, {
'svix-id': id, 'svix-timestamp': timestamp, 'svix-signature': signature,
'webhook-id': id, 'webhook-timestamp': timestamp, 'webhook-signature': signature,
}); // throws on a bad signature or stale timestamp → respond 400
}
The library always enforces a fixed 5-minute timestamp window. Hookdeck retries and replays resend the original svix-timestamp, so the library rejects them once they are more than 5 minutes old. If you receive through Hookdeck and need late retries to pass, use the manual verifier in references/verification.md, which can drop or widen the window. In Express, read the body with express.raw({ type: '*/*' }) so any content type verifies, and parse JSON only after the check.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Event Type and Payload
The event name is the top-level type in every product's body. Only type and data are common; the envelope around them differs:
| Product | Envelope |
|---|---|
| Wallets | { id: "evt_ |
| Checkout V3 | { actionId, type, data } |
Checkout V2 (purchase.succeeded) | Flat: { type, status, walletAddress, txId, ... }, no data |
| NFT minting | { actionId, startedAt, completedAt, type, data, resource } |
| Verifiable credentials | { type, data } (data.credential, data.error) |
| Offramp | { id: "pm-status-...", type, data, timestamp } |
{ "id": "evt_1b2c3d", "type": "wallets.transfer.in",
"data": { "status": "succeeded", "token": { "symbol": "USDC", "amount": "10", "decimals": 6 } } }
Common Event Types
| Product | Events |
|---|---|
| Wallets | wallets.transfer.in, wallets.transfer.out (also sent for failed transfers), wallets.signer.exported |
| Checkout V2 | purchase.succeeded |
| Checkout V3 | orders.quote.created, orders.quote.updated, orders.payment.succeeded, orders.payment.failed, orders.delivery.initiated, orders.delivery.completed, orders.delivery.failed |
| NFT minting | collections.create.succeeded, collections.update.succeeded, nfts.create.succeeded, nfts.create.failed, nfts.update.succeeded |
| Credentials | credential.creation.succeeded, credential.creation.failed |
| Offramp | payment_ |
These are the 19 documented types; the list may not be exhaustive, so ignore types you don't recognise. See references/overview.md.
Delivery Semantics
Return a 2xx within 15 seconds. Crossmint makes 8 attempts (immediately, then after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h), after which the message is marked failed and can be resent from the console. Delivery is at-least-once and offramp events can arrive out of order, so process asynchronously, deduplicate on svix-id (stable across retries) and compare timestamps before overwriting state. Return 400 for missing headers or a bad signature.
Environment Variables
CROSSMINT_WEBHOOK_SECRET=whsec_xxxxx # full value from the endpoint's Signing Secret in the console; handlers fail closed (500) without it
Local Development
# Express / Next.js (use 8000 for FastAPI). No install or account required.
npx hookdeck-cli listen 3000 crossmint --path /webhooks/crossmint
Add the printed URL as an endpoint in the Crossmint console (Webhooks > Add Endpoint).
Reference Materials
- references/overview.md - Event catalog and payload shapes per product
- references/setup.md - Add the endpoint, find the signing secret, test
- references/verification.md - Library and manual verification, gotchas, debugging