Files
dark-lord/app/api/btcpay/webhook/route.ts
2026-04-26 22:28:40 -07:00

96 lines
3.5 KiB
TypeScript

/**
* 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<boolean> {
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<string, unknown>;
try {
payload = JSON.parse(rawBody) as Record<string, unknown>;
} 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 });
}