better-auth-paystack
Version:
Production-ready Paystack billing plugin for Better Auth. Supports subscriptions, one-time payments, organization billing, secure webhooks and more
984 lines (741 loc) • 43.5 kB
Markdown
[English](README.md) · [Español](README.es-ES.md) · [Français](README.fr-FR.md)
# Better Auth Paystack Plugin
A TypeScript-first plugin that integrates Paystack into [Better Auth](https://www.better-auth.com), providing a production-ready billing system with support for subscriptions (native & local), one-time payments, trials, organization billing, and secure webhooks.
<div align="center">

[](https://github.com/alexasomba/better-auth-paystack/stargazers)
[](https://github.com/alexasomba/better-auth-paystack/releases)
[](https://bundlephobia.com/result?p=better-auth-paystack)
[](https://twitter.com/alexasomba)

</div>
[**Live Demo (Tanstack Start)**](https://better-auth-paystack.gittech.workers.dev) | [**Source Code**](https://github.com/alexasomba/better-auth-paystack/tree/main/examples/tanstack)
## AI Agent Skills
This package publishes agent skills so AI coding agents can load package-specific guidance for setup, subscriptions, organization billing, TanStack Start integration, client APIs, webhooks, local subscription lifecycle, schema changes, and testing.
Use [TanStack Intent](https://www.npmjs.com/package/@tanstack/intent) when you want to explicitly list or load individual skills:
```bash
npx /intent@latest list
npx /intent@latest load better-auth-paystack#better-auth-paystack-setup
npx /intent@latest load better-auth-paystack#paystack-testing-fixtures
```
If you use an AI agent, run `npx /intent@latest install` in your project so the agent knows how to discover package skills.
This package also ships skills in the npm package under `skills/*/SKILL.md`, so projects can use [skills-npm](https://www.npmjs.com/package/skills-npm) to symlink installed package skills for compatible agents:
```bash
npm install better-auth-paystack
npx skills-npm --yes
```
For this repository, maintainers can run `pnpm run skills:dry-run` to preview discovery through the TanStack example fixture or `pnpm run skills:install` to create local agent skill symlinks from that fixture.
## Features
- [x] **Billing Patterns**: Support for Paystack-native plans, local-managed subscriptions, and one-time payments (products/amounts).
- [x] **Auto Customer Creation**: Optional Paystack customer creation on user sign up or organization creation.
- [x] **Trial Management**: Configurable trial periods with built-in abuse prevention logic.
- [x] **Organization Billing**: Associate subscriptions with organizations and authorize access via roles.
- [x] **Subscription Channel Controls**: Restrict subscription checkout to specific Paystack payment channels such as card-only.
- [x] **Enforced Limits & Seats**: Automatic enforcement of member seat upgrades and resource limits (teams).
- [x] **Scheduled Changes**: Defer subscription updates or cancellations to the end of the billing cycle.
- [x] **Proration**: Immediate mid-cycle prorated upgrades for local plans, using saved-card charges when possible and checkout fallback when interactive payment is required.
- [x] **Popup Modal Flow**: Optional support for Paystack's inline checkout experience via `@alexasomba/paystack-inline`.
- [x] **Webhook Security**: Pre-configured signature verification (HMAC-SHA512) and optional IP whitelisting.
- [x] **Transaction History**: Built-in support for listing and viewing local transaction records.
---
## Quick Start
### Prerequisites
- **Node.js**: `v22.0.0` or higher.
- **Better Auth**: `v1.6.9` or higher.
### 1. Install Plugin & SDKs
```bash
npm install better-auth better-auth-paystack @alexasomba/paystack-node
```
#### Migrating from the scoped package
Replace the scoped dependency with the unscoped package:
```bash
npm uninstall @alexasomba/better-auth-paystack
npm install better-auth-paystack
```
Then update imports from `@alexasomba/better-auth-paystack` to `better-auth-paystack` and from
`@alexasomba/better-auth-paystack/client` to `better-auth-paystack/client`.
This is a package-name migration. The v4 release also moves Paystack billing data into
provider-owned tables. Existing client route names remain compatible, but a database migration
and the trusted migration operation described below are required.
#### Optional: Browser SDK (for Popup Modals)
```bash
npm install @alexasomba/paystack-inline
```
### 2. Configure Environment Variables
```env
PAYSTACK_SECRET_KEY=sk_test_...
BETTER_AUTH_SECRET=...
BETTER_AUTH_URL=http://localhost:8787
```
### 3. Setup Server Plugin
```ts title="auth.ts"
import { createPaystack } from "@alexasomba/paystack-node";
import { betterAuth } from "better-auth";
import { paystack } from "better-auth-paystack";
import { admin } from "better-auth/plugins";
const paystackClient = createPaystack({
secretKey: process.env.PAYSTACK_SECRET_KEY!,
});
export const auth = betterAuth({
plugins: [
admin(),
paystack({
paystackClient,
secretKey: process.env.PAYSTACK_SECRET_KEY!,
createCustomerOnSignUp: true,
subscription: {
enabled: true,
allowedPaymentChannels: ["card"], // Optional: enforce card-only subscriptions
plans: [
{
name: "pro",
group: "workspace", // Optional: one active/trialing subscription per group
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" }],
},
}),
],
});
```
Paystack signs webhook payloads with the same `PAYSTACK_SECRET_KEY` used for API authentication;
there is no separate webhook secret. Legacy `webhook.secret` and `paystackWebhookSecret` options
are accepted for source compatibility but ignored.
### 4. Configure Client Plugin
```ts title="client.ts"
import { paystackClient } from "better-auth-paystack/client";
import { createAuthClient } from "better-auth/client";
import { adminClient } from "better-auth/client/plugins";
export const client = createAuthClient({
plugins: [adminClient(), paystackClient({ subscription: true })],
});
```
### 5. Migrate Database Schema
```bash
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
```ts
await authClient.paystack.syncProducts();
await authClient.paystack.syncPlans();
await authClient.paystack.chargeRecurringSubscription({
subscriptionId: "sub_123",
});
```
### New
```ts
import {
chargeSubscriptionRenewal,
syncPaystackPlans,
syncPaystackProducts,
type ChargeRecurringSubscriptionResult,
type PaystackSyncResult,
} from "better-auth-paystack";
const ctx = { context: await auth.$context } as any;
const paystackOptions = {
secretKey: process.env.PAYSTACK_SECRET_KEY!,
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.
```ts
{ 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".
```ts
{ 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.
```ts
await authClient.paystack.transaction.initialize({
product: "credits_50",
});
```
#### Ad-hoc Amounts
Charge dynamic amounts for top-ups, tips, or custom invoices.
```ts
await authClient.paystack.transaction.initialize({
amount: 100000, // 1000 NGN
currency: "NGN",
metadata: { type: "donation" },
});
```
---
## Limits & Seat Management
The plugin automatically enforces limits across active and trialing subscriptions. When multiple
subscription groups define the same numeric limit, the highest value wins; plan features are
combined.
### 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:
```ts
plans: [{ name: "pro", limits: { teams: 5, seats: 10 } }];
```
The plugin natively checks the `teams` limit if using the Better Auth Organization plugin.
### Subscription Groups
Set `group` on related plans when a user or organization may own independent subscriptions:
```ts
plans: [
{ name: "pro", group: "workspace", planCode: "PLN_pro" },
{ name: "priority-support", group: "support", planCode: "PLN_support" },
];
```
Group names are trimmed and lowercased. A reference can have one active or trialing subscription
per group. Plans without `group` remain in the legacy default group, represented by a nullable
`subscription.groupId`, so existing configurations keep their previous single-subscription
behavior.
---
## 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 provider-owned `paystackCustomer` record.
- **Authorization**: Organization owners and admins can manage billing by default. Use `organization.billingRoles` to extend the trusted role list, or `subscription.authorizeReference` when you need fully custom authorization.
```ts
paystack({
subscription: {
enabled: true,
plans: [],
},
organization: {
enabled: true,
billingRoles: ["owner", "admin", "billing"],
},
});
```
### Inline Popup Modal
Use `@alexasomba/paystack-inline` for a seamless UI.
```ts
const { data } = await authClient.subscription.upgrade({ plan: "pro" });
if (data?.kind === "checkout") {
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.
```ts
const { data } = await authClient.paystack.transaction.initialize({
plan: "pro",
quantity: 5, // Upgrading seats
prorateAndCharge: true, // Charges saved authorization or returns a checkout redirect for the delta
});
if (data?.kind === "checkout") {
window.location.href = data.url;
}
if (data?.kind === "prorated") {
console.log(data.message);
}
```
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.
```ts
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
Webhook deliveries are persisted in the provider-specific `paystackWebhookEvent` table using a
stable hash of the verified raw payload. Duplicate deliveries that were already processed are
acknowledged without running billing side effects again. Failed or interrupted deliveries remain
pending for later reconciliation or replay handling.
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.
```ts
paystack({
secretKey: process.env.PAYSTACK_SECRET_KEY!,
webhook: {
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
},
});
```
Webhook signatures are always verified with `secretKey`, as required by Paystack. Do not create or
configure a separate `PAYSTACK_WEBHOOK_SECRET`.
### 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.
- `onSubscriptionUpdate`: Called after a persisted lifecycle change.
- `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 after the Paystack tokenization charge succeeds and a new trial begins.
- `onTrialEnd`: Called when a trial converts to an active subscription.
- `onTrialExpired`: Called when a trial ends without conversion.
Paystack does not offer a no-charge trial authorization flow. The plugin tokenizes the payment
method with a small one-time charge before the local trial; the default amount uses Paystack's
documented minimum for NGN, GHS, ZAR, KES, or USD. For another currency, configure
`freeTrial.tokenizationCharge` in minor units. Your UI must tell the customer the exact amount
before calling `initializeTransaction` and then send `confirmTrialTokenizationCharge: true`.
The response includes `trial.tokenizationCharge`; the subscription remains `incomplete` until
the successful-charge webhook starts the trial. Refunds are not automatic; handle them explicitly
with your own policy and [Paystack's refund API](https://paystack.com/docs/payments/refunds/) if you
promise one. See the provider guidance for [charging cards](https://paystack.com/docs/payments/charge-card/)
and [webhook events](https://paystack.com/docs/payments/webhooks/).
Callback failures leave webhook deliveries retryable (HTTP 503). Trusted transaction
reconciliation also persists a lifecycle delivery journal before subscription activation,
so a failed completion/update hook can resume from another verification transport without
resetting the billing period or repeating an already checkpointed hook. Application hooks
must remain idempotent by payment reference: a process can stop after an external side effect
and before its delivery checkpoint. Claims expire after five minutes for crash recovery.
Trial-start hooks use the same recovery mechanism after signed tokenization-charge webhooks.
Remote trial subscription creation is reserved before dispatch. If the provider response is
lost, the plugin keeps the reservation unresolved instead of creating a duplicate subscription;
reconcile the existing provider subscription (including its signed `subscription.create` event)
before retrying. A stored successful provider response can resume local activation normally.
#### 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:
```ts
import {
chargeSubscriptionRenewal,
reconcilePaystackTransaction,
syncPaystackPlans,
syncPaystackProducts,
} from "better-auth-paystack";
const ctx = { context: await auth.$context } as any;
await chargeSubscriptionRenewal(ctx, paystackOptions, {
subscriptionId: "sub_123",
});
const settlement = await reconcilePaystackTransaction(ctx, paystackOptions, {
reference: "PAYSTACK_REFERENCE",
source: "queue",
referenceId: "user_or_org_id",
});
if (settlement.ok) {
console.log(settlement.transaction.status, settlement.subscription.updated);
}
await syncPaystackProducts(ctx, paystackOptions);
await syncPaystackPlans(ctx, paystackOptions);
```
Use `reconcilePaystackTransaction` from webhook handlers, queue retries, cron jobs, or admin actions when trusted server code needs the same verification and local transaction/subscription side effects as the browser verify endpoint.
### Authorization & Security
#### `authorizeReference`
Control who can manage billing for specific references (Users or Organizations).
```ts
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 canonical methods under `authClient.paystack`, `authClient.transaction`, and `authClient.subscription`.
- `authClient.transaction.initialize`, `verify`, `list`
- `authClient.subscription.create`, `upgrade`, `cancel`, `restore`, `list`, `billingPortal`
- `authClient.paystack.config`, `listProducts`, `listPlans`, plus the transaction/subscription helpers above
Legacy compatibility aliases remain available for migration, but new code should use the canonical methods:
- `authClient.subscription.disable(...)` -> use `authClient.subscription.cancel(...)`
- `authClient.subscription.enable(...)` -> use `authClient.subscription.restore(...)`
### `authClient.subscription.upgrade` / `create`
Initializes a transaction to create or upgrade a subscription.
```ts
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;
/** Required for trial plans after your UI has disclosed the one-time tokenization charge. */
confirmTrialTokenizationCharge?: boolean;
/**
* 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`.
```ts
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
};
type initializeTransactionResult =
| {
kind: "checkout";
url: string;
reference: string;
accessCode: string;
redirect: true;
}
| { kind: "scheduled"; status: "success"; message: string; scheduled: true }
| { kind: "prorated"; status: "success"; message: string; prorated: true };
```
### `authClient.subscription.list`
List subscriptions for a user or organization. Organization-scoped billing actions require an owner/admin membership by default. To allow roles such as `billing`, configure `organization.billingRoles`. For custom resources or deeper policy checks, configure `subscription.authorizeReference`.
```ts
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.
```ts
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` and `organization`
Paystack customer codes are no longer added to Better Auth auth tables. Organization `email`
remains available for billing fallback.
| Field | Type | Required | Description |
| :------ | :------- | :------- | :--------------------------------------------------------------------------------- |
| `email` | `string` | No | The billing email for the organization. Falls back to the owner's email if absent. |
### `paystackCustomer`
| Field | Type | Required | Description |
| :-------------- | :------- | :------- | :----------------------------------- |
| `referenceType` | `string` | Yes | `user` or `organization`. |
| `referenceId` | `string` | Yes | Owning Better Auth identifier. |
| `referenceKey` | `string` | Yes | Unique provider-owned reference key. |
| `customerCode` | `string` | Yes | Paystack customer code. |
| `email` | `string` | No | Billing email. |
### `paystackPaymentCredential`
Payment credentials are stored separately as AES-256-GCM ciphertext. Plaintext values are not
returned by subscription APIs, callbacks, logs, or client actions. Set `credentialEncryptionKey`
to a dedicated production secret; the plugin falls back to `secretKey` for compatibility.
| Field | Type | Required | Description |
| :--------------------------- | :------- | :------- | :---------------------------------------- |
| `subscriptionId` | `string` | Yes | Unique Paystack subscription ID. |
| `authorizationCodeEncrypted` | `string` | No | Encrypted recurring-charge authorization. |
| `emailTokenEncrypted` | `string` | No | Encrypted subscription-management token. |
### `paystackSubscription`
| Field | Type | Required | Description |
| :--------------------- | :-------- | :------- | :------------------------------------------------------------------- |
| `plan` | `string` | Yes | Lowercased name of the active plan. |
| `referenceId` | `string` | Yes | Associated User ID or Organization ID. |
| `userId` | `string` | Yes | User who initiated the subscription checkout. |
| `customerCode` | `string` | No | The Paystack customer code for this subscription. |
| `subscriptionCode` | `string` | No | The unique code for the subscription (e.g., `SUB_...` or `LOC_...`). |
| `transactionReference` | `string` | No | The reference of the transaction that started the subscription. |
| `planCode` | `string` | No | The Paystack plan code, when Paystack manages the subscription. |
| `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. |
| `cancelAt` | `Date` | No | Scheduled cancellation timestamp. |
| `canceledAt` | `Date` | No | Cancellation timestamp. |
| `endedAt` | `Date` | No | Subscription end timestamp. |
| `billingInterval` | `string` | No | Billing interval used by the plan. |
| `groupId` | `string` | No | Optional subscription group. |
| `pendingPlan` | `string` | No | Plan pending a future lifecycle change. |
| `seats` | `number` | No | Purchased seat count for team billing. |
| `createdAt` | `Date` | Yes | Record creation timestamp. |
| `updatedAt` | `Date` | Yes | Record update timestamp. |
### `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. |
### `paystackWebhookEvent`
The plugin records verified webhook deliveries with their event type, raw payload, reference (when
available), processing status, and processed timestamp. This table is provider-namespaced so the
Paystack and Flutterwave plugins can be installed together without sharing webhook state.
| Field | Type | Required | Description |
| :------------ | :------- | :------- | :----------------------------------------- |
| `eventId` | `string` | Yes | Stable hash of the exact verified payload. |
| `eventType` | `string` | Yes | Paystack event name. |
| `reference` | `string` | No | Transaction reference when available. |
| `payload` | `string` | Yes | Exact raw webhook payload. |
| `status` | `string` | Yes | `pending` or `processed`. |
| `processedAt` | `Date` | No | Processing completion timestamp. |
### v4 schema migration
After upgrading the package, generate/apply the Better Auth schema first, then run the trusted
server-only operation:
```ts
import { migratePaystackSubscriptionSchema } from "better-auth-paystack";
const report = await migratePaystackSubscriptionSchema(ctx, {
secretKey: process.env.PAYSTACK_SECRET_KEY!,
credentialEncryptionKey: process.env.PAYSTACK_CREDENTIAL_ENCRYPTION_KEY,
});
```
Run it in this order:
1. Upgrade the package.
2. Run Better Auth schema generation/migration.
3. Run `migratePaystackSubscriptionSchema()` from a trusted server job.
4. Verify migration counts and billing behavior.
5. Remove legacy columns/tables manually only after verification.
The operation preserves legacy rows, keeps original subscription IDs where possible, skips already
migrated records, encrypts legacy authorization/email tokens, and reports partial failures so a
later run can retry them.
During the compatibility window, customer resolution can still read legacy auth-table customer
codes. Remove those legacy columns manually after the migration has been verified.
---
## Troubleshooting
- **Webhook Signature**: Ensure `PAYSTACK_SECRET_KEY` matches the integration that sends the webhook. Paystack uses this API secret key for `x-paystack-signature`.
- **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.
After upgrading, run `npx better-auth migrate` (or `npx better-auth generate` for schema-managed
adapters). The additive `cancelAt`, `canceledAt`, `endedAt`, and `billingInterval` subscription
columns are nullable; historical rows are populated as future lifecycle events are processed.
The following fields are indexed:
- **`paystackTransaction`**: `reference` (unique), `userId`, `referenceId`.
- **`paystackSubscription`**: `subscriptionCode` (unique), `referenceId`, `transactionReference`, `customerCode`, `plan`.
- **`paystackCustomer`**: `referenceKey` (unique), `referenceId`, `customerCode` (unique).
- **`paystackPaymentCredential`**: `subscriptionId` (unique).
- **`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:
```ts
import { syncPaystackProducts } from "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.
```bash
# Install dependencies
vp install
# Check project health (format, lint, types)
vp check --fix
# Build the core library
vp pack
# Run tests
vp test
# Run the TanStack Start example
vp -C 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](LICENSE) file for details.
## Roadmap
Future features planned for upcoming versions:
### v1.1.0 - Manual Recurring Subscriptions (Available Now)
- [x] **Stored Authorization Codes**: Securely store Paystack authorization codes from verified transactions.
- [x] **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
- GitHub Repository: [alexasomba/better-auth-paystack](https://github.com/alexasomba/better-auth-paystack)
- Comprehensive and up-to-date Paystack Node SDK: [alexasomba/paystack-node](https://github.com/alexasomba/paystack-node)
- Comprehensive and up-to-date Paystack Inline SDK: [alexasomba/paystack-inline](https://github.com/alexasomba/paystack-inline)
- [TanStack Start Example Implementation](https://github.com/alexasomba/better-auth-paystack/tree/main/examples/tanstack)
- 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](https://www.better-auth.com/docs)
### Optional subscription operation hooks
`subscription.operationHooks` lets an application place its own durable reservation and recovery boundary around plan operations. It is optional: leaving it unset retains the existing SDK behavior. The SDK does **not** provide a ledger, mutex, or a cross-request idempotency guarantee.
The `before(operation, ctx)` hook runs after authentication, reference authorization, and the applicable plan, seat, trial, and proration validation. It runs before provider initialization/charge or a local plan/schedule mutation. `operation` includes the actor, authorized reference, selected subscription, operation kind, and validated intent. Scheduling has a separate preflight before its local mutation.
Return one of:
- `{ kind: "proceed", providerReference, context? }`: supply a stable reference reserved by your application. The SDK passes it to ordinary checkout, prorated checkout, and saved authorization charge. `context` stays local to this invocation and is passed to the completion/error hooks.
- `{ kind: "replay", result }`: return an existing `PaystackInitializeResult` of the appropriate operation kind without another provider call or SDK write.
- `{ kind: "block", message? }`: reject this operation without dispatch or mutation.
`after(operation, ctx)` receives the result only after the operation's SDK writes have completed. `onError(operation, ctx)` receives the original error and a `provider`, `persistence`, or `completion` phase. A provider timeout, malformed/mismatched reference, persistence failure after payment, or completion-hook failure does not establish that no payment occurred. Retain the application's unresolved reservation and reconcile it; do not automatically repeat a charge. An error-hook failure is logged and does not replace the original operation error.
Hooks are server-only. Authenticate and authorize stored checkout replay data for the current actor/reference before returning it; authorization URLs and access codes must not appear in public status reads. Do not place provider credentials in hook context. The SDK keeps its existing reference authorization rules; applications remain responsible for their own stricter UI permissions.
A fail-closed configuration while an application reservation service is unavailable:
```ts
import type { PaystackSubscriptionOperationHooks } from "better-auth-paystack";
const operationHooks: PaystackSubscriptionOperationHooks = {
before: () =>
Promise.resolve({
kind: "block",
message: "Subscription changes are temporarily unavailable.",
}),
};
// Pass operationHooks under paystack({ subscription: { enabled: true, plans, operationHooks } }).
```
A production `proceed` implementation must reserve before returning, deduplicate concurrent requests, protect replay results, and resolve uncertain outcomes against authoritative provider and subscription state. One-time product/amount payments and recurring renewal operations do not use these subscription plan hooks.
### Pricing and webhook delivery
Catalog plans/products own checkout prices and currencies. Client `amount`/`currency` values apply only to ad hoc payments; product quantities and plan seats use server pricing. Subscription renewal operations also derive the amount from the configured plan.
Webhook processing requires the `paystackWebhookEvent` table, including its unique `eventId` constraint. Deliveries claim the event with a conditional database update; incomplete database changes or callbacks return HTTP 503 and remain retryable. A processing claim expires after five minutes for crash recovery. Callbacks must be idempotent by transaction/event reference because a crash after an external side effect can cause a retry. Historical successful transaction verification does not reactivate canceled subscriptions or reset an already active billing period.