Skip to main content
The Stripe template (apps/stripe-clone, name stripe) is a faithful, API-only clone of the Stripe REST API: a NestJS backend on Postgres. Auth is a Stripe-style secret key (sk_test_…) sent as a bearer token — no signup/login, no per-user session. Every request is scoped to the account that owns the key. It’s faithful enough at the URL + wire-format level that an agent built for Stripe can operate it.
asymmetric spin stripe

Routing — read this first

Real Stripe serves business routes at /v1/* (not /api/v1/*), while the clone health probe is /api/health. So the backend uses no global prefix: business controllers bind @Controller('v1/...') and a single health controller binds @Controller('api'). Both /v1/customers and /api/health resolve faithfully.

What’s inside

PieceNotes
backend/NestJS REST API, Bearer sk_ auth, Postgres via pg. Business at /v1/*, health at /api/health, Swagger at /api/docs.
supabase/migrations/001_initial_schema.sql + 002_constraints_and_helpers.sql — the schema (17 tables), gen_stripe_id, status checks, run on spin and replayed on reset.
seeds/acct.sql — the bundled deterministic fixture.

Modes

ModeServicesUse it for
api (default)backendAgents that talk to the REST API.
This template is API-only — there is no frontend or full mode.

Seeding

asymmetric spin stripe --seed acct        # deterministic fixture on create
asymmetric seed stripe-a1b2 --ai          # realistic data via the REST API
AI seeding generates customers, products, prices, and invoices (the template’s declared entities) through real /v1/* calls. See Seeding.

Auth — the seeded secret key

spin stripe seeds one account and one secret key. The plaintext key (only its sha256 is stored) is:
sk_test_asymmetric000000000000stripe
Use it as a bearer token against the REST API — no signup/login step:
curl -s http://127.0.0.1:3003/v1/customers \
  -H "authorization: Bearer sk_test_asymmetric000000000000stripe"
Real Stripe also accepts the key as the HTTP Basic username, and so does the clone:
curl -s -u sk_test_asymmetric000000000000stripe: http://127.0.0.1:3003/v1/balance
There is no POST /api/admin/provision route (it returns 404 by design), so the CLI’s provision step proceeds token-less — the seeded key above is the auth path. The optional Stripe-Account header is parsed but ignored (single seeded account).

Two flows worth pointing an agent at

The template is built around two end-to-end money-movement chains (both verified against a live Postgres):
B=http://127.0.0.1:3003
A="authorization: Bearer sk_test_asymmetric000000000000stripe"

# 1. Subscription that actually goes paid: creates items + the first invoice +
#    a PaymentIntent, confirms it to a charge + balance transaction, status=active.
curl -s $B/v1/subscriptions -H "$A" \
  -d customer=cus_JennyRosen0001 -d 'items[0][price]=price_ProMonthly01'

# 2. Partial then full refund: amount_refunded increments, a full refund sets
#    refunded=true, a negative balance transaction posts, GET /v1/balance reflects
#    it, and charge.refunded + refund.created webhooks fire.
curl -s $B/v1/refunds -H "$A" -d charge=ch_SeedPaid000001 -d amount=500
curl -s $B/v1/refunds -H "$A" -d charge=ch_SeedPaid000001
curl -s $B/v1/balance -H "$A"
Money movement is simulated: confirming a PaymentIntent with a seeded test card (pm_card_visa / pm_card_mastercard) succeeds synchronously. There is no real PSP, 3DS/SCA, or async bank webhook.

API shape — read this before pointing an agent at it

Unlike the Slack clone (REST-vs-RPC, capability-level match only), Stripe is itself a REST API, so the clone matches it on the same literal /v1/… paths with the same wire format — not just the capability level:
  • prefixed object ids (cus_, prod_, price_, pi_, ch_, re_, in_, sub_, …) — no UUIDs on the wire,
  • cursor pagination wrapped in {object:'list', url, has_more, data} with limit (default 10, max 100) / starting_after / ending_before,
  • form-encoded request bodies (nested items[0][price] keys), the Idempotency-Key replay header, a Request-Id: req_… response header, and the {error:{type,code,message,param}} error envelope,
  • per-resource search at GET /v1/<resource>/search, wrapped in {object:'search_result', url, has_more, data, next_page},
  • per-account rate limiting100 req/s for live keys, 25 req/s for test keys (matching Stripe’s published tiers); over the cap returns HTTP 429 with a Stripe-Rate-Limited-Reason header and a {error:{code:'rate_limit', …}} body,
  • HMAC-signed outgoing webhooks with a Stripe-Signature: t=…,v1=… header, and a byte-faithful event envelope (api_version, pending_webhooks, request, data.previous_attributes).
The template ships an API_PARITY.md with the per-endpoint breakdown, re-audited against Stripe’s live docs. The implemented core spans customers, products, prices, payment methods, payment intents (both automatic and manual capture), charges, refunds, invoices + invoice items, subscriptions + subscription items, a read-only balance, an events feed, and webhook endpoints — 72 endpoints:
StatusCount
✅ exact (path + convention + envelope align)72
❌ missing (sub-actions outside the targeted slice)10
That’s 88% of the targeted core surface, on exact Stripe paths. Whole Stripe surfaces outside that core (Connect, Checkout, Terminal, Issuing, tax, disputes, SetupIntents, …) are unimplemented by design — this is an MVP+Beta subset, not the full API. A handful of sub-actions in scope are also not yet implemented (payment_intents/:id/increment_authorization, invoices/:id/send, subscriptions/:id/resume, customer-scoped payment-method reads). Check apps/stripe-clone/API_PARITY.md for the full list, including the documented ?expand[] allowlist.
The verify command will fold live fidelity scoring into the CLI. Until then, API_PARITY.md and the /verify-api workflow are how fidelity is tracked.

Inspect a running clone

asymmetric query stripe-a1b2 "select stripe_id, email from customers"
asymmetric db stripe-a1b2          # prints a psql shell command
asymmetric logs stripe-a1b2 -f     # stream the backend
The schema (customers, charges, invoices, subscriptions, …) comes straight from 001_initial_schema.sql — read it to know what’s queryable.