Gareth Wilson Gareth Wilson

Guide to Crossmint Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationConsole, Webhooks > Add Endpoint. Staging and production are separate
Event selectionPer endpoint. No filter means every event from every product
Event listFull list in the console's Event Catalog tab
SignatureStandard Webhooks HMAC-SHA256
Signature headersvix-signature, with svix-id and svix-timestamp
SecretPer endpoint, whsec_..., under Signing Secret in the endpoint details
BodyJSON, envelope varies by product
Event routingBody field type
Response window2xx within 15 seconds
Retries8 attempts over about 28 hours, then resend from the console
OrderingAt least once. Offramp events can arrive out of order
Source IPsNot 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_methods.status.updated for offramp. Crossmint points to the console's Event Catalog tab for the full list.

The envelopes differ by product:

ProductEnvelope
Wallets{ id: "evt_...", type, data }; 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 V2Flat { 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

  1. Open the Webhooks page in the Crossmint console for the right environment. Staging and production each need their own endpoint.
  2. Click Add Endpoint and enter your HTTPS URL.
  3. Choose event types. Leaving the filter empty subscribes the endpoint to every event Crossmint has.
  4. 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 type prefix 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_methods.status.updated events can arrive out of order.

Workarounds:

  • Compare the body's timestamp with 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.