@smarthivelabs-devs/payments-next
v1.2.0
Published
Next.js integration for SmartHive Payments — server utilities, App Router webhook handler, and React hooks
Readme
@smarthivelabs-devs/payments-next
Official Next.js SDK for SmartHive Payments — App Router route handlers, server utilities, client hooks, and webhook processing.
Installation
npm install @smarthivelabs-devs/payments-nextThis automatically installs @smarthivelabs-devs/payments (Node.js core SDK) and @smarthivelabs-devs/payments-react (React hooks). You also need:
npm install next react react-domEnvironment Variables
SMARTHIVE_PAYMENTS_API_KEY=sk_test_...
SMARTHIVE_PAYMENTS_BASE_URL=https://api.smarthivepayments.com
SMARTHIVE_PAYMENTS_WEBHOOK_SECRET=whsec_...| Variable | Required | Description |
|---|---|---|
| SMARTHIVE_PAYMENTS_API_KEY | Yes | Your API key (sk_test_... or sk_live_...) |
| SMARTHIVE_PAYMENTS_BASE_URL | Yes | SmartHive Payments API base URL |
| SMARTHIVE_PAYMENTS_WEBHOOK_SECRET | Webhooks only | Your webhook signing secret |
Quick Start
1. Create the server-side client
// lib/payments.ts
import { createSmartHiveClient } from '@smarthivelabs-devs/payments-next/server';
const apiKey = process.env.SMARTHIVE_PAYMENTS_API_KEY!;
export const payments = createSmartHiveClient({
apiKey,
baseUrl: process.env.SMARTHIVE_PAYMENTS_BASE_URL!,
// Derive mode from key prefix so they always agree.
// sk_test_... → sandbox database | sk_live_... → live database
mode: apiKey.startsWith('sk_live_') ? 'live' : 'sandbox',
});Or use the singleton version (one instance for the whole server):
import { getSmartHiveClient } from '@smarthivelabs-devs/payments-next/server';
// Creates the client on first call, reuses it after
const payments = getSmartHiveClient({
apiKey: process.env.SMARTHIVE_PAYMENTS_API_KEY!,
baseUrl: process.env.SMARTHIVE_PAYMENTS_BASE_URL!,
});2. Create a checkout session route
// app/api/payments/checkout/session/route.ts
import { NextResponse } from 'next/server';
import { payments } from '@/lib/payments';
export async function POST(req: Request) {
const { amount, currency, countryCode, customerEmail, customerName } = await req.json() as {
amount: number;
currency: string;
countryCode: string;
customerEmail?: string;
customerName?: string;
};
const session = await payments.checkout.createSession({
amount: String(amount),
currency,
countryCode,
customerEmail,
customerName,
platform: 'web',
returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/order/complete`,
callbackUrl: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks/payments`,
// mode is inherited from the client's default (derived from key prefix above)
});
return NextResponse.json(session);
}3. Add the verify endpoint
This route is called after payment, not after session creation. SmartHive appends
?reference=txn_xxxto yourreturnUrlwhen the customer pays — pass thattxn_xxxhere. Calling it with a session ID (chs_xxx) or before the customer has paid will return 404.
// app/api/payments/verify/[reference]/route.ts
import { NextResponse } from 'next/server';
import { payments } from '@/lib/payments';
export async function GET(
_req: Request,
{ params }: { params: { reference: string } },
) {
// params.reference must be a payment reference (txn_xxx) from:
// • the ?reference= query param on your returnUrl after checkout, OR
// • event.data.reference from a payment.success webhook
const result = await payments.payments.verify(params.reference);
return NextResponse.json(result);
}4. Set up webhooks
// app/api/webhooks/payments/route.ts
import { createWebhookHandler } from '@smarthivelabs-devs/payments-next/server';
export const POST = createWebhookHandler({
secret: process.env.SMARTHIVE_PAYMENTS_WEBHOOK_SECRET!,
handlers: {
'payment.success': async (event) => {
// Fulfill the order
await db.orders.update({
where: { reference: event.data.reference },
data: { status: 'paid', paidAt: new Date() },
});
},
'payment.failed': async (event) => {
await notifyCustomer(event.data.reference);
},
'payout.success': async (event) => {
await db.payouts.markComplete(event.data.reference);
},
},
onError: (err, req) => {
// Optional: log webhook errors
console.error('Webhook error:', err.message);
},
});5. Add React hooks to your UI
// app/checkout/page.tsx
'use client';
import { SmartHiveProvider, CheckoutButton } from '@smarthivelabs-devs/payments-next/client';
export default function CheckoutPage() {
return (
<SmartHiveProvider apiBaseUrl="">
<CheckoutButton
params={{ amount: 5000, currency: 'GHS', countryCode: 'GH' }}
label="Pay GHS 50"
onSuccess={(data) => console.log('Paid:', data.reference)}
/>
</SmartHiveProvider>
);
}Server API Reference
createSmartHiveClient(config?)
Creates a new SmartHivePayments instance. Config is optional — omit to read from env vars.
import { createSmartHiveClient } from '@smarthivelabs-devs/payments-next/server';
const apiKey = process.env.SMARTHIVE_PAYMENTS_API_KEY!;
const payments = createSmartHiveClient({
apiKey,
baseUrl: process.env.SMARTHIVE_PAYMENTS_BASE_URL!,
// sk_test_... → sandbox DB | sk_live_... → live DB (server-enforced)
mode: apiKey.startsWith('sk_live_') ? 'live' : 'sandbox',
timeout: 30_000, // ms
maxRetries: 2,
});getSmartHiveClient(config?)
Same as createSmartHiveClient but returns a singleton — useful in serverless environments to avoid creating a new client per request.
createWebhookHandler(config)
Returns a Next.js App Router POST handler function. Reads the raw body, verifies the HMAC signature, and dispatches to your handlers.
createWebhookHandler({
secret: string; // HMAC signing secret (or set SMARTHIVE_PAYMENTS_WEBHOOK_SECRET env var)
handlers: {
[eventType: string]: (event: SmartHiveEvent) => Promise<void>;
'*'?: (event: SmartHiveEvent) => Promise<void>; // catch-all for all events
};
onError?: (err: Error, req: Request) => void;
})Supported event types:
| Event | Description |
|---|---|
| payment.success | Payment completed |
| payment.failed | Payment failed |
| payment.pending | Awaiting confirmation |
| payment.abandoned | Customer abandoned checkout |
| payout.success | Payout completed |
| payout.failed | Payout failed |
| mandate.approved | Direct debit mandate approved |
| mandate.declined | Mandate declined by customer |
| refund.success | Refund processed |
| * | Catch-all (runs before the specific handler) |
All Use Cases
Checkout flow (hosted page)
Two-phase flow:
createSessiononly creates a pre-payment container — no transaction exists yet. The payment reference (txn_xxx) is created when the customer completes payment on the hosted checkout page. Read it from the?reference=query parameter on yourreturnUrl, or from thepayment.successwebhook.
// STEP 1: Server creates a checkout session and returns the URL to the frontend.
// At this point there is NO payment transaction — just a checkout container.
const session = await payments.checkout.createSession({
amount: '5000',
currency: 'GHS',
countryCode: 'GH',
platform: 'web',
customerEmail: '[email protected]',
returnUrl: 'https://yourapp.com/order/complete', // SmartHive adds ?reference=txn_xxx here after payment
callbackUrl: 'https://yourapp.com/api/webhooks/payments',
callerReference: 'ORDER-001', // optional — attach your own ID, verify with it later
mode: 'sandbox',
});
// session.data.sessionId is a session ID (chs_xxx), NOT a payment reference.
// session.data.callerReference is echoed back ('ORDER-001') if you passed one.
// Do NOT call payments.payments.verify() with sessionId — use callerReference or txn_xxx.
return NextResponse.json({ checkoutUrl: session.data.checkoutUrl });
// STEP 2: Frontend redirects the customer to checkoutUrl.
// Customer pays on the SmartHive hosted page.
// STEP 3A: After payment, SmartHive redirects to your returnUrl with ?reference=txn_xxx&status=success
// app/order/complete/page.tsx
// const reference = searchParams.get('reference'); // e.g. txn_xxx
// → call GET /api/payments/verify/txn_xxx to confirm the payment
// STEP 3B: Simultaneously, SmartHive POSTs a payment.success webhook to your callbackUrl
// with event.data.reference = 'txn_xxx' — use this to fulfill the order server-sideDirect payment initialization
This is an alternative to the checkout session flow, not an addition. Do not call both
createSessionandpayments.initializefor the same payment — they create independent records and each produces its own checkout URL. Pick one flow per payment.
const payment = await payments.payments.initialize({
amount: '5000',
currency: 'GHS',
countryCode: 'GH',
customerEmail: '[email protected]',
paymentMethod: 'card',
returnUrl: 'https://yourapp.com/order/complete',
callbackUrl: 'https://yourapp.com/api/webhooks/payments',
reference: 'ORDER-001', // optional custom reference
mode: 'sandbox',
});
// Redirect to payment.data.checkoutUrlVerify a payment
Pass a payment reference — one of:
- The
?reference=query parameter on yourreturnUrlafter the customer paysevent.data.referencefrom apayment.successwebhook event- Your own
callerReferencepassed tocreateSession(e.g.'ORDER-001')Do not pass a session ID (
chs_xxxfromcreateSession) — no transaction exists under that ID.
// Option A: use the txn_xxx from ?reference= on your returnUrl or webhook
const result = await payments.payments.verify(reference);
// Option B: if you passed callerReference: 'ORDER-001' to createSession, use that directly
const result = await payments.payments.verify('ORDER-001');
if (result.data.status === 'success') {
await fulfillOrder(result.data.reference);
}Direct debit (Paystack + Hubtel — GH, NG)
Two providers are supported. Pass preferredProvider to select one.
// ── Paystack: card authorization ──────────────────────────────────────────
// Step 1: Customer approves on a hosted page — you receive a card token
const mandate = await payments.directDebit.createMandate({
amount: '5000', currency: 'GHS', countryCode: 'GH',
customerEmail: '[email protected]',
returnUrl: 'https://yourapp.com/mandate/complete',
callbackUrl: 'https://yourapp.com/api/webhooks/payments',
preferredProvider: 'paystack',
mode: 'sandbox',
});
// Redirect customer to mandate.data.checkoutUrl (Paystack authorization page)
// Receive card authorizationCode via mandate.approved webhook
// ── Hubtel: mobile money USSD/OTP ─────────────────────────────────────────
// Step 1: Customer approves via USSD/OTP on their phone — no redirect
const mandate = await payments.directDebit.createMandate({
amount: '5000', currency: 'GHS', countryCode: 'GH',
customerEmail: '[email protected]',
customerPhone: '233244123456', // required
callbackUrl: 'https://yourapp.com/api/webhooks/payments',
preferredProvider: 'hubtel',
metadata: { mobileMoneyProvider: 'mtn' }, // mtn | vodafone | airteltigo
mode: 'sandbox',
});
// No checkoutUrl — store mandate.data.rawResponse.authorizationCode (the MSISDN)
// Receive confirmation via mandate.approved webhook
// ── Step 2: Charge (both providers, no customer interaction) ──────────────
const charge = await payments.directDebit.chargeMandate({
amount: '5000', currency: 'GHS', countryCode: 'GH',
customerEmail: '[email protected]',
authorizationCode: 'auth_code_from_webhook', // card token (Paystack) or MSISDN (Hubtel)
reference: 'SUBSCRIPTION-001',
metadata: { mobileMoneyProvider: 'mtn' }, // required for Hubtel only
mode: 'live',
});Payouts
// Step 1: Create recipient
const recipient = await payments.payouts.createRecipient({
type: 'mobile_money',
name: 'Kwame Mensah',
accountNumber: '0244123456',
countryCode: 'GH',
currency: 'GHS',
mode: 'live',
});
// Step 2: Send payout
const payout = await payments.payouts.send({
amount: '10000',
currency: 'GHS',
countryCode: 'GH',
recipientCode: recipient.data.recipientCode,
payoutMethod: 'mobile_money',
reason: 'Contest winnings',
mode: 'live',
});
// Confirmed via payout.success webhookBank verification
// List banks
const banks = await payments.payments.listBanks({ countryCode: 'GH', mode: 'sandbox' });
// Verify account before payout
const account = await payments.payments.verifyBankAccount({
accountNumber: '0123456789',
bankCode: 'GCB',
countryCode: 'GH',
currency: 'GHS',
mode: 'sandbox',
});
console.log(account.data.accountName);Idempotency
// Safe to retry — won't double-charge
const payment = await payments.payments.initialize(
{ amount: '5000', currency: 'GHS', countryCode: 'GH', ... },
'order-001-attempt-1', // idempotency key
);Error handling in route handlers
import {
AuthenticationError,
ValidationError,
PaymentError,
RateLimitError,
} from '@smarthivelabs-devs/payments-next/server';
export async function POST(req: Request) {
try {
const session = await payments.checkout.createSession({ ... });
return NextResponse.json(session);
} catch (err) {
if (err instanceof AuthenticationError) {
return NextResponse.json({ error: 'Invalid API key' }, { status: 401 });
}
if (err instanceof ValidationError) {
return NextResponse.json({ error: err.message }, { status: 422 });
}
if (err instanceof RateLimitError) {
return NextResponse.json(
{ error: 'Rate limited', retryAfter: err.retryAfter },
{ status: 429 },
);
}
if (err instanceof PaymentError) {
return NextResponse.json({ error: err.message, code: err.code }, { status: 400 });
}
throw err;
}
}Subscriptions
Server helpers
Gate server components, middleware, and Route Handlers against subscription access with a single import:
import {
getCustomerSubscription,
getCustomerSubscriptions,
getSubscription,
hasSubscriptionAccess,
hasAnySubscriptionAccess,
} from '@smarthivelabs-devs/payments-next/server';hasSubscriptionAccess — middleware gate
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { hasSubscriptionAccess } from '@smarthivelabs-devs/payments-next/server';
export async function middleware(req: NextRequest) {
const userId = req.cookies.get('user_id')?.value;
const ok = await hasSubscriptionAccess(
'plan_pro',
{ customerId: userId },
process.env.SMARTHIVE_PAYMENTS_API_KEY!,
);
if (!ok) return NextResponse.redirect(new URL('/upgrade', req.url));
return NextResponse.next();
}
export const config = { matcher: ['/dashboard/pro/:path*'] };hasAnySubscriptionAccess — multi-plan gate
// Gate access if the user has ANY of these plans
const ok = await hasAnySubscriptionAccess(
['plan_weekly_pro', 'plan_monthly_pro'],
{ customerId: userId },
process.env.SMARTHIVE_PAYMENTS_API_KEY!,
);getCustomerSubscription — fetch subscription data
// app/api/subscription/route.ts
import { getCustomerSubscription } from '@smarthivelabs-devs/payments-next/server';
export async function GET(req: Request) {
const userId = getUserId(req); // your auth logic
const sub = await getCustomerSubscription(
'plan_pro',
{ customerId: userId },
process.env.SMARTHIVE_PAYMENTS_API_KEY!,
);
if (!sub) return Response.json({ subscribed: false }, { status: 200 });
return Response.json({ subscribed: true, status: sub.status });
}getCustomerSubscriptions — batch lookup (single request)
const subs = await getCustomerSubscriptions(
['plan_basic', 'plan_pro', 'plan_enterprise'],
{ customerId: userId },
process.env.SMARTHIVE_PAYMENTS_API_KEY!,
);
// subs is Record<string, Subscription | null>
const hasBasic = subs['plan_basic'] !== null;
const hasPro = subs['plan_pro'] !== null;getSubscription — fetch by subscription code
// app/api/webhooks/payments/route.ts — after receiving subscription.charged event
const sub = await getSubscription(event.data.subscriptionCode, process.env.SMARTHIVE_PAYMENTS_API_KEY!);Client-side subscription hooks
All subscription hooks and components from @smarthivelabs-devs/payments-react are available via /client:
'use client';
import {
useSubscription,
useMultiPlanAccess,
SubscriptionGuard,
GracePeriodBanner,
} from '@smarthivelabs-devs/payments-next/client';
export default function PremiumFeaturePage({ userId }: { userId: string }) {
return (
<>
<GracePeriodBanner
planCode="plan_pro"
customerId={userId}
apiBaseUrl="/api"
ctaLabel="Update Payment"
onCtaClick={() => router.push('/billing')}
/>
<SubscriptionGuard
planCode="plan_pro"
customerId={userId}
apiBaseUrl="/api"
fallback={<UpgradePrompt />}
graceFallback={<PaymentFailedBanner />}
>
<ProFeature />
</SubscriptionGuard>
</>
);
}Multi-plan access (different tiers on the same page)
'use client';
import { SubscriptionGuard } from '@smarthivelabs-devs/payments-next/client';
export default function AppPage({ userId }: { userId: string }) {
return (
<>
{/* Basic features — any active basic subscription */}
<SubscriptionGuard planCode="plan_basic" customerId={userId} apiBaseUrl="/api" fallback={<Upgrade />}>
<BasicFeature />
</SubscriptionGuard>
{/* Pro features — weekly OR monthly pro plan */}
<SubscriptionGuard
planCodes={['plan_weekly_pro', 'plan_monthly_pro']}
customerId={userId}
apiBaseUrl="/api"
fallback={<UpgradeToPro />}
>
<ProFeature />
</SubscriptionGuard>
{/* Specific entitlement gate */}
<SubscriptionGuard
planCode="plan_pro"
entitlement="download"
customerId={userId}
apiBaseUrl="/api"
fallback={<UpgradeForDownloads />}
>
<DownloadButton />
</SubscriptionGuard>
</>
);
}Subscription webhook events
Add these handlers to your existing createWebhookHandler:
export const POST = createWebhookHandler({
secret: process.env.SMARTHIVE_PAYMENTS_WEBHOOK_SECRET!,
handlers: {
'subscription.created': async (event) => { /* new subscriber */ },
'subscription.activated': async (event) => { /* trial → active */ },
'subscription.charged': async (event) => { /* recurring charge succeeded */ },
'subscription.charge_failed': async (event) => { /* charge failed, entering grace period */ },
'subscription.paused': async (event) => { /* merchant paused */ },
'subscription.resumed': async (event) => { /* merchant resumed */ },
'subscription.cancelled': async (event) => { /* cancelled or max retries exhausted */ },
'subscription.trial_ending': async (event) => { /* 72h before trial ends — send reminder */ },
'subscription.trial_ended': async (event) => { /* trial just ended */ },
},
});Coupons
Server-side helpers
Validate a coupon code or fetch its details in a Route Handler or Server Component without importing browser code.
// app/api/payments/coupon/validate/route.ts
import { validateCoupon, getCoupon } from '@smarthivelabs-devs/payments-next/server';
export async function POST(req: Request) {
const { code, amountMinor, currency } = await req.json();
const result = await validateCoupon(
code,
{ amountMinor, currency, applicableTo: 'checkout' },
process.env.SMARTHIVE_PAYMENTS_API_KEY!,
{ baseUrl: process.env.SMARTHIVE_PAYMENTS_BASE_URL! },
);
if (!result) return Response.json({ valid: false }, { status: 400 });
return Response.json(result);
// { valid: true, couponCode: 'SUMMER20', discountAmountMinor: 1000, finalAmountMinor: 4000 }
}// Get a coupon's details (e.g. to display name + discount type in the UI)
const coupon = await getCoupon('SUMMER20', process.env.SMARTHIVE_PAYMENTS_API_KEY!);
if (coupon) console.log(coupon.name, coupon.discountType, coupon.currentUses);Signature
import {
validateCoupon,
getCoupon,
} from '@smarthivelabs-devs/payments-next/server';
validateCoupon(
code: string,
params: CouponValidateParams,
apiKey: string,
options?: { baseUrl?: string }
): Promise<CouponValidateResponse | null>
getCoupon(
code: string,
apiKey: string,
options?: { baseUrl?: string }
): Promise<Coupon | null>Apply coupon at session creation (server-side)
Pass couponCode to createSession to pre-apply a discount. Set allowCouponInput: true to show a coupon entry field on the hosted checkout page — without this flag the coupon UI is hidden regardless of whether the app has active coupons.
const session = await payments.checkout.createSession({
amount: '5000',
currency: 'GHS',
countryCode: 'GH',
couponCode: 'SUMMER20', // optional — pre-apply; discount stored server-side
allowCouponInput: true, // optional — show coupon input on the hosted checkout page
customerEmail: '[email protected]',
returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/order/complete`,
callbackUrl: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks/payments`,
});
// session.data.originalAmountMinor — '5000'
// session.data.discountAmountMinor — '1000'
// session.data.amountMinor — '4000' (what the customer pays)Client-side coupon hooks and components
useCouponValidate and CouponInput from @smarthivelabs-devs/payments-react are re-exported from /client:
'use client';
import {
useCouponValidate,
CouponInput,
} from '@smarthivelabs-devs/payments-next/client';
function CheckoutForm() {
const [coupon, setCoupon] = useState<{ code: string; final: number } | null>(null);
return (
<CouponInput
amountMinor={5000}
currency="GHS"
apiKey={process.env.NEXT_PUBLIC_SMARTHIVE_API_KEY!}
onApply={(code, discountAmountMinor, finalAmountMinor) =>
setCoupon({ code, final: finalAmountMinor })
}
onRemove={() => setCoupon(null)}
/>
);
}See the @smarthivelabs-devs/payments-react README for full hook and component props.
Import Paths
| Path | Use in | What it provides |
|---|---|---|
| @smarthivelabs-devs/payments-next/server | Server Components, Route Handlers, middleware | createSmartHiveClient, getSmartHiveClient, createWebhookHandler, subscription server helpers, coupon server helpers (validateCoupon, getCoupon), all types |
| @smarthivelabs-devs/payments-next/client | Client Components ('use client') | All React hooks + subscription hooks + all components + useCouponValidate + CouponInput |
| @smarthivelabs-devs/payments-next | Barrel (re-exports server) | Same as /server |
Client Components
All React hooks from @smarthivelabs-devs/payments-react are available via @smarthivelabs-devs/payments-next/client:
'use client';
import {
SmartHiveProvider,
useCheckout,
useCheckoutSession,
usePaymentStatus,
CheckoutButton,
PaymentStatusDisplay,
// Subscription
useSubscription,
useMultiPlanAccess,
SubscriptionGuard,
GracePeriodBanner,
} from '@smarthivelabs-devs/payments-next/client';See the @smarthivelabs-devs/payments-react README for full hook documentation.
TypeScript
Types are available from both import paths:
// Server types
import type {
SmartHivePaymentsConfig,
WebhookHandlerConfig,
WebhookEventHandler,
SmartHiveEvent,
SmartHiveEventType,
PaymentInitializeParams,
CheckoutSessionParams,
DirectDebitMandateParams,
DirectDebitChargeParams,
PayoutParams,
PaymentMode,
// Coupon types (server)
Coupon,
CouponValidateParams,
CouponValidateResponse,
CouponDiscountType,
CouponApplicableTo,
} from '@smarthivelabs-devs/payments-next/server';
// Coupon types (client)
import type {
UseCouponValidateOptions,
UseCouponValidateResult,
CouponInputProps,
} from '@smarthivelabs-devs/payments-next/client';Support
Contact Smart Hive Labs or open an issue in the workspace repository.
