vesk-paystack
v0.1.0
Published
Paystack for Vesk — server-authoritative amounts, an inline-JS island, and an idempotent webhook. No React, no provider, no trusted client callback.
Readme
vesk-paystack
Paystack for Vesk. Server-authoritative amounts, an inline-JS island, a working no-JS checkout, and an idempotent webhook. No React, no provider, no trusted client callback.
npm install vesk-paystackRequires @vesk/runtime as a peer.
What it is
The browser only ever sees a reference. The amount is computed and stored on the server before the button renders, and nothing is granted until Paystack confirms the charge to a verify route or a signed webhook. Editing the client callback, the DOM, or the fetch body cannot change what is checked.
Two components:
PayButton— an inline-SDK island. SSR renders a disabled button with the stored amount; hydration loads the SDK and enables it.PaystackStack— card/bank/USSD choice with a real<form method="post">fallback. The submit button works with JavaScript disabled.
Quick start
1. Create the server pieces
// app/paystack.ts
import { createMemoryPaymentStore, createPaystack } from 'vesk-paystack';
export const paystackStore = createMemoryPaymentStore();
export const paystack = createPaystack({
secretKey: process.env.PAYSTACK_SECRET_KEY!,
});
export async function onPaid(record) {
// Grant the entitlement. Must be idempotent on `record.reference`.
}createMemoryPaymentStore() is for tests and single-process prototypes.
Production needs a PaymentStore whose markSuccess, markFailed and
markFulfilled are atomic. Paystack redelivers webhooks, and the verify route
can race the webhook; exactly one transition may return changed: true.
2. Record the amount before rendering
// app/checkout/page.vsk (server side)
import { createPayment, generateReference, majorToKobo } from 'vesk-paystack';
import { paystackStore } from '../paystack.ts';
const reference = generateReference();
const payment = await createPayment(paystackStore, {
reference,
email: customer.email,
amount: majorToKobo('5000'), // 500000 kobo, never a float
});The amount comes from your cart, plan, or admin action. Never from the form.
3. Mount the three routes
// app/api/paystack/verify/route.ts
import { createVerifyHandler } from 'vesk-paystack';
import { onPaid, paystack, paystackStore } from '../../../paystack.ts';
export const POST = createVerifyHandler({
store: paystackStore,
paystack,
onPaid,
}).POST;
// app/api/paystack/checkout/route.ts
import { createCheckoutHandler } from 'vesk-paystack';
export const POST = createCheckoutHandler({
store: paystackStore,
paystack,
returnUrl: '/checkout/return',
}).POST;
// app/api/webhooks/paystack/route.ts
import { createPaystackWebhook } from 'vesk-paystack';
export const POST = createPaystackWebhook({
secret: process.env.PAYSTACK_SECRET_KEY!,
store: paystackStore,
onPaid,
});Use the same store in all three. The verify route and the webhook call one idempotent settlement path; whichever sees the charge first fulfils it.
4. Render a button
import { PayButton } from 'vesk-paystack/components/PayButton.vsk'
<PayButton
paystackKey={paystackPublicKey}
email={payment.email}
amount={payment.amount}
reference={payment.reference}
verifyUrl="/api/paystack/verify"
/>Or the channel picker with the no-JS fallback:
import { PaystackStack } from 'vesk-paystack/components/PaystackStack.vsk'
<PaystackStack
paystackKey={paystackPublicKey}
email={payment.email}
amount={payment.amount}
reference={payment.reference}
action="/api/paystack/checkout"
verifyUrl="/api/paystack/verify"
channels={['card', 'bank', 'ussd']}
/>Both components emit paystack:success, paystack:cancel and
paystack:error DOM events, so an app can react without passing functions
across the hydration boundary.
Custom islands
The hook is available directly when the components do not fit:
import { usePaystackPayment } from 'vesk-paystack/client';
import { get, track } from '@vesk/runtime';
const ready = track(false);
const loading = track(false);
const error = track<string | null>(null);
const pay = usePaystackPayment(
{
publicKey,
email,
amount,
reference,
verifyUrl: '/api/paystack/verify',
},
{ ready, loading, error },
);Pass your own cells when bindings live in the island body. They are optional; without them the hook creates cells. Passing them keeps the hook and the bindings on the same island owner block.
scriptUrl: false means the page already loaded the inline SDK (for example
with a CSP nonce). The hook checks for PaystackPop when it runs, so the SDK
must be present before the island hydrates. The default is
https://js.paystack.co/v2/inline.js.
How the no-JS path works
PaystackStack posts only the reference (and selected channel) to the checkout
route. The route looks up the stored record, initializes a hosted transaction
with the stored amount, and answers 303 to Paystack. Paystack returns the
shopper to returnUrl with ?reference=..., where the verify route can settle
and display the result.
Already-successful or already-failed records redirect back with a status
query parameter instead of opening a second charge.
Security model
- Amounts are minor units on the server.
createPayment()is the only place an amount enters the system.majorToKobo()converts display values; floats are rejected. - The success callback is a hint. The hook posts the reference to the verify route and only reports success after the server has asked Paystack and compared amount and currency.
- Webhooks are HMAC-SHA512 over
amount + reference, not the raw body. Signature comparison is constant-time; a bad signature is401and the handler never runs. - Success is terminal. A late
charge.failedcannot revoke it. A successful charge whose amount or currency does not match the record is markedflagged, never fulfilled automatically. onPaidis at-least-once. It runs only when a transition reportschanged: true, but it can run again if it throws after the charge was stored as successful. Make it idempotent on the reference.- Verify is not a substitute for the webhook. Verify gives the shopper an immediate answer; the webhook is the authority when the browser never returns.
CSP
The inline SDK needs script, frame and network access:
script-src 'self' https://js.paystack.co;
frame-src 'self' https://checkout.paystack.com https://js.paystack.co;
connect-src 'self' https://api.paystack.co https://js.paystack.co;
form-action 'self' https://checkout.paystack.com;form-action must include https://checkout.paystack.com or the browser
blocks the no-JS 303 redirect. Development servers that compile in the
browser may also need 'unsafe-eval'; production does not.
API
| Export | Purpose |
| --- | --- |
| createPaystack({ secretKey, baseUrl?, fetch?, timeoutMs? }) | Initialize and verify transactions. |
| createPayment(store, input) | Validate and store a pending payment. |
| createMemoryPaymentStore() | Test/dev PaymentStore. |
| createVerifyHandler(deps) | POST handler for browser verification. |
| createCheckoutHandler(deps) | POST handler for the no-JS hosted checkout. |
| createPaystackWebhook(deps) | POST handler with signature checking and idempotent settlement. |
| settleTransaction, runFulfillment | The shared settlement path. |
| generateReference, isReference, assertReference | Reference rules. |
| majorToKobo, formatKobo, normalizeKobo, sameAmount | Money helpers. |
| verifyPaystackSignature, paystackSignature | HMAC-SHA512 helpers. |
| PaystackError, PaystackCancelledError | Typed errors with stable codes. |
PaymentStore is three atomic transitions plus reads:
interface PaymentStore {
create(input: CreatePaymentInput): Promise<PaymentRecord>;
get(reference: string): Promise<PaymentRecord | null>;
markSuccess(reference: string, patch?: PaymentPatch): Promise<StoreTransition>;
markFailed(reference: string, patch?: PaymentPatch): Promise<StoreTransition>;
markFulfilled(reference: string, at: string): Promise<boolean>;
}Testing
npm test # library tests, one file per process
npm run check # typecheck + tests + buildThe test suite covers the money and reference rules, the server client, the store's transition rules, verify, the no-JS checkout, webhook signature and replay behavior, SSR component output, and the client hook against a fake inline SDK.
See DESIGN.md for the reasoning behind the boundaries.
