Connects Payload to Paystack by syncing configured collections such as products, plans, customers and transactions with their Paystack resources. It adds a webhook endpoint with signature verification and an optional authenticated REST proxy endpoint for Paystack API calls. Requires a Paystack secret key and Payload 3.37 or later.
A Payload CMS plugin for seamless Paystack integration, featuring two-way sync, webhooks, REST proxy, and customer blacklisting
Installation
npm i paystack-payload-plugin6
Stars
0
Forks
1
Open Issues
1 years ago
Last Updated
0
Weekly Downloads
v1.1.25
npm Version
232.0 KB
Unpacked Size
0
Dependencies
Trends appear after 7 days of data. Daily snapshots have not started yet.
Trends appear after 7 days of data. Daily snapshots have not started yet.
Each point is npm's rolling 7-day download count.
Health Score
Composite score based on GitHub activity, stars, npm downloads, publish recency, dependency count, and package size.
Badge for your README
[](https://payloaddirectory.dev/plugins/990607615-root)<a href="https://payloaddirectory.dev/plugins/990607615-root"><img src="https://payloaddirectory.dev/badge/990607615-root.svg" alt="Payload Directory"></a>README
A robust Payload CMS plugin to sync and manage your Paystack resources directly from Payload. Supports two-way sync, REST/webhook integration, and advanced customer blacklisting—with full control over how you manage your collections!
⭐ Star this repository if you find it useful! Your support helps the project grow and encourages further development.
⚠️ IMPORTANT: TypeScript Declaration
To avoid TypeScript declaration errors if you have, create a file namedpaystack-payload-plugin.d.tsin your project's root orsrcdirectory with the following content:declare module 'paystack-payload-plugin';This will resolve any TypeScript declaration errors when using the plugin.
⚠️ IMPORTANT UPDATE NOTE
Changes not HMR and importMap issues have been fixed in the latest version.
If you were experiencing issues with Hot Module Replacement (HMR) or importMap configuration, please update to the latest version.
/paystack/rest endpoint for any Paystack API call, directly from your authenticated frontend or backend.npm install paystack-payload-plugin
# or
yarn add paystack-payload-plugin
# or
pnpm add paystack-payload-plugin
After installation, run:
npx payload generate:importmap
This ensures proper module resolution for the plugin.
If you encounter installation issues, try these solutions:
Using pnpm (Recommended)
pnpm add paystack-payload-plugin@latest
Using npm with legacy peer deps
npm install paystack-payload-plugin@latest --legacy-peer-deps
Clear npm cache and retry
npm cache clean --force
npm install paystack-payload-plugin@latest
Install directly from GitHub
npm install github:Jose-henry/payload-paystack-plugin
If you're still experiencing issues, please:
If you see warnings about peer dependencies or build scripts, these are normal and won't affect functionality:
Peer Dependency Warnings
Build Script Warnings
pnpm approve-builds if you need to allow specific build scriptsThe plugin is compatible with all major package managers, but each has its own requirements:
npm
# First, clear npm cache
npm cache clean --force
# Then try installing with legacy peer deps
npm install paystack-payload-plugin@latest --legacy-peer-deps
# If that doesn't work, try with force
npm install paystack-payload-plugin@latest --force
pnpm (Recommended)
pnpm add paystack-payload-plugin@latestnpm "matches" Error
TypeScript Errors
Peer Dependency Warnings
import { buildConfig } from 'payload'
import { paystackPlugin } from 'paystack-payload-plugin'
import type { PaystackPluginConfig } from 'paystack-payload-plugin/types'
export default buildConfig({
// ...your config
plugins: [
paystackPlugin({
paystackSecretKey: process.env.PAYSTACK_SECRET_KEY!,
webhookSecret: process.env.PAYSTACK_SECRET_KEY!,
rest: true,
logs: true,
blacklistCustomerOption: true,
testMode: false,
defaultCurrency: 'NGN',
pollingInterval: 60 * 60 * 1000,
sync: [
{
collection: 'product',
paystackResourceType: 'product',
paystackResourceTypeSingular: 'product',
fields: [
{ fieldPath: 'name', paystackProperty: 'name' },
{ fieldPath: 'description', paystackProperty: 'description' },
{ fieldPath: 'price', paystackProperty: 'price' },
{ fieldPath: 'quantity', paystackProperty: 'quantity' },
],
},
{
collection: 'plan',
paystackResourceType: 'plan',
paystackResourceTypeSingular: 'plan',
fields: [
{ fieldPath: 'title', paystackProperty: 'name' },
{ fieldPath: 'amount', paystackProperty: 'amount' },
],
},
{
collection: 'customer',
paystackResourceType: 'customer',
paystackResourceTypeSingular: 'customer',
fields: [
{ fieldPath: 'name', paystackProperty: 'first_name' },
{ fieldPath: 'email', paystackProperty: 'email' },
{ fieldPath: 'lastName', paystackProperty: 'last_name' },
{ fieldPath: 'phone', paystackProperty: 'phone' },
],
},
{
collection: 'transaction',
paystackResourceType: 'transaction',
paystackResourceTypeSingular: 'transaction',
fields: [
{ fieldPath: 'paystackID', paystackProperty: 'id' },
{ fieldPath: 'status', paystackProperty: 'status' },
{ fieldPath: 'reference', paystackProperty: 'reference' },
{ fieldPath: 'amount', paystackProperty: 'amount' },
{ fieldPath: 'currency', paystackProperty: 'currency' },
{ fieldPath: 'paid_at', paystackProperty: 'paid_at' },
// The following fields use webhook events with nested values:
{ fieldPath: 'customer_code', paystackProperty: 'customer.customer_code' }, // nested: customer.customer_code
{ fieldPath: 'channel', paystackProperty: 'channel' },
{ fieldPath: 'customer_first_name', paystackProperty: 'customer.first_name' }, // nested: customer.first_name
{ fieldPath: 'customer_last_name', paystackProperty: 'customer.last_name' }, // nested: customer.last_name
{ fieldPath: 'customer_email', paystackProperty: 'customer.email' }, // nested: customer.email
],
},
],
}),
],
})
You control your Payload collections—make them editable or read-only as you wish.
Example: Making a Collection Read-Only
{
slug: 'transaction',
access: {
create: () => false,
update: () => false,
delete: () => false,
read: () => true,
},
fields: [
{ name: 'status', type: 'text' },
{ name: 'reference', type: 'text' },
{ name: 'amount', type: 'number' },
{ name: 'currency', type: 'text' },
],
}
These collections are synced using the REST proxy, webhook, or manual update logic on payload (as configured in your sync settings).
To enable true two-way sync, use both the REST endpoint and configure the Paystack webhook to point to your Payload API.
Set your webhook URL in the Paystack dashboard to:
https://xxxx.ngrok.io/api/paystack/webhook
Use the same value for paystackSecretKey and webhookSecret (usually your Paystack secret key).
On localhost, run:
Follow and Read ngrok docs, to create a local domain
and use the public URL in your Paystack dashboard.
await fetch('/paystack/rest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
paystackPath: '/transaction/initialize',
paystackMethod: 'POST',
paystackArgs: [{ email: "[email protected]", amount: 20000 }],
}),
});
You can use any Paystack API path and method. The request will be proxied using your secret key.
Besides using the REST proxy endpoint, you have two other options to interact with Paystack:
Direct Paystack API Calls:
// Using fetch
await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: '[email protected]',
amount: 20000
})
});
Using the paystackProxy Utility:
import { paystackProxy } from 'paystack-payload-plugin/utilities';
// Make API calls using the proxy function
const response = await paystackProxy({
path: '/transaction/initialize',
method: 'POST',
body: { email: '[email protected]', amount: 20000 },
secretKey: process.env.PAYSTACK_SECRET_KEY!,
})
The paystackProxy utility provides a convenient way to make Paystack API calls while maintaining consistent error handling and authentication. It's particularly useful when you want to make Paystack API calls from your backend code without exposing the REST endpoint.
⚠️ Important Security Note:
Setting rest: true in your plugin configuration opens up the /api/paystack/rest endpoint, allowing authenticated users to directly interact with the Paystack REST API. While this might be convenient for development, in production it poses a security risk because any authenticated user would have unrestricted access to Paystack's API. This could lead to unintended actions or data exposure.
Development vs Production:
rest: true is acceptable for testing and development purposesAlternative Approaches:
Remember: The REST proxy is a powerful feature that should be used with caution. Always prioritize security over convenience in production environments.
When blacklistCustomerOption: true, a "Blacklisted on Paystack" checkbox appears in the Payload admin sidebar for the customer collection.
About Polling
Because Paystack does not send webhooks for customer blacklist/risk status changes, this plugin includes a built-in background polling mechanism (runs on server start and at your chosen interval if enabled). Polling ensures that customer blacklist status in Payload stays in sync with Paystack. Polling only happens if youve set its parameter in plugin config to true
pollingInterval controls how often the sync runs (in milliseconds).
pollingRunImmediately (default: false) controls whether the polling runs once as soon as Payload starts.
You do NOT need to manually call any polling function in your Payload config. The plugin does this automatically.
Deleting a customer in Payload sets risk_action: "deny" in Paystack.
Deleting a record in Payload is a destructive process that will:
paystackID, attempt to delete/blacklist it in PaystackskipSync was checked during creationThe skipSync flag only prevents creation/updates in Paystack, but deletion will still be attempted if a paystackID exists.
⚠️ NOTE:
As of 2024, Paystack webhooks only support a limited number of event types (see below). This means most collections cannot be synced from Paystack to Payload via webhook.
For best data integrity, always make changes in Payload (not Paystack), since webhooks will NOT cover most update scenarios (e.g., customer, plan, or product changes). We recommend treating Payload as your "single source of truth."
| Event Name | Example Usage |
|---|---|
| charge.dispute.create | Disputes |
| charge.dispute.remind | Disputes |
| charge.dispute.resolve | Disputes |
| charge.success | Transaction success (e.g., orders) |
| customeridentification.failed | KYC/ID validation failed |
| customeridentification.success | KYC/ID validation passed |
| dedicatedaccount.assign.failed | DVA (dedicated virtual account) |
| dedicatedaccount.assign.success | DVA assigned |
| invoice.create | Invoice for a subscription |
| invoice.payment_failed | Invoice payment failed |
| invoice.update | Invoice updated/charged |
| paymentrequest.pending | Payment request sent |
| paymentrequest.success | Payment request paid |
| refund.failed | Refund failed |
| refund.pending | Refund pending |
| refund.processed | Refund processed |
| refund.processing | Refund processing |
| subscription.create | Subscription created |
| subscription.disable | Subscription disabled |
| subscription.expiring_cards | Card for sub expiring |
| subscription.not_renew | Sub won't auto-renew |
| transfer.failed | Transfer failed |
| transfer.success | Transfer success |
| transfer.reversed | Transfer reversed |
To handle a supported webhook event, pass a handler to the plugin config. Here's an example that logs refunds processed:
import type { PaystackWebhookHandler } from '{plugin-name}/types.js'
export const chargeSuccess: PaystackWebhookHandler = async ({
event,
payload,
pluginConfig,
req,
}) => {
// Log the full request details for debugging
payload.logger.info(`[Paystack Webhook] Processing charge.success event:`, {
reference: event.data.reference,
amount: event.data.amount,
status: event.data.status,
})
try {
const transaction = event.data
// Create or update transaction record
const existing = await payload.find({
collection: 'transaction',
where: { reference: { equals: transaction.reference } },
})
if (existing.docs?.length > 0) {
await payload.update({
collection: 'transaction',
id: existing.docs[0].id,
data: {
status: transaction.status,
amount: transaction.amount,
currency: transaction.currency,
paid_at: transaction.paid_at,
customer_code: transaction.customer?.customer_code,
channel: transaction.channel,
},
})
payload.logger.info(`✅ Updated transaction ${transaction.reference}`)
} else {
await payload.create({
collection: 'transaction',
data: {
status: transaction.status,
reference: transaction.reference,
amount: transaction.amount,
currency: transaction.currency,
paid_at: transaction.paid_at,
customer_code: transaction.customer?.customer_code,
channel: transaction.channel,
},
})
payload.logger.info(`✅ Created new transaction ${transaction.reference}`)
}
} catch (err) {
payload.logger.error(`❌ Error processing charge.success: ${err}`)
}
}
// Register in your plugin config:
paystackPlugin({
// ...other config,
webhooks: {
'charge.success': chargeSuccess,
// ...
},
})
Example event data: Here is the data in a nicely formatted JSON:
{
"event": "charge.success",
"data": {
"id": 5025288020569,
"domain": "test",
"status": "success",
"reference": "ie54rnrehtrd",
"amount": 1000,
"message": null,
"gateway_response": "Successful",
"paid_at": "2025-06-03T14:17:44.000Z",
"created_at": "2025-06-03T14:17:31.000Z",
"channel": "card",
"currency": "NGN",
"ip_address": "132.247.137.240",
"metadata": "",
"fees_breakdown": null,
"log": null,
"fees": 15,
"fees_split": null,
"authorization": {
"authorization_code": "AUTH_mxcnr5432q",
"bin": "408408",
"last4": "4081",
"exp_month": "12",
"exp_year": "2030",
"channel": "card",
"card_type": "visa",
"bank": "TEST BANK",
"country_code": "NG",
"brand": "visa",
"reusable": true,
"signature": "SIG_2X409u7rWzEMBBp95CaN",
"account_name": null
},
"customer": {
"id": 2784703341,
"first_name": null,
"last_name": null,
"email": "[email protected]",
"customer_code": "CUS_2rxuhf44dda4rwk8jxi",
"phone": null,
"metadata": null,
"risk_action": "default",
"international_format_phone": null
},
"plan": {},
"subaccount": {},
"split": {},
"order_id": null,
"paidAt": "2025-06-03T14:17:44.000Z",
"requested_amount": 100,
"pos_transaction_data": null,
"source": {
"type": "api",
"source": "merchant_api",
"entry_point": "transaction_initialize",
"identifier": null
}
}
}
Tip: Most other resource types (product, plan, customer, etc) do NOT have webhook event support, but you can verify this by reading all the data from events, they send alot.
To ensure the out-of-the-box webhook sync works correctly, your Payload collections must include the correct identifier field for each Paystack resource you want to sync. For example:
paystackID (e.g., id, customer_code, etc.).charge.success for transactions) use a reference field as the unique identifier.You must:
paystackID or reference) to your Payload collection schema.fields array (e.g., { fieldPath: 'reference', paystackProperty: 'reference' }).If neither identifier is present in the event data or your collection, the plugin will log a warning and skip syncing for that event.
🚨 IMPORTANT: If you do not add the unique identifier (e.g.,
paystackIDorreferenceor others according to the paystack resource data property) to your sync config'sfieldsarray, the plugin may create duplicate records in your Payload collection. Always include the identifier in both your collection schema and your sync config for reliable syncing and updates.
| Scenario | Identifier in Event | Identifier in Collection Schema | Identifier in Sync Config | What Happens? |
|---|---|---|---|---|
Only reference present | Yes | Yes | Yes | Upsert by reference (works as expected) |
Only reference present | Yes | Yes | No | Creates record, but identifier field is empty; future updates may duplicate |
Only paystackID present | Yes | Yes | Yes | Upsert by paystackID (works as expected) |
| Neither present | No | - | - | Logs warning, skips processing (no create) |
Best Practice:
- Always add the unique identifier field (e.g.,
paystackIDorreferenceor others according to the paystack resource data property) to your Payload collection schema.- Always include that field in your sync config's
fieldsarray.- Refer to Paystack documentation to determine which identifier is required for each event/resource type.
| Option | Type | Description |
|---|---|---|
| paystackSecretKey | string | Required. Your Paystack API key. |
| webhookSecret | string | Webhook verification secret (usually same as secret key). |
| rest | boolean | Expose /paystack/rest proxy endpoint. |
| logs | boolean | Enable verbose logs. |
| blacklistCustomerOption | boolean | Enables blacklisting UI and polling support. |
| pollingInterval | number | Interval in ms for polling (default: 1 hour). |
| pollingPageSize, pollingMaxPages | number | Control paging for polling. |
| defaultCurrency | string | Currency code, default: 'NGN'. |
| sync | array | List of collections and Paystack mappings. |
| testMode | boolean | If true, requests are simulated and not sent to Paystack—useful for safe development/testing! |
sk_test_xxx): Use in test mode during development.sk_live_xxx): Use in production only.testMode Parametertrue: Simulate all requests (safe for development/testing).false (default): Send requests to Paystack.PAYSTACK_SECRET_KEY=sk_test_xxx
PAYSTACK_WEBHOOK_SECRET=sk_test_xxx
# or for live:
# PAYSTACK_SECRET_KEY=sk_live_xxx
# PAYSTACK_WEBHOOK_SECRET=sk_live_xxx
Q: Why aren't my customers deleted from Paystack? A: Deletion is not supported via API; Payload deletion only blacklists the customer.
Q: How do I make a collection read-only?
A: Use the access property in your Payload config.
Q: Why do I need to poll for blacklist status?
A: Paystack does not emit webhooks for risk_action changes.
Q: How do I sync only some fields?
A: Use the fields mapping in your sync config.
Q: I get webhook signature errors!
A: Use the same key for paystackSecretKey and webhookSecret.
Happy coding! 🚀
MIT License
Topics
stripe
Connect Payload to Stripe for payments and subscriptions
997.7k/wk45.4kv3
payload ecommerce tpay
Tpay payment adapter for the Payload ecommerce plugin with BLIK, bank transfers and card payments
573/wk2v3
payload razorpay
Razorpay payment adapter for the Payload Ecommerce plugin
570/wk0v3
payload plugin netlify deploy
Publish button in the Payload admin that builds a Netlify site, streams the log and publishes a preview
6720/wk29v3
payload isr
Revalidate Next.js paths and cache tags when Payload documents change, with optional full rebuild fallback
650/wk3v3
payload markdown docs
Sync a repo-local Markdown docs tree into Payload and render it in a Next.js site
630/wk4v3