@halfagiraf/paykit
v0.1.0
Published
Payments and entitlements for Node apps — Stripe subscriptions and one-off purchases, with the access rules that Stripe deliberately leaves to you.
Maintainers
Readme
Paykit
@halfagiraf/paykit — payments and entitlements for Node applications.
Stripe subscriptions and one-off purchases, with the access rules Stripe
deliberately leaves to you.
Install it, point your app at it, and one version bump updates every app you own.
Why it exists
Stripe knows about customers, prices and subscriptions. It has no idea what a "plan" means in your application, no notion that Team should satisfy a gate written for Pro, and no concept of someone owning a downloadable report forever while their subscription lapses.
That mapping is identical in every app that sells anything, and writing it again per app is how twenty apps end up with twenty subtly different bugs. This is that layer, as a dependency rather than a template.
Install
npm install @halfagiraf/paykit pgPublished to GitHub Packages, so .npmrc needs:
@halfagiraf:registry=https://npm.pkg.github.comUse
import { createPaykit } from '@halfagiraf/paykit';
import { registerPaykit, gate } from '@halfagiraf/paykit/fastify';
const paykit = createPaykit({
pool, // your existing pg Pool
publicUrl: 'https://yourapp.com',
catalogue: [
{ key: 'free', name: 'Free', kind: 'subscription', tier: 0,
priceId: '', priceLabel: '£0', features: [], limits: { projects: 1 } },
{ key: 'pro', name: 'Pro', kind: 'subscription', tier: 1,
priceId: process.env.STRIPE_PRICE_PRO!, priceLabel: '£19/month',
features: ['Unlimited projects'], limits: { projects: null }, trialDays: 14 },
{ key: 'ebook', name: 'The Ebook', kind: 'one_off',
priceId: process.env.STRIPE_PRICE_EBOOK!, priceLabel: '£29',
features: ['PDF download'], repeatable: false },
],
});
await paykit.init(); // creates paykit_* tables, warns about misconfiguration
registerPaykit(app, paykit, {
currentUser: async (req) => myAuth.userFrom(req), // your auth, not ours
});
// Gate anything. Same call for both kinds — a route never needs to know whether
// the thing was sold as a subscription or bought outright.
app.get('/api/export', async (req, reply) => {
if (!await gate(paykit, 'pro')(req, reply)) return;
return doExport();
});
app.get('/api/ebook.pdf', async (req, reply) => {
if (!await gate(paykit, 'ebook')(req, reply)) return;
return streamEbook();
});registerPaykit mounts /api/billing/{catalogue,me,checkout,portal,webhook}.
Environment: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and one
STRIPE_PRICE_* per paid product.
The MCP server
An agent can set products up and — more usefully — work out why a payment did nothing.
{
"mcpServers": {
"paykit": {
"command": "npx",
"args": ["-y", "@halfagiraf/paykit", "paykit-mcp"],
"env": {
"STRIPE_SECRET_KEY": "sk_test_...",
"DATABASE_URL": "postgres://..."
}
}
}
}Eleven tools, nine of them read-only. paykit_create_product writes to Stripe;
paykit_replay_webhook clears one local row and touches Stripe not at all.
| Tool | What it does |
|---|---|
| paykit_status | Stripe mode, webhook secret, unprocessed count. Start here. |
| paykit_diagnose | Compares what Stripe says it sent against what this app recorded |
| paykit_list_stripe_prices | Existing products and prices, so nothing is duplicated |
| paykit_verify_catalogue | Every priceId checked against the account before a customer finds it |
| paykit_create_product | Creates the Stripe product and price, returns the catalogue entry |
| paykit_check_entitlement | What a given user actually holds |
| paykit_find_customer | Whether this person actually paid, answered from Stripe |
| paykit_inspect_stripe_object | One Stripe object, and what Paykit would make of it |
| paykit_failed_webhooks | Webhooks that failed, with the error |
| paykit_replay_webhook | Clears a failed event so a resend processes cleanly |
| paykit_webhook_setup | The endpoint URL and event list to configure |
Live-mode writes are refused unless PAYKIT_MCP_ALLOW_LIVE=yes. An agent with
a live key could otherwise create billable objects in a real account on a
misunderstanding.
Things worth knowing before taking real money
Stripe is the source of truth. This database caches what Stripe said. Nothing here computes what anyone owes, so a bug gates someone wrongly for a few seconds until the next webhook — it cannot charge them wrongly.
The webhook body must stay raw. The signature covers the exact bytes Stripe sent; re-serialising parsed JSON changes them and the signature never matches. The Fastify adapter registers the right content-type parser so you cannot forget. This is the usual reason webhooks fail in production, and it fails silently.
Webhooks are idempotent by event id, claimed with a UNIQUE insert before any
work happens. Stripe retries every non-2xx for three days and duplicates even on
success.
One-off purchases outlive subscriptions. Separate tables, not a kind column:
a subscription has a status that keeps changing for years, a purchase has none.
Cancelling Pro never repossesses something bought outright.
past_due still grants access. The card failed and Stripe is retrying.
Cutting someone off the hour their card expires loses more than a few days of
service costs.
Partial refunds don't revoke. Only a full refund removes access.
Stripe customer ids are remembered. Paykit writes its own paykit_customers
row the first time it creates a customer for one of your users, so a returning
customer reuses theirs rather than accumulating one per checkout, and portal()
works even if your application stores nothing itself. Pass onCustomerCreated if
you want the id mirrored into your own users table as well.
limits is enforced, if you ask. paykit.withinLimit(entitlement, 'projects',
count) answers whether one more is allowed; paykit.limitFor gives the number, or
null for unlimited. Paykit cannot count your projects, so the count is yours — but
the comparison, and the fact that 0 means none rather than unlimited, is not.
Your users stay yours. user_id is TEXT, so a bigint, UUID or Auth0 subject
all work. Paykit ships optional paykit_users/paykit_sessions tables for apps
with no auth of their own, but references user ids without a foreign key so it
sits alongside your schema untouched. All tables are paykit_-prefixed.
Going live: swap to sk_live_, create a separate live webhook endpoint with
its own whsec_, and re-create the products — test and live share nothing,
including price ids.
Stripe API version
Pinned in src/billing/stripe.ts, in step with the SDK's own default. The types
enforce it, so bumping the SDK past a version boundary is a compile error here
rather than a runtime surprise in production. Read Stripe's changelog for the
intervening versions before changing that line.
Not included, deliberately
Email (Stripe sends its own invoices and receipts), team seat management, and usage-based billing. Each is real work and none is needed for a first payment.
Tests
npm test241 tests across 8 files, weighted towards the two things expensive to get wrong: webhook signature verification — forged signatures, tampered bodies, replayed timestamps, an unset secret — and the entitlement rules, where treating both kinds of sale alike misbehaves quietly. A purchase must survive a cancelled subscription, a subscription must never imply ownership, and a second purchase of a non-repeatable product must be refused rather than charged.
