Skip to content

REST API Reference

Rift provides a Mountebank-compatible REST API for managing imposters.


Base URL

http://localhost:2525

Authentication

When Rift is started with --api-key <TOKEN> (or --apikey, or MB_APIKEY), every admin API request must send the token, as the raw value, in the Authorization header or in Mountebank's x-api-key header. When a request sends both, Authorization decides alone and x-api-key is ignored, so a wrong Authorization is refused even beside a right x-api-key. Requests without a matching token receive 401 Unauthorized. Data-plane traffic — direct imposter ports and the /__rift/:port/... gateway — is not gated, and needs neither header.

A blank token is a startup error, not a key: it would enable this gate and then authenticate every request. Omit the option to run the admin API explicitly unauthenticated.

curl -H "Authorization: <TOKEN>" http://localhost:2525/imposters
curl -H "x-api-key: <TOKEN>" http://localhost:2525/imposters   # Mountebank's header

HEAD, OPTIONS and imposter ids

Every admin route answers HEAD and OPTIONS, as Mountebank's Express server does: HEAD is the GET without its body (a route with no GET answers 404), and OPTIONS is 200 with the route's methods, sorted, in Allow and as a text/plain body (DELETE, GET, HEAD, POST, PUT). An imposter id that is not a port (/imposters/abc, /imposters/70000) is "no such imposter", answered as an absent port is on every /imposters/:id route.

A path or method no route takes (/nope, POST /config, PATCH /imposters) answers 404 with Express's HTML error page (Cannot POST /config), byte for byte as Mountebank does, not the JSON error envelope. An absent imposter on a real route (GET /imposters/9999) stays a JSON 404.

A trailing / is ignored on every admin route, as Mountebank's Express router ignores it: /imposters/, /imposters/4545/ and /config/ answer exactly as /imposters, /imposters/4545 and /config do. Only one / is dropped, so /imposters// is 404. Gateway paths (/__rift/:port/…) keep their trailing slash: it is part of the path the imposter matches.

Route names are case-insensitive, as in Mountebank: /IMPOSTERS, /Imposters/4545/Stubs/0 and /CONFIG answer exactly as /imposters, /imposters/4545/stubs/0 and /config do. Ids, keys and names in a path are not: the space Alpha is not the space alpha, and a flow-state key Key is not key. The gateway prefix /__rift/ matches only in lowercase.


Root

GET /

Get API information and links.

Response:

{
  "_links": {
    "imposters": { "href": "http://localhost:2525/imposters" },
    "config": { "href": "http://localhost:2525/config" },
    "logs": { "href": "http://localhost:2525/logs" }
  }
}

The links are absolute. They use the Host header of the request.


Imposters

GET /imposters

List all imposters.

