# 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_methods.status.updated` 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_...", 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 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/crossmint/latest/collections.create.succeeded.json):

```json
{
  "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](/docs/cli):

```bash
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:

```javascript
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:

```javascript
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](/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](/docs/filters) on `type` can send each product's events to its own destination, and a [transformation](/docs/transformations) 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](/docs/use-cases/receive-webhooks#replay-for-debugging-and-recovery) 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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) can split that stream by product and hold events through your outages. [Get started free](https://dashboard.hookdeck.com/signup).