# Guide to MoonPay Webhooks: Features and Best Practices

MoonPay webhooks tell your application what is happening to your customers' crypto purchases and sales: a buy or sell transaction is created, changes status, needs a new quote or fails, and a customer's identity check moves on. If you embed MoonPay's on-ramp or off-ramp, webhooks are how those asynchronous updates reach your backend without polling the API.

This guide covers how MoonPay webhooks are registered and signed, the widget and Platform events and their payloads, MoonPay's delivery rules, and the practices that keep a MoonPay receiver reliable.

## What are MoonPay webhooks?

MoonPay sends a JSON `POST` to each endpoint you register in the dashboard whenever a subscribed event happens. You choose per endpoint whether to receive every event type or only some, and one event can go to several endpoints. Test and live mode are separate: to send both to the same URL, you create two settings.

Two MoonPay integrations send webhooks. The **widget** integration sends transaction, identity check and virtual account events with a `type` and a `data` object. The newer **Platform** API sends a customer KYC event with an `id`, a `type`, a `timestamp` and `data`, and also sends updates for buy transactions.

Every request carries a `Moonpay-Signature-V2` header of the form `t=<timestamp>,s=<signature>`. The signature is a lowercase hex HMAC-SHA256 of the timestamp, a period and the raw request body, keyed with your account's webhook API key.

## MoonPay webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard **Developers > Webhooks**: **Add Endpoint** |
| Modes | One setting per test or live mode. Both can use the same URL |
| Event selection | All event types, or selected ones, per endpoint |
| Signature header | `Moonpay-Signature-V2: t=<unix seconds>,s=<hex>` |
| Scheme | HMAC-SHA256, lowercase hex |
| Signed content | `<t>.<raw request body>` |
| Key | The webhook API key from the Developers page, for example `wk_test_...` |
| Legacy header | `Moonpay-Signature`, keyed with the endpoint's signing secret. Verify V2 instead |
| Replay window | None documented |
| Response window | A `2xx` within 5 seconds |
| Retries | Up to 9 retries with exponential backoff from 1 second (10 attempts), then the event is dropped |
| Guarantees | At-least-once and unordered |
| Delivery ID | `webhook-id` on Platform webhooks, the same on every redelivery |
| Source IPs | `34.76.118.184`, `34.77.238.249`, `34.79.17.44`, `34.89.242.107`, `34.140.248.51`, `34.159.58.44`, `34.159.89.69`, `35.246.202.26` (IPv4 only) |

MoonPay stopped sending webhooks from IPv6 addresses on July 15, 2026. Your endpoint must use HTTPS.

## Common events

Route on the body's `type` field.

| Event | Integration | Fires when |
| --- | --- | --- |
| `transaction_created` | Widget | A customer creates a buy transaction |
| `transaction_updated` | Widget and Platform | A buy transaction's status or details change |
| `transaction_failed` | Widget | A buy transaction fails |
| `sell_transaction_created` | Widget | A customer creates a sell transaction |
| `sell_transaction_updated` | Widget | A sell transaction's status or details change |
| `sell_transaction_failed` | Widget | A sell transaction fails |
| `sell_transaction_requote_required` | Widget | The price moved before the customer's deposit arrived, and the transaction needs a new quote |
| `identity_check_updated` | Widget | A customer's KYC status changes |
| `virtual_account_status_updated` | Widget | A virtual account's status changes |
| `virtual_account_transaction_status_updated` | Widget | A transaction in a virtual account changes status |
| `customer.identity_status_updated` | Platform | A customer's KYC status transitions |

The dashboard may also list `swap_*` event types. MoonPay says these never fire for widget accounts.

MoonPay publishes schemas for each event but no complete example body. Built from the schema, with placeholder values and most fields left out, a widget event has this shape, where `data` holds the full transaction, identity check or virtual account object:

```json
{
  "type": "transaction_updated",
  "data": {
    "id": "<transaction id>",
    "createdAt": "<ISO 8601>",
    "updatedAt": "<ISO 8601>",
    "status": "completed",
    "baseCurrencyAmount": 100,
    "quoteCurrencyAmount": 0.0021,
    "walletAddress": "<wallet address>",
    "cryptoTransactionId": "<chain transaction id>",
    "externalTransactionId": "<your id>",
    "externalCustomerId": "<your customer id>"
  },
  "externalCustomerId": "<your customer id>"
}

```

