Spaces:
Sleeping
Sleeping
| // 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'); | |
| }); |