Skip to content

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

Terminal window
npm install @venturekit/billing

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.

vk.config.ts
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):

Terminal window
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.

src/lib/billing.ts
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.

VariableRequiredDefaultPurpose
BILLING_PROVIDERnozazuProvider selected by createBillingClientFromEnv()
ZAZU_SECRET_ARNno—Secrets Manager entry holding {apiKey, accountId, webhookSecret}; injected by the intent, makes the three below optional
ZAZU_API_KEYunless ZAZU_SECRET_ARN—Bearer token for the Zazu API
ZAZU_ACCOUNT_IDeffectively yes—Default funding/destination account
ZAZU_BASE_URLnohttps://zazu.maAPI host — https://zazu.africa for South Africa, https://staging.zazu.ma for staging
ZAZU_WEBHOOK_SECRETfor webhooks—Endpoint signing secret
ZAZU_WEBHOOK_TOLERANCE_SECONDSno300Replay window; 0 disables the freshness check
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.url

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:

FlowZazu channelreferencemetadata
checkout sessionmetadata map in the create body✅✅ (≤50 keys, values ≤500 chars)
payment linkclient_reference_id URL query parameter✅❌ — passing it throws UnsupportedCorrelationError
transferpayment_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.

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:

Eventdata carries
checkout_session.completedsessionId, status, completedAt?, customerEmail?, description?, transaction?
payment_link.paidpaymentLinkId, status, slug?, title?, paidAt?, paymentsCount?, customer fields
transfer.executedtransferId, providerStatus (verbatim, e.g. "accepted"), operation?, paymentReference?, postedAt? — no accountId; resolve it via correlation

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:

CapabilityOperationsZazuMock
corecheckout sessions, payment links, webhooks✅✅
invoicinginvoices + customers✅✅
transfersoutgoing transfers✅✅
accountsfunding accounts + ledger entries✅✅
webhookEndpointsendpoint 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)

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

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.