DEV Community

Cover image for I built a Stateful Mock API server because existing tools only return fixed responses
ToolboxMApp
ToolboxMApp

Posted on Edited on

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:

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
Enter fullscreen mode Exit fullscreen mode

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.

ScenarioMock landing page:


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
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Endpoint list of the E-Commerce API demo project in ScenarioMock


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;
}
Enter fullscreen mode Exit fullscreen mode
  • 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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For Sequence scenarios, the request counter is:

key:   seq:{endpointId}:{scenarioId}:{scopeKey}
type:  String (integer)
ops:   INCR, EXPIRE
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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) }),
  // ...
]);
Enter fullscreen mode Exit fullscreen mode

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 },
  });
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
toolboxm_dc7e385f763e5d5 profile image
ToolboxMApp •
## Update: 2 more scenario examples in the live demo

A couple of readers asked what else the Scenario Engine can simulate beyond
payments and auth checks, so I added two more examples to the live demo:

**Feature flag rollout (Conditional)** — the same `if / then / default` rule
structure used for auth, applied to A/B testing instead:

Enter fullscreen mode Exit fullscreen mode


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": [...]}


**Flaky shipping API (Sequence)** — instead of a payment retry, this
simulates a third-party carrier API that times out, fails, then recovers:

Enter fullscreen mode Exit fullscreen mode


bash
POST /api/shipping/quote

1st call → 504 (3s delay, simulated timeout)

2nd call → 500 (simulated carrier outage)

3rd call onward → 200 (quote returned)


Useful for testing retry logic and circuit breakers against something more
realistic than a clean 200/500 split.

Try both live, no signup required:
https://scenariomock.com/shared/demo-mock--ecommerce-api
Enter fullscreen mode Exit fullscreen mode
Collapse
 
toolboxm_dc7e385f763e5d5 profile image
ToolboxMApp •
## Update: endpoints can now document their own requests

Sharing a public project URL surfaced a problem I hadn't anticipated:
someone would open an endpoint whose Conditional scenario branches on a
header — say, `X-Role: admin` — and they'd have no way of knowing that
header existed. They'd hit Send with nothing set, get the default
response, and assume the mock was broken.

The endpoint URL and method were being shared, but the *knowledge* of
what to send wasn't.

So I added a `request_docs` structure to endpoints: each header, query
param, and path param can carry a description and an example value.
The body can have an example payload and a free-text description. You
can also list expected error responses ("returns 401 if Authorization
is missing").

The important part is what happens with the example values — they
auto-fill the built-in API client. So instead of a blank form, visitors
to a shared URL see a request that's already populated with sensible
defaults for that specific endpoint. Hit Send, see the scenario behave
as documented, done.

Enter fullscreen mode Exit fullscreen mode


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


One implementation note if you're building something similar: the
example values only need to seed React state once, but if the owner
edits the docs after the API client component has already mounted,
`useState`'s initial value won't pick up the change. I ended up keying
the component on `JSON.stringify(requestDocs)` to force a remount when
the docs change — simpler than threading a reset callback through.

Try it on the role-based access endpoint in the live demo — the
headers are pre-filled, just hit Send:
https://scenariomock.com/shared/demo-mock--ecommerce-api
Enter fullscreen mode Exit fullscreen mode