Full-stack Stripe-style billing sandbox for local development, CI scenarios, and controlled staging checks.
Billtap gives subscription teams a local billing lab: a Go server, practical Stripe-like API subset, React checkout/portal/dashboard surfaces, signed webhook delivery controls, YAML scenario runs, fixture apply/snapshot/assert APIs, and diagnostic bundles that explain what happened when a billing test failed.
It is not a payment processor. Billtap's goal is practical Stripe compatibility for the documented local-test subset: use it as the fast deterministic lane, then keep Stripe testmode or the real provider sandbox for behavior outside the published contract.
| Surface | What it is for |
|---|---|
| Stripe-like API | Local customers, products, prices, coupons, promotion codes, checkout sessions, subscriptions, schedules, invoices, payment intents, cash balance, refunds, credit notes, disputes, test clocks, webhook endpoints, and events for supported billing flows. |
| Hosted checkout | Browser-visible sandbox checkout for exercising app integration and deterministic payment outcomes. |
| Billing portal | Local customer portal for plan changes, seats, cancellation, resume, and payment-method update flows. |
| Developer dashboard | Billing objects, timeline, webhook delivery attempts, app responses, and debug bundle export in one place. |
| Webhook lab | Signed delivery with retry, duplicate, delay, out-of-order, grouped replay, masking, and delivery evidence. |
| Fixtures and scenarios | JSON/YAML setup, structured assertions, SaaS workspace profiles, and CI-readable reports. |
| Diagnostics | Request traces and bundles that help agents distinguish app misconfiguration, unsupported API calls, webhook failures, and wrong local state. |
flowchart LR
App["Your app / e2e tests"] --> Billtap["Billtap local billing lab"]
Billtap --> API["Stripe-like API"]
Billtap --> UI["Checkout + portal UI"]
Billtap --> Webhooks["Signed webhook delivery"]
Billtap --> Evidence["Dashboard + traces + debug bundles"]
App --> Stripe["Stripe testmode / provider sandbox"]
Stripe -. "provider-specific parity lane" .-> App
Use two lanes instead of forcing one tool to satisfy every billing test:
- Default lane: Billtap-backed local development, isolated e2e tests, CI regression scenarios, fixture setup, and webhook reliability tests.
- Fallback lane: Stripe testmode or the real provider sandbox for provider-specific behavior, hosted-provider parity, settlement, risk, tax, invoice rendering, and final compatibility checks.
For the exact supported surface, see docs/COMPATIBILITY.md. Anything outside
that contract should be treated as unsupported until it has fixture-backed tests
and documentation. Known but unimplemented Stripe OpenAPI routes return a
Stripe-shaped unsupported_endpoint error so test agents can identify coverage
gaps instead of mistaking them for app bugs.
| Use Billtap when... | Use a provider sandbox when... |
|---|---|
| You need deterministic subscription billing tests in local dev or CI. | You need full Stripe API behavior or hosted Stripe Dashboard parity. |
| You need to validate webhook idempotency, retries, duplicate delivery, delays, or replay. | You are proving settlement, risk, tax, invoice rendering, account, payout, or real dispute behavior. |
| You want fixture-driven setup with easy snapshot/assert APIs. | You need live provider validation for a newly adopted endpoint. |
| You need a local checkout/portal/dashboard loop for app integration work. | You are handling real card data, live credentials, or production payment paths. |
Current distribution state: source plus a GitHub Container Registry image. No package, Homebrew formula, or signed binary release is published yet.
Requirements:
- Go 1.25+ (
go.mod); currently verified with Go 1.26.1 - Node.js 20+ and npm; currently verified with Node 24.14.0 and npm 11.9.0
- Docker, optional for image smoke checks
Build and run locally:
npm ci
npm run build
go run ./cmd/billtapOpen:
http://localhost:8080
Run a scenario with the sample app assertion endpoint:
PORT=3300 npm --prefix examples/sample-app startIn another terminal:
go run ./cmd/billtap scenario run examples/subscription-payment-retry.yml \
--report-json billtap-report.json \
--report-md billtap-report.mdRun the generic SaaS workspace scenario:
go run ./cmd/billtap scenario run examples/saas-adoption-contract.ymlBuild a local image:
docker build -t billtap:local .
docker run --rm -p 8080:8080 -v billtap-data:/data billtap:localUse the published container image:
docker run --rm -p 8080:8080 -v billtap-data:/data ghcr.io/midagedev/billtap:mainImage tags:
ghcr.io/midagedev/billtap:main: latest successfulmainbuildghcr.io/midagedev/billtap:sha-<short-sha>: immutable commit buildghcr.io/midagedev/billtap:<version>: release tag builds such as0.1.0
Images are published for linux/amd64 and linux/arm64.
Billtap can run behind a shared browser origin such as
https://localhost:8081/billtap while keeping internal service-to-service calls
on the unprefixed container URL, such as http://billtap:8080.
Set one of these before starting Billtap:
PUBLIC_BASE_PATH=/billtap
# or, to override only this app when a shared stack sets PUBLIC_BASE_PATH:
BILLTAP_PUBLIC_BASE_PATH=/billtapSet BILLTAP_PUBLIC_BASE_URL to the browser-visible origin without the path
prefix when Billtap generates hosted checkout and portal URLs:
BILLTAP_PUBLIC_BASE_URL=https://localhost:8081 \
PUBLIC_BASE_PATH=/billtap \
go run ./cmd/billtapThe server also honors X-Forwarded-Prefix, so a proxy can strip /billtap
before forwarding while Billtap still generates prefixed Location headers and
hosted URLs. Browser links, static assets, dashboard API calls, and Stripe-like
calls are prefix-aware:
/billtap/app/dashboard/
/billtap/app/assets/...
/billtap/api/diagnostics
/billtap/v1/customers
The published GHCR image is runtime-prefix safe. You do not need to rebuild the frontend for each mount path.
Billtap can hold several fully isolated billing datasets in one running server, so parallel test suites do not have to restart Billtap or reset shared state between runs. The Stripe-compatible service URL can include a run scope:
http://billtap:8080/runs/<runId>
Stripe SDKs can keep their normal /v1/... paths because the SDK appends them
under that base URL.
- Requests with no run selector use the
defaultrun, backed by the configureddatabase_url. Existing integrations keep working unchanged. - Name a run to get an independent dataset (its own customers, invoices, webhooks, idempotency keys, and test clocks). It is created on first use.
- Select a run with
/runs/<runId>/.... The resolved name is echoed onX-Billtap-Run-IdandX-Billtap-Workspace. - For backward compatibility,
X-Billtap-Workspaceand theworkspacequery parameter still select the same isolated run on unprefixed requests. DELETE /runs/<runId>removes that run's dataset.GET /admin/runslists known runs and row-count summaries.
# default run (backward compatible)
curl http://localhost:8080/v1/customers
# isolated dataset for one test suite
curl http://localhost:8080/runs/suite-a/v1/customers
curl http://localhost:8080/runs/suite-a/v1/webhook_endpoints
# legacy workspace selectors, mapped to runs
curl -H 'X-Billtap-Workspace: suite-a' http://localhost:8080/v1/customers
curl 'http://localhost:8080/v1/customers?workspace=suite-a'
# list and clean up runs
curl http://localhost:8080/admin/runs
curl -X DELETE http://localhost:8080/runs/suite-a
# legacy listing alias
curl http://localhost:8080/workspacesRun IDs accept letters, digits, ., -, and _, must start with a letter or
digit, and are case-insensitive. Each named run is stored next to the default
database as an isolated SQLite file. For compatibility with earlier Billtap
builds, those files currently live under the existing workspaces/ directory
(for example .billtap/workspaces/suite-a.db).
When several proxied stacks share one Billtap server, each stack usually has
its own browser origin (for example https://localhost:19029/billtap), so a
single global BILLTAP_PUBLIC_BASE_URL cannot describe them all. A run can pin
its own public base, and every absolute URL generated under that run — checkout
session.url, billing portal URLs, hosted-page links — uses it:
# usually issued by the stack's seed container, which knows the external origin
curl -X POST http://billtap:8080/runs/suite-a/v1/config \
-d public_base_url=https://localhost:19029 \
-d public_base_path=/billtap
curl http://billtap:8080/runs/suite-a/v1/config # inspect (also /api/config)
curl -X DELETE http://billtap:8080/runs/suite-a/v1/config # back to the global baseThe base of generated absolute URLs resolves in this order:
- The run's configured
public_base_url(+ optionalpublic_base_path). - An explicit
X-Billtap-Public-Base-Urlrequest header. - For run-scoped requests arriving through a reverse proxy:
X-Forwarded-Proto/X-Forwarded-Host/X-Forwarded-Prefix. - The global
BILLTAP_PUBLIC_BASE_URL(+BILLTAP_PUBLIC_BASE_PATH), then the request host — exactly the previous behaviour, so the default run and single-stack setups are unchanged.
The per-run base lives in memory with the run's API handler: it survives until the run is deleted or the server restarts, so seed it together with the run's catalog and webhooks.
When a run has a public_base_url, hosted pages also repoint caller-provided
localhost redirect targets at that run's origin. Consumers often store one
static redirect URL (for example https://localhost:8080/checkout-success)
while each CI job listens on its own port; the hosted checkout "Return to app"
link and the billing portal return link/redirect then swap only the
scheme/host/port for the run's origin, keeping path and query:
- The stored session is untouched:
GET /v1/checkout/sessions/{id}keepssuccess_urlexactly as created and exposes the rewritten link as the extension fieldbilltap_return_url(also returned beside the session in the completion response). Portal responses keepreturn_urlas provided and embed the rewritten target only in the hostedurlquery. - Only
localhostand127.0.0.1hosts are rewritten; external domains are never touched. Runs without apublic_base_urlkeep redirects unchanged.
Fixture packs can also be applied directly to a run:
go run ./cmd/billtap seed --run-id suite-a --pack seed/sample-basic.ymlWhen the fixture pack has a top-level runId, that value is used for the run
scope and fixture metadata.
Billtap includes local integration-test helpers:
POST /api/fixtures/apply: apply JSON/YAML customers, catalog, test clocks, subscriptions, refunds, credit notes, and assertionsGET /api/fixtures/resolve: resolve a fixtureref, explicit ID, or lookup key to the local customer, subscription, invoice, payment intent, checkout session, product, and price IDsGET /api/fixtures/snapshot: read a filtered fixture-scoped billing snapshotPOST /api/fixtures/assert: assert expected customer, product, price, subscription, invoice, payment intent, and timeline state
Fixture-applied subscriptions use the normal checkout-completion path so invoices, payment intents, checkout sessions, and timeline evidence stay consistent.
Fixture-provided object IDs are preserved where the fixture supplies them, and
every created object is tagged with billtap_fixture_ref metadata so local E2E
tests can find the seeded graph without relying on random IDs.
Subscription fixture lifecycle fields are authoritative. If a subscription
fixture sets status, that status wins over outcome for the final seeded
subscription state. This lets a fixture use outcome: payment_succeeded to
build checkout, invoice, and payment-intent evidence while still seeding
trialing, canceled, past_due, unpaid, incomplete, or
incomplete_expired. Explicit current_period_start, current_period_end,
trial_start, trial_end, canceled_at, and ended_at values are applied as
absolute times and are restored on re-apply. For trialing, attach a
test_clock to the customer or subscription and set trial_end; advancing the
clock past that timestamp emits the local trial-to-active update evidence.
Billtap records Stripe-like /v1 and /v2 API requests as redacted request traces.
This is designed for local dev servers and isolated e2e jobs where an agent
needs to answer whether the app was configured to call Billtap, what it asked
for, what Billtap returned, and whether webhooks were emitted and delivered.
GET /api/request-traces: inspect recent Stripe-like method, path, query, status, idempotency key, masked headers, redacted request/response evidence, Stripe error fields, and related billing object IDsGET /api/diagnostics: export a single diagnostic bundle with object state, fixture snapshot, timeline, request traces, webhook events, and delivery attemptsPOST /api/debug-bundles: target one customer, checkout session, subscription, invoice, or payment intent; debug bundles now include matching request traces as well as timeline and webhook evidence
Example:
curl -fsS "http://localhost:8080/api/diagnostics?limit=100" \
-o billtap-diagnostics.json| Area | Current level | Notes |
|---|---|---|
| Runtime | Go server with SQLite local default | In-memory storage exists for tests |
| Frontend | React checkout, portal, and dashboard apps | Built with Vite into dist/app |
| Stripe-like API | Practical local subset | Customers, catalog, checkout, portal sessions, subscriptions, schedules, invoices, payment intents, cash balance, refunds, credit notes, disputes, test clocks, webhook endpoints, events, search/list projections used by tests |
| Webhooks | Signed delivery with reliability controls | Retry, duplicate, delay, out-of-order, grouped replay, endpoint attempts, delivery evidence, redaction |
| Scenarios | YAML runner | Local clock, app assertions, JSON/Markdown reports, exit-code policy |
| Fixtures | Apply/snapshot/assert APIs | JSON/YAML input, fixture metadata isolation, structured pass/fail reports |
| SaaS profile | Generic workspace billing profile | Plans, seats, members, export quota, extra export, payment history, support bundle, platform/connect-style webhook evidence |
| Release state | Source plus GHCR image | Local Docker image builds and GHCR image workflow; no package/Homebrew/signed binary yet |
| Stripe API inventory | 160 / 587 operations, 27.3% L1+ |
OpenAPI route inventory is schema-visible for all 587 operations; implemented coverage is tracked in docs/STRIPE_COMPATIBILITY_90_TARGET.md |
Detailed compatibility matrix: docs/COMPATIBILITY.md.
- This is not full Stripe compatibility.
- It does not process real payments and rejects real card-data fields.
- Hosted UI behavior is a sandbox approximation, not a Stripe-hosted UI clone.
- Dashboard access control is not a production security boundary.
- Relay mode is only for controlled testmode or staging-adjacent debugging and stores raw payloads as metadata-only.
- Package, Homebrew, and signed binary releases are not published yet.
go test ./...
go run ./cmd/billtap compatibility scorecard --output-dir /tmp/billtap-compatibility
npm run typecheck
npm run build
npm run smoke:sample
npm run smoke:sdk
npm run smoke:web:install
npm run smoke:web
go build -o /tmp/billtap ./cmd/billtap
docker build -t billtap:local .Scenario smoke, with PORT=3300 npm --prefix examples/sample-app start running:
go run ./cmd/billtap scenario run examples/subscription-payment-retry.yml
go run ./cmd/billtap scenario run examples/saas-adoption-contract.yml- Documentation index:
docs/README.md - Product goal:
docs/FINAL_GOAL.md - Architecture:
docs/ARCHITECTURE.md - Compatibility:
docs/COMPATIBILITY.md - SaaS profile:
docs/SAAS_PROFILE.md - Testing:
docs/TESTING.md - Production boundaries:
docs/PRODUCTION_BOUNDARIES.md - Release process:
docs/RELEASE.md - Release checklist:
docs/RELEASE_CHECKLIST.md - Public release readiness:
docs/PUBLIC_RELEASE_READINESS.md - Roadmap:
docs/ROADMAP.md - Scenario contract:
specs/000-product/contracts/scenario.md - API contract:
specs/000-product/contracts/api.md - Webhook contract:
specs/000-product/contracts/webhooks.md - Changelog:
CHANGELOG.md - Contributing:
CONTRIBUTING.md - Code of conduct:
CODE_OF_CONDUCT.md - Support:
SUPPORT.md - Security:
SECURITY.md
Before opening an issue or pull request, read CONTRIBUTING.md and SUPPORT.md. Security reports and production-boundary bypasses should follow SECURITY.md rather than public issues.
Public reports and examples must be sanitized. Do not include real card data, live credentials, production customer data, private company data, or production payment payloads.
Public release procedure: docs/RELEASE.md.
Maintainer checklist summary:
- Run the development commands above.
- Run the scenario smoke commands above.
- Build and smoke the local Docker image.
- Confirm
docs/COMPATIBILITY.mdmatches the implemented API surface. - Confirm the public-surface scan is clean and
.private/is ignored. - Tag the release as
vX.Y.Z. - Publish the package or signed binary only after that release automation is explicitly added.
Billtap is licensed under the Apache License, Version 2.0. See LICENSE and
NOTICE.
