First-party plugins

Stripe plugin

Supported Stripe SDK resources, recurring billing, virtual-time renewal, signed webhooks, and deliberate differences.

@localhost2137/stripe models one stateful local Stripe account for a fixed recurring-billing slice. It persists customers, products, prices, subscriptions, invoices, events, and webhook attempts in SQLite. The useful workflow is deliberately narrow: create or seed a catalog, subscribe a customer, cross exact billing boundaries, and exercise the application's invoice webhook behavior.

Use this page as a compatibility boundary. The plugin does not emulate general payment processing, and an SDK method resolving at runtime does not make an unlisted resource supported.

Install

Install the runtime, plugin, and runtime host peers as development dependencies. Install Stripe Node as an application dependency:

The plugin uses better-sqlite3. Before installing, add its project-scoped build permission to pnpm-workspace.yaml at the project root, or merge it into the existing allowBuilds map:

allowBuilds:
  better-sqlite3: true
pnpm add -D localhost2137 @localhost2137/stripe hono@^4.13.4 zod@^4.4.3
pnpm add stripe@22.5.0

Omit the second command when the application already has that exact tested client version.

Configure the account

localhost.config.ts
import { stripe } from "@localhost2137/stripe";
import { defineConfig } from "localhost2137";

export default defineConfig({
	clock: { mode: "pinned", startAt: "2026-01-01T00:00:00.000Z" },
	services: {
		stripe: stripe({
			config: {
				secretKey: "sk_test_local_sdk",
				webhookSecret: "whsec_local_sdk",
				webhookUrl: null,
			},
		}),
	},
});

This is the checked SDK example config. Set webhookUrl to the application's receiver for webhook scenarios. Seed data is optional; the same factory accepts the seed fields below.

Config fieldContract
secretKeyRequired key beginning with sk_test_; used for API bearer authentication.
webhookSecretRequired key beginning with whsec_; used to sign webhook bodies.
webhookUrlAbsolute callback URL or null; defaults to null. No webhook delivery row is created when it is null.

Seed customers accept name, optional email, and optional id. Seed products accept name and optional id. Seed prices accept a product ID, a non-negative integer unitAmount, optional ID, and a lowercase three-letter currency that defaults to usd. Products are created before prices, so a seeded price can refer to a seeded product. Subscriptions, invoices, events, and delivery attempts cannot be seeded.

The seed is used only when instance creation requests it and is applied as one plugin transaction. An invalid product reference or record rolls back the Stripe seed. See Seeding for the instance-level seed lifecycle and recovery rules.

Connection values

Each instance exposes these typed values under instance[serviceKey].connection:

ValueEnvironment projectionUse
apiUrlSTRIPE_API_URLInstance-scoped service URL without /v1; pass it to the SDK fetch adapter or append an HTTP path.
secretKeySTRIPE_SECRET_KEYBearer key for the local API.
webhookSecretSTRIPE_WEBHOOK_SECRETSecret shared with the application's webhook receiver.

The webhook destination is not a connection value. webhookUrl belongs to mount configuration, so all instances of this mount deliver to the same receiver. Event IDs are allocated within an instance and the same values can recur in another instance; they do not route a shared receiver by themselves. Serialize such scenarios, use application data that genuinely distinguishes them, or configure separate runtimes. The runtime does not add an instance header or rewrite the URL.

Use the official Node SDK

Stripe's Node client fixes requests to api.stripe.com, while a localhost2137 service URL contains instance and service path segments. createStripeSdkFetch replaces the request origin with that service URL and preserves its path, query, method, headers, and body:

src/local-stripe.ts
import { createStripeSdkFetch } from "@localhost2137/stripe";
import Stripe from "stripe";

export interface LocalStripeConnection {
	readonly apiUrl: string;
	readonly secretKey: string;
}

/** Builds the official Stripe SDK against one localhost2137 instance-scoped account. */
export function createLocalStripe(connection: LocalStripeConnection): Stripe {
	return new Stripe(connection.secretKey, {
		httpClient: Stripe.createFetchHttpClient(createStripeSdkFetch(connection.apiUrl)),
		maxNetworkRetries: 0,
	});
}

The adapter accepts an HTTP or HTTPS service URL without a query or fragment. It performs routing only: it does not add resources, reshape responses, emulate idempotency, or implement SDK retry behavior. maxNetworkRetries: 0 keeps application tests explicit and prevents a client retry from being confused with plugin webhook delivery.

