A TypeScript-first plugin that integrates Paystack into Better Auth, providing a production-ready billing system with support for subscriptions (native & local), one-time payments, trials, organization billing, and secure webhooks.
Live Demo (Tanstack Start) | Source Code
- Billing Patterns: Support for Paystack-native plans, local-managed subscriptions, and one-time payments (products/amounts).
- Auto Customer Creation: Optional Paystack customer creation on user sign up or organization creation.
- Trial Management: Configurable trial periods with built-in abuse prevention logic.
- Organization Billing: Associate subscriptions with organizations and authorize access via roles.
- Subscription Channel Controls: Restrict subscription checkout to specific Paystack payment channels such as card-only.
- Enforced Limits & Seats: Automatic enforcement of member seat upgrades and resource limits (teams).
- Scheduled Changes: Defer subscription updates or cancellations to the end of the billing cycle.
- Proration: Immediate mid-cycle prorated upgrades for local plans, using saved-card charges when possible and checkout fallback when interactive payment is required.
- Popup Modal Flow: Optional support for Paystack's inline checkout experience via
@alexasomba/paystack-inline. - Webhook Security: Pre-configured signature verification (HMAC-SHA512) and optional IP whitelisting.
- Transaction History: Built-in support for listing and viewing local transaction records.
- Node.js:
v24.0.0or higher. - Better Auth:
v1.6.5or higher.
npm install better-auth @alexasomba/better-auth-paystack @alexasomba/paystack-nodenpm install @alexasomba/paystack-inlinePAYSTACK_SECRET_KEY=sk_test_...
PAYSTACK_WEBHOOK_SECRET=sk_test_... # Usually same as your paystack secret key
BETTER_AUTH_SECRET=...
BETTER_AUTH_URL=http://localhost:8787import { betterAuth } from "better-auth";
import { paystack } from "@alexasomba/better-auth-paystack";
import { createPaystack } from "@alexasomba/paystack-node";
import { admin } from "better-auth/plugins";
const paystackClient = createPaystack({
secretKey: process.env.PAYSTACK_SECRET_KEY!,
});
export const auth = betterAuth({
plugins: [
admin(),
paystack({
paystackClient,
webhook: { secret: process.env.PAYSTACK_WEBHOOK_SECRET! },
createCustomerOnSignUp: true,
subscription: {
enabled: true,
allowedPaymentChannels: ["card"], // Optional: enforce card-only subscriptions
plans: [
{
name: "pro",
planCode: "PLN_pro_123", // Native: Managed by Paystack
freeTrial: { days: 14 },
limits: { teams: 5, seats: 10 }, // Custom resource & member limits
},
{
name: "starter",
amount: 50000, // Local: Managed by your app (500 NGN)
currency: "NGN",
interval: "monthly",
},
],
},
products: {
products: [{ name: "credits_50", amount: 200000, currency: "NGN" }],
},
}),
],
});webhook.secret is the preferred webhook-signing config.
If you still have older code using top-level paystackWebhookSecret, it is treated as a deprecated alias and falls back to the same signature check.
import { createAuthClient } from "better-auth/client";
import { paystackClient } from "@alexasomba/better-auth-paystack/client";
import { adminClient } from "better-auth/client/plugins";
export const client = createAuthClient({
plugins: [
adminClient(),
paystackClient({ subscription: true })
],
});npx better-auth migrateVersion 2.0.0 contains a security-focused breaking change.
- Removed public/client operator actions:
authClient.paystack.syncProducts()authClient.paystack.syncPlans()authClient.paystack.chargeRecurringSubscription(...)
- Removed public Better Auth endpoints for:
/paystack/sync-products/paystack/sync-plans/paystack/charge-recurring
- Added trusted server operations:
chargeSubscriptionRenewalsyncPaystackProductssyncPaystackPlans
await authClient.paystack.syncProducts();
await authClient.paystack.syncPlans();
await authClient.paystack.chargeRecurringSubscription({
subscriptionId: "sub_123",
});import {
chargeSubscriptionRenewal,
syncPaystackPlans,
syncPaystackProducts,
type ChargeRecurringSubscriptionResult,
type PaystackSyncResult,
} from "@alexasomba/better-auth-paystack";
const ctx = { context: await auth.$context } as any;
const paystackOptions = {
secretKey: process.env.PAYSTACK_SECRET_KEY!,
webhook: { secret: process.env.PAYSTACK_WEBHOOK_SECRET! },
paystackClient,
};
await syncPaystackProducts(ctx, paystackOptions);
await syncPaystackPlans(ctx, paystackOptions);
await chargeSubscriptionRenewal(ctx, paystackOptions, {
subscriptionId: "sub_123",
});These operations are intentionally server-only. Do not expose them through browser-triggered auth client calls.
Use planCode from your Paystack Dashboard. Paystack handles the recurring logic and emails.
{ name: "pro", planCode: "PLN_xxx" }Use amount and interval. The plugin stores the status locally, allowing you to manage custom recurring logic or one-off "access periods".
{ name: "starter", amount: 50000, interval: "monthly" }Define pre-configured products in your server settings and purchase them by name.
await authClient.paystack.transaction.initialize({
product: "credits_50",
});Charge dynamic amounts for top-ups, tips, or custom invoices.
await authClient.paystack.transaction.initialize({
amount: 100000, // 1000 NGN
currency: "NGN",
metadata: { type: "donation" },
});The plugin automatically enforces limits based on the active subscription.
Purchased seats are stored in the subscription.seats field. The plugin hooks into member.create and invitation.create to block additions once the limit is reached.
Define limits in your plan config, and they will be checked during resource creation:
plans: [{ name: "pro", limits: { teams: 5, seats: 10 } }];The plugin natively checks the teams limit if using the Better Auth Organization plugin.
The plugin supports the following currencies with automatic minimum transaction amount validation:
| Currency | Name | Minimum Amount |
|---|---|---|
| NGN | Nigerian Naira | ₦50.00 |
| GHS | Ghanaian Cedi | ₵0.10 |
| ZAR | South African Rand | R1.00 |
| KES | Kenyan Shilling | KSh 3.00 |
| USD | United States Dollar | $2.00 |
| XOF | West African CFA Franc | CFA 100 |
Transactions below these thresholds will be rejected with a BAD_REQUEST error.
Enable organization.enabled to bill organizations instead of users.
- Auto Customer: Organizations get their own
paystackCustomerCode. - Authorization: Use
authorizeReferenceto control who can manage billing (e.g., Owners/Admins).
Use @alexasomba/paystack-inline for a seamless UI.
const { data } = await authClient.subscription.upgrade({ plan: "pro" });
if (data?.accessCode) {
const paystack = createPaystack({ publicKey: "pk_test_..." });
paystack.checkout({
accessCode: data.accessCode,
onSuccess: (res) => authClient.paystack.transaction.verify({ reference: res.reference }),
});
}Defer changes to the end of the current billing cycle:
- Upgrades: Pass
scheduleAtPeriodEnd: trueininitializeTransaction(). - Cancellations: Use
authClient.subscription.cancel({ subscriptionCode, atPeriodEnd: true })to keep the subscription active until the period ends.
The plugin can dynamically calculate the cost difference for immediate mid-cycle upgrades (like adding more seats). For locally managed plans:
- If the subscription already has a reusable Paystack authorization code, the plugin charges the prorated delta off-session, records a local
paystackTransaction, and immediately updates the subscription. - If there is no reusable authorization code available (for example, transfer-based payments), the plugin initializes a new checkout for the prorated delta instead of silently upgrading without payment.
- If the prorated amount is below Paystack's minimum charge for the currency, the request is rejected so you can schedule the change for period end instead of undercharging.
await authClient.paystack.transaction.initialize({
plan: "pro",
quantity: 5, // Upgrading seats
prorateAndCharge: true, // Charges saved authorization or returns a checkout redirect for the delta
});When the flow falls back to checkout, verify the returned transaction reference after payment. The plugin uses the stored proration metadata to apply the pending plan/seat change only after successful verification.
Use subscription.allowedPaymentChannels to constrain which Paystack checkout channels can be used for subscription flows.
This applies to standard subscription checkout, trial authorization flows, and interactive proration checkout fallbacks.
paystack({
subscription: {
enabled: true,
allowedPaymentChannels: ["card"],
plans: [{ name: "starter", amount: 50000, currency: "NGN", interval: "monthly" }],
},
});If a subscription payment is later verified with a disallowed channel, the plugin rejects activation instead of silently creating the subscription.
The plugin automatically verifies the x-paystack-signature header to ensure events are authentic. For an extra layer of security, you can enable IP Whitelisting to restrict processing to Paystack's official servers.
paystack({
webhook: {
secret: process.env.PAYSTACK_WEBHOOK_SECRET!,
verifyIP: true, // Enable IP whitelisting (defaults to false for flexible proxy support)
trustedIPs: ["52.31.139.75", "52.49.173.169", "52.214.14.220"], // Optional: override trusted IPs
},
});Resolution order for webhook signature verification is:
webhook.secret -> paystackWebhookSecret (deprecated) -> secretKey.
The plugin checks the referenceId history. If a trial was ever used (active, expired, or trialing), it will not be granted again, preventing resubscribe-abuse.
React to billing events on the server by providing callbacks in your configuration:
onSubscriptionComplete: Called after successful transaction verification (Native or Local).onSubscriptionCreated: Called when a subscription record is first initialized in the DB.onSubscriptionCancel: Called when a user or organization cancels their subscription.
onCustomerCreate: Called after the plugin successfully creates a Paystack customer.getCustomerCreateParams: Return a custom object to override/extend the data sent to Paystack during customer creation.
onTrialStart: Called when a new trial period begins.
onEvent: Receives every webhook event payload sent from Paystack for custom processing.
Recurring renewals and Paystack catalog sync are intentionally not exposed through the browser auth client. Invoke them from trusted backend code only:
import {
chargeSubscriptionRenewal,
syncPaystackPlans,
syncPaystackProducts,
} from "@alexasomba/better-auth-paystack";
const ctx = { context: await auth.$context } as any;
await chargeSubscriptionRenewal(ctx, paystackOptions, {
subscriptionId: "sub_123",
});
await syncPaystackProducts(ctx, paystackOptions);
await syncPaystackPlans(ctx, paystackOptions);Control who can manage billing for specific references (Users or Organizations).
paystack({
subscription: {
authorizeReference: async ({ user, referenceId, action }) => {
// Example: Only allow Org Admins to initialize transactions
if (referenceId.startsWith("org_")) {
const member = await db.findOne({
model: "member",
where: [
{ field: "organizationId", value: referenceId },
{ field: "userId", value: user.id },
],
});
return member?.role === "admin";
}
return user.id === referenceId;
},
},
});The client plugin exposes fully typed methods under authClient.paystack and authClient.subscription.
Initializes a transaction to create or upgrade a subscription.
type upgradeSubscription = {
/**
* The name of the plan to subscribe to.
*/
plan: string;
/**
* The email of the subscriber. Defaults to the current user's email.
*/
email?: string;
/**
* Amount to charge (if not using a Paystack Plan Code).
*/
amount?: number;
/**
* Currency code (e.g., "NGN").
*/
currency?: string;
/**
* The callback URL to redirect to after payment.
*/
callbackURL?: string;
/**
* Additional metadata to store with the transaction.
*/
metadata?: Record<string, unknown>;
/**
* Reference ID for the subscription owner (User ID or Org ID).
* Defaults to the current user's ID.
*/
referenceId?: string;
/**
* Number of seats to purchase (for team plans).
*/
quantity?: number;
};Same as upgrade, but can also be used for one-time payments by omitting plan and providing amount or product.
type initializeTransaction = {
/**
* Plan name (for subscriptions).
*/
plan?: string;
/**
* Product name (for one-time purchases).
*/
product?: string;
/**
* Amount to charge (if sending raw amount).
*/
amount?: number;
/**
* For existing locally managed subscriptions, calculate a mid-cycle delta and either
* charge the saved authorization or return a checkout redirect for interactive payment.
*/
prorateAndCharge?: boolean;
// ... same as upgradeSubscription
};List subscriptions for a user or organization.
type listSubscriptions = {
query?: {
/**
* Filter by reference ID (User ID or Org ID).
*/
referenceId?: string;
};
};Cancel or restore a subscription.
- Cancel: Sets
cancelAtPeriodEnd: true. The subscription remainsactiveuntil the end of the current billing period, after which it moves tocanceled. - Restore: Reactivates a subscription that is scheduled to cancel.
type cancelSubscription = {
/**
* Optional reference owner (user ID or org ID) when managing another billing entity.
*/
referenceId?: string;
/**
* The Paystack subscription code (e.g. SUB_...)
*/
subscriptionCode: string;
/**
* The email token required by Paystack to manage the subscription.
* Optional: The server will try to fetch it if omitted.
*/
emailToken?: string;
/**
* When true, keep the subscription active until the current period ends.
*/
atPeriodEnd?: boolean;
};The plugin extends your database with the following fields and tables.
| Field | Type | Required | Description |
|---|---|---|---|
paystackCustomerCode |
string |
No | The unique customer identifier from Paystack. |
| Field | Type | Required | Description |
|---|---|---|---|
paystackCustomerCode |
string |
No | The unique customer identifier for the organization. |
email |
string |
No | The billing email for the organization. fallsback to organization owner's email if absent. |
| Field | Type | Required | Description |
|---|---|---|---|
plan |
string |
Yes | Lowercased name of the active plan. |
referenceId |
string |
Yes | Associated User ID or Organization ID. |
paystackCustomerCode |
string |
No | The Paystack customer code for this subscription. |
paystackSubscriptionCode |
string |
No | The unique code for the subscription (e.g., SUB_... or LOC_...). |
paystackTransactionReference |
string |
No | The reference of the transaction that started the subscription. |
paystackAuthorizationCode |
string |
No | Stored card authorization code for recurring charges (local plans). |
status |
string |
Yes | active, trialing, canceled, incomplete. |
periodStart |
Date |
No | Start date of the current billing period. |
periodEnd |
Date |
No | End date of the current billing period. |
trialStart |
Date |
No | Start date of the trial period. |
trialEnd |
Date |
No | End date of the trial period. |
cancelAtPeriodEnd |
boolean |
No | Whether to cancel at the end of the current period. |
seats |
number |
No | Purchased seat count for team billing. |
| Field | Type | Required | Description |
|---|---|---|---|
reference |
string |
Yes | Unique transaction reference. |
referenceId |
string |
Yes | Associated User ID or Organization ID. |
userId |
string |
Yes | The ID of the user who initiated the transaction. |
amount |
number |
Yes | Transaction amount in smallest currency unit. |
currency |
string |
Yes | Currency code (e.g., "NGN"). |
status |
string |
Yes | success, pending, failed, abandoned. |
plan |
string |
No | Name of the plan associated with the transaction. |
product |
string |
No | Name of the product associated with the transaction. |
metadata |
string |
No | JSON string of extra transaction metadata. |
paystackId |
string |
No | The internal Paystack ID for the transaction. |
createdAt |
Date |
Yes | Transaction creation timestamp. |
updatedAt |
Date |
Yes | Transaction last update timestamp. |
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Product name. |
description |
string |
No | Product description. |
price |
number |
Yes | Price in smallest currency unit. |
currency |
string |
Yes | Currency code (e.g., "NGN"). |
quantity |
number |
No | Available stock quantity. |
unlimited |
boolean |
No | Whether the product has unlimited stock. |
paystackId |
string |
No | The internal Paystack Product ID. |
slug |
string |
Yes | Unique slug for the product. |
metadata |
string |
No | JSON string of extra product metadata. |
createdAt |
Date |
Yes | Product creation timestamp. |
updatedAt |
Date |
Yes | Product last update timestamp. |
- Webhook Signature: Ensure
PAYSTACK_WEBHOOK_SECRETis correct matches your Paystack Dashboard's secret key. - Email Verification: Use
requireEmailVerification: trueto prevent unverified checkouts. - Redirect Failures: Check your browser console; Paystack often returns 429 errors if you're hitting the test API too frequently.
- Reference mismatches: Ensure
referenceIdis passed correctly for Organization billing. - Authorization Denied: Verify your
authorizeReferencelogic is correctly checking user roles or organization memberships. Unauthorized attempts to verify transactions now return a401 Unauthorizedresponse to prevent data leaks.
The plugin's schema definition includes recommended indexes and uniqueness constraints for performance. When you run npx better-auth migrate, these will be automatically applied to your database.
The following fields are indexed:
paystackTransaction:reference(unique),userId,referenceId.subscription:paystackSubscriptionCode(unique),referenceId,paystackTransactionReference,paystackCustomerCode,plan.user&organization:paystackCustomerCode.paystackProduct:slug(unique),paystackId(unique).
Proration upgrades and trusted renewal charges also persist paystackTransaction rows, so local transaction history stays aligned with successful off-session charges.
The plugin provides two ways to keep your product inventory aligned with Paystack:
Whenever a successful one-time payment is made (via webhook or manual verification), the plugin automatically calls syncProductQuantityFromPaystack. This fetches the real-time remaining quantity from the Paystack API and updates your local database record, ensuring your inventory is always accurate.
The public /paystack/sync-products endpoint was removed in 2.0.0.
Run the trusted server operation from backend code instead:
import { syncPaystackProducts } from "@alexasomba/better-auth-paystack";
const ctx = { context: await auth.$context } as any;
await syncPaystackProducts(ctx, paystackOptions);The plugin now targets the official @alexasomba/paystack-node grouped client surface directly.
If you inject a custom client, it should match the real SDK methods used by the plugin such as transaction.initialize, transaction.verify, transaction.chargeAuthorization, subscription.create, subscription.disable, and subscription.enable.
This repository is powered by Vite+. You use the vp CLI to manage the entire workspace.
# Install dependencies
vp i
# Check project health (format, lint, types)
vp check --fix
# Build the core library
vp build
# Run tests
vp test
# Run the TanStack Start example
vp run examples/tanstack devContributions are welcome! Please open an issue or pull request.
This project is licensed under the MIT License - see the LICENSE file for details.
Future features planned for upcoming versions:
- Stored Authorization Codes: Securely store Paystack authorization codes from verified transactions.
- Trusted Renewal Operation: Server-side helper to charge stored cards for renewals.
- Card Management UI: Let users view/delete saved payment methods (masked card data only) - Upcoming
- Renewal Scheduler Integration: Documentation for integrating with Cloudflare Workers Cron, Vercel Cron, etc. - Upcoming
Note: For local-managed subscriptions (no
planCode), the plugin automatically captures and stores theauthorization_code. Trigger renewals from trusted backend code withchargeSubscriptionRenewal(...).
- Multi-currency support improvements
- Invoice generation
- Payment retry logic for failed renewals
- GitHub Repository: alexasomba/better-auth-paystack
- Comprehensive and up-to-date Paystack Node SDK: alexasomba/paystack-node
- Comprehensive and up-to-date Paystack Inline SDK: alexasomba/paystack-inline
- TanStack Start Example Implementation
- Paystack Webhooks: https://paystack.com/docs/payments/webhooks/
- Paystack Transaction API: https://paystack.com/docs/api/transaction/
- Paystack Subscription API: https://paystack.com/docs/api/subscription/
- Paystack Plan API: https://paystack.com/docs/api/plan/
- Better Auth Documentation