// paystack-webhook.js // Node >= 14, install dependencies: // npm install express firebase-admin axios const express = require('express'); const crypto = require('crypto'); const admin = require('firebase-admin'); const axios = require('axios'); const app = express(); // ------------------------------------------------- // Environment variables (set these in your env/secrets) // ------------------------------------------------- // - FIREBASE_CREDENTIALS: stringified JSON service account // - PAYSTACK_DEMO_SECRET // - PAYSTACK_LIVE_SECRET // - PORT (optional) // ------------------------------------------------- const { FIREBASE_CREDENTIALS, PAYSTACK_DEMO_SECRET, PAYSTACK_LIVE_SECRET, PORT, } = process.env; // choose which secret to use (dev/demo by default) const PAYSTACK_SECRET = PAYSTACK_DEMO_SECRET || PAYSTACK_LIVE_SECRET; if (!PAYSTACK_SECRET) { console.warn('WARNING: PAYSTACK_SECRET is not set. Outgoing Paystack calls and webhook verification will fail.'); } // Initialize Firebase Admin using credentials read from env if (FIREBASE_CREDENTIALS) { try { const serviceAccount = JSON.parse(FIREBASE_CREDENTIALS); admin.initializeApp({ credential: admin.credential.cert(serviceAccount), }); console.log('Firebase admin initialized from FIREBASE_CREDENTIALS env var.'); } catch (err) { console.error('Failed to parse FIREBASE_CREDENTIALS JSON:', err); process.exit(1); } } else { console.warn('FIREBASE_CREDENTIALS not provided. Firebase admin not initialized.'); } // ------------------------------------------------- // Webhook raw parser (required to verify Paystack signature) // ------------------------------------------------- app.use( express.raw({ type: 'application/json', limit: '1mb', }) ); // Utility: verify x-paystack-signature function verifyPaystackSignature(rawBodyBuffer, signatureHeader) { if (!PAYSTACK_SECRET) return false; const hmac = crypto.createHmac('sha512', PAYSTACK_SECRET); hmac.update(rawBodyBuffer); const expected = hmac.digest('hex'); try { return crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(signatureHeader || '', 'utf8')); } catch (e) { return false; } } // Helper: safe JSON parse function safeParseJSON(raw) { try { return JSON.parse(raw); } catch (err) { return null; } } // ------------------------ // Existing webhook endpoint // ------------------------ app.post('/webhook/paystack', (req, res) => { const raw = req.body; // Buffer because we used express.raw const signature = req.get('x-paystack-signature') || req.get('X-Paystack-Signature'); // Verify signature if (!signature || !verifyPaystackSignature(raw, signature)) { console.warn('Paystack webhook signature verification failed. Signature header:', signature); return res.status(400).json({ ok: false, message: 'Invalid signature' }); } // Parse payload const bodyStr = raw.toString('utf8'); const payload = safeParseJSON(bodyStr); if (!payload) { console.warn('Could not parse webhook JSON payload.'); return res.status(400).json({ ok: false, message: 'Invalid JSON' }); } const event = (payload.event || payload.type || '').toString(); const isRefund = /refund/i.test(event); const isChargeSuccess = /charge\.success/i.test(event); const isInvoiceOrSubscription = /invoice\.(payment_succeeded|paid)|subscription\./i.test(event); const isPayment = isChargeSuccess || isInvoiceOrSubscription; if (isRefund || isPayment) { console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---'); console.log('event:', event); console.log('payload:', JSON.stringify(payload, null, 2)); // Example: persist webhook to Firestore (uncomment if you want) // const db = admin.firestore(); // db.collection('paystack-webhooks').add({ // event, // payload, // receivedAt: admin.firestore.FieldValue.serverTimestamp(), // }); } else { console.log(`Received Paystack webhook for event "${event}" — ignored (not refund/payment).`); } return res.status(200).json({ ok: true }); }); // ------------------------ // New: Create Payment Page (link) for a plan // ------------------------ // This endpoint expects JSON body, so we attach express.json() for this route. // Required fields in body: planId, userId, name (friendly page name) optionally: amount, redirect_url, collect_phone, fixed_amount app.post('/create-payment-link', express.json(), async (req, res) => { // If req.body was left as a Buffer (because express.raw ran), parse it: let body = req.body; if (Buffer.isBuffer(body)) { try { body = JSON.parse(body.toString('utf8')); } catch (err) { return res.status(400).json({ ok: false, message: 'Invalid JSON' }); } } const { planId, userId, name, amount, redirect_url, collect_phone = false, fixed_amount = false } = body || {}; if (!planId) return res.status(400).json({ ok: false, message: 'planId is required' }); if (!userId) return res.status(400).json({ ok: false, message: 'userId is required' }); if (!name) return res.status(400).json({ ok: false, message: 'name is required (page title)' }); // Build the body per Paystack's Payment Pages API. // We'll set type to "subscription" (so it links to a plan) and pass metadata.userId // Also add a custom_fields array so the dashboard shows the user id if you open the transaction. // See: Paystack Payment Pages and Metadata docs. 1 const payload = { name, type: 'subscription', plan: planId, metadata: { userId, }, custom_fields: [ { display_name: 'User ID', variable_name: 'user_id', value: userId, }, ], // optional properties collect_phone, fixed_amount, }; if (amount) payload.amount = amount; // amount is optional (in kobo/cents) if (redirect_url) payload.redirect_url = redirect_url; try { const response = await axios.post('https://api.paystack.co/page', payload, { headers: { Authorization: `Bearer ${PAYSTACK_SECRET}`, 'Content-Type': 'application/json', }, timeout: 10_000, }); // Paystack returns a response with data object. Return minimal info to caller. const pageData = response.data && response.data.data ? response.data.data : response.data; // The created page usually has a slug; you can build public url with https://paystack.com/pay/[slug] // Return the page data so caller can redirect or store details. return res.status(201).json({ ok: true, page: pageData }); } catch (err) { console.error('Error creating Paystack payment page:', err?.response?.data || err.message || err); const errorDetail = err?.response?.data || { message: err.message }; return res.status(500).json({ ok: false, error: errorDetail }); } }); // Simple health check app.get('/health', (_req, res) => res.json({ ok: true })); app.listen(PORT, () => { console.log(`Paystack webhook server listening on port ${PORT}`); console.log('POST /webhook/paystack to receive Paystack callbacks'); console.log('POST /create-payment-link to create a subscription payment page (expects JSON body).'); });