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_ | 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_ | Widget | A virtual account's status changes |
virtual_ | Widget | A transaction in a virtual account changes status |
customer.identity_ | 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:
{
"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_ 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
- 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. - Open the webhook settings and click Add Endpoint.
- Enter your HTTPS URL and choose all event types or the ones you need.
- Repeat in the other mode if you want both test and live events.
- 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.
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:
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 verifies the
Moonpay-Signature-V2header, 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
200before 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-idfor Platform events, and ontypeplusdata.idplusdata.updatedAtfor widget events, as MoonPay suggests. - Compare
data.updatedAt(ortimestampon 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-V2only, 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
typeand 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_ 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 and our guide to 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 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 for free and handle MoonPay webhooks reliably in minutes.