// paystack-webhook.js // Node >= 14, install dependencies: // npm install express firebase-admin const express = require('express'); const crypto = require('crypto'); const admin = require('firebase-admin'); const app = express(); // ------------------------------------------------- // Environment variables (set these in your env/secrets) // ------------------------------------------------- // - FIREBASE_CREDENTIALS: stringified JSON service account // - PAYSTACK_SECRET: your Paystack webhook secret // - PORT (optional) // ------------------------------------------------- const { FIREBASE_CREDENTIALS, PAYSTACK_DEMO_SECRET, PAYSTACK_LIVE_SECRET, PORT, } = process.env; const PAYSTACK_SECRET = PAYSTACK_DEMO_SECRET; // PAYSTACK_LIVE_SECRET if (!PAYSTACK_SECRET) { console.warn('WARNING: PAYSTACK_SECRET is not set. Webhook signature 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.'); } // Important: use raw body parser to verify Paystack signature. // Paystack signs the raw request body (HMAC SHA512) and puts signature in header `x-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'); // Paystack header is hex string; compare in constant-time return crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(signatureHeader || '', 'utf8')); } // Helper: safe JSON parse function safeParseJSON(raw) { try { return JSON.parse(raw); } catch (err) { return null; } } // 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); // Respond 400 to indicate invalid 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' }); } // Paystack typically uses an "event" field: e.g. "charge.success", "refund.create", ... const event = (payload.event || payload.type || '').toString(); // Identify refund events and payment events (first-time or recurring) const isRefund = /refund/i.test(event); // matches refund.* etc. const isChargeSuccess = /charge\.success/i.test(event); // common payment event // add some broad matches for invoice/subscription events that might indicate recurring payments const isInvoiceOrSubscription = /invoice\.(payment_succeeded|paid)|subscription\./i.test(event); const isPayment = isChargeSuccess || isInvoiceOrSubscription; // Only interested in refunds and payments if (isRefund || isPayment) { console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---'); console.log('event:', event); console.log('payload:', JSON.stringify(payload, null, 2)); // TODO: add your business logic here: // - update Firestore / user subscription state // - create refund records, notify user, retry flows, etc. // Example (pseudo): // const db = admin.firestore(); // await db.collection('paystack-webhooks').add({ receivedAt: admin.firestore.FieldValue.serverTimestamp(), event, payload }); // If you want to persist the webhook to Firestore for auditing, uncomment above and ensure Firebase is initialized } else { // For debugging, you can log minimal info for non-interesting events console.log(`Received Paystack webhook for event "${event}" — ignored (not refund/payment).`); } // Acknowledge the webhook promptly with 200 OK so Paystack stops retrying return res.status(200).json({ ok: true }); }); // 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'); });