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

@venturekit/billing

v0.0.41

Published

VentureKit multi-provider billing — payment provider abstraction (Zazu, Stripe, PayPal) with checkout sessions, payment links, invoices, transfers, and normalized webhook events.

Readme

@venturekit/billing

Warning: This package is in active development and not production-ready. APIs may change without notice.

Multi-provider payment processing for VentureKit — a provider-agnostic abstraction over payment providers (Zazu today; Stripe and PayPal are the intended next adapters) covering hosted checkout, payment links, invoicing, transfers, and normalized webhook events.

Not to be confused with @venturekit-pro/billing, which handles plans, subscriptions, usage metering, and invoice generation. That package deliberately excludes payment processing — this one provides it. They compose.

Installation

npm install @venturekit/billing@dev

Quick start

import { createBillingClient, createZazuAdapter } from '@venturekit/billing';

// createZazuAdapter() resolves config from ZAZU_* env vars.
const billing = createBillingClient({
  providers: { zazu: createZazuAdapter() },
  defaultProvider: 'zazu',
});

const session = await billing.createCheckoutSession({
  amount: { amount: '1500.000', currency: 'MAD' },
  successUrl: 'https://app.example.com/success',
  // Your identifiers. Handed back on the webhook so you can find your
  // own record — see "Correlation" below.
  correlation: { reference: 'ORD-123', metadata: { user_id: 'usr_456' } },
});

// → redirect the customer to session.url

The funding account comes from ZAZU_ACCOUNT_ID, not from every call site — it is a deployment property. Pass accountId explicitly only to override it (multi-account tenants).

Correlation

Webhook payloads are projections: each carries a strict subset of what the REST API returns, and the subset differs per event. So this package does not try to rebuild a full object from an event. Instead you attach your own identifiers on create, and the event hands them back:

// 1. Attach your reference when creating.
await billing.createCheckoutSession({
  amount: { amount: '1500.000', currency: 'MAD' },
  successUrl: 'https://app.example.com/success',
  correlation: { reference: order.id },
});

// 2. Recover it on the webhook.
const event = await billing.handleWebhookEvent({ rawBody, headers });
const order = await orders.findByReference(event.correlation?.reference);

// 3. Verify the amount before fulfilling — correlation tells you WHICH
//    order the event is for, not that the right amount was paid.
if (event.amount?.amount !== order.total) throw new Error('amount mismatch');

