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 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: truepnpm add -D localhost2137 @localhost2137/stripe hono@^4.13.4 zod@^4.4.3
pnpm add stripe@22.5.0Omit the second command when the application already has that exact tested client version.
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 field | Contract |
|---|---|
secretKey | Required key beginning with sk_test_; used for API bearer authentication. |
webhookSecret | Required key beginning with whsec_; used to sign webhook bodies. |
webhookUrl | Absolute 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.
Each instance exposes these typed values under instance[serviceKey].connection:
| Value | Environment projection | Use |
|---|---|---|
apiUrl | STRIPE_API_URL | Instance-scoped service URL without /v1; pass it to the SDK fetch adapter or append an HTTP path. |
secretKey | STRIPE_SECRET_KEY | Bearer key for the local API. |
webhookSecret | STRIPE_WEBHOOK_SECRET | Secret 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.
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:
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:
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.tsPlugin 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.
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 path | Supported behavior |
|---|---|
POST /v1/customers | Create a customer from required name and optional email. |
GET /v1/customers/:id | Retrieve one customer. |
GET /v1/customers | List customers with cursor pagination. |
GET /v1/products/:id | Retrieve one control-created or seeded product. |
GET /v1/products | List products with cursor pagination. |
GET /v1/prices/:id | Retrieve one control-created or seeded recurring price. |
GET /v1/prices | List prices with cursor pagination. |
POST /v1/subscriptions | Create one subscription from customer and items[0][price]; immediately create and pay its first invoice. |
GET /v1/subscriptions/:id | Retrieve one subscription and its single item. |
DELETE /v1/subscriptions/:id | Cancel immediately; a repeated cancellation returns the already canceled subscription. |
GET /v1/invoices/:id | Retrieve one invoice with its single line item. |
GET /v1/invoices | List 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.
| Operation | Input and effect |
|---|---|
createCustomer | Required name, optional email; returns the local customer. |
createProduct | Required name; creates an active product. |
createPrice | productId, non-negative integer unitAmount, optional currency (default usd); creates one active fixed recurring price. |
createSubscription | customerId and priceId; creates an active subscription, paid initial invoice, and invoice.paid event. |
listInvoices | Optional customerId and subscriptionId; returns matching invoices in creation order. |
listEvents | Optional type filter for invoice.paid or invoice.payment_failed; returns stable event IDs and invoice IDs. |
setNextPaymentOutcome | subscriptionId 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.
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.
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.
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.
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.