npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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-paystack

Requires @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 is 401 and the handler never runs.
  • Success is terminal. A late charge.failed cannot revoke it. A successful charge whose amount or currency does not match the record is marked flagged, never fulfilled automatically.
  • onPaid is at-least-once. It runs only when a transition reports changed: 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 + build

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