Every event also carries objectId (the provider's id, for follow-up calls), eventId (deduplicate on this — Zazu retries up to 8 times over ~28 hours), and raw (the untouched payload).

Per-flow support

reference round-trips on every flow. metadata does not, because the transports genuinely differ — query it rather than assuming:

billing.adapter.correlationSupport('paymentLink');
// → { reference: true, metadata: false, channel: 'urlQuery' }

| Flow | Zazu channel | reference | metadata | | --- | --- | --- | --- | | checkout session | metadata map in the create body | ✅ | ✅ (≤50 keys, values ≤500 chars) | | payment link | client_reference_id URL query parameter | ✅ | ❌ | | transfer | payment_reference in the create body | ✅ | ❌ |

Two consequences worth knowing:

  • Payment links — the reference is appended to the returned url, so share that exact URL; a hand-built link loses it. Passing metadata throws UnsupportedCorrelationError rather than letting Zazu silently discard it (it accepts only four whitelisted query parameters and ignores the rest without erroring).
  • Transfers — the reference travels as payment_reference, which is visible on bank statements, so use a human-meaningful value. It cannot be combined with an explicit paymentReference; setting both throws rather than silently picking one.

Configuration

Every value resolves from the environment, with explicit overrides accepted so secrets never live in source.

| Variable | Required | Default | Purpose | | --- | --- | --- | --- | | BILLING_PROVIDER | no | zazu | Provider selected by createBillingClientFromEnv() | | ZAZU_SECRET_ARN | no | — | Secrets Manager entry holding {apiKey, accountId, webhookSecret}. Injected automatically by a billing intent; makes the three variables below optional | | ZAZU_API_KEY | unless ZAZU_SECRET_ARN | — | Bearer token for the Zazu API | | ZAZU_ACCOUNT_ID | effectively yes | — | Default funding/destination account. Zazu requires one to create anything; a per-call accountId overrides it | | ZAZU_BASE_URL | no | https://zazu.ma | API host. Defaults to Morocco production; set https://zazu.africa for South Africa, or https://staging.zazu.ma for staging. Not the pay.zazu.ma checkout domain | | ZAZU_WEBHOOK_SECRET | for webhooks | — | Endpoint signing secret | | ZAZU_WEBHOOK_TOLERANCE_SECONDS | no | 300 | Replay window; 0 disables the freshness check |

Declaring billing in vk.config.ts

Declare the intent and VentureKit wires the credentials for you — it provisions the Secrets Manager entry, grants the Lambda role secretsmanager:GetSecretValue on that one secret, and injects ZAZU_SECRET_ARN:

infrastructure: {
  billing: [{ id: 'main', provider: 'zazu' }],
}

No credential ever passes through vk.config.ts, a build artifact, or source control. The intent names the provider; nothing more.

Resolution order per credential, highest first:

  • Explicit argumentcreateZazuAdapter({ apiKey })
  • Environment variableZAZU_API_KEY
  • Secrets Manager — the matching field in ZAZU_SECRET_ARN

Precedence is per credential, not all-or-nothing: exporting only ZAZU_API_KEY locally while the rest still resolve from the deployed secret works fine.

The secret is read lazily, once per container, on the first credential access — not at construction. Adapter creation stays synchronous and cold starts pay nothing when a request never touches billing. A failed read is not cached, so a transient Secrets Manager error doesn't poison the container.

Post-deploy: populate the credentials (DevOps)

VentureKit provisions the secret with every field set to PLACEHOLDER. Until a DevOps engineer populates it, billing calls fail loudly with the exact command to run — by design, rather than surfacing later as an opaque 401 or a webhook signature mismatch.

After the first deploy, resolve the ARN and paste the real values:

# The stack publishes the ARN to SSM:
aws ssm get-parameter \
  --name "/venturekit/<project>/<stage>/config/billing/main/secret-arn" \
  --query Parameter.Value --output text

aws secretsmanager put-secret-value \
  --secret-id <arn-from-above> \
  --secret-string '{"apiKey":"…","accountId":"…","webhookSecret":"…"}'

Values take effect on the next container start; no redeploy is needed. Rotation is the same command. Redeploys never overwrite what you paste — the secret is generated at create only.

Capabilities

Core operations — checkout sessions, payment links, inbound webhooks — are always available. Everything else is an optional capability an adapter opts into, so a future Stripe or PayPal adapter never stubs methods it has no equivalent for.

| Capability | Operations | Zazu | Mock | | --- | --- | --- | --- | | core | checkout sessions, payment links, webhooks | ✅ | ✅ | | invoicing | invoices + customers | ✅ | ✅ | | transfers | outgoing transfers | ✅ | ✅ | | accounts | funding accounts + ledger entries | ✅ | ✅ | | webhookEndpoints | endpoint provisioning | ✅ | ✅ |

Feature-detect, or assert:

if (billing.accounts) {
  const page = await billing.accounts.listAccounts();
}

const invoice = await billing.requireInvoicing().createInvoice({ /* … */ });

Webhooks

handleWebhookEvent verifies the signature and returns a normalized PaymentEvent, ready to publish to your event bus.

import { WebhookPayloadError, WebhookSignatureError } from '@venturekit/billing';
import { publishEvent } from '@venturekit/runtime/patterns';

try {
  const event = await billing.handleWebhookEvent({ rawBody, headers });
  await publishEvent(event.type, event.data, { source: 'billing' });
  return { statusCode: 200 };
} catch (err) {
  if (err instanceof WebhookSignatureError) return { statusCode: 401 };
  if (err instanceof WebhookPayloadError) return { statusCode: 400 };
  throw err;
}

Four things the caller must get right:

  • Pass the raw body. Signatures are computed over the bytes the provider sent. Re-serializing the parsed JSON breaks verification.
  • Keep the two error classes apart. WebhookSignatureError is an auth failure; WebhookPayloadError is an authentic request this adapter can't consume. Answering 401 to an unmapped-but-authentic event makes the provider redeliver it forever — Zazu retries for ~28 hours before giving up.
  • Deduplicate on event.eventId. Providers deliver at-least-once. Signature verification rejects anything outside a 300-second window, which stops indefinite replay — but not a redelivery inside it.
  • Reconcile through correlation, not the payload. An event carries only what its payload is documented to include — never a reconstructed REST object. Use event.correlation to find your own record, and check event.amount before fulfilling.

Each event's data is narrowed to that payload's documented fields:

| Event | data carries | | --- | --- | | checkout_session.completed | sessionId, status, completedAt?, customerEmail?, description?, transaction? | | payment_link.paid | paymentLinkId, status, slug?, title?, paidAt?, paymentsCount?, customer fields | | transfer.executed | transferId, providerStatus, operation?, paymentReference?, postedAt? |

if (isCheckoutSessionCompleted(event)) {
  const order = await orders.findByReference(event.correlation?.reference);
  if (event.amount?.amount !== order.total) throw new Error('amount mismatch');
  await fulfil(order, event.data.transaction?.cardLastFour);
}

Note transfer.executed exposes providerStatus verbatim (Zazu sends "accepted") rather than coercing it into the transfers API's requested|processing|completed|failed vocabulary, which would misreport it. It carries no accountId — the payload has none, so resolve the source account from your own record via correlation.

Retries and idempotency

The Zazu HTTP client retries reads only. Zazu exposes no idempotency-key header, so a POST whose response was merely lost cannot be safely repeated — a retry creates a second resource. maxRetries therefore applies to GET requests; mutations fail fast with a ProviderError carrying a transient flag, letting callers that own an application-level idempotency key retry deliberately.

Worth knowing what the duplicate actually is: POST /api/payments creates a transfer draft in requested status for a human to approve in the Zazu app — an API key alone cannot move money. So a retried transfer duplicates entries in the approval queue (which an approver may authorize more than once), rather than settling twice. POST /api/invoices is the sharper edge: duplicates are sent to the customer.

try {
  await billing.requireTransfers().createTransfer({ /* … */ });
} catch (err) {
  if (err instanceof ProviderError && err.transient) {
    // Safe to retry only if you can guarantee idempotency yourself.
  }
}

Testing

createMockProvider() is an in-memory provider implementing the full surface. It records every call and can be told to fail.

It is reachable only by subpath — deliberately not re-exported from the package barrel, so it stays out of production bundles:

import { createBillingClientFor } from '@venturekit/billing';
import { createMockProvider } from '@venturekit/billing/providers/mock';

const provider = createMockProvider({ failNext: 1 });
const billing = createBillingClientFor(provider);

expect(provider.calls.map((c) => c.method)).toContain('createCheckoutSession');

The mock cannot run in a deployed environment

It accepts webhooks without verifying any signature and fabricates successful payments, so three things keep it out of production:

  • Not in the barrel. Importing @venturekit/billing does not pull it in.
  • No self-registration. It does not register itself on import, so createProvider('mock') throws UnknownProviderError unless you call registerMockProvider() yourself. This also means BILLING_PROVIDER=mock is not a working configuration.
  • A construction guard. createMockProvider() throws MockProviderNotAllowedError unless VENTURE_LOCAL=true (set by vk dev) or NODE_ENV=test (set by vitest).

The guard asks "is this provably local?" rather than "is this production?" on purpose. The Lambda runtime does not set NODE_ENV=production, so a NODE_ENV === 'production' check reads false in production and fails open — the same trap that once shipped session cookies without Secure (see packages/auth/src/server/cookies.ts). Asking for positive proof of a local environment fails closed instead.

Adding a provider

No core file needs editing:

  1. Add src/providers/<name>/ with config.ts, client.ts, maps.ts, adapter.ts, correlation.ts, and webhooks.ts — mirroring providers/zazu/.
  2. Implement PaymentProvider, plus any optional capability interfaces the provider genuinely supports.
  3. Implement correlationSupport(flow) honestly. If a flow cannot round-trip metadata, report false and throw UnsupportedCorrelationError when it is supplied — never accept data the provider will discard.
  4. Round-trip correlation.reference on every flow, and populate correlation, objectId, amount, and raw on every event.
  5. Map provider errors to ProviderError with transient set correctly, and throw WebhookSignatureError / WebhookPayloadError from webhook handling.
  6. Call registerProvider('<name>', factory) in the provider's barrel.
  7. Add a ./providers/<name> entry to exports in package.json.
  8. Re-export from src/providers/index.ts.

License

Apache-2.0 — see LICENSE for details.