Imposters are returned in ascending port order. This ordering is guaranteed and stable across calls, so a client may rely on it — for example when diffing two snapshots of the server state. Earlier releases followed an internal hash map order, which could vary between calls (#713).

Query Parameters:

  • replayable (boolean) - Return each imposter's full config, for export
  • removeProxies (boolean) - Strip proxy responses. With replayable it applies to the export; on its own it returns the full imposters (the GET /imposters/{port} document for each) with proxies removed, as Mountebank does, instead of the summary
  • list (boolean) - Return a shorter entry per imposter: protocol, port, name, defaultResponse, numberOfRequests, _links

Without either flag each entry is the summary shown below, plus _links. In both shapes an imposter's defaultResponse is included when it has one (#1334), as Mountebank prints it.

Response:

{
  "imposters": [
    {
      "port": 4545,
      "protocol": "http",
      "name": "User Service",
      "defaultResponse": { "statusCode": 404, "body": "not mocked" },
      "numberOfRequests": 42,
      "stubCount": 3,
      "enabled": true,
      "recordRequests": false
    },
    {
      "port": 4546,
      "protocol": "https",
      "name": "Payment Service",
      "numberOfRequests": 15,
      "stubCount": 1,
      "enabled": true,
      "recordRequests": true
    }
  ]
}

Example:

curl http://localhost:2525/imposters
curl "http://localhost:2525/imposters?replayable=true"


POST /imposters

Create a new imposter.

Request Body:

{
  "port": 4545,
  "protocol": "http",
  "name": "My Service",
  "stubs": [
    {
      "predicates": [{ "equals": { "path": "/test" } }],
      "responses": [{ "is": { "statusCode": 200, "body": "OK" } }]
    }
  ]
}

Response: 201 Created with the imposter detail (as GET /imposters/{port}, so it carries defaultResponse and, for https, the TLS metadata). 400 for invalid JSON or an invalid imposter. That includes a single-valued header object (proxy.injectHeaders, _rift.fault.error.headers) naming one header twice in different cases (#1050), and a scripted config without --allowInjection (400 invalid injection).

{
  "port": 4545,
  "protocol": "http",
  "name": "My Service",
  "numberOfRequests": 0,
  "enabled": true,
  "recordRequests": false,
  "requests": [],
  "stubs": [...],
  "_links": { "self": { "href": "http://localhost:2525/imposters/4545" }, ... }
}

A port that another imposter already holds is a 400 (not a 409); a port held by some other process fails to bind and is a 500.

A top-level "mode": "binary" is read at creation and not stored, as in Mountebank: it refuses a matches predicate or a jsonpath/xpath selector with 400 (the matches predicate is not allowed in binary mode), here, on PUT /imposters and in a config file. It is never printed, and the stub routes do not apply it to an imposter created with it.

Example:

curl -X POST http://localhost:2525/imposters \
  -H "Content-Type: application/json" \
  -d '{
    "port": 4545,
    "protocol": "http",
    "stubs": [{
      "responses": [{ "is": { "statusCode": 200 } }]
    }]
  }'


PUT /imposters

Replace all imposters (bulk create/update). The whole set is validated first, then the running set is reconciled toward the payload — the same incremental engine as POST /admin/reload — rather than deleted wholesale and recreated, so a failure never deletes what was running: imposters absent from the payload are deleted, changed ones are replaced (or stub-patched), and a port that fails is reported in failed while the rest are applied.

The response lists the body's imposters in request order, as Mountebank does, with each port-less imposter at its position; GET /imposters lists them by ascending port.

After a successful apply, every imposter in the body starts with fresh runtime state, as in Mountebank: response cycles, recorded requests and numberOfRequests, injection state, scenario and flow state, and saved proxyOnce responses. Its listener stays bound. A flow store supplied by an embedder's FlowStoreProvider is left alone.

PUT /imposters?reconcile=true keeps the runtime state of imposters whose config is unchanged (and of unchanged stubs in a stub-patched one), the same reconcile as POST /admin/reload and rift_apply_config. reconcile=false is the default; any other value is a 400. An engine older than this parameter ignores it and reconciles, so a client may send it unconditionally.

Request Body:

{
  "imposters": [
    { "port": 4545, "protocol": "http", "stubs": [...] },
    { "port": 4546, "protocol": "http", "stubs": [...] }
  ]
}

Response: 200 OK

{
  "imposters": [...],
  "created": [4546],
  "replaced": [],
  "stubPatched": [4545],
  "toggled": [],
  "deleted": [4544]
}

imposters is the same listing as GET /imposters, unchanged for Mountebank clients. The five port arrays are always present and mirror POST /admin/reload: created, replaced, stubPatched, deleted, and toggled (ports whose only change was the enabled flag, applied in place with runtime state intact). An unchanged imposter appears in none of them.

Errors:

  • 400 Bad Request — the set failed validation (bad protocol, duplicate port, duplicate stub id); the running imposters are unchanged. An absent or 0 port is auto-assigned, never a duplicate; such an imposter is re-created on each PUT, after every imposter with an explicit port, so it never takes a port an explicit imposter in the set is serving.
  • 500 Internal Server Error — one or more imposters failed to apply (e.g. a port bind failure); the body carries the per-port failed list plus the created/replaced/stubPatched/toggled/deleted report of what did apply, mirroring POST /admin/reload.

GET /imposters/{port}

Get imposter details.

Query Parameters:

  • replayable (boolean) - Return the imposter's config for export instead of the details below: the same document GET /imposters?replayable=true lists for it, with the stubs it serves now and no numberOfRequests, requests or _links
  • removeProxies (boolean) - Exclude proxy responses (a stub left with none is dropped)

Response:

{
  "port": 4545,
  "protocol": "http",
  "name": "My Service",
  "defaultResponse": { "statusCode": 404, "body": "not mocked" },
  "numberOfRequests": 42,
  "enabled": true,
  "recordRequests": true,
  "requests": [
    {
      "method": "GET",
      "path": "/test",
      "headers": {...},
      "timestamp": "2024-01-15T10:30:00.123Z"
    }
  ],
  "stubs": [...],
  "_links": { "self": { "href": "http://localhost:2525/imposters/4545" }, ... }
}

The detail prints the keys Mountebank's detail prints (#1334): defaultResponse when set, and for an https imposter its TLS metadata — mutualAuth, rejectUnauthorized and ca always (ca is null when unset, and otherwise printed as posted: a one-element array stays an array), ciphers upper-cased when set, and cert and key when the imposter configured them (a self-signed or server-default certificate is not printed). The create response and ?replayable=true carry the same TLS keys (#1576); the --datadir file stores only what was posted. allowCORS is a Mountebank key, printed in every view when truthy: it accepts any JSON value, read by JavaScript truthiness, and the create response, the detail, ?replayable=true and the list all print it exactly as configured, as Mountebank 2.9.2+ does. Rift-only config keys — defaultForward, strictBehaviors, recordMatches, serviceName, serviceInfo and the config _rift block — appear only with ?replayable=true, and only when set (recordMatches only when true, so an export re-posts to the same imposter); Mountebank prints none of them, and the detail's _rift key carries warnings and a redacted flowState instead. Use ?replayable=true to copy an imposter.

Example:

curl http://localhost:2525/imposters/4545
curl "http://localhost:2525/imposters/4545?replayable=true"


DELETE /imposters/{port}

Delete an imposter.

The response is returned only after the imposter is fully torn down (issue #596): its listener socket is unbound and its established (keep-alive) connections are closed — bounded by a short drain for any in-flight response. So once DELETE returns you can immediately re-POST an imposter on the same port without racing the old one: a pooled client connection gets a clean close and reconnects to the new imposter, never the deleted one's state.

A request still being answered — in a wait behavior, on a proxy upstream, in a shellTransform or in an inject — is cut off, as in Mountebank: its connection closes without a response (over HTTP/2, the whole connection closes), so the delete does not wait for it and the deleted imposter never answers it. This holds whichever way the request arrived, including the /__rift/:port/ gateway, and for a request that arrived just before the delete. A response already being written is still finished. Mountebank never kills a running shellTransform command or an upstream request either: their results are discarded.

Query parameters, read as on GET /imposters/{port}: replayable=true answers the replayable export instead of the detail, and removeProxies=true strips proxy responses from either. Values are case-insensitive and the default is the detail, as in Mountebank.

With --datadir, the imposter's <datadir>/<port>.json is removed first. If that fails, the call returns 503 naming the file and the imposter is not deleted: it keeps serving, and deleting it anyway would bring it back on the next restart.

Response: 200 OK with the imposter as it was, the document GET /imposters/{port} prints: defaultResponse, the final numberOfRequests, and requests when recordRequests is on. Its stubs are the ones it served at delete time, including any added or replaced through the stub routes or recorded by a proxy. If no imposter is on that port, 200 OK with {}: teardown is idempotent, as in Mountebank. The C-ABI rift_delete_imposter still returns -1 for a missing port.

{
  "port": 4545,
  "protocol": "http",
  "numberOfRequests": 2,
  "recordRequests": false,
  "requests": [],
  "stubs": [...]
}

Example:

curl -X DELETE http://localhost:2525/imposters/4545


DELETE /imposters

Delete all imposters.

Each entry in imposters is the imposter as it was at delete time: the same document a GET /imposters?replayable=true issued just before would list, stubs added or replaced through the stub routes or recorded by a proxy included. That makes the response usable as a save-before-reset export, as in Mountebank.

Query parameters: removeProxies=true strips proxy responses from each entry, and replayable=false (any case) answers each imposter's detail, the document GET /imposters/{port} prints, instead of the export. Unlike DELETE /imposters/{port}, the default is the export.

If an imposter's <datadir>/<port>.json cannot be removed, that imposter is not deleted and keeps serving. The call then returns 503 with an errors entry naming each such port and file, and imposters lists the ones that were deleted.

Response: 200 OK

{
  "imposters": [...]
}

Example:

curl -X DELETE http://localhost:2525/imposters


Stub Management

GET /imposters/{port}/stubs

List all stubs for an imposter: {"stubs": [...]}, each stub with its HATEOAS _links. 404 if there is no imposter on that port.


PUT /imposters/{port}/stubs

Replace every stub on the imposter. Request Body: {"stubs": [ ... ]}. Response: 200 OK with the imposter detail. 400 for invalid JSON, an invalid stub, or a scripted stub without --allowInjection.


POST /imposters/{port}/stubs

Add a stub to an existing imposter.

Request Body:

{
  "stub": {
    "predicates": [{ "equals": { "path": "/new" } }],
    "responses": [{ "is": { "statusCode": 200 } }]
  },
  "index": 0
}

index is optional (it appends when omitted); an out-of-range index is a 400. A stub id that another stub on the imposter already uses is a 409.

Response: 200 OK with the imposter detail.

Example:

curl -X POST http://localhost:2525/imposters/4545/stubs \
  -H "Content-Type: application/json" \
  -d '{
    "stub": {
      "predicates": [{ "equals": { "path": "/new" } }],
      "responses": [{ "is": { "statusCode": 201 } }]
    }
  }'


GET /imposters/{port}/stubs/{index}

Get a single stub by its array index, with its _links.

An {index} must be the decimal position of an existing stub: 0, 1, and so on, never 01 or +1. On this route and on PUT and DELETE below, any other index is answered 404 with type: "bad data" and Mountebank's message, 'stubIndex' must be a valid integer, representing the array index position of the stub to replace. An absent imposter is answered first, with its own 404, and a PUT checks the index before it reads the body.


PUT /imposters/{port}/stubs/{index}

Replace a stub at a specific index. The body is the bare stub, with no {"stub": …} envelope. The stub starts its response cycle over, even when the new stub is identical, as in Mountebank. Its _rift.conditional Last-Modified: load moves only when the content changed. Response: 200 OK with the imposter detail.

Request Body:

{
  "predicates": [{ "equals": { "path": "/updated" } }],
  "responses": [{ "is": { "statusCode": 200 } }]
}


DELETE /imposters/{port}/stubs/{index}

Delete a stub at a specific index. Response: 200 OK with the imposter detail. The stubs behind it get a new _rift.conditional Last-Modified: load, since they may now answer its requests.


Stub operations by stable id

A stub you give an id can be addressed by it; a stub without one is addressable by index only, as in Mountebank, which never generates one (#1598). These endpoints address a stub by its id instead of by positional index, so concurrent edits don't shift the target.

Method Path Action
GET /imposters/{port}/stubs/by-id/{id} Get the stub with this id (the bare stub JSON)
PUT /imposters/{port}/stubs/by-id/{id} Replace the stub with this id (position and response cycle preserved)
DELETE /imposters/{port}/stubs/by-id/{id} Delete the stub with this id

PUT takes the bare stub. PUT and DELETE answer 200 with the imposter detail. An unknown id is a 404.

curl http://localhost:2525/imposters/4545/stubs/by-id/6f1c...e2

Imposter State

POST /imposters/{port}/enable

Re-enable a disabled imposter.

POST /imposters/{port}/disable

Disable an imposter without deleting it. A disabled imposter stops matching stubs. Each request to its port gets 503 with an imposter disabled error and an x-rift-imposter-disabled: true header.

Both answer 200 with {"message": "Imposter enabled"} / {"message": "Imposter disabled"}.

curl -X POST http://localhost:2525/imposters/4545/disable
curl -X POST http://localhost:2525/imposters/4545/enable

Requests

GET /imposters/{port}/savedRequests

Get recorded requests (if recordRequests: true). Also available under the alias GET /imposters/{port}/requests.

Query Parameters:

  • match=header:<Name>=<Value> — keep only requests carrying a matching header
  • match=flow_id=<Value> — keep only requests whose resolved flow id matches
  • match=method=<Verb> — keep only requests whose method matches exactly (case-sensitive)
  • match=path=<Path> — keep only requests whose bare path matches exactly (the query string is not compared)
  • since=<index> — keep only requests newer than a cursor (see Tailing with a cursor)

Multiple match clauses are AND-ed together. since is applied first, then the match clauses.

Response: a JSON array of recorded requests. Each element carries requestFrom (the client ip:port); body is present only when the request had one. status and latencyMs (issue #940) give the status sent back and how long the imposter took to produce it, in whole milliseconds. They are either both present or both absent. Absent means "not recorded", never 0: an X-Rift-Debug request (the report is not the stub's answer), a TCP fault (the connection is aborted, nothing is sent), a request abandoned before a response existed, and a custom journal without stable indices all leave them out. An error response the client receives is recorded like any other answer. latencyMs: 0 is a normal reading for a stub served from memory. node is present only when a clustered embedder's journal sets it; single-node Rift never does.

[
  {
    "requestFrom": "127.0.0.1:52344",
    "method": "GET",
    "path": "/api/users",
    "query": {},
    "headers": {
      "Host": "localhost:4545",
      "User-Agent": "curl/7.88.0"
    },
    "timestamp": "2024-01-15T10:30:00.123Z",
    "status": 404,
    "latencyMs": 0,
    "matchOutcome": {
      "matched": false,
      "tried": [
        { "stubIndex": 0, "stubId": "users",
          "why": { "reason": "failedPredicate", "predicateIndex": 1 } },
        { "stubIndex": 1, "why": { "reason": "skippedScenarioState" } }
      ]
    }
  }
]

Why a request did not match

matchOutcome answers that without re-deriving the scan by hand. On a hit it carries matched: true plus stubIndex/stubId; on a miss, matched: false and no winner.

tried lists the candidates the matcher visited, in visit order — a stub the candidate index ruled out before the scan, or one sitting after the winner, is not listed, because no verdict was ever reached for it. Each entry says why that candidate fell out: failedPredicate with the position of the first predicate the request failed (predicates are AND-ed and the scan short-circuits, so later ones were never evaluated), or skippedSpace / skippedScenarioState for a stub whose eligibility gate excluded it before predicates ran at all.

The list is capped at 25 entries; anything beyond that is counted in triedOmitted rather than silently dropped.

The whole object is absent when no outcome was recorded — recordRequests was off (in which case there is no entry at all), a custom RequestJournal backend has no stable indices to attach to, the request took the X-Rift-Debug path, or matching itself errored. Absence means "not recorded", never "did not match".

Tailing with a cursor

Polling this endpoint without a cursor re-sends the whole journal every time. since=<index> makes a poll cost only what is new. Every recorded request is assigned a stable, 1-based, per-port index; the cursor rides in response headers so the body above is unchanged.

Response headers:

Header Meaning
x-rift-next-index The cursor to pass as the next since. 0 means nothing has been recorded yet.
x-rift-truncated Present (true) only when retention discarded entries you had not seen — your view has a hole. Absent otherwise; it is never false.
# Baseline: everything retained, plus the cursor to resume from.
curl -i localhost:2525/imposters/3000/savedRequests
# x-rift-next-index: 12

# Only what arrived since — composes with match=.
curl -i "localhost:2525/imposters/3000/savedRequests?since=12&match=flow_id=tenant-a"

Contract:

  • since is exclusive — you receive entries strictly newer than the index you pass. Pass back x-rift-next-index verbatim; a cursor at or beyond the tip returns an empty array.
  • x-rift-next-index always advances past everything scanned, including entries your match= clauses rejected. A filtered tail therefore never re-scans the same range.
  • Indices survive deletion. DELETE savedRequests and scoped clears do not reset them: entries recorded afterwards simply get larger indices, so a cursor held across a clear stays valid and is not reported as truncated. Deleting data you asked to delete is not a hole.
  • x-rift-truncated means one thing: the journal's byte budget (--journal-max-bytes, default 768 MiB per imposter) evicted entries you had not seen. Re-poll without since to rebuild a baseline. Note that since=0 ("replay everything") does report truncation once anything has been evicted, while omitting since ("snapshot what is retained") never does — the two return the same entries but ask different questions.
  • No x-rift-next-index means do not advance. Keep your existing cursor and poll again. Its absence covers three cases, all handled the same way: an older engine (which ignores the unknown parameter), a custom RequestJournal backend without stable indices, and a backend that served a degraded partial read. In the degraded case the entries returned are real but incomplete, so the cursor is deliberately withheld — advancing on it would skip the entries the backend could not reach. A synthetic index is never returned, because offsets shift under eviction and would silently skip or replay entries.

Canonical SDK tail: baseline poll → keep x-rift-next-index → poll ?since=<cursor> on an interval, updating the cursor each time → on x-rift-truncated, re-baseline.


POST /imposters/{port}/verify

Count — and optionally return — recorded requests matching a predicate set, evaluated by the engine's own predicate engine rather than re-implemented per client. This is what an SDK's verify(match, times(n)) calls instead of fetching savedRequests and re-evaluating predicates locally (where operators like xpath/inject are impractical) or shipping the whole journal over the wire just to count it.

Request body:

{
  "predicates": [ { "equals": { "path": "/api/users" } } ],
  "flowId": "tenant-a",
  "includeRequests": false,
  "includeClosest": false
}
  • predicates — standard Mountebank/Rift predicate objects, AND-ed together (same semantics as a stub's predicates). A request header the client sent more than once matches if any of its values satisfies the predicate — and not on such a header fails as soon as one value matches. That is the same rule live stub matching, intercept rule matching and savedRequests filtering all apply (#1025, #1026), so a verify count and what actually matched at request time agree. Header names are compared case-insensitively, and a recorded request's headers holds one entry per name however the document spelled it (#1039). Note that this normalisation applies to the request side only: a deepEquals object in your own predicate is compared as written, so spelling one name twice there still counts as two names and will not match.
  • flowId (optional) — scope the count to one space, resolved via the imposter's flow_id_source (the same scoping as match=flow_id=<Value> on savedRequests).
  • includeRequests (optional, default false) — return the matching requests, not just the count.
  • includeClosest (optional, default false) — return the best-scoring non-match — the request satisfying the most predicate clauses (ties resolve to the most recent) — with per-clause failure details, for rendering a readable diff on a failed verification.

An inject predicate requires the server to be started with --allowInjection; otherwise the request is rejected with 400 invalid injection (the same gate the stub endpoints apply).

Response:

{
  "matched": 2,
  "total": 17,
  "requests": [ /* present only with includeRequests */ ],
  "closest": {
    "request": { /* the closest non-matching recorded request */ },
    "failedPredicates": [
      { "predicate": { "equals": { "path": "/api/users" } }, "actual": { "path": "/api/orders" } }
    ]
  }
}
matched counts requests matching every predicate; total is the number of recorded requests in scope (after any flowId filter). requests/closest are present only when the corresponding option is set.


DELETE /imposters/{port}/savedRequests

Clear recorded requests. Also available under the alias DELETE /imposters/{port}/requests (in Mountebank that alias clears saved proxy responses instead; see Differences from Mountebank). Accepts the same match= query parameters as the GET, in which case only matching requests are removed.


DELETE /imposters/{port}/savedProxyResponses

Clear responses recorded by proxy stubs (proxyOnce / proxyAlways), as Mountebank's deleteSavedProxyResponses does, so the next request reaches the upstream again:

  • Every response a proxy recorded is removed: one whose is carries a numeric _proxyResponseTime. That includes the responses a proxyAlways proxy added to a stub you wrote, and a marked response you posted yourself.
  • A stub left with no responses is removed.
  • Every remaining stub starts its response cycle over.
  • Recorded requests are kept.
  • A stub recorded by an earlier release carries recordedFrom but no _proxyResponseTime, and is removed whole.

Answers 200 with the imposter, as GET /imposters/{port} does.


Events (Server-Sent Events)

GET /events

A Server-Sent Events stream of recorded requests and imposter lifecycle changes — a push upgrade of polling GET /savedRequests, for live request tails (ZStream/fs2.Stream, Go channels, async iterators). Gated by the admin API key like every other admin route. Older engines return 404, so an SDK probes this endpoint and falls back to polling.

Query parameters:

  • types=requests,lifecycle — which event families to stream (default: both).
  • port=<port> — restrict to one imposter.
  • match=header:<Name>=<Value> / match=flow_id=<Value> / match=method=<Verb> / match=path=<Path> — filter request events (AND-ed). method=/path= are exact-equality against the recorded request. flow_id= compares the request's record-time resolved flow id (per the imposter's flow_id_source); a header:-source imposter whose request lacks that header falls back to the port, which GET /savedRequests?match=flow_id= treats as "no match" instead — the only edge where the two disagree.

Event stream (Content-Type: text/event-stream):

event: hello
data: {"engineVersion":"X.Y.Z","seq":42,"types":["requests","lifecycle"],"port":null}

event: request
id: 43
data: {"port":3000,"flowId":"tenant-a","index":12,"request":{ …RecordedRequest, as recorded… }}

event: imposter
id: 44
data: {"action":"created|replaced|stubsChanged|deleted|allDeleted","port":3000}

event: lagged
data: {"missed":7}

: ping    ← comment heartbeat every 15s

  • Request events require recordRequests: true — the stream is a tail of recorded requests, exactly like savedRequests, not a tap of all traffic.
  • A request event is pushed when the request is recorded, before it is matched or answered, so its request never carries matchOutcome, status or latencyMs. Fetch the entry from GET /savedRequests?since=<index − 1> when you need them.
  • The id: is a monotonic sequence number spanning both event families. v1 does not replay: on reconnect, a gap in id: (or a lagged event, emitted when a slow consumer falls behind the bounded buffer) means "reconcile via GET /savedRequests". The stream is lossy-but-loud by design; polling remains the source of truth.
  • index on a request event is that entry's journal index — the same cursor the polling side reports as x-rift-next-index (see Tailing with a cursor). It is what makes reconciling cheap: pass the last index you saw as ?since=<index> and get only what you missed, instead of re-polling the whole journal and de-duplicating by content. Omitted when the journal backend has no stable indices — the same capability probe as the polling side's missing header.

Canonical tail: connect → hello → baseline GET /savedRequests (keep x-rift-next-index) → consume events, tracking index → on lagged or a reconnect gap, GET /savedRequests?since=<last index> to fill the hole, then resume.

Errors. A bad request is refused before the stream opens, as application/json in the same errors envelope as every other admin route:

Cause Status type
unknown types value, port that is not a port number, unsupported match clause 400 bad data
port names no imposter 404 no such resource

The alias below refuses the same way. Through 0.18.1 these refusals were a bare {"error": "…"} object; read errors[0] instead.

GET /imposters/{port}/savedRequests/stream

Sugar alias for GET /events?types=requests&port={port} — a handle-scoped request tail that mirrors the savedRequests polling endpoint one-to-one.


Scenarios

Declarative state machines (Mountebank/WireMock style) gate stubs by requiredScenarioState and transition via newScenarioState. State is partitioned per flow id.

GET /imposters/{port}/scenarios

List scenario states. Accepts an optional ?flowId=<id> query parameter (defaults to the imposter port). Response: {"flowId", "scenarios": [{"name", "state"}]}.

PUT /imposters/{port}/scenarios/{name}/state

Arrange a scenario's state directly.

Request Body: { "state": "AWAITING_PAYMENT", "flowId": "order-42" } (flowId optional; a missing state is a 400). Response: {"flowId", "name", "state"}.

POST /imposters/{port}/scenarios/reset

Reset scenarios. Request Body: { "flowId": "order-42" } (optional; omit to reset the default flow). Response: {"flowId", "reset": true}.


Spaces (Correlated Isolation)

A "space" isolates stubs and state to a correlation id (flowId), so parallel test runs don't collide.

POST /imposters/{port}/spaces/{flowId}/stubs

Add a stub scoped to this space. The body is the bare stub. This differs from POST /imposters/{port}/stubs, which takes a {"stub": …} envelope. Response: 201 Created with {"space", "stubs"}.

A body that has none of the recognised stub fields is refused with 400 (#932). Before that fix it created a stub with no predicates, which matched everything in the space. A body with a stub key gets a message that points to the envelope mistake. An empty object {} names no stub field, so it is refused the same way. To author a space-wide default, name the field explicitly: {"predicates": [], "responses": [...]} is accepted and matches every request in the space.

GET /imposters/{port}/spaces/{flowId}/stubs

List this space's stubs: {"space", "stubs"}.

GET /imposters/{port}/spaces/{flowId}

Inspect the space: {"space", "stubs", "scenarios": [{"name", "state"}], "numberOfRequests"}.

DELETE /imposters/{port}/spaces/{flowId}

Tear down the space, removing its scoped stubs, recorded requests, and scenario state. Response: {"space", "tornDown": true}.


Flow State

A per-flow key/value store backing stateful stubs (e.g. retry-then-succeed). These admin endpoints inspect and arrange it directly.

Method Path Action
GET /admin/imposters/{port}/flow-state/{flow_id}/{key} Read a value: {"flowId","key","value"}, or 404 if absent
PUT /admin/imposters/{port}/flow-state/{flow_id}/{key} Set a value. Body { "value": <any JSON> } (a missing value is a 400); returns {"flowId","key","value"}
DELETE /admin/imposters/{port}/flow-state/{flow_id}/{key} Delete a key: {"flowId","key","deleted":true}
DELETE /admin/imposters/{port}/flow-state/{flow_id} Delete every key under flow_id (issue #530). Idempotent: {"flowId","cleared":true}. Embedded hosts: rift_flow_state_clear

Each route answers 404 for an unknown imposter, and returns the backend-unavailable 503 when the flow store fails.


Intercept proxy

These routes exist only when the server was built with an intercept control. The rift binary always has one, and so does an embedded rift_serve_admin. Without one, every /intercept* path returns 404. The request and rule schemas, CA options and authentication are documented in Intercept proxy. Authorization actions are intercept.read / intercept.write.

Method Path Success Errors
POST /intercept 201 + {"interceptPort","interceptUrl"} (plus caCertPem/caKeyPem with returnCaKey). An empty body or {} means defaults. The body may seed rules. 400 bad options, CA or bind; 400 invalid injection for a scripted seeded rule without --allowInjection; 403 off-host with no auth under --require-admin-auth; 409 already running; 429 seeded rules over capacity
GET /intercept 200 + {"interceptPort","interceptUrl"} 404 not running
DELETE /intercept 204, idempotent. Drops rules and the CA. —
POST /intercept/rules 201 + the added rules as an array. The body is one rule object or an array of rules. 400 bad JSON or scripted rule; 404 not running; 429 rule store full
PUT /intercept/rules 200 + the stored rules as an array. Replaces the whole set atomically with one rule object or an array ([] clears); first-match-wins means this, not POST, is how to change a rule. 400 bad JSON or scripted rule; 404 not running; 429 more than 10,000 rules — every error leaves the old set in place
GET /intercept/rules 200 + array of rules 404 not running
DELETE /intercept/rules 200 + {"deleted": N} 404 not running
GET /intercept/ca.pem 200, application/x-pem-file 404 not running
GET /intercept/truststore.p12, /intercept/truststore.jks 200, binary truststore (?password=, default changeit) 404 not running; 500 export failure

A serve rule's body may be any JSON value (#934), and a serve rule accepts the Mountebank statusCode and headers spellings (#938). The rule schema is canonical.


Gateway

/__rift/{port}/<path>

Dispatch any request to the imposter on {port}, rewriting the URI to /<path>. Lets a containerized Rift publish only the admin port while still reaching every imposter. Works with any HTTP method and is not gated by --api-key.

# equivalent to hitting the imposter on port 4545 at /api/users
curl http://localhost:2525/__rift/4545/api/users

System

GET /health

Liveness check. Returns {"status":"ok"}.

GET /metrics

Prometheus-format metrics (imposter count, per-imposter request counts). Also exposed on the dedicated metrics port (--metrics-port, default 9090).

POST /admin/reload

Hot-reload imposters from the startup config source (--configfile / --datadir), applying the difference incrementally. When no config source was provided, it answers 200 with {"message": "No config source configured; nothing to reload"} and changes nothing. New config is validated before any running imposter is changed. With both --configfile (or --imposters) and --datadir, both are re-read and applied as one set. See Hot Reload.


Configuration

GET /config

Get current configuration.

Response:

{
  "version": "X.Y.Z",
  "commit": "<sha>",
  "options": {
    "port": 2525,
    "allowInjection": true,
    "localOnly": false,
    "mock": false,
    "debug": false,
    "ipWhitelist": ["*"]
  },
  "serveOptions": [
    "host", "port", "apiKey", "metricsPort", "configFile", "noParse", "config",
    "allowInjection", "requireAdminAuth", "upstreamCaFile", "upstreamCaPem",
    "upstreamTlsSkipVerify", "maxBodySize", "bodyMemoryBudget", "journalMaxBytes",
    "scriptsDir"
  ],
  "process": {
    "nodeVersion": "N/A (Rust)", "architecture": "aarch64", "platform": "macos",
    "rss": 0, "heapTotal": 0, "heapUsed": 0, "uptime": 0, "cwd": "/srv/rift"
  }
}

commit is null unless the build was stamped with one. process mirrors Mountebank's shape: only architecture, platform and cwd carry real values.

Field notes (issue #879 — these were previously hardcoded literals):

  • port is the port the admin plane actually bound, so a --port 0 (ephemeral) server reports the real one rather than the 2525 default. An embedder that fronts the admin API with its own public listener can override it with AdminApiServer::with_reported_admin_port / ServerBuilder::reported_admin_port (issue #1135), so clients building URLs from options.port get the public port. See Embeddable Server.
  • localOnly reports whether --local-only was supplied, not whether the admin listener happened to bind loopback. With the flag, the admin API and /metrics bind loopback, and every imposter port and the front door refuse non-loopback clients. The distinction matters: --host 127.0.0.1 narrows the admin plane and every imposter created without a host, while /metrics stays on every interface (and an imposter that names its own host binds that) — so reporting true there would tell you nothing is reachable off-host while a listener still is.
  • mock and debug (issue #1569) echo whether --mock / --debug were supplied (on the command line or in an --rcfile), as Mountebank's /config does. mock has no effect in Rift; debug reports the flag, not whether debug logging is on (an explicit RUST_LOG overrides the level it sets). Unlike Mountebank, which omits mock when unset, both are always present.
  • ipWhitelist is always ["*"]. --ip-whitelist is accepted for Mountebank compatibility and never enforced, so every address may connect; see the CLI reference.

serveOptions (since 0.17.0, issue #877; 0.17.0 listed the first eight keys without noParse) lists the keys the embedded serve-options document accepts, so a consumer can feature-detect an option before sending it. Absence of the key means an engine too old to report capabilities — treat every option as unsupported rather than assuming a rejection you will never receive. It is the same list rift_build_info().serveOptions publishes over the C-ABI, and is a sibling of options rather than a member of it: options is the Mountebank-compatible shape and is unchanged.

Reading this from the standalone binary: there is no serve-options document in process mode — that door only exists for an embedded host going through rift_serve_admin. The list is still a faithful capability signal for the running release, but a process-mode consumer sets the equivalent lever on the command line instead: requireAdminAuth → --require-admin-auth, allowInjection → --allow-injection, configFile → --configfile, noParse → --no-parse, apiKey → --api-key, and so on.


Logs

GET /logs

Returns the most recent log entries, in Mountebank's shape:

{ "logs": [ { "level": "info", "message": "Created imposter on port 4545", "timestamp": "2026-10-17T17:43:52.652Z" } ] }
  • level is error, warn, info or debug (trace events are reported as debug). Only events that pass --loglevel / RUST_LOG are recorded, so --loglevel warn hides info.
  • timestamp is UTC with milliseconds and a Z.
  • message is the event's text followed by key=value for its fields, with no colour codes. CR and LF are JSON-escaped like any other character.
  • Query parameters: startIndex (default 0) and endIndex (default: the last entry) slice the log as JavaScript's slice(startIndex, endIndex + 1) does, so endIndex is inclusive, a negative value counts from the end, and a value that is not a number reads as 0 (startIndex) or selects nothing (endIndex). Indices count every entry since the process started, so ?startIndex=<previous count> returns only what is new, even after older entries were evicted.
  • Bounded. The buffer keeps the most recent 10,000 entries and at most 4 MiB, evicting the oldest first; a message longer than 4 KiB is cut and ends in ….
  • Output written by scripts (logger.*, target rift::script) is not recorded, since it often echoes request data.
  • With --nologfile, or on a server built without a log buffer (an embedded one), the answer is Mountebank's: {"logs":[{"level":"error","message":"No logfile"}]}.
  • Like the other admin routes it requires the --api-key when one is set, and it is the system.read action for an AdminAuthorizer.

Error Responses

Every error Rift serves in the Mountebank errors envelope — on the admin plane and on the imposter port alike — carries three fields:

{ "errors": [ { "code": "...", "type": "...", "message": "..." } ] }

One door carries extra fields. A backend outage (an unavailable flow-store or proxy store, and any matcher failure that is not an injection error) enriches the envelope's error object:

{
  "errors": [{
    "code": "503",
    "type": "backend unavailable",
    "message": "flowState: store connection refused",
    "feature": "flowState",
    "detail": "store connection refused"
  }]
}

feature names which backend failed and detail gives the underlying cause.

Removed in 0.18.0: this door used to duplicate error/feature/detail as top-level keys (the pre-0.16.0 shape, deprecated in 0.16.0, #801). They are gone; read errors[0].

Field Read it? What it is
type yes The stable symbolic error type. Always a lowercase slug, on every door. This is the field to branch on.
code legacy The HTTP status as a string on most doors, but a slug on a few (invalid injection, unauthorized, …). Frozen for backward compatibility — it is not reliably parseable as either.
message human Free text. Never pattern-match it.

type was added in 0.15.0 (issue #797). code is unchanged from earlier releases, so existing clients keep working; new clients should read type instead.

Error types

Six are Mountebank's own error types, so a client that already maps Mountebank errors keeps working:

type Typical status
bad data 400, 422
unauthorized 401
insufficient access 403
no such resource 404
resource conflict 409
invalid injection 400 for the --allowInjection refusal; 500 for an inject or wait function that throws at serve time

The rest name doors Mountebank does not have:

type Typical status
invalid predicate injection 500 (a predicate inject that throws at serve time)
injection timeout / predicate injection timeout 504
backend unavailable 503
script error / script timeout 500 / 504
behavior error 500
imposter disabled 503
request too large 413
upstream failure 502
unavailable 503
timeout 504
internal error 500
client error / server error any other 4xx / 5xx

400 Bad Request

Invalid request body or parameters.

{
  "errors": [
    {
      "code": "400",
      "type": "bad data",
      "message": "invalid JSON"
    }
  ]
}

404 Not Found

Imposter doesn't exist.

{
  "errors": [
    {
      "code": "404",
      "type": "no such resource",
      "message": "Imposter not found on port 4545"
    }
  ]
}

409 Conflict

A stub with that id already exists on the imposter.

{
  "errors": [
    {
      "code": "409",
      "type": "resource conflict",
      "message": "A stub with id 'login' already exists"
    }
  ]
}

Note that a port already being in use is not a 409 — it is a 400 / bad data, because the request describes an imposter that cannot be created:

{
  "errors": [
    {
      "code": "400",
      "type": "bad data",
      "message": "Port 4545 is already in use"
    }
  ]
}

The same 400 / bad data answers a port this server's own admin API, /metrics, front door or intercept listener holds (#1557), a port another process already holds (the message reads Port 4545 is already in use: Address already in use (os error 48)), a host that is not an address of this machine, one the process may not bind, and one that does not resolve (Failed to bind port 4545: ...). PUT /imposters answers 400 when every failed imposter is of this kind and keeps the created / deleted / failed report in the body. A bind that fails for any other reason, such as running out of file descriptors, stays 500.

413 Payload Too Large

The request body exceeds --max-body-size (default 512 MiB, Mountebank's own breaking point). The same limit applies to imposter requests; a proxied upstream response over it, or one that does not fit the memory budget, fails the proxy with 502 like any other upstream failure. See Body size limits.

{
  "errors": [
    {
      "code": "413",
      "type": "request too large",
      "message": "Request body exceeds the 536870912-byte body limit"
    }
  ]
}

503 Service Unavailable (body memory)

Every body being buffered at once shares --body-memory-budget (default 1 GiB). When it is used up, the request gets 503 with Retry-After: 1 rather than the process running out of memory. Retrying after a moment succeeds once in-flight bodies finish.

{
  "errors": [
    {
      "code": "503",
      "type": "unavailable",
      "message": "Not enough body memory is free to accept this request; retry shortly"
    }
  ]
}

Common Patterns

Export and Reimport

# Export
curl "http://localhost:2525/imposters?replayable=true" > imposters.json

# Clear
curl -X DELETE http://localhost:2525/imposters

# Reimport
curl -X PUT http://localhost:2525/imposters \
  -H "Content-Type: application/json" \
  -d @imposters.json

Verify Requests

# Create imposter with recording
curl -X POST http://localhost:2525/imposters \
  -H "Content-Type: application/json" \
  -d '{
    "port": 4545,
    "protocol": "http",
    "recordRequests": true,
    "stubs": [...]
  }'

# Run tests...

# Verify requests
curl http://localhost:2525/imposters/4545 | jq '.requests'