Skip to content

alexasomba/better-auth-paystack

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

155 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Better Auth Paystack Plugin

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.

npm downloads GitHub stars GitHub release bundlephobia Follow on Twitter GitHub License

Live Demo (Tanstack Start) | Source Code

Features

  • 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.

Quick Start

Prerequisites

  • Node.js: v24.0.0 or higher.
  • Better Auth: v1.6.5 or higher.

1. Install Plugin & SDKs

npm install better-auth @alexasomba/better-auth-paystack @alexasomba/paystack-node

Optional: Browser SDK (for Popup Modals)

npm install @alexasomba/paystack-inline

2. Configure Environment Variables

PAYSTACK_SECRET_KEY=sk_test_...
PAYSTACK_WEBHOOK_SECRET=sk_test_... # Usually same as your paystack secret key
BETTER_AUTH_SECRET=...
BETTER_AUTH_URL=http://localhost:8787

3. Setup Server Plugin

import { 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.

4. Configure Client Plugin

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 })
  ],
});

5. Migrate Database Schema

npx better-auth migrate

Migration Guide

Version 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:
    • chargeSubscriptionRenewal
    • syncPaystackProducts
    • syncPaystackPlans

Old

await authClient.paystack.syncProducts();
await authClient.paystack.syncPlans();
await authClient.paystack.chargeRecurringSubscription({
  subscriptionId: "sub_123",
});

New

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.


Billing Patterns

1. Subscriptions

Native (Recommended)

Use planCode from your Paystack Dashboard. Paystack handles the recurring logic and emails.

{ name: "pro", planCode: "PLN_xxx" }

Local

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" }

2. One-Time Payments

Fixed Products

Define pre-configured products in your server settings and purchase them by name.

await authClient.paystack.transaction.initialize({
  product: "credits_50",
});

Ad-hoc Amounts

Charge dynamic amounts for top-ups, tips, or custom invoices.

await authClient.paystack.transaction.initialize({
  amount: 100000, // 1000 NGN
  currency: "NGN",
  metadata: { type: "donation" },
});

Limits & Seat Management

The plugin automatically enforces limits based on the active subscription.

Member Seat Limits

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.

Resource Limits (e.g., Teams)

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.


Currency Support

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.

Advanced Usage

Organization Billing

Enable organization.enabled to bill organizations instead of users.

  • Auto Customer: Organizations get their own paystackCustomerCode.
  • Authorization: Use authorizeReference to control who can manage billing (e.g., Owners/Admins).

Inline Popup Modal

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 }),
  });
}

Scheduled Changes & Cancellation

Defer changes to the end of the current billing cycle:

  • Upgrades: Pass scheduleAtPeriodEnd: true in initializeTransaction().
  • Cancellations: Use authClient.subscription.cancel({ subscriptionCode, atPeriodEnd: true }) to keep the subscription active until the period ends.

Mid-Cycle Proration (prorateAndCharge)

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.

Restricting Subscription Payment Channels

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.

Webhook Security

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.

Trial Abuse Prevention

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.

Lifecycle Hooks

React to billing events on the server by providing callbacks in your configuration:

Subscription Hooks (subscription.*)

  • 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.

Customer Hooks (top-level or organization.*)

  • 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.

Trial Hooks (subscription.plans[].freeTrial.*)

  • onTrialStart: Called when a new trial period begins.

Global Hook

  • onEvent: Receives every webhook event payload sent from Paystack for custom processing.

Trusted Server Operations

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);

Authorization & Security

authorizeReference

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;
    },
  },
});

Client SDK Reference

The client plugin exposes fully typed methods under authClient.paystack and authClient.subscription.

authClient.subscription.upgrade / create

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;
};

authClient.paystack.transaction.initialize

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
};

authClient.subscription.list

List subscriptions for a user or organization.

type listSubscriptions = {
  query?: {
    /**
     * Filter by reference ID (User ID or Org ID).
     */
    referenceId?: string;
  };
};

authClient.subscription.cancel / restore

Cancel or restore a subscription.

  • Cancel: Sets cancelAtPeriodEnd: true. The subscription remains active until the end of the current billing period, after which it moves to canceled.
  • 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;
};

Schema Reference

The plugin extends your database with the following fields and tables.

user

Field Type Required Description
paystackCustomerCode string No The unique customer identifier from Paystack.

organization

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.

subscription

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.

paystackTransaction

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.

paystackProduct

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.

Troubleshooting

  • Webhook Signature: Ensure PAYSTACK_WEBHOOK_SECRET is correct matches your Paystack Dashboard's secret key.
  • Email Verification: Use requireEmailVerification: true to 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 referenceId is passed correctly for Organization billing.
  • Authorization Denied: Verify your authorizeReference logic is correctly checking user roles or organization memberships. Unauthorized attempts to verify transactions now return a 401 Unauthorized response to prevent data leaks.

Database Indexing

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.

Syncing Products

The plugin provides two ways to keep your product inventory aligned with Paystack:

1. Automated Inventory Sync

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.

2. Trusted Manual Bulk Sync

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);

SDK Compatibility Note

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.


🏗️ Development & Contributing

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 dev

Contributions are welcome! Please open an issue or pull request.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Roadmap

Future features planned for upcoming versions:

v1.1.0 - Manual Recurring Subscriptions (Available Now)

  • 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 the authorization_code. Trigger renewals from trusted backend code with chargeSubscriptionRenewal(...).

Future Considerations

  • Multi-currency support improvements
  • Invoice generation
  • Payment retry logic for failed renewals

Links

About

A TypeScript-first plugin that integrates Paystack into Better Auth, enabling seamless customer creation, secure checkout, webhook verification, and native subscription flows. Designed for modern frameworks like Tanstack Start, Next.js, Hono, and Cloudflare Workers, it provides typed APIs, subscription management, and end-to-end payment integration

Topics

Resources

License

Stars

Watchers

Forks

Sponsor this project

Packages

 
 
 

Contributors