Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion app/api/webhooks/moonpay/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,42 @@
import crypto from 'crypto'
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
import { sendPaymentReceivedEmail } from '@/lib/email'
import { logger } from '@/lib/logger'

function verifyMoonPaySignature(rawBody: string, signature: string, secret: string): boolean {
if (!signature || !secret) return false
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('base64')
try {
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
} catch {
return false // handles length mismatch
}
}

export async function POST(request: NextRequest) {
try {
const event = await request.json()
// Step 1: Read raw body and verify webhook signature
const rawBody = await request.text()
const signature = request.headers.get('moonpay-signature') ?? ''
const secret = process.env.MOONPAY_WEBHOOK_KEY ?? ''

if (!verifyMoonPaySignature(rawBody, signature, secret)) {
console.warn('MoonPay webhook: invalid signature')
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}

// Step 2: Parse the verified body
let event: any
try {
event = JSON.parse(rawBody)
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}

logger.info({ eventType: event.type }, 'MoonPay webhook')

if (
Expand Down
80 changes: 80 additions & 0 deletions scripts/verify-moonpay-webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Verification script for MoonPay webhook signature.
* Run: npx tsx scripts/verify-moonpay-webhook.ts
*
* Tests:
* 1. Valid signature + valid body β†’ passes
* 2. Valid signature + tampered body β†’ rejected
* 3. Wrong secret β†’ rejected
* 4. Missing/empty signature β†’ rejected
* 5. Missing/empty secret β†’ rejected
*/

import crypto from 'crypto'

function verifyMoonPaySignature(rawBody: string, signature: string, secret: string): boolean {
if (!signature || !secret) return false
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('base64')
try {
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
} catch {
return false // handles length mismatch
}
}

const testSecret = 'wk_test_secret_123'
const payload = JSON.stringify({
type: 'transaction_completed',
data: { status: 'completed', externalTransactionId: 'INV-2025-001' },
})
const validSignature = crypto
.createHmac('sha256', testSecret)
.update(payload)
.digest('base64')

type TestCase = { description: string; result: boolean; expected: boolean }

const tests: TestCase[] = [
{
description: 'Valid signature + valid body',
result: verifyMoonPaySignature(payload, validSignature, testSecret),
expected: true,
},
{
description: 'Valid signature + tampered body',
result: verifyMoonPaySignature(payload + 'x', validSignature, testSecret),
expected: false,
},
{
description: 'Correct body + wrong secret',
result: verifyMoonPaySignature(payload, validSignature, 'wrong_secret'),
expected: false,
},
{
description: 'Correct body + empty signature',
result: verifyMoonPaySignature(payload, '', testSecret),
expected: false,
},
{
description: 'Correct body + valid signature + empty secret',
result: verifyMoonPaySignature(payload, validSignature, ''),
expected: false,
},
]

let passed = 0
for (const test of tests) {
const ok = test.result === test.expected
if (ok) {
console.log(`βœ… PASS: ${test.description}`)
passed++
} else {
console.log(`❌ FAIL: ${test.description}`)
}
}

console.log(`\n${passed}/${tests.length} tests passed`)
process.exit(passed === tests.length ? 0 : 1)
Loading