/** * Stripe — Webhook receiver * ------------------------- * The authoritative record of truth for payments. Stripe POSTs events here; * we verify the signature, then react to payment lifecycle events. This fires * server-to-server even if the customer closes their browser, so the * transaction record must be written here (not only on the success page). * * Configure in the Stripe Dashboard → Developers → Webhooks: * Endpoint URL: https://.vercel.app/api/payments/webhook * Events: checkout.session.completed, * checkout.session.async_payment_succeeded, * checkout.session.async_payment_failed, * checkout.session.expired * * Env: * STRIPE_SECRET_KEY sk_… (to construct the Stripe client) * STRIPE_WEBHOOK_SECRET whsec_… (to verify the signature) * * Local testing: `stripe listen --forward-to localhost:5173/api/payments/webhook` * * Persistence uses Vercel KV when connected (same optional pattern as * api/hail-webhook.js); without KV it logs the event and ack's 200. */ // Stripe requires the *raw* request body to verify the signature, so the // default JSON body parser must be disabled for this route. export const config = { api: { bodyParser: false } }; async function readRawBody(req) { const chunks = []; for await (const chunk of req) { chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); } return Buffer.concat(chunks); } /** Persist a payment record (best-effort; no-op without Vercel KV). */ async function recordPayment(record) { try { const { kv } = await import('@vercel/kv'); // Keep a capped, newest-first list of transactions. await kv.lpush('payments:transactions', JSON.stringify(record)); await kv.ltrim('payments:transactions', 0, 199); if (record.invoiceId && record.status === 'paid') { await kv.set(`payments:invoice:${record.invoiceId}`, 'paid'); } console.log('[payments/webhook] recorded in KV:', record.id); } catch { console.log('[payments/webhook] KV unavailable — event received but not persisted:', record); } } export default async function handler(req, res) { if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }); } const secretKey = (process.env.STRIPE_SECRET_KEY || '').trim(); const webhookSecret = (process.env.STRIPE_WEBHOOK_SECRET || '').trim(); if (!secretKey || !webhookSecret) { return res.status(503).json({ error: 'Stripe webhook is not configured.' }); } let event; try { const Stripe = (await import('stripe')).default; const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' }); const rawBody = await readRawBody(req); const signature = req.headers['stripe-signature']; event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); } catch (err) { // Signature verification failed — reject (could be a forged request). console.error('[payments/webhook] signature verification failed:', err.message); return res.status(400).send(`Webhook Error: ${err.message}`); } try { switch (event.type) { case 'checkout.session.completed': case 'checkout.session.async_payment_succeeded': { const s = event.data.object; await recordPayment({ id: s.id, invoiceId: s.metadata?.invoiceId || null, amount: s.amount_total, currency: s.currency, customerEmail: s.customer_details?.email || null, status: s.payment_status === 'paid' ? 'paid' : s.payment_status, eventType: event.type, createdAt: new Date().toISOString(), }); break; } case 'checkout.session.async_payment_failed': { const s = event.data.object; await recordPayment({ id: s.id, invoiceId: s.metadata?.invoiceId || null, amount: s.amount_total, currency: s.currency, status: 'failed', eventType: event.type, createdAt: new Date().toISOString(), }); break; } case 'checkout.session.expired': { const s = event.data.object; console.log('[payments/webhook] checkout session expired/cancelled:', s.id); break; } default: // Unhandled event types are acknowledged so Stripe stops retrying. console.log('[payments/webhook] unhandled event:', event.type); } } catch (err) { console.error('[payments/webhook] handler error:', err.message); // Still 200 below if we got a valid event — returning 500 makes Stripe // retry, which is only desirable for transient persistence failures. } return res.status(200).json({ received: true }); }