A simple HTTP server that can be used to mock HTTP responses for testing purposes. Inspired by httpb
50K+
A simple HTTP server that can be used to mock HTTP responses for testing purposes. Inspired by httpbin and built using nodejs and fastify with the idea of running it via https://mockhttp.org, via docker jaredwray/mockhttp, or nodejs npm install @jaredwray/mockhttp.
@fastify/helmet built in by defaultnodejs, typescript, and fastifydocker or nodejsdocker run -d -p 3000:3000 jaredwray/mockhttp
services:
mockhttp:
image: jaredwray/mockhttp:latest
ports:
- "3000:3000"
If you want to run it on a different port, just change the 3000 to whatever port you want and add in the environment variable PORT to the environment.
services:
mockhttp:
image: jaredwray/mockhttp:latest
ports:
- "3001:3001"
environment:
- PORT=3001
You can see an example of this in the docker-compose.yaml file.
npm install @jaredwray/mockhttp --save
then run mockhttp in your code.
import { MockHttp } from '@jaredwray/mockhttp';
const mock = new MockHttp();
await mock.start(); // start the server
const response = await fetch('http://localhost:3000/get');
console.log(response);
await mock.close(); // stop the server
MockHttp supports HTTPS with auto-generated self-signed certificates or your own custom certificates. No external dependencies are required — certificate generation uses only Node.js built-in crypto.
The simplest way to enable HTTPS is to pass https: true. A self-signed certificate for localhost is generated automatically:
import { MockHttp } from '@jaredwray/mockhttp';
const mock = new MockHttp({ https: true });
await mock.start();
console.log(mock.isHttps); // true
// Use Fastify's built-in inject() for testing (no TLS setup needed)
const response = await mock.server.inject({ method: 'GET', url: '/get' });
console.log(response.statusCode); // 200
await mock.close();
Note: Self-signed certificates are not trusted by default. When making real HTTPS requests (e.g. with
fetch), setNODE_TLS_REJECT_UNAUTHORIZED=0in your test environment or use a custom HTTPS agent.
You can customize the auto-generated certificate by passing certificateOptions:
const mock = new MockHttp({
https: {
certificateOptions: {
commonName: 'my-test-server',
validityDays: 30,
keySize: 4096,
altNames: [
{ type: 'dns', value: 'example.local' },
{ type: 'dns', value: '*.example.local' },
{ type: 'ip', value: '192.168.1.100' },
],
},
},
});
await mock.start();
// Make requests...
await mock.close();
You can supply your own PEM-encoded certificate and key, either as strings or file paths:
// Using PEM strings
const mock = new MockHttp({
https: {
cert: '-----BEGIN CERTIFICATE-----\n...',
key: '-----BEGIN PRIVATE KEY-----\n...',
},
});
await mock.start();
// Make requests...
await mock.close();
// Using file paths
const mock = new MockHttp({
https: {
cert: '/path/to/cert.pem',
key: '/path/to/key.pem',
},
});
await mock.start();
// Make requests...
await mock.close();
You can also generate certificates independently using the exported utility functions:
import { generateCertificate, generateCertificateFiles } from '@jaredwray/mockhttp';
// Generate in-memory PEM strings
const { cert, key } = generateCertificate({
commonName: 'my-app',
validityDays: 90,
});
// Generate and write to disk
const result = await generateCertificateFiles({
certPath: './certs/cert.pem',
keyPath: './certs/key.pem',
commonName: 'my-app',
});
| Option | Type | Default | Description |
|---|---|---|---|
cert | string | — | PEM-encoded certificate string or file path |
key | string | — | PEM-encoded private key string or file path |
autoGenerate | boolean | true | Auto-generate a self-signed certificate when cert/key are not provided |
certificateOptions | CertificateOptions | — | Options for the auto-generated certificate |
| Option | Type | Default | Description |
|---|---|---|---|
commonName | string | 'localhost' | Certificate subject Common Name (CN) |
altNames | Array<{ type, value }> | [dns:localhost, ip:127.0.0.1, ip:::1] | Subject Alternative Names with type 'dns' or 'ip' |
validityDays | number | 365 | Certificate validity period in days |
keySize | number | 2048 | RSA key size in bits |
MockHttp supports HTTP/2 in two modes:
http2: true and https: truehttp2: true, useful for service-to-service testingimport { MockHttp } from '@jaredwray/mockhttp';
const mock = new MockHttp({ http2: true, https: true });
await mock.start();
console.log(mock.http2); // true
console.log(mock.isHttps); // true
const response = await mock.server.inject({ method: 'GET', url: '/get' });
console.log(response.statusCode); // 200
await mock.close();
By default, HTTP/1.1 clients can still connect via ALPN negotiation (http1 defaults to true). To disable HTTP/1.1 fallback:
const mock = new MockHttp({ http2: true, https: true, http1: false });
await mock.start();
const mock = new MockHttp({ http2: true });
await mock.start();
console.log(mock.http2); // true
await mock.close();
Note: Browsers do not support h2c. This mode is useful for testing gRPC or service-to-service communication.
HTTP2=true node your-app.js
The injection/tap feature allows you to "tap into" the request flow and inject custom responses for specific requests. This is particularly useful for:
A "tap" is a reference to an injected response, similar to "wiretapping" - you're intercepting requests and returning predefined responses. Each tap can be removed when you're done with it, restoring normal server behavior.
import { mockhttp } from '@jaredwray/mockhttp';
const mock = new mockhttp();
await mock.start();
// Inject a simple response
const tap = mock.taps.inject(
{
response: "Hello, World!",
statusCode: 200,
headers: { "Content-Type": "text/plain" }
},
{
url: "/api/greeting",
method: "GET"
}
);
// Make requests - they will get the injected response
const response = await fetch('http://localhost:3000/api/greeting');
console.log(await response.text()); // "Hello, World!"
// Remove the injection when done
mock.taps.removeInjection(tap);
await mock.close();
const tap = mock.taps.inject(
{
response: { message: "Success", data: { id: 123 } },
statusCode: 200
},
{ url: "/api/users/123" }
);
// Match all requests under /api/
const tap = mock.taps.inject(
{
response: "API is mocked",
statusCode: 503
},
{ url: "/api/*" }
);
const tap1 = mock.taps.inject(
{ response: "Users data" },
{ url: "/api/users" }
);
const tap2 = mock.taps.inject(
{ response: "Posts data" },
{ url: "/api/posts" }
);
// View all active injections
console.log(mock.taps.injections); // Map of all active taps
// Remove specific injections
mock.taps.removeInjection(tap1);
mock.taps.removeInjection(tap2);
// Only intercept POST requests
const tap = mock.taps.inject(
{ response: "Created", statusCode: 201 },
{ url: "/api/users", method: "POST" }
);
const tap = mock.taps.inject(
{ response: "Authenticated response" },
{
url: "/api/secure",
headers: {
"authorization": "Bearer token123"
}
}
);
// Match ALL requests (no matcher specified)
const tap = mock.taps.inject({
response: "Server is in maintenance mode",
statusCode: 503
});
You can provide a function that dynamically generates the response based on the incoming request:
// Function response with access to the request object
const tap = mock.taps.inject(
(request) => {
return {
response: {
message: `You requested ${request.url}`,
method: request.method,
timestamp: new Date().toISOString()
},
statusCode: 200,
headers: {
"X-Request-Path": request.url
}
};
},
{ url: "/api/*" }
);
// Conditional responses based on request
const tap = mock.taps.inject((request) => {
// Return error for URLs containing 'error'
if (request.url.includes('error')) {
return {
response: { error: "Something went wrong" },
statusCode: 500
};
}
// Return success for everything else
return {
response: { status: "success" },
statusCode: 200
};
});
// Dynamic headers based on request
const tap = mock.taps.inject(
(request) => ({
response: "OK",
statusCode: 200,
headers: {
"X-Original-Method": request.method,
"X-Original-URL": request.url,
"X-Original-Host": request.hostname
}
}),
{ url: "/api/mirror" }
);
Bins are ephemeral URL endpoints that capture incoming HTTP requests so you can inspect them later. They're the inverse of Taps: instead of injecting a response, they record everything they receive. Useful for:
There are two URL prefixes:
/bins — JSON management API: create, list, inspect, delete bins/b/:id — the capture URL. Any HTTP method, any sub-path, any body sent
here is stored against bin :id# 1. create a bin
curl -s -X POST http://localhost:3000/bins
# → { "id": "abc123def456", "url": "http://localhost:3000/b/abc123def456",
# "createdAt": "2026-05-18T12:00:00.000Z",
# "expiresAt": "2026-05-19T12:00:00.000Z",
# "requestCount": 0 }
# 2. send anything to the capture URL
curl -X POST "http://localhost:3000/b/abc123def456/webhook?source=stripe" \
-H 'content-type: application/json' \
-d '{"event":"payment.succeeded"}'
# → { "ok": true, "binId": "abc123def456", "requestId": "f9c2e1a40b3d" }
# 3. list captured requests (newest first)
curl http://localhost:3000/bins/abc123def456/requests
| Method | Path | Description |
|---|---|---|
| POST | /bins | Create a new bin |
| GET | /bins | List all active bins |
| GET | /bins/:id | Get bin metadata |
| GET | /bins/:id/requests | List captured requests (newest first) |
| GET | /bins/:id/requests/:reqId | Get a single captured request |
| DELETE | /bins/:id/requests | Clear all captured requests in a bin |
| DELETE | /bins/:id | Delete a bin |
| ANY | /b/:id and /b/:id/* | Capture requests sent to a bin |
A complete walkthrough using curl:
BASE=http://localhost:3000
# Create the bin and grab its id
ID=$(curl -s -X POST $BASE/bins | jq -r .id)
echo "Bin URL: $BASE/b/$ID"
# Simulate a Stripe webhook
curl -s -X POST "$BASE/b/$ID/stripe/events?secret=whsec_test" \
-H 'content-type: application/json' \
-H 'stripe-signature: t=1700000000,v1=abc123' \
-d '{"id":"evt_1","type":"payment_intent.succeeded","data":{"object":{"amount":2000}}}'
# Simulate a GitHub webhook
curl -s -X POST "$BASE/b/$ID/github" \
-H 'content-type: application/json' \
-H 'x-github-event: pull_request' \
-d '{"action":"opened","number":42}'
# Inspect everything that arrived
curl -s "$BASE/bins/$ID/requests" | jq
# Drill into the most recent one
RID=$(curl -s "$BASE/bins/$ID/requests" | jq -r '.requests[0].id')
curl -s "$BASE/bins/$ID/requests/$RID" | jq
# {
# "id": "f9c2e1a40b3d",
# "binId": "abc123def456",
# "method": "POST",
# "url": "/github",
# "path": "/github",
# "query": {},
# "headers": {
# "host": "localhost:3000",
# "content-type": "application/json",
# "x-github-event": "pull_request"
# },
# "remoteAddress": "127.0.0.1",
# "contentType": "application/json",
# "bodySize": 32,
# "body": "{\"action\":\"opened\",\"number\":42}",
# "bodyEncoding": "utf8",
# "truncated": false,
# "capturedAt": "2026-05-18T12:00:01.234Z"
# }
# Clean up
curl -s -X DELETE "$BASE/bins/$ID/requests" # clear captures, keep the bin
curl -s -X DELETE "$BASE/bins/$ID" # delete the bin entirely
{
"id": "f9c2e1a40b3d",
"binId": "abc123def456",
"method": "POST",
"url": "/webhook?source=stripe",
"path": "/webhook",
"query": { "source": "stripe" },
"headers": { "content-type": "application/json", "...": "..." },
"remoteAddress": "127.0.0.1",
"contentType": "application/json",
"bodySize": 32,
"body": "{\"event\":\"payment.succeeded\"}",
"bodyEncoding": "utf8",
"truncated": false,
"capturedAt": "2026-05-18T12:00:00.000Z"
}
url is the request URL relative to the bin (everything after /b/:id).path is the sub-path only, without the query string.bodyEncoding is "utf8" for text content types (text/*, application/json,
application/xml, application/x-www-form-urlencoded, application/javascript),
"base64" for everything else (binary payloads), or "none" when no body
was sent.truncated is true when the body exceeded maxBodySize and was cut
off. bodySize reflects the original length.JSON, XML, form-encoded, plain text, and JavaScript are stored as UTF-8 strings so you can read them directly:
curl -X POST "http://localhost:3000/b/$ID" \
-H 'content-type: application/json' \
-d '{"hello":"world"}'
curl -s "http://localhost:3000/bins/$ID/requests" | jq '.requests[0] | {bodyEncoding, body}'
# {
# "bodyEncoding": "utf8",
# "body": "{\"hello\":\"world\"}"
# }
Any non-text content type is base64-encoded, preserving the bytes exactly:
# Upload a PNG to the bin
curl -X POST "http://localhost:3000/b/$ID/upload" \
-H 'content-type: image/png' \
--data-binary @logo.png
curl -s "http://localhost:3000/bins/$ID/requests" | jq '.requests[0] | {bodyEncoding, bodySize}'
# {
# "bodyEncoding": "base64",
# "bodySize": 14823
# }
# Decode the body back to bytes
curl -s "http://localhost:3000/bins/$ID/requests" \
| jq -r '.requests[0].body' | base64 -d > recovered.png
Bodies larger than maxBodySize (1 MiB by default) are cut off and flagged:
// In test setup
import { MockHttp, BinManager } from '@jaredwray/mockhttp';
const mock = new MockHttp();
mock.bins = new BinManager({ maxBodySize: 1024 }); // 1 KiB cap
await mock.start();
# Send 5000 bytes to a bin with a 1 KiB cap
head -c 5000 /dev/urandom | curl -X POST "http://localhost:3000/b/$ID" \
-H 'content-type: application/octet-stream' \
--data-binary @-
curl -s "http://localhost:3000/bins/$ID/requests" | jq '.requests[0] | {bodySize, truncated, body_len: (.body | length)}'
# {
# "bodySize": 5000, ← original size
# "truncated": true,
# "body_len": 1368 ← base64 of the first 1024 bytes
# }
Once a bin reaches maxRequestsPerBin captures (100 by default), the oldest
ones are dropped:
# Send 105 requests
for i in $(seq 1 105); do
curl -s -X POST "http://localhost:3000/b/$ID/event/$i" > /dev/null
done
# The bin holds the most recent 100; the first 5 are gone
curl -s "http://localhost:3000/bins/$ID/requests" | jq '.requests | length'
# 100
# Newest is at the top
curl -s "http://localhost:3000/bins/$ID/requests" | jq '.requests[0].path'
# "/event/105"
The bin manager is exposed on the MockHttp instance as mock.bins. This
makes it easy to drive bins from a test suite without going through HTTP:
import { MockHttp } from '@jaredwray/mockhttp';
const mock = new MockHttp({ logging: false });
await mock.start();
// Create a bin
const bin = mock.bins.createBin();
console.log(`Webhook target: http://localhost:${mock.port}/b/${bin.id}`);
// ... point your code-under-test at that URL ...
// Read captured requests (newest first)
const requests = mock.bins.getRequests(bin.id);
console.log(`Captured ${requests.length} requests`);
for (const req of requests) {
console.log(`${req.method} ${req.path} (${req.bodySize} bytes)`);
}
// Clean up
mock.bins.deleteBin(bin.id);
await mock.close(); // also stops the bin cleanup timer
import { afterAll, beforeAll, expect, test } from 'vitest';
import { MockHttp } from '@jaredwray/mockhttp';
let mock: MockHttp;
beforeAll(async () => {
mock = new MockHttp({ logging: false });
await mock.start();
});
afterAll(async () => {
await mock.close();
});
test('my SDK sends the right webhook payload', async () => {
const bin = mock.bins.createBin();
const webhookUrl = `http://localhost:${mock.port}/b/${bin.id}`;
// Drive your SDK at the bin
await mySdk.notify(webhookUrl, { event: 'user.created', id: 42 });
// Assert on what actually arrived
const [captured] = mock.bins.getRequests(bin.id);
expect(captured.method).toBe('POST');
expect(captured.contentType).toBe('application/json');
expect(JSON.parse(captured.body!)).toEqual({
event: 'user.created',
id: 42,
});
expect(captured.headers['x-signature']).toBeDefined();
});
Bins are enabled by default. To disable the routes entirely:
const mock = new MockHttp({
httpBin: { bins: false },
});
To tune limits, replace the default BinManager before starting:
import { MockHttp, BinManager } from '@jaredwray/mockhttp';
const mock = new MockHttp();
mock.bins = new BinManager({
defaultTtlMs: 60 * 60 * 1000, // 1 hour (default: 24h)
maxRequestsPerBin: 500, // (default: 100)
maxBodySize: 5 * 1024 * 1024, // 5 MiB (default: 1 MiB)
idLength: 16, // (default: 12)
cleanupIntervalMs: 30 * 1000, // sweep every 30s (default: 60s)
});
await mock.start();
| Option | Default | Description |
|---|---|---|
defaultTtlMs | 86400000 (24h) | Bin lifetime. Expired bins return 404 and are lazily removed. |
maxRequestsPerBin | 100 | When exceeded, oldest captures are dropped (FIFO). |
maxBodySize | 1048576 (1 MiB) | Larger bodies are truncated; truncated: true is set on the capture. |
idLength | 12 | Length of generated bin and request ids. |
cleanupIntervalMs | 60000 (1 min) | How often expired bins are swept. The timer is unref()'d so it never keeps the process alive. |
BinManager accepts a store: BinStore so you can plug in alternative
backends (Redis, SQLite, etc.) without changing the rest of the codebase. The
default InMemoryBinStore keeps state in process memory.
import {
BinManager,
InMemoryBinStore,
type Bin,
type BinStore,
type CapturedRequest,
} from '@jaredwray/mockhttp';
class RedisBinStore implements BinStore {
createBin(bin: Bin): void { /* SET bin:${bin.id} ... */ }
getBin(id: string): Bin | undefined { /* GET ... */ }
listBins(): Bin[] { /* SCAN ... */ }
deleteBin(id: string): boolean { /* DEL ... */ }
addRequest(binId: string, req: CapturedRequest, max: number): void {
/* LPUSH bin:${binId}:requests + LTRIM to max */
}
getRequests(binId: string): CapturedRequest[] { /* LRANGE ... */ }
getRequest(binId: string, reqId: string): CapturedRequest | undefined { /* ... */ }
clearRequests(binId: string): void { /* DEL bin:${binId}:requests */ }
cleanupExpired(now: number): string[] { /* scan + delete */ return []; }
}
mock.bins = new BinManager({ store: new RedisBinStore() });
MockHttp supports rate limiting using @fastify/rate-limit. Rate limiting is enabled by default at 1000 requests per minute with localhost (127.0.0.1 and ::1) excluded from rate limiting.
By default, MockHttp applies the following rate limit:
import { MockHttp } from '@jaredwray/mockhttp';
const mock = new MockHttp();
await mock.start();
// Rate limiting is active (1000 req/min) except for localhost
To customize rate limiting, pass a rateLimit configuration object when creating your MockHttp instance:
import { MockHttp } from '@jaredwray/mockhttp';
const mock = new MockHttp({
rateLimit: {
max: 100, // Maximum 100 requests
timeWindow: '1 minute' // Per 1 minute window
}
});
await mock.start();
The rateLimit option accepts all @fastify/rate-limit options:
// Limit to 50 requests per minute
const mock = new MockHttp({
rateLimit: {
max: 50,
timeWindow: '1 minute'
}
});
const mock = new MockHttp({
rateLimit: {
max: 30,
timeWindow: 60000, // 1 minute in milliseconds
errorResponseBuilder: (req, context) => ({
statusCode: 429,
error: 'Too Many Requests',
message: `Rate limit exceeded. Try again in ${context.after}`
})
}
});
const mock = new MockHttp({
rateLimit: {
max: 100,
timeWindow: '1 minute',
allowList: ['127.0.0.1', '192.168.1.100'] // These IPs byp
Content type
Image
Digest
sha256:e9b773549…
Size
142.3 MB
Last updated
12 days ago
docker pull jaredwray/mockhttp