Testing provider
Try revenue-sdk with no account, no API keys, no fetch stubs and no test cards — a seedable in-memory provider from revenue-sdk/testing.
revenue-sdk/testing ships createInMemoryProvider, a seedable in-memory RevenueProvider you hand to
createClient like any other: no account, no API keys, no fetch stubs, no sandbox, no test cards.
Every client.* example in these docs runs unchanged against it — it implements the same
RevenueProvider interface, and it reports every capability enabled by default.
Basic usage
import { createClient } from 'revenue-sdk';
import { createInMemoryProvider } from 'revenue-sdk/testing';
const provider = createInMemoryProvider({
products: [{ id: 'pro', name: 'Pro', prices: [{ amount: 2900, interval: 'month' }] }],
customers: [{ id: 'cus-1', email: '[email protected]' }],
subscriptions: [{ id: 'sub-1', customerId: 'cus-1', productId: 'pro', status: 'active' }],
});
const client = createClient({ provider });
const subscription = await client.subscriptions.get({ id: 'sub-1' });
subscription.status; // 'active'
The seed is optional — createInMemoryProvider() starts empty, and anything looked up by an ID that
isn’t seeded throws RevenueError with code not_found.
Simulating a provider and its capabilities
A second options argument simulates a specific provider’s identity and its capability gaps, so code
that branches on client.capabilities is testable:
const provider = createInMemoryProvider(
{},
{
name: 'paddle',
capabilities: {
hostedCheckout: false,
checkoutSuccessUrl: false,
checkoutExpiresAt: false,
revoke: false,
},
},
);
const client = createClient({ provider });
client.providerName; // 'paddle'
await client.subscriptions.revoke({ id: 'sub-1' }); // throws RevenueError { code: 'unsupported' }
| Option | Type | Default | Description |
|---|---|---|---|
name |
ProviderName |
'testing' |
Simulate a specific provider’s identity. Also tags the pagination cursors. |
capabilities |
Partial<RevenueCapabilities> |
— | Merged over the full-featured defaults — override only what your test needs. |
Check the capability matrix for the values each real provider reports.
Asserting against state
The provider exposes its data on provider.state, so you can assert on the effects of your code:
import { expect, test } from 'vitest';
import { createClient } from 'revenue-sdk';
import { createInMemoryProvider } from 'revenue-sdk/testing';
test('cancelling schedules the end of the period', async () => {
const provider = createInMemoryProvider({
customers: [{ id: 'cus-1', email: '[email protected]' }],
subscriptions: [
{ id: 'sub-1', customerId: 'cus-1', currentPeriodEnd: new Date('2026-09-01') },
],
});
const client = createClient({ provider });
const subscription = await client.subscriptions.cancel({ id: 'sub-1' });
expect(subscription.cancelAtPeriodEnd).toBe(true);
expect(subscription.endsAt).toEqual(new Date('2026-09-01'));
expect(provider.state.subscriptions[0]!.cancelAtPeriodEnd).toBe(true);
});
test('creating a checkout records it', async () => {
const provider = createInMemoryProvider({ products: [{ id: 'pro' }] });
const client = createClient({ provider });
const checkout = await client.checkouts.create({
items: [{ product: 'pro' }],
customerEmail: '[email protected]',
metadata: { userId: 'user_123' },
});
expect(checkout.status).toBe('open');
expect(provider.state.checkouts[0]!.metadata).toEqual({ userId: 'user_123' });
});
provider.state is { products: Product[], customers: Customer[], subscriptions: Subscription[], orders: Order[], licenseKeys: LicenseKey[], checkouts: Checkout[], usageEvents: InMemoryUsageEvent[] }.
Mutating operations write straight into it, so the seeded objects and the returned ones are the same
instances.
Reported usage events
usage.report appends to state.usageEvents, so you can assert on what your code would have billed:
const provider = createInMemoryProvider();
await createClient({ provider }).usage.report({
customerId: 'cus-1',
eventName: 'api_request',
value: 25,
metadata: { region: 'eu' },
});
expect(provider.state.usageEvents[0]).toEqual({
customerId: 'cus-1',
eventName: 'api_request',
payload: { value: 25, region: 'eu' },
idempotencyKey: undefined,
timestamp: undefined,
});
customerId?string
The reported customer.
stringeventName?string
The reported meter event name.
stringpayload?Record<string, string | number | boolean>
metadata with value already merged in — exactly what a real provider receives. {} when neither was given.
Record<string, string | number | boolean>idempotencyKey?string
The key as passed, undefined when omitted. The in-memory provider does not deduplicate.
stringtimestamp?Date
The timestamp as passed, undefined when omitted.
DateThe type is exported as InMemoryUsageEvent. Override capabilities: { usageReporting: false } to
exercise the Lemon Squeezy and Paddle fallback path — see
Usage-based billing.
Seeded orders
orders seeds surface on state.orders, filtered by customerId when you pass one, and paginated like
everything else. orders.getInvoiceUrl returns a deterministic fake —
https://invoices.example.com/<order id> — for any seeded order, and throws not_found for an unknown
one. Override capabilities: { listOrdersByCustomer: false } to exercise the Lemon Squeezy path, where
orders.list({ customerId }) throws unsupported.
const provider = createInMemoryProvider({
orders: [
{ id: 'ord-1', customerId: 'cus-1', amount: 2900, currency: 'usd', status: 'paid' },
{ id: 'ord-2', customerId: 'cus-1', amount: 2900, status: 'partially_refunded', refundStatus: 'partial' },
],
});
const { items } = await createClient({ provider }).orders.list({ customerId: 'cus-1' });
expect(items.map((order) => order.status)).toEqual(['paid', 'partially_refunded']);
Seeded license keys
licenseKeys seeds surface on state.licenseKeys, and licenseKeys.update writes into them. Override
capabilities: { licenseKeys: false } to exercise the Stripe and Paddle fallback path.
const provider = createInMemoryProvider({
licenseKeys: [{ id: 'lk-1', key: 'AAAA-BBBB', activationLimit: 3 }],
});
await createClient({ provider }).licenseKeys.update({ id: 'lk-1', disabled: true });
expect(provider.state.licenseKeys[0]!.status).toBe('disabled');
Testing webhook handlers
signWebhook signs a raw body the way a real provider does and returns the exact headers that
provider’s verifyWebhook accepts, so your handler test runs the real verification path instead of
skipping it:
import { expect, test } from 'vitest';
import { signWebhook } from 'revenue-sdk/testing';
import { handleWebhook } from './webhook-handler.ts';
const SECRET = 'whsec_ovyN6cPrTv56AApvzCaJno08SSmGJmgb';
test('a paid order is recorded', async () => {
const body = JSON.stringify({
type: 'order.paid',
data: { id: 'order-1', status: 'paid', total_amount: 2900, currency: 'usd' },
});
const headers = await signWebhook({ provider: 'polar', secret: SECRET, body });
const response = await handleWebhook(
new Request('https://example.com/webhooks/polar', { method: 'POST', headers, body }),
);
expect(response.status).toBe(200);
});
Sign the exact string your handler will read — every provider signs the raw bytes, so re-serializing the JSON in between invalidates the signature.
provider?'polar' | 'lemon-squeezy' | 'stripe' | 'paddle' | 'dodo-payments'
Whose signature scheme to use. The in-memory provider sends no webhooks.
'polar' | 'lemon-squeezy' | 'stripe' | 'paddle' | 'dodo-payments'secret?string
The signing secret, exactly as the provider's dashboard shows it — the same value your handler verifies with.
stringbody?string
The raw request body.
stringtimestamp?Date
Defaults to now. Back-date it by more than 300 seconds to exercise your replay rejection.
Dateid?string
Message ID for the Standard Webhooks providers (Polar, Dodo Payments). Defaults to a random msg_ id.
stringThe returned record uses lowercase header names: webhook-id / webhook-timestamp /
webhook-signature for Polar and Dodo Payments, stripe-signature for Stripe, paddle-signature for
Paddle, and x-signature for Lemon Squeezy.
Pagination is exercised
The in-memory provider uses a deliberately small page size of 2 and ignores limit, so a seed with
three or more items returns a cursor and your cursor-following code runs in tests rather than always
fitting on one page:
const provider = createInMemoryProvider({
products: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
});
const client = createClient({ provider });
const first = await client.products.list();
first.items.length; // 2
first.cursor; // defined — there's a third product
let count = 0;
for await (const _ of client.products.listAll()) count++;
count; // 3
Cursors are tagged with the simulated provider name, so they behave exactly like real ones — including
rejecting a cursor from a different provider.
Seed reference
products, customers, subscriptions, orders, and licenseKeys are arrays; every field is
optional and gets a sensible default. Missing IDs are generated (product-1, price-2, customer-3,
…).
products?InMemoryProductSeed[]
{ id?, name?, description?, prices? }. name defaults to the id.
InMemoryProductSeed[]customers?InMemoryCustomerSeed[]
{ id?, email?, name?, metadata? }. email defaults to <id>@example.com.
InMemoryCustomerSeed[]subscriptions?InMemorySubscriptionSeed[]
{ id?, status?, cancelAtPeriodEnd?, pauseAtPeriodEnd?, customerId?, productId?, quantity?, currency?, amount?, interval?, currentPeriodEnd?, trialEndsAt?, resumesAt?, endsAt?, metadata? }.
InMemorySubscriptionSeed[]orders?InMemoryOrderSeed[]
{ id?, status?, amount?, currency?, customerId?, customerEmail?, subscriptionId?, createdAt?, refundStatus?, metadata? }. status defaults to paid; nothing else is defaulted, so an unseeded field stays undefined.
InMemoryOrderSeed[]licenseKeys?InMemoryLicenseKeySeed[]
{ id?, key?, status?, activationLimit?, activationCount?, expiresAt?, customerId?, productId? }. key defaults to the id, status to active.
InMemoryLicenseKeySeed[]Price seeds default to a fixed, recurring, monthly price of 1000 in usd, and checkoutRef
defaults to the parent product’s ID:
id?string
Generated when omitted.
stringcheckoutRef?string
Defaults to the parent product's id.
stringtype?'one_time' | 'recurring'
Price type.
'one_time' | 'recurring'recurringmodel?PriceModel
Pricing model.
PriceModelfixedamount?number | null
Minor units. Pass null explicitly for non-fixed models.
number | null1000currency?string
Lowercase ISO 4217 code.
stringusdinterval?BillingInterval
Omitted when type is 'one_time'.
BillingIntervalmonthintervalCount?number
Intervals per billing cycle.
numbertrialDays?number
Trial length in days.
numberBehavior of the mutating operations
| Call | Effect on state |
|---|---|
checkouts.create |
Appends a Checkout with a generated ID, status: 'open', a fake URL, and expiresAt carried through from the params. |
subscriptions.cancel |
Sets cancelAtPeriodEnd: true and endsAt = currentPeriodEnd. Status unchanged. |
subscriptions.uncancel |
Clears cancelAtPeriodEnd and endsAt. |
subscriptions.changePlan |
Sets productId to params.product, and quantity when given. |
subscriptions.endTrial |
Sets status: 'active' and clears trialEndsAt. |
subscriptions.pause |
With behavior: 'period_end' sets pauseAtPeriodEnd: true and leaves the status alone; otherwise sets status: 'paused'. Stores resumesAt when given. |
subscriptions.resume |
Sets status: 'active', clears pauseAtPeriodEnd and resumesAt. |
subscriptions.revoke |
Sets status: 'canceled', clears cancelAtPeriodEnd, and sets endedAt. |
customerPortal.createSession |
Returns a fake portal URL, or throws not_found for an unknown customer. |
usage.report |
Appends an InMemoryUsageEvent; value is merged into payload. |
licenseKeys.update |
Sets status to disabled/active from disabled, and writes activationLimit and expiresAt when given (null clears them). |