# Guide to CryptoProcessing Webhooks: Features and Best Practices

[CryptoProcessing](https://cryptoprocessing.com/), from CoinsPaid, lets online businesses accept and pay out cryptocurrency: deposit addresses for customers, withdrawals to external wallets, conversions between currencies and one-off payment requests. Blockchain transactions take time to confirm, and some never do, so each transaction passes through several states after your API call returns. CryptoProcessing's callbacks report each of those state changes, and they are how a casino, exchange or merchant knows when to credit a balance or mark a payout as sent.

This guide covers the callback kinds and their statuses, how to verify the HMAC-SHA512 signature with the right key, and how CryptoProcessing's retry schedule and its `200` versus `201` convention work.

## What are CryptoProcessing webhooks?

CryptoProcessing calls them callbacks. You set one callback URL in the merchant portal, and every transaction kind is sent there: deposits, withdrawals, exchanges, payment requests and legacy invoices. A callback fires whenever a transaction's status changes, so a single deposit typically produces several callbacks as it moves from `not_confirmed` to `confirmed`. The body's `type` tells you the kind and `status` the new state.

Callbacks are signed with the secret of your account's active **Master API key**. `X-Processing-Signature` holds a hex HMAC-SHA512 of the raw body, and `X-Processing-Key` names the Master key that signed it.

## CryptoProcessing webhook features

| Feature | Details |
| --- | --- |
| Configuration | Merchant portal: one callback URL, plus **Configure callbacks** for optional ones |
| URL rules | HTTPS, on a domain that scanners such as VirusTotal consider safe |
| Signature | hex HMAC-SHA512 |
| Signature header | `x-processing-signature` |
| Key header | `X-Processing-Key`, the public part of the signing Master key |
| Signed content | The exact callback body |
| Secret | The secret of the active Master API key |
| Body | JSON |
| Event routing | Body field `type`, then `status` |
| Success | Only HTTP `200` stops delivery |
| Retries | 12 after the first attempt, over about 24 hours. Not configurable |
| Duplicates | Answer `201` to stop retries of a callback you already processed |
| Source IPs | Available on request from Support |

## Events and payloads

| `type` | Triggered When | Common Use Cases |
| --- | --- | --- |
| `deposit` | A deposit to one of your addresses changes status | Credit user balances |
| `withdrawal` | A withdrawal changes status | Settle or fail a payout |
| `exchange` | A currency conversion changes status | Update conversion records |
| `payment_request` | A payment request changes status | Fulfil orders |
| `deposit_exchange` | A deposit that is converted on arrival changes status | Credit the converted amount |
| `deposit_wallet_client` | A wallet client deposit changes status | Wallet top-ups |
| `withdrawal_exchange` | A withdrawal funded by a conversion changes status | Payouts in another currency |
| `withdrawal_instant` | An instant withdrawal changes status | Fast payouts |
| `withdrawal_instant_exchange` | An instant withdrawal funded by a conversion changes status | Fast payouts in another currency |
| `invoice` | A legacy invoice changes status | Older integrations |

`status` values include `confirmed`, `not_confirmed`, `cancelled`, `pending`, `paid`, `expired` and `failed`; the set differs per kind (a `payment_request` callback shows `processing`, for example). Check the per-kind page of the docs for the full list.

### Event Payload Structure

* `id` identifies the transaction; `payment_request` callbacks use `payment_request_id` instead.
* `foreign_id` is the id you supplied when creating the address, withdrawal or request.
* `currency_sent`, `currency_received`, `transactions` and `fees` carry the amounts, as decimal strings.
* Other kinds add their own fields (for example `expires_at` on `payment_request` and `invoice`).

`deposit_wallet_client` is only sent if business wallet transaction callbacks are enabled, and callbacks for transactions started in the portal are off by default. Switch on what you need under **Configure callbacks**.

### Published payload example

This is the `deposit` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/cryptoprocessing/latest/deposit.json). It is the example from CryptoProcessing's deposit callback reference.