A buy transaction's `status` is one of `waitingPayment`, `pending`, `waitingAuthorization`, `failed` or `completed`, and `failureReason` is set when it fails. `externalCustomerId` and `externalTransactionId` are the identifiers you passed to MoonPay, which makes them the easiest way to match an event to your own records.

The Platform `customer.identity_status_updated` event carries `id`, `type`, `timestamp` and `data`, where `id` matches the `webhook-id` header. MoonPay's Platform OpenAPI file describes Platform buy transaction updates as `{eventTime, data}` with no `type` at all, while the Platform overview points to the widget `transaction_updated` schema. Capture one real Platform delivery before you depend on either shape.

## Setting up MoonPay webhooks

1. In the MoonPay dashboard, open **Developers** and copy your webhook API key. Test and live mode have different keys. Store it server-side, for example as `MOONPAY_WEBHOOK_API_KEY`.
2. Open the [webhook settings](https://dashboard.moonpay.com/developers/#webhooks) and click **Add Endpoint**.
3. Enter your HTTPS URL and choose all event types or the ones you need.
4. Repeat in the other mode if you want both test and live events.
5. If your network restricts inbound traffic, allow MoonPay's eight IPv4 addresses.

If you receive through Hookdeck, create a MoonPay source, use its URL as the endpoint, and paste the webhook API key into the source's **Webhook API Key** field. Hookdeck verifies `Moonpay-Signature-V2` on `POST` webhooks. MoonPay also makes signed `GET` requests to some partner endpoints, where the signature covers the query string instead of a body. Those are not webhooks, and the MoonPay source does not verify them.

## Securing MoonPay webhooks

Split `Moonpay-Signature-V2` on commas, take `t` and `s`, build `<t>.<raw body>`, compute an HMAC-SHA256 with the webhook API key as a UTF-8 key, encode it as lowercase hex, and compare it with `s` in constant time. Ignore the legacy `Moonpay-Signature` header, which uses a different key.

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

function parseMoonpayHeader(header) {
  const parts = {};
  for (const pair of header.split(',')) {
    const i = pair.indexOf('=');
    if (i > 0) parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
  }
  return parts;
}

function verifyMoonpaySignature(rawBody, signatureHeader, apiKey, toleranceSeconds = 0) {
  if (!signatureHeader || !apiKey) return false;
  const { t, s } = parseMoonpayHeader(signatureHeader);
  if (!t || !s || !/^\d+$/.test(t)) return false;

  // Optional freshness check. MoonPay documents no window, so it is off by default.
  if (toleranceSeconds > 0 && Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSeconds) {
    return false;
  }

  const expected = crypto
    .createHmac('sha256', apiKey)
    .update(`${t}.`)
    .update(rawBody)
    .digest('hex');

  const received = Buffer.from(s.toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/moonpay', express.raw({ type: '*/*' }), (req, res) => {
  const apiKey = process.env.MOONPAY_WEBHOOK_API_KEY;
  if (!apiKey) return res.sendStatus(500); // fail closed

  if (!verifyMoonpaySignature(req.body, req.get('moonpay-signature-v2'), apiKey)) {
    return res.sendStatus(400); // MoonPay's docs return 400 for a bad signature
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.type. Store it, respond within 5 seconds, process afterwards.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import time

def verify_moonpay_signature(raw_body: bytes, signature_header, api_key: str, tolerance_seconds: int = 0) -> bool:
    if not signature_header or not api_key:
        return False
    parts = dict(p.strip().split("=", 1) for p in signature_header.split(",") if "=" in p)
    t, s = parts.get("t"), parts.get("s")
    if not t or not s or not t.isdigit():
        return False

    # Optional freshness check, off by default.
    if tolerance_seconds > 0 and abs(int(time.time()) - int(t)) > tolerance_seconds:
        return False

    signed = t.encode("utf-8") + b"." + raw_body
    expected = hmac.new(api_key.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(s.lower().encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_moonpay_signature(raw_body, request.headers.get("moonpay-signature-v2"),
#                          os.environ["MOONPAY_WEBHOOK_API_KEY"])

```

MoonPay documents no replay window, so the timestamp check above is off unless you pass a tolerance. If you turn it on behind a queue, keep it wide: retries and replays keep the original signature header, so a short window rejects legitimate late deliveries.

> **Make MoonPay webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `Moonpay-Signature-V2` header, answers MoonPay well inside its 5-second window, and durably queues every transaction and KYC event before your service sees it.

## MoonPay webhook limitations and pain points

### A 5-second window and a short retry tail

**The Problem:** Events are lost for good after a brief outage or a slow deploy.

**Why It Happens:** MoonPay counts anything slower than 5 seconds as a failure. It retries up to 9 times with exponential backoff that starts at 1 second and roughly doubles, so the whole retry sequence is over in minutes. Once retries are exhausted, the event is not redelivered.

**Workarounds:**

* Verify, store the event and return `200` before doing any other work.
* Keep the endpoint highly available, and reconcile transactions through the MoonPay API after any outage.

**How Hookdeck Can Help:** Hookdeck acknowledges each delivery straight away and stores it, then retries delivery to your service on a schedule you set, for as long as you need, and lets you replay anything that failed.

### Duplicates and out-of-order delivery

**The Problem:** A transaction moves backwards in your database, or a completion is processed twice.

**Why It Happens:** MoonPay delivers at least once and makes no ordering promise. A retried `pending` update can arrive after `completed`.

**Workarounds:**

* Deduplicate on `webhook-id` for Platform events, and on `type` plus `data.id` plus `data.updatedAt` for widget events, as MoonPay suggests.
* Compare `data.updatedAt` (or `timestamp` on Platform events) with the state you have stored, and ignore older updates.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeats based on a header such as `webhook-id` or on body fields you choose, before they reach your service.

### Two keys, two headers

**The Problem:** Verification fails even though the HMAC code is correct.

**Why It Happens:** MoonPay sends two signature headers. `Moonpay-Signature-V2` uses the account's webhook API key, and the legacy `Moonpay-Signature` uses a per-endpoint signing secret. Checking one header with the other's key always fails, and so does using a test-mode key for live events.

**Workarounds:**

* Read `Moonpay-Signature-V2` only, keyed with the webhook API key from the Developers page.
* Keep test and live keys in separate configuration, matched to the endpoint setting for each mode.

**How Hookdeck Can Help:** Hookdeck's MoonPay source reads `Moonpay-Signature-V2` with the key you give it. Using one source per mode keeps the keys apart.

### Platform documentation that disagrees with itself

**The Problem:** You can't be sure which header or body shape a Platform delivery uses.

**Why It Happens:** MoonPay's Platform overview says Platform webhooks carry `Moonpay-Signature-V2`, keyed with the webhook API key. Its Platform OpenAPI file declares an `X-Signature` header computed with "your secret key", and describes buy transaction updates as `{eventTime, data}` without a `type`.

**Workarounds:**

* Send a test-mode Platform webhook and inspect its headers and body before you build on either description.
* Route on the presence of `type` and fall back gracefully when it is missing.

**How Hookdeck Can Help:** Hookdeck records the full headers and body of every request, so one test delivery settles the question, and you can replay it while you adjust your handler.

## Best practices

### Verify against the raw body

The signature covers the exact bytes MoonPay sent. In Express, mount `express.raw()` on the route. In Next.js, read `await request.text()`. In FastAPI, read `await request.body()`. Parse the JSON only after verification passes.

### Use your own identifiers

Pass `externalCustomerId` and `externalTransactionId` when you create widget sessions and transactions. They come back in the payload and spare you a lookup on MoonPay's IDs.

### Treat the webhook as a trigger for state

Because events can arrive late or out of order, store each transaction's latest `updatedAt` and status, and only move forward. For money movement, confirm the final state through the API.

### Handle requotes in the product

`sell_transaction_requote_required` means the customer needs to accept a new price before the sale can continue. Surface it in your app rather than only logging it.

### Acknowledge fast and process asynchronously

Five seconds is a tight budget. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

MoonPay signs every webhook `POST` with a hex HMAC-SHA256 of `<t>.<raw body>` in `Moonpay-Signature-V2`, keyed with your webhook API key. Verify that header against the raw bytes, respond within 5 seconds, deduplicate, order updates by `updatedAt`, and confirm the Platform payload shape with a test delivery before relying on it.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every MoonPay signature, and holds each event until your service has processed it, so MoonPay's short retry sequence no longer decides whether you lose events.

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