Dev.to WebDev πŸ›  Dev πŸ‘ 1 πŸ“– 5 min read

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

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
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 πŸ‘‡

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.