```json
{
  "id": 1,
  "end_user_reference": "user_12345",
  "type": "deposit",
  "crypto_address": {
    "id": 1,
    "currency": "USDC",
    "address": "0x9F2E4b7A1C3D5F6E8B0A2C4D6E8F1A3B5C7D9E0F",
    "tag": null,
    "foreign_id": "12345"
  },
  "currency_sent": {
    "currency": "USDC",
    "amount": "1000.00000000"
  },
  "currency_received": {
    "currency": "USDC",
    "amount": "1000.00000000",
    "amount_minus_fee": "997.00000000"
  },
  "transactions": [
    {
      "id": 1,
      "currency": "USDC",
      "transaction_type": "blockchain",
      "type": "deposit",
      "address": "0x9F2E4b7A1C3D5F6E8B0A2C4D6E8F1A3B5C7D9E0F",
      "tag": null,
      "amount": "1000.00000000",
      "txid": "0x3c8f1a92e6d4b7c0a5f2e8d1b6c9a4f7e0d3b8c5a1f6e9d2b4c7a0f3e8d5b1c9",
      "riskscore": "0.5",
      "confirmations": 3,
      "sender": {
        "address": "0x1D3F5A7C9E0B2D4F6A8C0E2B4D6F8A1C3E5B7D90"
      }
    }
  ],
  "fees": [
    {
      "type": "deposit",
      "currency": "USDC",
      "amount": "3.00000000"
    }
  ],
  "error": "",
  "status": "confirmed"
}

```

## Setting up CryptoProcessing webhooks

### Find the signing secret

Callbacks are signed with the secret of your **active Master API key**.

> Paste the secret key of your active Master API key. Rotating that key changes the callback secret.

1. Open the CryptoProcessing merchant portal and find your API keys.
2. Identify your active Master key. Only the Master key signs callbacks; custom keys never do.
3. Copy its secret key and store it as `WEBHOOK_SECRET`. Use it as-is: it is not hex- or base64-decoded.

The same secret signs your API requests, so whoever holds it can call the API as well as forge callbacks. Store it accordingly.

### Set the callback URL

1. In the merchant portal, set the callback URL, for example `https://your-app.example.com/webhooks/cryptoprocessing`.
2. Under **Configure callbacks**, enable any optional callbacks you need.
3. Route on the body's `type` in your handler, since everything arrives at this one URL.

To try it before deploying, forward callbacks to your machine with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 cryptoprocessing --path /webhooks/cryptoprocessing

```

## Securing CryptoProcessing webhooks

Compute an HMAC-SHA512 of the raw body with the Master key's secret, encode it as hex and compare it with `X-Processing-Signature` in constant time. The result is 128 hex characters with no prefix. CryptoProcessing documents the hex encoding on its API authentication page, which uses the same scheme for signing requests. See the [verification documentation](https://docs.cryptoprocessing.com/api-reference/callbacks/callbacks-overview).

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-processing-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha512', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{128}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler verifies, then applies each status change once. A repeat of a callback it has already applied gets `201`, which is CryptoProcessing's signal to stop retrying it.

```javascript
const express = require('express');

function createCryptoProcessingApp({ applyStatus }) {
  const app = express();
  app.post('/webhooks/cryptoprocessing', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    let callback;
    try {
      callback = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!callback || typeof callback !== 'object' || Array.isArray(callback)) {
      return res.sendStatus(400);
    }
    if (typeof callback.type !== 'string' || callback.status === undefined) {
      return res.sendStatus(400);
    }
    const txId = callback.type === 'payment_request' ? callback.payment_request_id : callback.id;
    try {
      // Returns false if this (type, txId, status) was already applied.
      const applied = await applyStatus(callback.type, txId, callback.status, callback);
      return res.sendStatus(applied ? 200 : 201);
    } catch {
      return res.sendStatus(503);
    }
  });
  return app;
}

