Custom Paystack Plugin Development: A Primer
A custom Paystack plugin wraps the Paystack API into a reusable class: initialize(amount, email, metadata) calls POST /transaction/initialize, verifyAndFulfill(reference) calls GET /transaction/verify and runs your fulfillment logic, and handleWebhook(rawBody, signature) validates HMAC and dispatches events. Register these with your platform's payment hook system and export them as a package.
Plugin Architecture
A Paystack plugin has three concerns: initialization (get a checkout URL), verification (confirm a payment), and webhook handling (receive server-to-server events). Keep these separate so the platform can use whichever flow it supports.
class PaystackPlugin {
constructor({ secretKey, webhookSecret, currency = 'NGN', onSuccess }) {
this.secretKey = secretKey;
this.webhookSecret = webhookSecret;
this.currency = currency;
this.onSuccess = onSuccess; // async (transaction) => {}
this._processed = new Set(); // idempotency
}
async initialize(email, amountMinorUnit, reference, metadata = {}) {
var res = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: { Authorization: 'Bearer ' + this.secretKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ email, amount: amountMinorUnit, reference, currency: this.currency, metadata }),
});
var data = await res.json();
if (!data.status) throw new Error(data.message);
return data.data.authorization_url;
}
async verifyAndFulfill(reference) {
if (this._processed.has(reference)) return { already: true };
var res = await fetch('https://api.paystack.co/transaction/verify/' + reference, {
headers: { Authorization: 'Bearer ' + this.secretKey },
});
var data = await res.json();
if (data.data.status !== 'success') throw new Error('Payment not successful');
this._processed.add(reference);
await this.onSuccess(data.data);
return data.data;
}
handleWebhook(rawBody, signature) {
var crypto = require('crypto');
var expected = crypto.createHmac('sha512', this.webhookSecret).update(rawBody).digest('hex');
if (expected !== signature) throw new Error('Invalid webhook signature');
var event = JSON.parse(rawBody);
if (event.event === 'charge.success') return this.verifyAndFulfill(event.data.reference);
return Promise.resolve({ ignored: true });
}
}
Registering with Your Platform
Most frameworks have a payment hook system. Register the plugin once at startup and let the platform call the methods:
// Express app example
var plugin = new PaystackPlugin({
secretKey: process.env.PAYSTACK_SECRET_KEY,
webhookSecret: process.env.PAYSTACK_WEBHOOK_SECRET,
currency: 'KES',
onSuccess: async (txn) => {
await db.orders.update({ reference: txn.reference }, { status: 'paid', paidAt: new Date() });
},
});
app.post('/checkout/start', async (req, res) => {
var url = await plugin.initialize(req.body.email, req.body.amount, req.body.reference);
res.json({ url });
});
app.get('/checkout/callback', async (req, res) => {
await plugin.verifyAndFulfill(req.query.reference);
res.redirect('/order-confirmed');
});
app.post('/webhooks/paystack', express.raw({ type: 'application/json' }), (req, res) => {
plugin.handleWebhook(req.body, req.headers['x-paystack-signature'])
.then(() => res.sendStatus(200))
.catch(() => res.sendStatus(400));
});
Learn More
See the full Paystack plugins overview for all integration options.
Key Takeaways
- ✓A Paystack plugin wraps initialize, verify, and webhook handling into a reusable class.
- ✓HMAC SHA-512 webhook signature validation must be in the plugin, not left to the implementer.
- ✓Pass a fulfillment callback into the plugin so the business logic stays outside the payment layer.
- ✓Idempotency key your verify calls — the webhook and the redirect can both trigger verification.
- ✓Export your plugin with a configuration object: secretKey, webhookSecret, currency, and optional hooks.
Frequently Asked Questions
- Should the plugin store the secret key or expect it from the environment?
- Accept it as a constructor parameter, not a hardcoded value. This keeps the plugin flexible — the consuming app passes process.env.PAYSTACK_SECRET_KEY. Never hardcode credentials in the plugin source.
- How do I handle the case where both the redirect and the webhook fire for the same payment?
- Use idempotency: keep a Set of fulfilled references in memory, or better, use your database. Before calling onSuccess, check if the reference already has status "paid" in your DB. If yes, skip fulfillment and return early.
- How should I package the plugin for distribution?
- Export the class from an index.js, add a peerDependency for node-fetch or use globalThis.fetch (Node 18+), and document the constructor config object. For npm: set "main" to index.js, "types" to index.d.ts if TypeScript. For internal use, a local package in your monorepo packages/ folder is sufficient.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon