Webhooks
Verify, parse, and process Paystack webhook deliveries with typed events and optional deduplication.
Paystack sends event notifications to your webhook URL as signed JSON POST requests. Always verify the x-paystack-signature header against the raw request body before parsing JSON or delivering value.
Processing flow
Re-serializing JSON after reading the body will break signature verification — use the exact bytes Paystack sent.
Client webhook APIs
Namespace: paystack.webhook.* on a Paystack client instance (uses the configured secretKey for HMAC-SHA512).
| Method | Use case |
|---|---|
processWebhookDelivery(request, options) | Primary — verify + parse + run handler with optional deduplication via WebhookDeliveryStore. |
processWebhookRequest(request) | Advanced — verify + parse only, no handler. |
verifyWebhookRequest(request) | Advanced — returns raw body after signature verification. |
parseWebhookPayload(rawBody) | Advanced — parse JSON and Zod-validate. Call only after verification. |
verifyPaystackWebhookSignature(rawBody, signature) | Advanced — manual verification when you already have the raw body and header. |
Production handler (primary)
import { Paystack, isPaystackEvent } from "@g14o/paystack";
const paystack = new Paystack({ secretKey: process.env.PAYSTACK_SECRET_KEY! });
export async function POST(request: Request) {
const result = await paystack.webhook.processWebhookDelivery(request, {
handler: async (event) => {
if (isPaystackEvent(event, "charge.success")) {
// Fulfill order using event.data.reference
}
},
store: {
claim: async ({ eventId }) => {
// Return "duplicate" if already processed
return "claimed";
},
markProcessed: async (eventId) => {},
markFailed: async (eventId, errorMessage) => {},
},
});
if (result.duplicate) {
return Response.json({ received: true, duplicate: true });
}
return Response.json({ received: true });
}Options: ProcessWebhookDeliveryRequestOptions, WebhookDeliveryStore.
Omit store for at-least-once delivery when deduplication is not required.
Advanced two-step flow
Use when you need to persist the raw body before handling:
const rawBody = await paystack.webhook.verifyWebhookRequest(request);
await saveRawPayload(rawBody);
const event = paystack.webhook.parseWebhookPayload(rawBody);Verify signature manually
paystack.webhook.verifyPaystackWebhookSignature(
rawBody,
request.headers.get("x-paystack-signature")
);
// throws WebhookVerificationError on missing/invalid signatureStandalone exports
Import from @g14o/paystack (not on the client instance):
| Export | Use case |
|---|---|
processWebhookDelivery(request, options, { secretKey }) | Request-first delivery without a client instance. |
processVerifiedWebhookDelivery(options) | Advanced — delivery when verification/parsing happens elsewhere (e.g. queue workers). Requires pre-verified event + rawBody. |
createWebhookEventId(event) | Advanced — stable dedupe key from Webhook Delivery identity rules. |
parsePaystackWebhookEvent(payload) | Parse/validate without the client. Throws ZodError on failure. |
safeParsePaystackWebhookEvent(payload) | Non-throwing parse for logging or soft validation. |
isPaystackEvent(event, name) | Type-narrowing guard in handler blocks. |
paystackWebhookEventSchema | Zod schema for custom validation pipelines. |
SUPPORTED_PAYSTACK_EVENTS | Runtime list of all validated event names. |
import {
createWebhookEventId,
isPaystackEvent,
SUPPORTED_PAYSTACK_EVENTS,
} from "@g14o/paystack";Supported event types
The package validates 27 Paystack webhook events via Zod. Each event is discriminated by the event field with a typed data payload.
| Category | Events |
|---|---|
| Bank transfers | bank.transfer.rejected |
| Charges | charge.success |
| Disputes | charge.dispute.create, charge.dispute.remind, charge.dispute.resolve |
| Customer identification | customeridentification.failed, customeridentification.success |
| Dedicated accounts | dedicatedaccount.assign.failed, dedicatedaccount.assign.success |
| Invoices | invoice.create, invoice.payment_failed, invoice.update |
| Payment requests | paymentrequest.pending, paymentrequest.success |
| Refunds | refund.failed, refund.pending, refund.processed, refund.processing |
| Subscriptions | subscription.create, subscription.disable, subscription.not_renew, subscription.expiring_cards |
| Transfers | transfer.failed, transfer.success, transfer.reversed |
Exported data types include ChargeSuccessData, TransferData, SubscriptionCreateData, and PaystackEventDataMap for typed handler code.
Errors
| Error | Code | When |
|---|---|---|
WebhookVerificationError | WEBHOOK_MISSING_SIGNATURE | x-paystack-signature header absent |
WebhookVerificationError | WEBHOOK_INVALID_SIGNATURE | HMAC mismatch |
WebhookVerificationError | WEBHOOK_INVALID_PAYLOAD | Missing or empty request body during verify |
WebhookDeliveryError | WEBHOOK_INVALID_PAYLOAD | Invalid JSON or event shape after verify (400) |
WebhookDeliveryError | WEBHOOK_PROCESSING_ERROR | Handler threw during processWebhookDelivery (400) |
Important constraints
- Use the same
secretKeyas your REST API client for HMAC verification. - Read
x-paystack-signaturefrom headers; never parse JSON before verification. - In Next.js, avoid middleware that consumes the request body before the route handler.
- Respond with 200 quickly; defer heavy work to a queue after verification.
- For idempotency, implement
WebhookDeliveryStoreor use @g14o/paystack-better-auth built-in persistence.
Better Auth webhooks
When using @g14o/paystack-better-auth, configure your Paystack dashboard webhook URL to:
https://your-domain.com/api/auth/paystack/webhookThe plugin uses processWebhookDelivery with database-backed deduplication via paystackWebhookEvent.