The repository integration example exercises Stripe Node 22.5.0 across customer and subscription creation, a typed missing-customer error, a 30-day renewal, and filtered invoice listing:

test/subscription.test.ts
import { createTestRuntime } from "localhost2137/testing";
import { afterEach, describe, expect, it } from "vitest";
import config from "../localhost.config.js";
import { createLocalStripe } from "../src/local-stripe.js";

const runtimes: Array<Awaited<ReturnType<typeof createTestRuntime>>> = [];

afterEach(async () => {
	await Promise.all(runtimes.splice(0).map((runtime) => runtime.close()));
});

describe("official Stripe SDK", () => {
	it("creates and renews a subscription through normal SDK calls", async () => {
		const runtime = await createTestRuntime({ config, port: 0, storage: "temporary" });
		runtimes.push(runtime);
		const instance = await runtime.createInstance();
		try {
			const product = await instance.stripe.createProduct({ name: "Pro" });
			const price = await instance.stripe.createPrice({
				productId: product.id,
				unitAmount: 2_500,
			});
			const client = createLocalStripe(instance.stripe.connection);

			const customer = await client.customers.create({
				email: "ada@example.test",
				name: "Ada",
			});
			await expect(client.customers.retrieve("cus_missing")).rejects.toMatchObject({
				code: "customer_missing",
				statusCode: 404,
				type: "StripeInvalidRequestError",
			});
			const subscription = await client.subscriptions.create({
				customer: customer.id,
				items: [{ price: price.id }],
			});
			expect(subscription).toMatchObject({
				customer: customer.id,
				id: "sub_000001",
				latest_invoice: "in_000001",
				status: "active",
			});

			await instance.clock.advance("30d");
			const invoices = await client.invoices.list({
				limit: 10,
				subscription: subscription.id,
			});
			expect(invoices.data.map(({ id }) => id)).toEqual(["in_000001", "in_000002"]);
			expect(invoices.data[1]).toMatchObject({
				amount_paid: 2_500,
				customer: customer.id,
				paid: true,
				subscription: subscription.id,
			});
		} finally {
			await instance.destroy();
		}
	});
});
pnpm exec vitest run test/subscription.test.ts

Plugin compatibility tests cover the other routes and webhook behavior without claiming the SDK exercises them all. Other client versions and methods remain unverified until exercised.

Supported HTTP surface

All routes require the exact Authorization: Bearer <secretKey> header. POST bodies must use application/x-www-form-urlencoded. List routes accept limit from 1 through 100 (default 10) and an opaque starting_after ID from the same resource collection. Results follow durable creation order.

Method and pathSupported behavior
POST /v1/customersCreate a customer from required name and optional email.
GET /v1/customers/:idRetrieve one customer.
GET /v1/customersList customers with cursor pagination.
GET /v1/products/:idRetrieve one control-created or seeded product.
GET /v1/productsList products with cursor pagination.
GET /v1/prices/:idRetrieve one control-created or seeded recurring price.
GET /v1/pricesList prices with cursor pagination.
POST /v1/subscriptionsCreate one subscription from customer and items[0][price]; immediately create and pay its first invoice.
GET /v1/subscriptions/:idRetrieve one subscription and its single item.
DELETE /v1/subscriptions/:idCancel immediately; a repeated cancellation returns the already canceled subscription.
GET /v1/invoices/:idRetrieve one invoice with its single line item.
GET /v1/invoicesList invoices; optional customer and subscription filters combine with pagination.

Products and prices are intentionally read-only through HTTP in the current slice. Create them with seed data or control operations, then let the application use normal Stripe reads. Supported responses include the fields needed by the workflow above; they are not complete Stripe resource objects.

Authentication failures return HTTP 401. Missing resources return 404, and invalid form or parameters return 400. Errors use Stripe-shaped { error: { code, message, param?, type: "invalid_request_error" } } envelopes. Unsupported routes return not found rather than a plausible success.

Control operations

