Local mock API server: 99 offline, stateful stand-ins for public APIs (Stripe, GitHub, Twilio, AWS, Auth0…) — one static Go binary. Test without accounts, keys, or network.
2
stars
395
commits
Go
primary language
Aug 23, 2026
updated
stunt spins up stateful, realistic local stand-ins for the APIs you integrate
(Stripe, Drive, Dropbox, gRPC services, GraphQL APIs, …) so you can develop and test
without creating accounts, handling live credentials, burning money, hitting rate limits,
or depending on the network. One static Go binary. Everything deterministic.
See the magic in 30 seconds:

stunt demo # boots a stateful Stripe-style sim; prints copy-paste curl that creates a charge,
# lists it back (stateful!), captures it, and fires a webhook — all locally
Contents: Why · Install · Quickstart · Manifest · Rules · Profiles · Adapters · Transports · State & primitives · Webhooks · Networking & TLS · Observability & lifecycle · Determinism · Safety · Status · Reference
Integrating remote APIs is painful for testing: you create accounts, juggle credentials, pay for usage, hit rate limits, depend on the network, and get non-deterministic results. Existing mock tools (WireMock, Prism, MSW, Microcks) are great at static and schema mocking, but they don't give you a stateful, runnable stand-in for an arbitrary service without hand-authoring everything.
stunt does. A Stripe-style adapter actually stores the charge you create — create it,
list it, capture it, get the webhook — and it all resets on stunt clean.
| stunt | WireMock | Prism | MSW | Mockoon | |
|---|---|---|---|---|---|
| Stateful (create → list → mutate persists) | ✅ | partial | ❌ | ❌ | ❌ |
| Sandboxed adapter logic (safe to install strangers' mocks) | ✅ Starlark | ❌ | ❌ | ❌ JS | ❌ JS |
| Protocols | REST, gRPC (+streaming), WebSocket, GraphQL | REST (+ limited) | REST, OpenAPI | REST, GraphQL | REST |
| Single static binary, no runtime deps | ✅ Go | JVM | node | node | electron |
| Generate from real API descriptions | OpenAPI, HAR, proto | OpenAPI | OpenAPI | — | OpenAPI |
| Adapter ecosystem / catalog | ✅ (git-distributed) | stubs | — | — | templates |
(Each of those tools is excellent at what it does; this is a feature matrix, not a verdict. Corrections welcome.)
Go (any OS — Linux, macOS, Windows):
go install stuntapi.com/stunt/cmd/stunt@latest
macOS (Homebrew):
brew install --cask stuntapi/tap/stunt
Windows (winget):
irm https://raw.githubusercontent.com/stuntapi/winget/main/install.ps1 | iex
Pre-built binaries for every platform are also on the Releases page.
stunt init # writes a sample stunt.yaml
stunt plan # validate + show what will run (warns on unloadable adapters)
stunt up # serve all services (Ctrl-C to stop) — logs every request
stunt stop # stop a server by PID or this manifest's — graceful, works on any OS
stunt down # stop a backgrounded `stunt up`
stunt clean # reset all adapter state to seed fixtures
Point your client at the served address (YOURAPI_BASE_URL=http://127.0.0.1:8000) and
run your tests. Every request is logged, state persists across requests and restarts,
and stunt clean gives you a fresh world.
stunt.yaml)One file declares everything stunt serves. A service is either rules-only (declarative responses, no code) or adapter-backed (a directory of Starlark handlers
version: 1
rng_seed: 42 # fixed seed → identical synthetic data + fault rolls every run
network:
mode: port # one port per service: base_port, +1 each, alphabetical
base_port: 8000
services:
stripe:
adapter: embedded:stripe-style # bundled IN the binary — nothing to clone, no network
config:
webhook_url: http://127.0.0.1:9999/hooks # where events_emit() delivers webhooks
myapi:
adapter: ./adapters/myapi-style # local dir, or git:github.com/org/repo@ref
max_body_bytes: 8388608 # per-request body cap (default 1 MiB; oversize → 413)
example: # rules-only service — inline declarative behavior
rules:
- match: { method: GET, path: /hello }
when: { chance: 20 } # 20% of replies error
respond: { status: 503, body: { inline: { error: boom } } }
- match: { method: GET, path: /hello }
respond: { status: 200, body: { template: '{"message":"hi","id":"{{ faker.ID "k" }}"}' } }
Networking modes: port (each service on 127.0.0.1:<port>; network.mode is
required — pick one explicitly) or subdomain
(real TLS via a locally-generated CA, SNI-routed https://stripe.localhost-style hosts;
see Networking & TLS).
Adapter sources: embedded:<name> (ships in the binary) · a local path ·
git:github.com/org/repo@ref. stunt adapter add <src> wires a source into the
manifest for you.
Rules answer requests without any code. Within a service they evaluate in declaration
order, first match wins — a catch-all match: { path: "/**" } is the usual 404
backstop:
rules:
- name: flaky-when-debugging
match:
method: GET
path: /flaky/** # glob over the path
headers: { X-Debug: "1" } # header conditions
when:
chance: 20 # percent probability, 0..100 (rng_seed-driven)
# expr: "request.body.amount > 1000" # OR a boolean expression over request.*
respond:
status: 503
headers: { Content-Type: application/json, Retry-After: "1" }
body: { inline: { error: simulated } } # OR { file: body.json }
# OR { template: tmpl.json }
latency_ms: 100 # simulated latency
# behavior: timeout # force a hang; drops the connection after
# latency_ms (default 30s)
Templates are Go text/template with fake-data helpers — {{ faker.Email }},
{{ faker.ID "k" }}, {{ uuid }}, {{ now.Format "2006-01-02T15:04:05Z07:00" }} — so
even rules-only services return varied, realistic, deterministic payloads.
Rules-only services cover static and probabilistic mocking; when you need state (create → list → mutate), auth flows, or webhooks, use an adapter.
A profile is a named behavior mode you can switch on and off at runtime — the on-demand version of "what does my client do when the dependency misbehaves?". Retry/backoff paths, circuit breakers, degraded UX: activate the profile, run the test, deactivate. No YAML edits, no restart.
$ stunt profile activate launch-day
activated preset "launch-day"
(runtime-only — resets on restart; `stunt up --profile` boots with one)
$ curl -s http://127.0.0.1:8000/v1/charges | head -c 60
{"error":"rate_limit_error"} # the world changed — same YAML, no restart
$ stunt profile deactivate
deactivated all profiles
1. Rule bundles per service — declared right in stunt.yaml. While active, the
rules run as a pre-dispatch override layer: they intercept requests before
handlers and base rules, so they reach handler-backed routes that base rules cannot
(matching your real fault-injection needs — chaos that can't touch /v1/charges would
be useless).
services:
stripe:
adapter: embedded:stripe-style
profiles: # rule bundles for THIS service
degraded:
description: occasional 429s + slow responses
rules:
- match: { path: /v1/** }
when: { chance: 30 }
respond: { status: 429, body: { inline: { error: rate_limited } } }
2. Adapter-authored modes — an adapter ships behavior modes its handlers implement,
so the provider's own degraded semantics come pre-modeled. The sqs-style adapter
declares throttled in its adapter.yaml; its handlers read the profile_active()
builtin:
# adapters/sqs-style/adapter.yaml
profiles:
throttled: "alternate ReceiveMessage calls return empty — exercise consumer retry/backoff paths"
# inside a handler:
if profile_active() == "throttled":
return respond(200, {"Messages": []}) # this receive yields nothing; client retries
3. Global presets — one activation assigns profiles across services, so a whole scenario flips at once:
profiles: # top level of stunt.yaml
launch-day: # `stunt profile activate launch-day`
description: both dependencies degraded
set:
stripe: degraded # the manifest bundle from (1)
sqs: throttled # authored by the sqs-style adapter in (2)
Nothing predefined ships — presets like launch-day are yours to declare. A name
declared in both the manifest and an adapter activates both layers together (manifest
rules + the handler behavior), which is the point: one name, one world.
stunt profile list # every activatable profile, active ones marked
stunt profile show launch-day # what it sets, where it's active
stunt profile activate launch-day # preset
stunt profile activate throttled # unique name → auto-targeted to its service
stunt profile activate degraded --service stripe # disambiguate a shared name
stunt profile deactivate # all services
stunt profile deactivate --service sqs
stunt up --profile launch-day # boot default (unknown names fail before serving)
The dashboard's profiles panel does the same with one click, and the read commands
(stunt profile list, stunt requests, stunt ps, …) print --json for scripts.
Semantics worth knowing:
stunt up --profile restores a default if you want one).rng_seed → reproducible fault rolls). Adapter modes that keep
counters across calls (like sqs-style's alternating throttling) persist the counter
in service state: restart resets the activation, not the counter — pair those with
stunt reset <service> for a fully fresh sequence. Details in the
determinism contract.Use-case guide — chaos testing, revoked credentials on demand, the one broken
customer, hanging dependencies, flipping worlds between test cases:
docs/profiles.md.
An adapter is a directory describing how to simulate one API: adapter.yaml + Starlark
handlers + fixtures/schemas. Logic runs in a sandboxed Starlark VM (no host I/O —
that's why strangers' adapters are safe to install) backed by stateful primitives
(Collection / KV / Blob / Identity / Events). Build your own:
stunt adapter new myapi-style # scaffold (synthetic data)
stunt adapter import openapi spec.yaml # generate from an OpenAPI doc
stunt adapter import har session.har # infer endpoints + synthetic fixtures
stunt adapter import proto api.proto # scaffold a gRPC adapter (descriptor + handlers)
stunt adapter lint ./myapi-style # enforce SYNTHETIC data only (the safety guard)
stunt adapter test ./myapi-style # conformance vs your local real traces
stunt catalog search stripe # browse the adapter registry
Reference adapters in this repo — 99 of them (Stripe, Salesforce, Discord, Twilio,
Square, Adyen, AWS S3, Google/Microsoft/Apple families, blockchain RPCs, …; all unofficial,
synthetic-data-only, with a DISCLAIMER). Browse them with stunt catalog search. Every one
passes an adversarial input-safety sweep (garbage params, null/malformed bodies, ~30 tampered
cursor/limit param names — never a 5xx), coverage-guided fuzzing of the engine's parsers and
dispatch (just fuzz), and conformance suites that drive real provider SDKs — stripe-go,
aws-sdk-go-v2, go-github, go-ethereum, twilio-go, go-shopify, google-api-go-client —
end-to-end against the adapters (just conformance), plus Node suites driving
stripe-node, octokit, twilio-node, @slack/web-api, plaid, @hubspot/api-client,
square, openai, resend, @discordjs/rest, jsforce, node-zendesk, and jira.js
through the real stunt binary (just conformance-node). The full
per-adapter scorecard — which SDK at which version, covered behaviors, the exact
route surface covered, what's missing from the real API, documented deviations,
verification tier — is generated into
CONFORMANCE.md (just conformance-matrix; CI fails on drift).
Highlights:
| Adapter | Simulates | Backing |
|---|---|---|
stripe-style | payments — full API surface (158 endpoints): PaymentIntents, disputes, refunds, the Billing suite (subscriptions/invoices/credit notes), Checkout Sessions, SetupIntents, balance transactions, Connect (persons/capabilities/application fees), Test Clocks (deterministic billing), Idempotency-Key, cursor-paginated lists, signed webhooks + registration-gated delivery | Collection + Starlark |
salesforce-style | CRM — sObjects CRUD, general SOQL (WHERE/IN/LIKE/AND/OR, ORDER BY, LIMIT/OFFSET), OAuth (password/auth-code/refresh/JWT bearer) | Collection + Starlark |
discord-style | bot API — REST + WebSocket Gateway (HELLO→IDENTIFY→READY→dispatch) + Ed25519-signed interactions | Collection + Starlark |
emailoctopus-style | email — lists + contact lifecycle (double/single opt-in, unsubscribe/resubscribe), fields/tags CRUD, campaigns reports, RFC 7807 errors + cursor paging | Collection + Starlark |
drive-style | files API — upload/get/download/list/patch/delete, folders, about/quota, resumable uploads | Blob + Collection |
dropbox-style | files API (RPC-style) — upload/download/list_folder/get_metadata | Blob + Collection |
twitter-style | mock OAuth, tweets (CRUD), users, timeline | Collection (pure-mock) |
echo-style | gRPC service (unary + streaming) + WebSocket — multi-transport reference | Collection + KV |
blog-style | GraphQL blog API — users/posts/comments, nested relations, mutations | Collection + Starlark |
Full adapter authoring reference (the adapter.yaml schema, Starlark builtins reference with
exact signatures, gRPC/WebSocket/GraphQL sections): adapters/README.md.
Fidelity platform (cross-cutting, in every adapter): real list-filter/query params
(query_select), cursor pagination, validated tokens with expiry (401 paths), per-provider
signed webhook delivery (HMAC/ECDSA/Ed25519 schemes), derive-on-read async state machines
(RUNNING/FAILED + failure injection), multipart uploads, byte-exact binary round-trips.
{param} captures, per-method handlers, query/body/header
access, streaming request/response bodies, multipart.FileDescriptorSet your adapter ships; clients work unmodified against the local address.Mix transports in one adapter (echo-style serves gRPC + WebSocket from the same manifest).
Adapters (and the engine) run on a small set of stateful primitives. State lives in
.stunt/state/ under the manifest, persists across requests and restarts, and
resets only when you say so:
| Primitive | What it's for |
|---|---|
| Collection | documents in SQLite — insert/get/list/update/delete |
| KV | key-value + atomic counters (store_kv_incr — id sequences) |
| Blob | binary/large content on the filesystem, byte-exact round-trips |
| Identity | HMAC-backed tokens — mint, validate, scopes; expiry paths for 401 tests |
| Events | webhook delivery with per-provider signing and retry (see below) |
| Clock + scheduler | deterministic time — virtual clocks for billing cycles, Test Clocks |
| Generator | synthetic data (the {{ faker.* }} templates) |
| Validator | JSON-Schema validation of requests/responses |
Lifecycle commands:
stunt clean # reset EVERYTHING to seed fixtures (state, CA, hosts)
stunt reset stripe # reset one service's state on a RUNNING server
stunt snapshot save -o pre-migration.tar.gz # capture the whole world
stunt snapshot load pre-migration.tar.gz # and put it back — deterministic replays
stunt state collections stripe # browse a service's state from the CLI (blobs/kv too)
Adapters emit webhooks like the real provider does: the stripe-style sim POSTs
charge.created to your sink when you capture a charge. Configure the destination once:
services:
stripe:
adapter: embedded:stripe-style
config:
webhook_url: http://127.0.0.1:9999/hooks # events_emit() delivers here
Handlers deliver with events_emit("charge.created", {...}); adapters whose providers
sign their webhooks (Stripe, Twilio, Square, GitHub, …) compute the real signature
scheme — HMAC-SHA256, ECDSA, Ed25519 — over the exact bytes, and expose the registered
target to handlers via events_target() (for providers that MAC the destination URL
into the signature). Delivery retries with exponential backoff, like a real provider
would.
Port mode (default): each service on 127.0.0.1:<port>, starting at base_port.
Zero setup; point clients at the port.
Subdomain mode: real TLS with per-service subdomains —
https://stripe.localhost, https://sqs.localhost — via a locally-generated CA, an
SNI-routing reverse proxy, and a managed /etc/hosts block:
network:
mode: subdomain
tld: localhost
tls: true
sync_hosts: true # manage the /etc/hosts block for *.tld
# spoof_real_hosts: true # redirect REAL hostnames (api.stripe.com) to the local sim
stunt trust # install stunt's CA into the system trust store (privileged)
stunt proxy start # start the TLS reverse proxy
stunt hosts sync # manage hosts entries manually if needed
The privileged listener forwards to an unprivileged engine, so adapter code never runs as root; HTTP/2 and WSS pass through verified.
Every running server serves its own localhost dashboard — a live request inspector for
HTTP traffic (bodies, headers, copy-as-curl, replay), a state browser (the
collections/kv/blobs your tests created), snapshot/restore for deterministic runs,
the profiles panel, and an instance manager. A matching CLI (--json) backs
every feature:
$ stunt up
dashboard: http://127.0.0.1:54321 (token: 9f3c…)
$ stunt ui # open it
$ stunt requests --follow # live request feed in the terminal
$ stunt replay <request-id> # re-issue a captured request
$ stunt ps # every running stunt server, across manifests
$ stunt doctor # health check: CA, manifest, adapters, ports
Servers stop gracefully (stunt stop / stunt down drain in-flight requests), work on
every OS, and can run as a system service (stunt service install). Loopback-only,
token-authed, DNS-rebinding-guarded; sensitive headers redacted; logging is async and
never backpressures requests. Full guide with screenshots:
docs/dashboard.md.

The whole point of a stunt double: the same run twice must behave the same way.
rng_seed fixes synthetic data (same seed → same fakes, same ids) and fault
rolls (when.chance), per service, from a fresh boot.stunt clean / stunt reset restore the seed world; stunt snapshot
captures and restores mid-run state for replay-style tests.The defining property: a community adapter is safe to install — adapter logic is sandboxed
Starlark with no host I/O, bounded by execution-step limits; all file reads an adapter can trigger
are path-containment-guarded; stunt adapter lint enforces synthetic-data-only. See
SECURITY.md for the full threat model.
Contributions are welcome — especially adapters. See CONTRIBUTING.md
for the workflow and quality gates (just ci = build + test -race + vet + gofmt + mod-tidy +
lint-adapters). Quick path: stunt adapter new myapi-style → edit → stunt adapter lint → PR.
Every reference adapter ships embedded in the binary, and nearly all are verified by
real test suites — official provider SDKs driven end-to-end in CI plus engine-level
suites — with the per-adapter scorecard published in
CONFORMANCE.md. The website (stuntapi.com)
carries the live conformance matrix and adapter catalog.
On the roadmap: a public catalog (today's stunt catalog is offline/bundled + git
refs), stunt setup privileged-path hardening, and broader adapter coverage.
Not planned for v1: GraphQL subscriptions, npm adapter distribution.
Found a security issue? See SECURITY.md — do not open a public issue.
cmd/stunt (CLI) · internal/{rules,manifest,engine,adapter(+runtime),starlark,grpcsim,graphqlsim}
· internal/primitives{,/blob,/clock,/events,/gen,/identity,/kv,/validator} ·
internal/netutil{,/proxy} · internal/contrib{,/openapi,/har,/lint,/conform,/proto,/scaffold}
· internal/catalog · internal/cli · internal/adapterdist · adapters/.
AGENTS.md (or run stunt llm for the in-binary reference) — the full
manifest schema, CLI reference, and the complete Starlark handler API.adapters/README.md — the adapter.yaml schema and the complete
Starlark builtins reference with exact signatures.docs/profiles.md.docs/dashboard.md.CONTRIBUTING.md.395 commits
Go
60.2%
Starlark
38.6%
Local mock API server: 99 offline, stateful stand-ins for public APIs (Stripe, GitHub, Twilio, AWS, Auth0…) — one static Go binary. Test without accounts, keys, or network.
2
stars
395
commits
Go
primary language
Aug 23, 2026
updated
stunt spins up stateful, realistic local stand-ins for the APIs you integrate
(Stripe, Drive, Dropbox, gRPC services, GraphQL APIs, …) so you can develop and test
without creating accounts, handling live credentials, burning money, hitting rate limits,
or depending on the network. One static Go binary. Everything deterministic.
See the magic in 30 seconds:

stunt demo # boots a stateful Stripe-style sim; prints copy-paste curl that creates a charge,
# lists it back (stateful!), captures it, and fires a webhook — all locally
Contents: Why · Install · Quickstart · Manifest · Rules · Profiles · Adapters · Transports · State & primitives · Webhooks · Networking & TLS · Observability & lifecycle · Determinism · Safety · Status · Reference
Integrating remote APIs is painful for testing: you create accounts, juggle credentials, pay for usage, hit rate limits, depend on the network, and get non-deterministic results. Existing mock tools (WireMock, Prism, MSW, Microcks) are great at static and schema mocking, but they don't give you a stateful, runnable stand-in for an arbitrary service without hand-authoring everything.
stunt does. A Stripe-style adapter actually stores the charge you create — create it,
list it, capture it, get the webhook — and it all resets on stunt clean.
| stunt | WireMock | Prism | MSW | Mockoon | |
|---|---|---|---|---|---|
| Stateful (create → list → mutate persists) | ✅ | partial | ❌ | ❌ | ❌ |
| Sandboxed adapter logic (safe to install strangers' mocks) | ✅ Starlark | ❌ | ❌ | ❌ JS | ❌ JS |
| Protocols | REST, gRPC (+streaming), WebSocket, GraphQL | REST (+ limited) | REST, OpenAPI | REST, GraphQL | REST |
| Single static binary, no runtime deps | ✅ Go | JVM | node | node | electron |
| Generate from real API descriptions | OpenAPI, HAR, proto | OpenAPI | OpenAPI | — | OpenAPI |
| Adapter ecosystem / catalog | ✅ (git-distributed) | stubs | — | — | templates |
(Each of those tools is excellent at what it does; this is a feature matrix, not a verdict. Corrections welcome.)
Go (any OS — Linux, macOS, Windows):
go install stuntapi.com/stunt/cmd/stunt@latest
macOS (Homebrew):
brew install --cask stuntapi/tap/stunt
Windows (winget):
irm https://raw.githubusercontent.com/stuntapi/winget/main/install.ps1 | iex
Pre-built binaries for every platform are also on the Releases page.
stunt init # writes a sample stunt.yaml
stunt plan # validate + show what will run (warns on unloadable adapters)
stunt up # serve all services (Ctrl-C to stop) — logs every request
stunt stop # stop a server by PID or this manifest's — graceful, works on any OS
stunt down # stop a backgrounded `stunt up`
stunt clean # reset all adapter state to seed fixtures
Point your client at the served address (YOURAPI_BASE_URL=http://127.0.0.1:8000) and
run your tests. Every request is logged, state persists across requests and restarts,
and stunt clean gives you a fresh world.
stunt.yaml)One file declares everything stunt serves. A service is either rules-only (declarative responses, no code) or adapter-backed (a directory of Starlark handlers
version: 1
rng_seed: 42 # fixed seed → identical synthetic data + fault rolls every run
network:
mode: port # one port per service: base_port, +1 each, alphabetical
base_port: 8000
services:
stripe:
adapter: embedded:stripe-style # bundled IN the binary — nothing to clone, no network
config:
webhook_url: http://127.0.0.1:9999/hooks # where events_emit() delivers webhooks
myapi:
adapter: ./adapters/myapi-style # local dir, or git:github.com/org/repo@ref
max_body_bytes: 8388608 # per-request body cap (default 1 MiB; oversize → 413)
example: # rules-only service — inline declarative behavior
rules:
- match: { method: GET, path: /hello }
when: { chance: 20 } # 20% of replies error
respond: { status: 503, body: { inline: { error: boom } } }
- match: { method: GET, path: /hello }
respond: { status: 200, body: { template: '{"message":"hi","id":"{{ faker.ID "k" }}"}' } }
Networking modes: port (each service on 127.0.0.1:<port>; network.mode is
required — pick one explicitly) or subdomain
(real TLS via a locally-generated CA, SNI-routed https://stripe.localhost-style hosts;
see Networking & TLS).
Adapter sources: embedded:<name> (ships in the binary) · a local path ·
git:github.com/org/repo@ref. stunt adapter add <src> wires a source into the
manifest for you.
Rules answer requests without any code. Within a service they evaluate in declaration
order, first match wins — a catch-all match: { path: "/**" } is the usual 404
backstop:
rules:
- name: flaky-when-debugging
match:
method: GET
path: /flaky/** # glob over the path
headers: { X-Debug: "1" } # header conditions
when:
chance: 20 # percent probability, 0..100 (rng_seed-driven)
# expr: "request.body.amount > 1000" # OR a boolean expression over request.*
respond:
status: 503
headers: { Content-Type: application/json, Retry-After: "1" }
body: { inline: { error: simulated } } # OR { file: body.json }
# OR { template: tmpl.json }
latency_ms: 100 # simulated latency
# behavior: timeout # force a hang; drops the connection after
# latency_ms (default 30s)
Templates are Go text/template with fake-data helpers — {{ faker.Email }},
{{ faker.ID "k" }}, {{ uuid }}, {{ now.Format "2006-01-02T15:04:05Z07:00" }} — so
even rules-only services return varied, realistic, deterministic payloads.
Rules-only services cover static and probabilistic mocking; when you need state (create → list → mutate), auth flows, or webhooks, use an adapter.
A profile is a named behavior mode you can switch on and off at runtime — the on-demand version of "what does my client do when the dependency misbehaves?". Retry/backoff paths, circuit breakers, degraded UX: activate the profile, run the test, deactivate. No YAML edits, no restart.
$ stunt profile activate launch-day
activated preset "launch-day"
(runtime-only — resets on restart; `stunt up --profile` boots with one)
$ curl -s http://127.0.0.1:8000/v1/charges | head -c 60
{"error":"rate_limit_error"} # the world changed — same YAML, no restart
$ stunt profile deactivate
deactivated all profiles
1. Rule bundles per service — declared right in stunt.yaml. While active, the
rules run as a pre-dispatch override layer: they intercept requests before
handlers and base rules, so they reach handler-backed routes that base rules cannot
(matching your real fault-injection needs — chaos that can't touch /v1/charges would
be useless).
services:
stripe:
adapter: embedded:stripe-style
profiles: # rule bundles for THIS service
degraded:
description: occasional 429s + slow responses
rules:
- match: { path: /v1/** }
when: { chance: 30 }
respond: { status: 429, body: { inline: { error: rate_limited } } }
2. Adapter-authored modes — an adapter ships behavior modes its handlers implement,
so the provider's own degraded semantics come pre-modeled. The sqs-style adapter
declares throttled in its adapter.yaml; its handlers read the profile_active()
builtin:
# adapters/sqs-style/adapter.yaml
profiles:
throttled: "alternate ReceiveMessage calls return empty — exercise consumer retry/backoff paths"
# inside a handler:
if profile_active() == "throttled":
return respond(200, {"Messages": []}) # this receive yields nothing; client retries
3. Global presets — one activation assigns profiles across services, so a whole scenario flips at once:
profiles: # top level of stunt.yaml
launch-day: # `stunt profile activate launch-day`
description: both dependencies degraded
set:
stripe: degraded # the manifest bundle from (1)
sqs: throttled # authored by the sqs-style adapter in (2)
Nothing predefined ships — presets like launch-day are yours to declare. A name
declared in both the manifest and an adapter activates both layers together (manifest
rules + the handler behavior), which is the point: one name, one world.
stunt profile list # every activatable profile, active ones marked
stunt profile show launch-day # what it sets, where it's active
stunt profile activate launch-day # preset
stunt profile activate throttled # unique name → auto-targeted to its service
stunt profile activate degraded --service stripe # disambiguate a shared name
stunt profile deactivate # all services
stunt profile deactivate --service sqs
stunt up --profile launch-day # boot default (unknown names fail before serving)
The dashboard's profiles panel does the same with one click, and the read commands
(stunt profile list, stunt requests, stunt ps, …) print --json for scripts.
Semantics worth knowing:
stunt up --profile restores a default if you want one).rng_seed → reproducible fault rolls). Adapter modes that keep
counters across calls (like sqs-style's alternating throttling) persist the counter
in service state: restart resets the activation, not the counter — pair those with
stunt reset <service> for a fully fresh sequence. Details in the
determinism contract.Use-case guide — chaos testing, revoked credentials on demand, the one broken
customer, hanging dependencies, flipping worlds between test cases:
docs/profiles.md.
An adapter is a directory describing how to simulate one API: adapter.yaml + Starlark
handlers + fixtures/schemas. Logic runs in a sandboxed Starlark VM (no host I/O —
that's why strangers' adapters are safe to install) backed by stateful primitives
(Collection / KV / Blob / Identity / Events). Build your own:
stunt adapter new myapi-style # scaffold (synthetic data)
stunt adapter import openapi spec.yaml # generate from an OpenAPI doc
stunt adapter import har session.har # infer endpoints + synthetic fixtures
stunt adapter import proto api.proto # scaffold a gRPC adapter (descriptor + handlers)
stunt adapter lint ./myapi-style # enforce SYNTHETIC data only (the safety guard)
stunt adapter test ./myapi-style # conformance vs your local real traces
stunt catalog search stripe # browse the adapter registry
Reference adapters in this repo — 99 of them (Stripe, Salesforce, Discord, Twilio,
Square, Adyen, AWS S3, Google/Microsoft/Apple families, blockchain RPCs, …; all unofficial,
synthetic-data-only, with a DISCLAIMER). Browse them with stunt catalog search. Every one
passes an adversarial input-safety sweep (garbage params, null/malformed bodies, ~30 tampered
cursor/limit param names — never a 5xx), coverage-guided fuzzing of the engine's parsers and
dispatch (just fuzz), and conformance suites that drive real provider SDKs — stripe-go,
aws-sdk-go-v2, go-github, go-ethereum, twilio-go, go-shopify, google-api-go-client —
end-to-end against the adapters (just conformance), plus Node suites driving
stripe-node, octokit, twilio-node, @slack/web-api, plaid, @hubspot/api-client,
square, openai, resend, @discordjs/rest, jsforce, node-zendesk, and jira.js
through the real stunt binary (just conformance-node). The full
per-adapter scorecard — which SDK at which version, covered behaviors, the exact
route surface covered, what's missing from the real API, documented deviations,
verification tier — is generated into
CONFORMANCE.md (just conformance-matrix; CI fails on drift).
Highlights:
| Adapter | Simulates | Backing |
|---|---|---|
stripe-style | payments — full API surface (158 endpoints): PaymentIntents, disputes, refunds, the Billing suite (subscriptions/invoices/credit notes), Checkout Sessions, SetupIntents, balance transactions, Connect (persons/capabilities/application fees), Test Clocks (deterministic billing), Idempotency-Key, cursor-paginated lists, signed webhooks + registration-gated delivery | Collection + Starlark |
salesforce-style | CRM — sObjects CRUD, general SOQL (WHERE/IN/LIKE/AND/OR, ORDER BY, LIMIT/OFFSET), OAuth (password/auth-code/refresh/JWT bearer) | Collection + Starlark |
discord-style | bot API — REST + WebSocket Gateway (HELLO→IDENTIFY→READY→dispatch) + Ed25519-signed interactions | Collection + Starlark |
emailoctopus-style | email — lists + contact lifecycle (double/single opt-in, unsubscribe/resubscribe), fields/tags CRUD, campaigns reports, RFC 7807 errors + cursor paging | Collection + Starlark |
drive-style | files API — upload/get/download/list/patch/delete, folders, about/quota, resumable uploads | Blob + Collection |
dropbox-style | files API (RPC-style) — upload/download/list_folder/get_metadata | Blob + Collection |
twitter-style | mock OAuth, tweets (CRUD), users, timeline | Collection (pure-mock) |
echo-style | gRPC service (unary + streaming) + WebSocket — multi-transport reference | Collection + KV |
blog-style | GraphQL blog API — users/posts/comments, nested relations, mutations | Collection + Starlark |
Full adapter authoring reference (the adapter.yaml schema, Starlark builtins reference with
exact signatures, gRPC/WebSocket/GraphQL sections): adapters/README.md.
Fidelity platform (cross-cutting, in every adapter): real list-filter/query params
(query_select), cursor pagination, validated tokens with expiry (401 paths), per-provider
signed webhook delivery (HMAC/ECDSA/Ed25519 schemes), derive-on-read async state machines
(RUNNING/FAILED + failure injection), multipart uploads, byte-exact binary round-trips.
{param} captures, per-method handlers, query/body/header
access, streaming request/response bodies, multipart.FileDescriptorSet your adapter ships; clients work unmodified against the local address.Mix transports in one adapter (echo-style serves gRPC + WebSocket from the same manifest).
Adapters (and the engine) run on a small set of stateful primitives. State lives in
.stunt/state/ under the manifest, persists across requests and restarts, and
resets only when you say so:
| Primitive | What it's for |
|---|---|
| Collection | documents in SQLite — insert/get/list/update/delete |
| KV | key-value + atomic counters (store_kv_incr — id sequences) |
| Blob | binary/large content on the filesystem, byte-exact round-trips |
| Identity | HMAC-backed tokens — mint, validate, scopes; expiry paths for 401 tests |
| Events | webhook delivery with per-provider signing and retry (see below) |
| Clock + scheduler | deterministic time — virtual clocks for billing cycles, Test Clocks |
| Generator | synthetic data (the {{ faker.* }} templates) |
| Validator | JSON-Schema validation of requests/responses |
Lifecycle commands:
stunt clean # reset EVERYTHING to seed fixtures (state, CA, hosts)
stunt reset stripe # reset one service's state on a RUNNING server
stunt snapshot save -o pre-migration.tar.gz # capture the whole world
stunt snapshot load pre-migration.tar.gz # and put it back — deterministic replays
stunt state collections stripe # browse a service's state from the CLI (blobs/kv too)
Adapters emit webhooks like the real provider does: the stripe-style sim POSTs
charge.created to your sink when you capture a charge. Configure the destination once:
services:
stripe:
adapter: embedded:stripe-style
config:
webhook_url: http://127.0.0.1:9999/hooks # events_emit() delivers here
Handlers deliver with events_emit("charge.created", {...}); adapters whose providers
sign their webhooks (Stripe, Twilio, Square, GitHub, …) compute the real signature
scheme — HMAC-SHA256, ECDSA, Ed25519 — over the exact bytes, and expose the registered
target to handlers via events_target() (for providers that MAC the destination URL
into the signature). Delivery retries with exponential backoff, like a real provider
would.
Port mode (default): each service on 127.0.0.1:<port>, starting at base_port.
Zero setup; point clients at the port.
Subdomain mode: real TLS with per-service subdomains —
https://stripe.localhost, https://sqs.localhost — via a locally-generated CA, an
SNI-routing reverse proxy, and a managed /etc/hosts block:
network:
mode: subdomain
tld: localhost
tls: true
sync_hosts: true # manage the /etc/hosts block for *.tld
# spoof_real_hosts: true # redirect REAL hostnames (api.stripe.com) to the local sim
stunt trust # install stunt's CA into the system trust store (privileged)
stunt proxy start # start the TLS reverse proxy
stunt hosts sync # manage hosts entries manually if needed
The privileged listener forwards to an unprivileged engine, so adapter code never runs as root; HTTP/2 and WSS pass through verified.
Every running server serves its own localhost dashboard — a live request inspector for
HTTP traffic (bodies, headers, copy-as-curl, replay), a state browser (the
collections/kv/blobs your tests created), snapshot/restore for deterministic runs,
the profiles panel, and an instance manager. A matching CLI (--json) backs
every feature:
$ stunt up
dashboard: http://127.0.0.1:54321 (token: 9f3c…)
$ stunt ui # open it
$ stunt requests --follow # live request feed in the terminal
$ stunt replay <request-id> # re-issue a captured request
$ stunt ps # every running stunt server, across manifests
$ stunt doctor # health check: CA, manifest, adapters, ports
Servers stop gracefully (stunt stop / stunt down drain in-flight requests), work on
every OS, and can run as a system service (stunt service install). Loopback-only,
token-authed, DNS-rebinding-guarded; sensitive headers redacted; logging is async and
never backpressures requests. Full guide with screenshots:
docs/dashboard.md.

The whole point of a stunt double: the same run twice must behave the same way.
rng_seed fixes synthetic data (same seed → same fakes, same ids) and fault
rolls (when.chance), per service, from a fresh boot.stunt clean / stunt reset restore the seed world; stunt snapshot
captures and restores mid-run state for replay-style tests.The defining property: a community adapter is safe to install — adapter logic is sandboxed
Starlark with no host I/O, bounded by execution-step limits; all file reads an adapter can trigger
are path-containment-guarded; stunt adapter lint enforces synthetic-data-only. See
SECURITY.md for the full threat model.
Contributions are welcome — especially adapters. See CONTRIBUTING.md
for the workflow and quality gates (just ci = build + test -race + vet + gofmt + mod-tidy +
lint-adapters). Quick path: stunt adapter new myapi-style → edit → stunt adapter lint → PR.
Every reference adapter ships embedded in the binary, and nearly all are verified by
real test suites — official provider SDKs driven end-to-end in CI plus engine-level
suites — with the per-adapter scorecard published in
CONFORMANCE.md. The website (stuntapi.com)
carries the live conformance matrix and adapter catalog.
On the roadmap: a public catalog (today's stunt catalog is offline/bundled + git
refs), stunt setup privileged-path hardening, and broader adapter coverage.
Not planned for v1: GraphQL subscriptions, npm adapter distribution.
Found a security issue? See SECURITY.md — do not open a public issue.
cmd/stunt (CLI) · internal/{rules,manifest,engine,adapter(+runtime),starlark,grpcsim,graphqlsim}
· internal/primitives{,/blob,/clock,/events,/gen,/identity,/kv,/validator} ·
internal/netutil{,/proxy} · internal/contrib{,/openapi,/har,/lint,/conform,/proto,/scaffold}
· internal/catalog · internal/cli · internal/adapterdist · adapters/.
AGENTS.md (or run stunt llm for the in-binary reference) — the full
manifest schema, CLI reference, and the complete Starlark handler API.adapters/README.md — the adapter.yaml schema and the complete
Starlark builtins reference with exact signatures.docs/profiles.md.docs/dashboard.md.CONTRIBUTING.md.395 commits
Go
60.2%
Starlark
38.6%