Convex Paystack
Accept payments and subscriptions with Paystack in your Convex app. Reactive transactions, subscription state, and webhooks.
git clone https://github.com/sholajegede/convex-paystack.git
cd convex-paystackThis upstream package has not been adapted or qualified for Bijection. Read the original documentation before adopting it.
Description
Accept payments and subscriptions with Paystack in your Convex app. Reactive transactions, subscription state, and webhooks.
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: sholajegede/convex-paystack. License: See repository. Consult the repository for license terms, notices, and contributor attribution.
From the upstream publisher
Accept payments and subscriptions with Paystack in your Convex app. Reactive transactions, subscription state, and webhook ingestion.
Explore the demo ↗Upstream overview
From the upstream package documentation.
convex-paystack integrates Paystack payments and subscriptions into a Convex backend by handling webhook ingestion, HMAC signature verification, and database persistence automatically. It exposes reactive queries for transaction and subscription state, so connected clients update in real time as webhooks arrive. Developers get checkout initialization, server-side transaction verification, plan management, subscription lifecycle control, and idempotent webhook processing without writing any of that infrastructure themselves.
Use cases
From the upstream package documentation.
- Adding one-time payment flows to a Convex app where transaction status needs to be reactively readable in queries without polling Paystack's API
- Building subscription-based SaaS products that need to track plan enrollment, renewal state, and cancellation in Convex-native storage
- Replacing hand-rolled webhook receivers that lack idempotency, signature verification, or automatic database writes
- Checking whether a user holds an active subscription inside a Convex query to gate access to features or content
- Syncing existing Paystack customer subscriptions into Convex when webhooks were missed or the webhook URL was not registered at project creation
How it works
From the upstream package documentation.
The component is registered in convex/convex.config.ts using app.use(convexPaystack), which provisions the component's internal database tables for transactions, subscriptions, and raw webhook events. A Paystack class instance is constructed with components.convexPaystack and your Paystack secret key, and this instance is used across all action and query calls.
A webhook handler is mounted in convex/http.ts via paystack.webhookHandler on a route like /webhooks/paystack. When Paystack delivers an event, the handler verifies the HMAC signature, checks for duplicate delivery using stored event IDs, and writes the relevant state into the component's tables. Supported events include charge.success, subscription.create, subscription.disable, and invoice events.
Checkout is initiated with initializeTransaction(), which calls Paystack's Initialize Transaction endpoint, records a pending transaction locally, and returns authorizationUrl for client-side redirect. Passing a plan code to initializeTransaction() converts the checkout into a subscription flow. After the customer completes payment, verifyTransaction() confirms the final status server-side. Subscription data becomes available through reactive queries like getSubscription(), listSubscriptions(), and hasActiveSubscription() once the corresponding webhooks are processed.
For cases where webhook delivery was missed, syncCustomerSubscriptions() fetches live subscription data from Paystack's Fetch Customer endpoint and upserts it directly into the component's tables, bypassing the webhook path entirely.
Upstream package FAQ
What Paystack webhook events does convex-paystack handle?
convex-paystack processes charge.success, subscription.create, subscription.disable, and invoice-related events from Paystack. Events it does not recognize are silently ignored. Each processed event is deduplicated so repeated webhook deliveries of the same event do not cause duplicate writes to Convex.
How do I recover subscription state if webhooks were not delivered?
convex-paystack includes a syncCustomerSubscriptions action that fetches a customer's live subscriptions directly from Paystack's Fetch Customer endpoint and upserts them into Convex. This is useful during initial setup when the webhook URL was not yet registered in the Paystack dashboard and events were never delivered.
How long does it take for payment state to appear in Convex after a transaction?
After a payment or subscription action in Paystack, there is typically a few seconds delay before Paystack delivers the webhook to your Convex deployment. Once the webhook arrives, Convex's real-time reactivity propagates the updated state to all active subscribers instantly. You can also call verifyTransaction immediately after the callback redirect for server-side confirmation without waiting for the webhook.
What currencies does convex-paystack support?
convex-paystack supports any currency enabled on your Paystack account. Amounts are specified in the smallest subunit for each currency, such as kobo for NGN, pesewas for GHS, and cents for USD. You can call listBalances to see which currencies are active on your account. Charging a currency not enabled on your account throws a Currency not supported by merchant error.
What are the requirements to use convex-paystack?
convex-paystack requires Convex v1.34.1 or later, Node.js 18 or later, and a Paystack account with a secret key. Install it with npm install convex-paystack, register the component in convex.config.ts, mount the webhook handler in convex/http.ts, and set the PAYSTACK_SECRET_KEY environment variable using npx convex env set.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Convex Paystack with Bijection.
Read: https://bijection.com/components/convex-paystack/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from sholajegede/convex-paystack, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
convex-paystack
Accept payments and subscriptions with Paystack in your Convex app. Reactive transactions, subscription state, and webhook ingestion.
const paystack = new Paystack(components.convexPaystack, {
secretKey: process.env.PAYSTACK_SECRET_KEY!,
});
// Start a checkout
const { authorizationUrl } = await paystack.initializeTransaction(ctx, {
email: "customer@example.com",
amount: 500000, // kobo
});
// Confirm it server-side after redirect
const result = await paystack.verifyTransaction(ctx, { reference });
// Is this customer on an active subscription?
const active = await paystack.hasActiveSubscription(ctx, { customerEmail: "customer@example.com" });
What this does
Paystack fires webhook events every time a payment or subscription action happens — a charge succeeds, a subscription is created, an invoice is billed. Without this component, you have to write and maintain your own webhook receiver, HMAC signature verification, database schema, and reactive queries.
This component owns all of that. Drop it in, mount the webhook, and your Convex app immediately has:
- Reactive transaction state — every transaction, live in Convex, keyed by reference
- Reactive subscription state — subscription status, plan, next payment date, live in Convex
- Checkout —
initializeTransaction()generates a Paystack-hosted checkout link for one-time payments or, with aplancode, for subscriptions - Plan management —
createPlan()/listPlans()manage the billing plans subscriptions are built on - Server-side verification —
verifyTransaction()confirms a transaction directly with Paystack - Subscription management —
cancelSubscription()/enableSubscription()call Paystack directly and keep local state in sync - Webhook idempotency — duplicate deliveries of the same event are detected and skipped
Webhook timing: After a payment or subscription action occurs in Paystack, there is a short delay — usually a few seconds — before the webhook arrives and your Convex data updates. Once the webhook arrives, Convex's real-time reactivity propagates the change to all subscribers instantly.
Table of Contents
- Install
- Quick Start
- Setup
- Usage
- Checkout
- Plans
- Subscriptions
- API Reference
- Type Reference
- Webhook Events
- Database Schema
- Customer IDs
- Testing
- Example App
- Limitations
- Troubleshooting
- Contributing
- Changelog
Install
npm install convex-paystack
Requirements: Convex v1.34.1 or later, Node.js 18+, a Paystack account
Quick Start
Five steps to add Paystack to your Convex app.
1. Add the component
In convex/convex.config.ts:
import { defineApp } from "convex/server";
import convexPaystack from "convex-paystack/convex.config";
const app = defineApp();
app.use(convexPaystack);
export default app;
2. Set environment variables
npx convex env set PAYSTACK_SECRET_KEY sk_live_xxxxxxxxxxxx
3. Mount the webhook handler
In convex/http.ts:
import { httpRouter } from "convex/server";
import { components } from "./_generated/api";
import { Paystack } from "convex-paystack";
const paystack = new Paystack(components.convexPaystack, {
secretKey: process.env.PAYSTACK_SECRET_KEY!,
});
const http = httpRouter();
http.route({
path: "/webhooks/paystack",
method: "POST",
handler: paystack.webhookHandler,
});
export default http;
4. Register the webhook in Paystack
- In Paystack Dashboard → Settings → API Keys & Webhooks
- Set the webhook URL:
https://your-deployment.convex.site/webhooks/paystack - Save. Paystack sends every event to this URL — the handler ignores events it doesn't recognize.
Your Convex site URL is in the Convex dashboard under Settings → URL & Deploy Key — it ends in .convex.site.
5. Initialize the client
In convex/payments.ts:
import { components } from "./_generated/api";
import { Paystack } from "convex-paystack";
export const paystack = new Paystack(components.convexPaystack, {
secretKey: process.env.PAYSTACK_SECRET_KEY!,
});
Import paystack from this file in any Convex function that needs payments.
Setup
convex/payments.ts — your central payments module:
import { components } from "./_generated/api";
import { Paystack } from "convex-paystack";
import { action } from "./_generated/server";
import { v } from "convex/values";
export const paystack = new Paystack(components.convexPaystack, {
secretKey: process.env.PAYSTACK_SECRET_KEY!,
});
export const checkout = action({
args: { email: v.string(), amount: v.number() },
handler: async (ctx, args) => paystack.initializeTransaction(ctx, args),
});
convex/http.ts — webhook entry point (shown in Quick Start).
Usage
Start a checkout
export const checkout = action({
args: { email: v.string(), amount: v.number() },
handler: async (ctx, args) => {
return await paystack.initializeTransaction(ctx, {
email: args.email,
amount: args.amount, // subunit — kobo for NGN, pesewas for GHS, cents for USD
callbackUrl: "https://yourapp.com/payment/callback",
});
},
});
// Returns: { authorizationUrl, accessCode, reference }
// Redirect the customer to authorizationUrl.
Verify a transaction
export const confirmPayment = action({
args: { reference: v.string() },
handler: async (ctx, args) => {
return await paystack.verifyTransaction(ctx, args);
},
});
// Returns: { status, reference, amount, currency, channel, paidAt, customerEmail, ... }
Read a transaction reactively
export const getTransaction = query({
args: { reference: v.string() },
handler: async (ctx, args) => {
return await paystack.getTransaction(ctx, args);
},
});
// Returns: Transaction | null
List a customer's transactions
export const getHistory = query({
args: { customerEmail: v.string() },
handler: async (ctx, args) => {
return await paystack.listTransactions(ctx, {
customerEmail: args.customerEmail,
limit: 20,
});
},
});
// Returns: Transaction[] ordered newest first
Checkout
initializeTransaction() calls Paystack's Initialize Transaction endpoint, records a pending transaction locally, and returns the hosted checkout URL. Send the customer to authorizationUrl; Paystack redirects them back to callbackUrl after payment.
Call verifyTransaction() from your callback route (or rely on the charge.success webhook) to confirm the final status — never trust the client-side redirect alone.
Two optional arguments shape what the customer sees at checkout:
channelsrestricts which payment methods Paystack's hosted page offers — any of"card","bank","apple_pay","ussd","qr","mobile_money","bank_transfer","eft","capitec_pay","payattitude". Omit it to let Paystack offer everything enabled on your account.planturns a one-time checkout into a subscription — see Plans.
await paystack.initializeTransaction(ctx, {
email: "customer@example.com",
amount: 500000,
channels: ["card", "bank_transfer", "ussd"],
});
Pass currency to charge in something other than your account's default (Paystack test accounts are usually NGN-only until you enable more in Dashboard → Settings → Preferences). Rather than guessing, check what's actually enabled with listBalances():
const balances = await paystack.listBalances(ctx);
// [{ currency: "NGN", balance: 0 }]
const enabledCurrencies = balances.map((b) => b.currency);
Charging a currency that isn't enabled throws Currency not supported by merchant — this component does not silently convert or substitute currencies for you.
Plans
Plans are the billing schedule a subscription is built on — an amount, a currency, and an interval (daily, weekly, monthly, quarterly, biannually, annually). Create one, then pass its planCode to initializeTransaction(): the customer's first successful payment automatically starts the subscription, and this component records it as soon as the subscription.create webhook arrives.
export const createProPlan = action({
args: {},
handler: async (ctx) => {
return await paystack.createPlan(ctx, {
name: "Pro Monthly",
amount: 500000, // ₦5,000.00 in kobo
interval: "monthly",
currency: "NGN",
});
},
});
// Returns: { planCode, name, amount, interval, currency, description? }
export const startSubscription = action({
args: { email: v.string(), planCode: v.string(), amount: v.number() },
handler: async (ctx, args) => {
return await paystack.initializeTransaction(ctx, {
email: args.email,
amount: args.amount,
plan: args.planCode,
});
},
});
listPlans() returns every plan already created on your Paystack account, so you can check for an existing plan by name before creating a duplicate — this is exactly the pattern the example app uses to bootstrap its demo plans on first run.
Subscriptions
Once a plan exists, a subscription is created automatically the first time a customer pays through a checkout initialized with that plan code (see Plans above). This component mirrors subscription state reactively as webhooks arrive, and exposes cancel/enable:
export const cancelPlan = action({
args: { code: v.string(), token: v.string() },
handler: async (ctx, args) => {
await paystack.cancelSubscription(ctx, args);
return null;
},
});
code and token are the subscription's subscription_code and email_token, both delivered on the subscription.create webhook and available via getSubscription().
If a subscription isn't showing up locally, it's almost always a webhook that hasn't reached your deployment yet (a fresh Convex project whose webhook URL was never registered in Paystack, most commonly). syncCustomerSubscriptions() is a fallback for exactly that — it reads the customer's live subscriptions from Paystack's Fetch Customer endpoint and upserts them, no webhook required:
export const syncSubscriptions = action({
args: { email: v.string() },
handler: async (ctx, args) => {
return await paystack.syncCustomerSubscriptions(ctx, { email: args.email });
},
});
// Returns: number of subscriptions synced
API Reference
| Method | Kind | Description |
|---|---|---|
initializeTransaction(ctx, args) | action | Starts a Paystack checkout, returns the hosted payment link — pass plan to start a subscription |
verifyTransaction(ctx, args) | action | Confirms a transaction's final status with Paystack |
createPlan(ctx, args) | action | Creates a billing plan on Paystack |
listPlans(ctx) | action | Lists every billing plan on your Paystack account |
cancelSubscription(ctx, args) | action | Disables a subscription on Paystack and locally |
enableSubscription(ctx, args) | action | Re-enables a non-renewing subscription |
getTransaction(ctx, args) | query | Fetch one transaction by reference |
listTransactions(ctx, args) | query | List a customer's transactions, newest first |
getSubscription(ctx, args) | query | Fetch one subscription by subscription code |
listSubscriptions(ctx, args) | query | List a customer's subscriptions |
hasActiveSubscription(ctx, args) | query | true if the customer has an active or non-renewing subscription |
listRecentEvents(ctx, args?) | query | Raw webhook event log, newest first — audit trail or a live console |
getStats(ctx) | query | Aggregate transaction/subscription/event counts for a small dashboard |
listBalances(ctx) | action | Currencies actually enabled on your Paystack account, with their available balance |
syncCustomerSubscriptions(ctx, args) | action | Pulls a customer's subscriptions straight from Paystack and upserts them locally — a fallback for when the webhook hasn't arrived (or isn't registered) yet |
Type Reference
type InitializeTransactionArgs = {
email: string;
amount: number;
currency?: string;
callbackUrl?: string;
reference?: string;
channels?: Array<
| "card" | "bank" | "apple_pay" | "ussd" | "qr"
| "mobile_money" | "bank_transfer" | "eft" | "capitec_pay" | "payattitude"
>;
plan?: string;
metadata?: Record<string, unknown>;
};
type CreatePlanArgs = {
name: string;
amount: number;
interval: "daily" | "weekly" | "monthly" | "quarterly" | "biannually" | "annually";
currency?: string;
description?: string;
};
type PlanResult = {
planCode: string;
name: string;
amount: number;
interval: string;
currency: string;
description?: string;
};
type Transaction = {
reference: string;
customerEmail: string;
amount: number;
currency: string;
status: "pending" | "success" | "failed" | "abandoned";
channel?: string;
gatewayResponse?: string;
authorizationCode?: string;
paidAt?: number;
metadata?: string;
};
type Subscription = {
subscriptionCode: string;
emailToken?: string;
customerEmail: string;
customerCode?: string;
planCode: string;
status: "active" | "non-renewing" | "attention" | "completed" | "cancelled";
amount?: number;
nextPaymentDate?: number;
};
type Balance = {
currency: string;
balance: number;
};
Webhook Events
The webhook handler verifies the x-paystack-signature header (hex-encoded HMAC-SHA512 of the raw request body, keyed with your secret key) before processing anything, and de-duplicates by event id so retried deliveries are safe. It currently acts on:
| Event | Effect |
|---|---|
charge.success | Upserts the transaction as success |
subscription.create | Upserts the subscription record |
subscription.disable | Marks the subscription cancelled |
subscription.not_renew | Marks the subscription non-renewing |
invoice.update | Marks the subscription active or attention depending on invoice status |
invoice.payment_failed | Marks the subscription attention |
All other event types are accepted (HTTP 200) but ignored, so you can register every event on one endpoint without errors.
Database Schema
transactions: {
reference, customerEmail, amount, currency, status,
channel?, gatewayResponse?, authorizationCode?, paidAt?, metadata?,
createdAt, updatedAt,
}
subscriptions: {
subscriptionCode, emailToken?, customerEmail, customerCode?, planCode, status,
amount?, nextPaymentDate?, createdAt, updatedAt,
}
webhookEvents: {
eventId, eventType, reference?, payload, receivedAt,
}
Plans are not stored locally — Paystack is the source of truth for them, the same way it is for verified transactions. listPlans() reads live from Paystack.
listRecentEvents() reads webhookEvents directly — every event this component's webhook handler has ever received, whether or not it changed a transaction or subscription. getStats() returns row counts across all three tables with a full scan, intended for a small dashboard rather than a high-volume production metric.
Customer IDs
This component keys everything on customerEmail — the email Paystack has on file for the transaction or subscription. If your app identifies customers a different way (e.g. an internal user id), keep a mapping from your own id to the email you pass into this component.
Testing
npm run test
Component logic is tested with convex-test in src/component/lib.test.ts. Import convex-paystack/test in your own app to register this component's schema against your test instance.
Example App
example/ is a full Vite + React demo, styled with Paystack's and Convex's own brand colors, that exercises the entire component end to end against your own Paystack test-mode account:
- One-time payment — pick an amount and currency, choose which checkout channels to offer, pay, and land on a result screen driven by
verifyTransaction(). - Subscriptions — the app bootstraps two demo plans via
createPlan()/listPlans()on first load and lets you subscribe to either. - Retry flow — a declined or abandoned payment surfaces a "Try again" action that returns you to the same flow with your details preserved.
- Transaction history — a live Convex query over
listTransactions()/listSubscriptions()that updates the instant a webhook lands, no refresh needed. - Live developer console — a pinned panel (bottom of the page) that interleaves client-side actions (checkout started, verifying…) with the real webhook log from
listRecentEvents(), reactively, so you can watch the entire lifecycle of a payment or subscription as it happens. Click any webhook row to see its raw payload.
Run it with:
cd example
npm install
npx convex dev
# in another terminal
npm run dev
Use Paystack's test cards to exercise both outcomes — 4084 0840 8408 4081 always succeeds, 4084 0800 0000 5408 always declines so you can see the retry flow.
Limitations
- Amounts are in Paystack's subunit for the currency (kobo, pesewas, cents) — this component does not convert them. The example app converts major-unit input (e.g. Naira) to subunits before calling
initializeTransaction. - Only the webhook events listed above update local state; other events are received but not persisted beyond the raw idempotency record.
cancelSubscription/enableSubscriptionrequire the subscription'scodeandtoken, both only available after asubscription.createwebhook has been received.createPlan/listPlanstalk to Paystack directly on every call — this component does not cache plans locally.
Troubleshooting
Webhook returns 401 — the x-paystack-signature header didn't match. Confirm PAYSTACK_SECRET_KEY is your live/test secret key (not the public key) and matches the key used in the Paystack Dashboard for this webhook endpoint.
Transaction stays pending — initializeTransaction only records pending; it becomes success/failed once the charge.success webhook arrives or you call verifyTransaction.
Subscription never appears — subscriptions are created on Paystack, not by this component. Confirm the subscription.create webhook is registered and reaching your endpoint, and that the checkout was initialized with a plan code.
Contributing
See CONTRIBUTING.md.
Changelog
See CHANGELOG.md.