OperationInput and effect
createCustomerRequired name, optional email; returns the local customer.
createProductRequired name; creates an active product.
createPriceproductId, non-negative integer unitAmount, optional currency (default usd); creates one active fixed recurring price.
createSubscriptioncustomerId and priceId; creates an active subscription, paid initial invoice, and invoice.paid event.
listInvoicesOptional customerId and subscriptionId; returns matching invoices in creation order.
listEventsOptional type filter for invoice.paid or invoice.payment_failed; returns stable event IDs and invoice IDs.
setNextPaymentOutcomesubscriptionId and outcome set to succeeded or failed; consumes that choice once at the next renewal invoice.

HTTP routes and operations use the same account state, but they prove different boundaries. Arrange the catalog through operations, make customer or subscription calls through the application's SDK when those are the behavior under test, and use list operations for assertions. Discover the exact installed input and output schemas through scoped describe and operation-specific help; see Using plugins.

Control failures use localhost2137 codes such as STRIPE_PRODUCT_MISSING and STRIPE_SUBSCRIPTION_CANCELED. Application-facing HTTP returns the Stripe-shaped status and error envelope described above.

Renewal through virtual time

Every subscription has one fixed-price item with quantity one and an exact 30-day period. Creation issues the first invoice immediately. Each positive time advance reconciles every crossed boundary in stable order, so one 90-day jump creates the same three renewal periods as three 30-day jumps. The checked SDK test above shows a successful boundary. To test failure, call setNextPaymentOutcome for the created subscription before the same clock.advance("30d") call, then inspect listInvoices or listEvents through the typed instance handle.

The forced failure is consumed once: it creates an open invoice and invoice.payment_failed; later invoices return to paid unless another outcome is set. Canceling a subscription stops future renewal but does not remove earlier invoices or events.

The runtime gives every durable advance an identity. Replaying the same identity and time window is idempotent; replaying that identity with a different window is rejected. Invoice, event, next-period, and configured webhook-outbox changes commit in one transaction before delivery begins.

Webhook delivery and recovery

When webhookUrl is configured, each invoice event produces one JSON POST with a stable event ID, invoice.paid or invoice.payment_failed type, embedded invoice object, and Stripe-Signature header. The event advertises the local API version 2026-08-20.localhost2137; it does not claim a provider API version.

An attempt has a three-second timeout and any 2xx response counts as success. An ordinary non-2xx, transport error, or timeout is recorded as a terminal failure; this plugin does not schedule a retry for it. await instance.idle() establishes that currently tracked delivery work settled, not that the receiver accepted it. Read the Stripe webhook delivery completed. plugin log when the outcome matters.

There is one crash-recovery exception to the no-retry rule. If the receiver may have observed a request but the local attempt never committed success or failure, restart treats the durable row as pending and may send the same event ID and byte-identical body again. Applications should deduplicate on event ID when the scenario includes that ambiguity.

The package exports verifyStripeWebhookSignature({ body, secret, signature }). Pass the raw body, before JSON parsing, the instance connection's webhookSecret, and the request's Stripe-Signature header. The helper verifies the HMAC digest only; it does not impose a timestamp replay window. A production handler that enforces recency should keep that policy and use a clock arrangement appropriate to the test.

Persistence and ordering

Account resources, time-advance records, events, and webhook outcomes survive a persistent runtime restart. Generated IDs are allocated locally; seeds may supply explicit customer, product, and price IDs. Lists follow durable creation order independently of ID spelling. Resource, event, and webhook timestamps come from the instance clock. Pinned and real-offset clock modes use the same exact 30-day billing arithmetic.

Changing credentials or webhookUrl requires a runtime restart. Events created while webhookUrl was null have no pending delivery to replay later. A failed terminal webhook is not turned back into pending by changing configuration or restarting.

Deliberate differences

The plugin does not implement payment methods, PaymentIntents, charges, Checkout, refunds, disputes, taxes, discounts, coupons, trials, prorations, metered usage, multiple subscription items, quantities other than one, calendar-month billing, collection retries or dunning, cancel-at-period-end, idempotency keys, expansions, API-version negotiation, OAuth, rate limits, or hosted dashboard behavior.

Cancellation is immediate. A failed invoice remains open without automatic collection. Webhook delivery has the terminal and crash-recovery behavior documented above, not Stripe's full retry schedule. With a pinned clock, a fresh instance, and the same controlled actions, the documented locally allocated IDs, clock-derived timestamps, and list order are repeatable. They are not provider-issued values. Keep provider-facing checks for every behavior outside the supported tables.