Spaces:
Sleeping
Sleeping
File size: 6,947 Bytes
59b509f 77cc094 59b509f 77cc094 59b509f 77cc094 59b509f 072db80 59b509f fd68981 77cc094 59b509f 77cc094 7ccf1cc 59b509f 77cc094 59b509f b977bfa 59b509f 77cc094 59b509f 77cc094 59b509f 77cc094 59b509f 67d31ad 59b509f 77cc094 59b509f 77cc094 59b509f 77cc094 59b509f 7fe989f 6d4d78a 77cc094 59b509f 77cc094 59b509f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | // 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 = 3000,
} = 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) => {
const { planId, userId, name, amount, redirect_url, collect_phone = false, fixed_amount = false } = req.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).');
}); |