```

No timestamp is part of the signature, so dedupe on `type`, the transaction ID and `status` rather than on time.

Hookdeck's CryptoProcessing source type verifies `X-Processing-Signature` as a hex HMAC-SHA512 of the raw body with your Master key's secret, comparing case-insensitively. Hookdeck answers each callback with `200`, which stops CryptoProcessing's retries, records a failed check as failed verification and does not forward that request. It has no timestamp window.

> **Make CryptoProcessing webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) answers each callback with the `200` CryptoProcessing needs, verifies the HMAC-SHA512 signature, and keeps every deposit and withdrawal status change until your ledger has applied it.

## CryptoProcessing webhook limitations and pain points

### Rotating the Master key changes the callback secret

**The Problem:** After a key rotation, every callback fails verification.

**Why It Happens:** Callbacks are signed with the active Master API key's secret. Activating a different Master key, for example as part of routine API key hygiene, changes the signing secret immediately.

**Workarounds:**

* Treat Master key rotation as a webhook change too, and update `WEBHOOK_SECRET` at the same time.
* Log `X-Processing-Key` so you can see which key signed each callback.

**How Hookdeck Can Help:** Update the secret on the Hookdeck source when you rotate. Hookdeck's request log shows any callbacks that failed verification in between, ready to replay.

### Only `200` counts as success

**The Problem:** Callbacks keep arriving for a day even though your handler "succeeded".

**Why It Happens:** CryptoProcessing keeps a callback in its queue until it gets exactly `200`. A `202` or `204` triggers the full retry schedule. For duplicates, it asks for `201` instead.

**Workarounds:**

* Return `200` for a newly processed callback and `201` for a duplicate, and nothing else on success.
* Answer quickly, because no timeout is documented.

**How Hookdeck Can Help:** Hookdeck always answers CryptoProcessing with `200`, so its retries stop as soon as the callback is stored, and your service's status codes only affect Hookdeck's own retries.

### One URL for everything

**The Problem:** Your deposit service receives withdrawal and exchange callbacks it has no business handling.

**Why It Happens:** CryptoProcessing has a single callback URL per account.

**Workarounds:**

* Route on `type` in one handler and hand off to the right service.

**How Hookdeck Can Help:** Hookdeck filters on `type` can send deposits, withdrawals and exchanges to different destinations from one source.

### Status changes, not events

**The Problem:** A deposit is credited twice, or a `confirmed` is overwritten by a late `not_confirmed`.

**Why It Happens:** Each status change is a separate callback, retries resend the same one, and the retry schedule spans about a day, so an older status can arrive late.

**Workarounds:**

* Key processing on `type`, transaction ID and `status`.
* Treat `confirmed`, `cancelled` and `failed` as terminal and ignore anything that would move a transaction backwards.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeats of the same transaction and status before they reach your ledger.

## Best practices

### Verify the raw body

Use `express.raw({ type: '*/*' })` in Express, `request.arrayBuffer()` in Next.js or `await request.body()` in FastAPI, and don't gate the parser on `application/json`. Require exactly 128 hex characters and compare case-insensitively.

### Use the right key

Verify with the secret of the active Master key, never a custom key and never the public value from `X-Processing-Key`.

### Treat amounts as strings

`currency_sent`, `currency_received` and `fees` carry decimal strings. Use a decimal library rather than floating point when crediting balances.

### Match on `foreign_id`

`foreign_id` is the ID you supplied when you created the address, withdrawal or request. It is the most reliable way to tie a callback to your own record.

### Ask Support for the IP list

CryptoProcessing gives out callback IPs only on request. If you need an allowlist, request it from Support and keep signature verification as the primary check.

## Conclusion

CryptoProcessing callbacks report every status change of every crypto transaction, signed with your Master key and retried for about a day until they get a `200`. Verify with the active Master key's secret, answer `200` or `201` deliberately, and apply each status once in the right order.

[Hookdeck Event Gateway](https://hookdeck.com) takes the callback, satisfies CryptoProcessing's `200` rule, and delivers each status change to your ledger with retries you control.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle CryptoProcessing webhooks reliably in minutes.