The problem with existing mock tools
Every time I needed to test an API client, I ran into the same wall.
I'd set up Mockoon or WireMock, configure my endpoints, and then try to test a
realistic flow:
POST /users {"name": "Yoshi"}
# → 201 {"id": "abc123", "name": "Yoshi"} ✅
GET /users/abc123
# → 200 {"id": "abc123", "name": "Yoshi"} ❌ returns the fixed mock, not the created user
The second request always returns the preconfigured static response — not the
resource that was just created. Because that's how mock tools work: they replay
fixed responses, they don't maintain state.
To test a real CRUD flow, I had two options:
- Write custom server code every time
- Stand up an actual backend
Neither felt right for "I just want to test my API client."
So I built ScenarioMock.
What ScenarioMock does
Stateful Mock
Resources created via POST are stored in Redis and can be retrieved, updated,
and deleted — just like a real database-backed API.
# Create
curl -X POST https://yourname--my-api.scenariomock.com/users \
-H "Content-Type: application/json" \
-d '{"name": "Yoshi"}'
# → {"id": "abc123", "name": "Yoshi"}
# Retrieve — returns the created resource, not a fixed response
curl https://yourname--my-api.scenariomock.com/users/abc123
# → {"id": "abc123", "name": "Yoshi"}
# Update
curl -X PUT https://yourname--my-api.scenariomock.com/users/abc123 \
-H "Content-Type: application/json" \
-d '{"role": "admin"}'
# → {"id": "abc123", "name": "Yoshi", "role": "admin"}
# Delete
curl -X DELETE https://yourname--my-api.scenariomock.com/users/abc123
# → 204
# After delete → 404
curl https://yourname--my-api.scenariomock.com/users/abc123
# → 404
All of this is configured through a GUI — no code required.
Each project gets its own subdomain in the form {your-domain}--{project-slug}.scenariomock.com.
Scenario Engine
Three scenario types for dynamic behavior:
Static — Always return the same response. The simplest case.
Sequence — Return different responses based on request count:
Request #1 → 200 {"ok": true}
Request #2 → 429 {"error": "rate limited"}
Request #3+ → 200 {"ok": true}
Useful for testing retry logic, rate limit handling, or transient error recovery.
Conditional — Branch based on request content:
If Authorization header is missing → 401
Otherwise → 200
If body.email matches .*@test\.com$ → 403
Otherwise → 200
Conditions can reference headers, query params, body fields (dot notation), or
path parameters. Logic operators all / any / not can be nested.
Shared URL
Make a project public and share the URL. Anyone can:
- View the endpoint list
- Send test requests directly in the browser
- See real-time request logs via WebSocket
No account required to view or test. Perfect for sharing a mock with a client
or a teammate who just needs to hit an endpoint.
Here's a live example — a mock e-commerce API with stateful CRUD and a couple
of scenarios wired up — you can try right now, no signup required:
https://scenariomock.com/shared/demo-mock--ecommerce-api
Technical design decisions
Single Fastify process, hostname-based routing
ScenarioMock runs as a single Node.js process. The host header determines
which handler runs:
function isAppHost(hostname: string): boolean {
return hostname.startsWith('app.') || hostname === 'localhost';
}
// yourname--my-api.scenariomock.com → { domain: 'yourname', slug: 'my-api' }
function parseSubdomain(hostname: string): { domain: string; slug: string } | null {
const match = hostname.split(':')[0].match(/^([a-z0-9][a-z0-9-]*)\.(.+)$/);
if (!match || ['app', 'www'].includes(match[1])) return null;
const [domain, slug] = match[1].split('--');
return domain && slug ? { domain, slug } : null;
}
-
app.scenariomock.com→ Management API (auth required) -
yourname--my-api.scenariomock.com→ Mock handler (no auth, catch-all routes)
For local development, *.lvh.me resolves to 127.0.0.1 via a public DNS
service, so subdomain routing works without editing /etc/hosts.
Pluggable scenario handlers
Each scenario type implements a ScenarioHandler interface:
interface ScenarioHandler<TConfig = unknown> {
type: string;
configSchema: z.ZodType<TConfig>;
evaluate(
config: TConfig,
ctx: ScenarioContext,
state: StateStore,
responseLoader: ResponseLoader,
): Promise<ScenarioResult | null>; // null = skip to next scenario
}
The engine iterates through enabled scenarios in priority order. The first
non-null result wins. Adding a new scenario type means implementing this
interface — no changes to the engine itself.
Redis for scenario state
The stateful_collection scenario stores data in Redis Hash:
key: collection:{projectId}:{stateKey}
type: Hash
field: {itemId}
value: JSON string
TTL: configurable (default 24h)
Using Redis Hash gives us O(1) HGET/HSET operations and native TTL support.
The sequential ID counter lives alongside it:
key: collection:{projectId}:{stateKey}:seq
type: String (integer)
ops: INCR
For Sequence scenarios, the request counter is:
key: seq:{endpointId}:{scenarioId}:{scopeKey}
type: String (integer)
ops: INCR, EXPIRE
scopeKey can be global, the caller's IP, or a specific header value — so
you can simulate per-user rate limits. Each Sequence scenario keeps its own
counter.
Real-time log streaming
Every Mock request is logged to PostgreSQL and also published to a Redis
Pub/Sub channel:
channel: project:{projectId}:logs
payload: JSON (same shape as request_logs table)
The WebSocket handler subscribes and forwards messages to connected browsers.
Authorization headers and cookies are redacted server-side before logging.
Bugs I hit along the way
Zod union order matters
I had this in the conditional scenario's predicate schema:
const predicateSchema = z.union([
z.object({ equals: z.unknown() }), // ← first
z.object({ missing: z.literal(true) }),
// ...
]);
z.unknown() accepts undefined, so { missing: true } was parsed as
{ equals: undefined } — which JSON.stringify turns into {}. The evaluator
saw an empty object, matched nothing, and the condition was always false.
Fix: put the most constrained types first, z.unknown() last.
Vite proxy + DELETE + Content-Type = 500
My apiRequest wrapper always set Content-Type: application/json, even for
requests without a body. Through Vite's dev proxy, this caused DELETE requests
to be sent as chunked transfer encoding. Fastify saw Content-Type: application/json
and tried to parse the empty body — SyntaxError → 500.
Fix: only set Content-Type when options.body !== undefined.
ZodError wasn't caught by the error handler
Routes used schema.parse(req.body) which throws ZodError on invalid input.
The global error handler only checked for AppError and Fastify's built-in
error.validation — ZodError fell through to the 500 handler.
Fix: add instanceof ZodError check before the generic 500 branch:
if (error instanceof ZodError) {
return reply.code(400).send({
error: { code: 'validation_error', message: 'Validation failed', details: error.errors },
});
}
Stack summary
| Layer | Technology |
|---|---|
| Runtime | Node.js 20 + TypeScript strict |
| HTTP framework | Fastify 5 |
| Database | PostgreSQL 16 via Kysely |
| Cache / state | Redis 7 via ioredis |
| Validation | Zod |
| Frontend | React 18 + Vite + Tailwind + shadcn/ui |
| Auth | JWT (HS256) via @fastify/jwt |
| Payments | Stripe Checkout + Webhooks |
| Backend hosting | Fly.io (Singapore region) |
| Frontend hosting | Cloudflare Pages |
| DB hosting | Neon (serverless Postgres) |
| Redis hosting | Upstash (serverless Redis) |
| Error tracking | Sentry |
Monthly cost at launch (July 2026): ~$3 (domain only — everything else was
within free tiers).
Android companion app
I also build Android apps, and I wanted a way to test ScenarioMock endpoints
from my phone. My existing app fits perfectly:
API Tester – REST & JSON Client
Build the mock on ScenarioMock, fire requests from the Android app. A complete
API testing workflow without running any local server.
Try it
https://scenariomock.com — Free plan, no credit card required.
I'd love to hear what you think. Which scenario type would you use most?
What's missing? Drop a comment below 👇
If you want to see this in practice, I wrote up how to use it for
testing retry logic.


Top comments (2)
bash
GET /api/feature-flags/new-dashboard
no header → {"enabled": false, "variant": "stable"}
X-User-Segment: beta → {"enabled": true, "variant": "beta"}
X-User-Segment: internal → {"enabled": true, "variant": "internal", "experimental_features": [...]}
bash
POST /api/shipping/quote
1st call → 504 (3s delay, simulated timeout)
2nd call → 500 (simulated carrier outage)
3rd call onward → 200 (quote returned)
bash
Before: visitor has no idea X-Role matters
GET /api/users/me
→ default response, looks like nothing special is happening
After: request_docs sets header.x-role example = "admin",
ApiClient pre-fills it, visitor just hits Send
GET /api/users/me
X-Role: admin
→ admin profile, the branching is immediately obvious