I built a Stateful Mock API server because existing tools only return fixed responses
## 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: bash POS
## 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:
bash
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:
1. Write custom server code every time
2. 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.
bash
Create
curl -X POST https://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://my-api.scenariomock.com/users/abc123
β {"id": "abc123", "name": "Yoshi"}
Update
curl -X PUT https://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://my-api.scenariomock.com/users/abc123
β 204
After delete β 404
curl https://my-api.scenariomock.com/users/abc123
β 404
All of this is configured through a GUI β no code required.
### 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:
console
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:
plaintext
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:
typescript
function isAppHost(hostname: string): boolean {
return hostname.startsWith('app.');
}
function extractSlug(hostname: string): string | null {
const match = hostname.match(/^([a-z0-9-]+).(scenariomock.com|lvh.me)/);
if (!match || ['app', 'www'].includes(match[1])) return null;
return match[1];
}
- `app.scenariomock.com` β Management API (auth required)
- `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:
typescript
interface ScenarioHandler {
type: string;
configSchema: z.ZodType;
evaluate(
config: TConfig,
ctx: ScenarioContext,
state: StateStore,
responseLoader: ResponseLoader,
): Promise; // 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:
yaml
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:
yaml
key: collection:{projectId}:{stateKey}:seq
type: String (integer)
ops: INCR
For Sequence scenarios, the request counter is:
yaml
key: seq:{endpointId}:{scopeKey}
type: String (integer)
ops: INCR, EXPIRE
`scopeKey` can be `global`, the request IP, or a specific header value β so
you can simulate per-user rate limits.
### Real-time log streaming
Every Mock request is logged to PostgreSQL and also published to a Redis
Pub/Sub channel:
yaml
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:
typescript
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:
typescript
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 4 |
| 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 (Tokyo region) |
| Frontend hosting | Cloudflare Pages |
| DB hosting | Neon (serverless Postgres) |
| Redis hosting | Upstash (serverless Redis) |
| Error tracking | Sentry |
Monthly cost for the current scale: ~$3 (domain only β everything else is
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](https://play.google.com/store/apps/details?id=com.toolbox.m.mobile_api_tester)
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 π
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.
