Agent skill

MoonPay Webhooks Skill

Receive and verify MoonPay webhooks. Use when setting up MoonPay webhook endpoints, debugging Moonpay-Signature-V2 verification, or handling crypto on/off ramp events like transaction_created, transaction_updated, transaction_failed, sell_transaction_created, identity_check_updated, and customer.identity_status_updated.

Install this skill

npx skills add hookdeck/webhook-skills --skill moonpay-webhooks


When to Use This Skill

  • How do I receive MoonPay webhooks?
  • How do I verify the Moonpay-Signature-V2 header?
  • How do I handle transaction_created, transaction_updated, or transaction_failed events?
  • How do I react to sell_transaction_created or identity_check_updated webhooks?
  • How do I handle the Platform customer.identity_status_updated webhook?
  • Why is my MoonPay webhook signature verification failing?

Verification (core)

MoonPay sends a Moonpay-Signature-V2 header of the form t=<unix seconds>,s=<lowercase hex>. s is the HMAC-SHA256 hex of "<t>.<raw body>", keyed with the webhook API key (Developers page, e.g. wk_test_...) as a string. Not the legacy Moonpay-Signature. This covers POST webhooks only: GET callbacks sign the query string instead and are not covered here. Always sign over the raw body.

Timestamp window is opt-in. Hookdeck retries and replays resend the original timestamp, so a 5-minute window rejects late retries. Set MOONPAY_TIMESTAMP_TOLERANCE_SECONDS to a positive number to enable it (default 0 = off). references/verification.md shows where the window can be dropped or widened.

const crypto = require('crypto');

// toleranceSeconds = 0 (default) skips the timestamp window; a positive value opts in
function verifyMoonpayWebhook(rawBody, header, apiKey, toleranceSeconds = 0) {
  if (!header) return false;
  const parts = header.split(',').map((p) => p.split('='));
  const t = parts.find(([k]) => k === 't')?.[1];
  const s = parts.find(([k]) => k === 's')?.[1];
  if (!t || !/^\d+$/.test(t) || !s) return false;
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false; // opt-in
  const expected = crypto.createHmac('sha256', apiKey).update(`${t}.`).update(rawBody).digest('hex');
  try { return crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)); }
  catch { return false; } // length mismatch
}

In Express read the body with express.raw({ type: '*/*' }) so any content type verifies. MoonPay has no verification SDK; verification is manual in every framework.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Common Event Types

Widget webhooks have a body of { "type", "data", "externalCustomerId"? }, or { "type", "body" } for virtual account events; the Platform KYC webhook is { "id", "type", "timestamp", "data" }. Route on type:

EventTriggered WhenCommon Use Cases
transaction_createdA customer creates a Buy transactionRecord the order
transaction_updatedA Buy transaction's status or details changeTrack status
transaction_failedA Buy transaction failsNotify the user
sell_transaction_createdA customer creates a Sell transactionAwait deposit
sell_transaction_updatedA Sell transaction changesTrack payout
sell_transaction_failedA Sell transaction failsRefund handling
sell_transaction_requote_requiredPrice moved before the deposit arrivedPrompt for a new quote
identity_check_updatedA customer's KYC status changes (widget)Unlock limits
customer.identity_status_updatedA customer's KYC status transitions (Platform)Sync KYC state

More types (virtual_account_status_updated, ...) are listed in references/overview.md.

Environment Variables

MOONPAY_WEBHOOK_API_KEY=wk_test_xxxxx        # Webhook API key from the Developers page
MOONPAY_TIMESTAMP_TOLERANCE_SECONDS=0        # Optional replay window; a positive value opts in

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 moonpay --path /webhooks/moonpay

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 5, 2026

View on GitHub →