/** * BTCPay Server webhook — auto-credit VOID when an invoice settles. * * Configure in BTCPay: Store → Settings → Webhooks → Add Webhook * URL: http://127.0.0.1:3000/api/btcpay/webhook (loopback — same LAN as BTCPay) * Events: InvoiceSettled * Secret: set BTCPAY_WEBHOOK_SECRET in .env.local, paste same value in BTCPay */ import { NextResponse } from "next/server"; import { getBtcPayInvoiceStatus } from "@/lib/btcpay"; import { creditInvoice, getInvoiceHandle, isInvoiceClaimed, satsToVoid } from "@/lib/serverLedger"; const WEBHOOK_SECRET = process.env.BTCPAY_WEBHOOK_SECRET ?? ""; async function verifyBtcPaySignature(req: Request, body: string): Promise { const sig = req.headers.get("btcpay-sig") ?? ""; if (!WEBHOOK_SECRET || !sig) return !WEBHOOK_SECRET; // if no secret configured, skip verification try { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(WEBHOOK_SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const expected = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)); const expectedHex = Array.from(new Uint8Array(expected)).map((b) => b.toString(16).padStart(2, "0")).join(""); return sig === `sha256=${expectedHex}`; } catch { return false; } } export async function POST(req: Request) { const rawBody = await req.text(); if (!(await verifyBtcPaySignature(req, rawBody))) { return NextResponse.json({ ok: false, error: "Invalid webhook signature" }, { status: 401 }); } let payload: Record; try { payload = JSON.parse(rawBody) as Record; } catch { return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 }); } const type = payload.type as string | undefined; const invoiceId = (payload.invoiceId ?? payload.id) as string | undefined; if (!invoiceId || type !== "InvoiceSettled") { return NextResponse.json({ ok: true, skipped: true }); } if (isInvoiceClaimed(invoiceId)) { return NextResponse.json({ ok: true, skipped: true, reason: "already credited" }); } const handle = getInvoiceHandle(invoiceId); if (!handle) { return NextResponse.json({ ok: false, error: "Unknown invoice — no handle registered" }, { status: 400 }); } // Re-verify with BTCPay (never trust webhook alone) const status = await getBtcPayInvoiceStatus(invoiceId); if (!status.ok || status.status !== "Settled") { return NextResponse.json({ ok: false, error: `Invoice not settled (${status.ok ? status.status : status.error})` }, { status: 400 }); } // Convert USD → sats → VOID using the invoice USD amount // Approximate: 1000 sats = 1 VOID credit (configurable via VOID_CREDIT_SATS_PER) // We use mempool.space BTC price for the conversion const btcPriceRes = await fetch( "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd", { next: { revalidate: 60 } }, ).catch(() => null); const btcUsd = btcPriceRes?.ok ? ((await btcPriceRes.json()) as { bitcoin?: { usd?: number } }).bitcoin?.usd ?? 0 : 0; let voidToCredit = 0; if (btcUsd > 0) { const sats = Math.floor((status.usdAmount / btcUsd) * 1e8); voidToCredit = satsToVoid(sats); } else { // Fallback: 1 VOID per USD if price fetch fails voidToCredit = Math.floor(status.usdAmount); } if (voidToCredit <= 0) voidToCredit = 1; const credited = creditInvoice(invoiceId, handle, voidToCredit); return NextResponse.json({ ok: true, credited, handle, voidToCredit }); }