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