@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.
Maintainers
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@devQuick 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.urlThe 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. PassingmetadatathrowsUnsupportedCorrelationErrorrather 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 explicitpaymentReference; 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 argument —
createZazuAdapter({ apiKey }) - Environment variable —
ZAZU_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.
WebhookSignatureErroris an auth failure;WebhookPayloadErroris an authentic request this adapter can't consume. Answering401to 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. Useevent.correlationto find your own record, and checkevent.amountbefore 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/billingdoes not pull it in. - No self-registration. It does not register itself on import, so
createProvider('mock')throwsUnknownProviderErrorunless you callregisterMockProvider()yourself. This also meansBILLING_PROVIDER=mockis not a working configuration. - A construction guard.
createMockProvider()throwsMockProviderNotAllowedErrorunlessVENTURE_LOCAL=true(set byvk dev) orNODE_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:
- Add
src/providers/<name>/withconfig.ts,client.ts,maps.ts,adapter.ts,correlation.ts, andwebhooks.ts— mirroringproviders/zazu/. - Implement
PaymentProvider, plus any optional capability interfaces the provider genuinely supports. - Implement
correlationSupport(flow)honestly. If a flow cannot round-tripmetadata, reportfalseand throwUnsupportedCorrelationErrorwhen it is supplied — never accept data the provider will discard. - Round-trip
correlation.referenceon every flow, and populatecorrelation,objectId,amount, andrawon every event. - Map provider errors to
ProviderErrorwithtransientset correctly, and throwWebhookSignatureError/WebhookPayloadErrorfrom webhook handling. - Call
registerProvider('<name>', factory)in the provider's barrel. - Add a
./providers/<name>entry toexportsinpackage.json. - Re-export from
src/providers/index.ts.
License
Apache-2.0 — see LICENSE for details.
