@venturekit/billing
@venturekit/billing is a provider-agnostic abstraction over payment processors — Zazu today; Stripe and PayPal are the intended next adapters — covering hosted checkout, payment links, invoicing, transfers and normalized webhook events.
Installation
Section titled “Installation”npm install @venturekit/billing1. Declare the intent
Section titled “1. Declare the intent”Declare a billing intent and VentureKit wires the credentials: it provisions a Secrets Manager entry ({apiKey, accountId, webhookSecret}, every field set to PLACEHOLDER), grants the Lambda role secretsmanager:GetSecretValue on that one secret, injects ZAZU_SECRET_ARN, and publishes the ARN to SSM. No credential ever passes through vk.config.ts, a build artifact or source control.
export default defineVenture({ base, billing: [{ id: 'main', provider: 'zazu' }], envs: { dev, prod },});After the first deploy, an operator pastes the real values (rotation is the same command; redeploys never overwrite them):
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":"…"}'Until the secret is populated, billing calls fail loudly with the exact command to run — by design, rather than as a later opaque 401 or webhook signature mismatch. Values take effect on the next container start.
2. Create the client
Section titled “2. Create the client”import { createBillingClient, createZazuAdapter } from '@venturekit/billing';
// createZazuAdapter() resolves config from ZAZU_* env vars / ZAZU_SECRET_ARN.export const billing = createBillingClient({ providers: { zazu: createZazuAdapter() }, defaultProvider: 'zazu',});
// or, entirely from the environment (BILLING_PROVIDER, default 'zazu'):// export const billing = createBillingClientFromEnv();The funding account comes from ZAZU_ACCOUNT_ID — a deployment property — not from every call site; pass accountId explicitly only to override it (multi-account tenants). Credentials resolve per field, highest precedence first: explicit argument (createZazuAdapter({ apiKey })) → environment variable (ZAZU_API_KEY) → the matching field in ZAZU_SECRET_ARN. The secret is read lazily, once per container, on first access; a failed read is not cached.
| Variable | Required | Default | Purpose |
|---|---|---|---|
BILLING_PROVIDER | no | zazu | Provider selected by createBillingClientFromEnv() |
ZAZU_SECRET_ARN | no | — | Secrets Manager entry holding {apiKey, accountId, webhookSecret}; injected by the intent, makes the three 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_BASE_URL | no | https://zazu.ma | API host — https://zazu.africa for South Africa, https://staging.zazu.ma for staging |
ZAZU_WEBHOOK_SECRET | for webhooks | — | Endpoint signing secret |
ZAZU_WEBHOOK_TOLERANCE_SECONDS | no | 300 | Replay window; 0 disables the freshness check |
3. Take a payment
Section titled “3. Take a payment”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. correlation: { reference: order.id, metadata: { user_id: user.id } },});// → redirect the customer to session.urlCorrelation
Section titled “Correlation”Webhook payloads are projections — each carries a strict subset of what the REST API returns, and the subset differs per event — so the package never rebuilds a full object from an event. Attach your own identifiers on create; the event hands them back:
const event = await billing.handleWebhookEvent({ rawBody, headers });const order = await orders.findByReference(event.correlation?.reference);
// Correlation says WHICH order — verify the amount before fulfilling.if (event.amount?.amount !== order.total) throw new Error('amount mismatch');reference round-trips on every flow; metadata does not — query billing.adapter.correlationSupport(flow) rather than assuming:
| 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 | ✅ | ❌ — passing it throws UnsupportedCorrelationError |
| transfer | payment_reference in the create body (visible on bank statements) | ✅ | ❌ |
Payment links: the reference is appended to the returned url, so share that exact URL. Transfers: reference cannot be combined with an explicit paymentReference — setting both throws.
4. Handle webhooks
Section titled “4. Handle webhooks”handleWebhookEvent() verifies the signature and returns a normalized PaymentEvent:
import { handler, rawResult } from '@venturekit/runtime';import { publishEvent } from '@venturekit/runtime/patterns';import { isCheckoutSessionCompleted, WebhookPayloadError, WebhookSignatureError } from '@venturekit/billing';import { billing } from '../../lib/billing';
export const main = handler(async (_body, ctx) => { const respond = (statusCode: number) => rawResult({ statusCode, body: '' }); try { const event = await billing.handleWebhookEvent({ rawBody: ctx.rawEvent.body ?? '', // the bytes the provider sent — never re-serialized JSON headers: ctx.rawEvent.headers, }); if (await alreadySeen(event.eventId)) return respond(200); // at-least-once delivery if (isCheckoutSessionCompleted(event)) { await fulfil(event.correlation?.reference, event.amount, event.data.transaction?.cardLastFour); } await publishEvent(event.type, event.data, { source: 'billing' }); return respond(200); } catch (err) { if (err instanceof WebhookSignatureError) return respond(401); // auth failure if (err instanceof WebhookPayloadError) return respond(400); // authentic, but not consumable — don't 401 or Zazu redelivers for ~28h throw err; }});Every event carries type, eventId (deduplicate on this — Zazu retries up to 8 times over ~28 hours), objectId (the provider’s id for follow-up calls), correlation, amount and raw. The narrowing guards isCheckoutSessionCompleted, isPaymentLinkPaid and isTransferExecuted type event.data:
| 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 (verbatim, e.g. "accepted"), operation?, paymentReference?, postedAt? — no accountId; resolve it via correlation |
Capabilities
Section titled “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 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 | ✅ | ✅ |
if (billing.accounts) { // feature-detect (null when unsupported)… const page = await billing.accounts.listAccounts();}const invoice = await billing.requireInvoicing().createInvoice({ /* … */ }); // …or assert (throws CapabilityNotSupportedError)Retries and idempotency
Section titled “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 would create a second resource. Mutations fail fast with a ProviderError carrying a transient flag, so callers that own an application-level idempotency key can retry deliberately. (A retried transfer duplicates a draft in Zazu’s approval queue; a retried invoice is sent to the customer twice.)
Testing
Section titled “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 — not re-exported from the 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');Three guards keep it out of deployed environments: it is not in the barrel, it does not self-register (createProvider('mock') throws UnknownProviderError unless you call registerMockProvider(), so BILLING_PROVIDER=mock is not a working configuration), and createMockProvider() throws MockProviderNotAllowedError unless VENTURE_LOCAL=true (set by vk dev) or NODE_ENV=test — asking for positive proof of a local environment so the check fails closed.
Related
Section titled “Related”- API Reference — every export
- Billing Intent — the
billing[]declaration @venturekit-pro/billing— plans, usage and feature gating on top of these payments