← All guides

Mock APIs for Node-RED β€” prove your flow's error handling actually works

Node-RED flows live on the http request node β€” polling a service, pushing sensor readings, gluing two products together. And that node has two properties worth respecting: it never retries, and an HTTP error isn't an error. A 500 from the server doesn't raise anything a catch node would see β€” per the node's own help text, msg.statusCode carries "the status code of the response, or the error code if the request could not be completed", and the message keeps flowing to the next node. If that next node only reads msg.payload, failures sail through your flow silently. Whatever retry, backoff, or branching your flow has is wiring you built β€” and most such wiring has never once fired, because the happy-path API it points at has never failed on demand.

Node-RED's own cookbook recipe for parsed JSON responses demos against jsonplaceholder.typicode.com β€” fine for a first GET, but the data is fixed, the writes are fake, and it will never return the 500 you need to test the interesting half of your flow. This guide points the same nodes at a hosted, stateful mock instead: real persistent CRUD, plus failure modes you order from a URL parameter. No signup β€” the public demo project works from the first curl. Every command below was verified against production before publishing.

1. A first flow in 60 seconds

Wire inject β†’ http request β†’ debug. In the http request node set the method to GET, the URL to:

https://mockbird.mockbird.workers.dev/m/demo/products?limit=5

…and Return to a parsed JSON object, so msg.payload arrives as a real array of objects, not a string. Deploy, click the inject button, and the debug sidebar shows 5 seeded products (id, name, price, category, …). Same as the curl:

curl -s "https://mockbird.mockbird.workers.dev/m/demo/products?limit=5"

The URL field takes mustache-style tags built from the incoming message β€” the same pattern the cookbook recipe uses with {{post}}. Set the inject payload to 3 and the URL to:

https://mockbird.mockbird.workers.dev/m/demo/products/{{{payload}}}

…and each inject fetches that record. (Double braces URL-escape the substituted value; triple braces insert it raw β€” use {{{ }}} when the value legitimately contains / or &.)

2. Writes that persist β€” the part the cookbook's target can't do

JSONPlaceholder answers a POST with {"id": 101} and stores nothing β€” GET the id back and it's a 404, so a write-then-read flow can never be tested against it. Here, writes are real. Set an inject node's payload to JSON:

{"name": "Flow Sensor", "price": 19.5, "category": "iot"}

…wire it to an http request node with method POST, URL /m/demo/products, and Send as request body selected for the payload. The response is the created record with its new id β€” and a second flow (or a plain curl) reads it back:

curl -s -X POST https://mockbird.mockbird.workers.dev/m/demo/products \
  -H 'content-type: application/json' \
  -d '{"name": "Flow Sensor", "price": 19.5, "category": "iot"}'
# β†’ {"id":31, "name":"Flow Sensor", …}
curl -s https://mockbird.mockbird.workers.dev/m/demo/products/31   # β†’ the record, persisted
curl -s -X DELETE https://mockbird.mockbird.workers.dev/m/demo/products/31   # tidy up

That makes end-to-end flow tests honest: the dashboard your flow feeds, the follow-up GET, the dedupe logic β€” all run against data your flow actually wrote.

3. Watch a 500 sail straight through your flow

Add ?mock_status=500 to the URL and inject:

https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=500

The flow doesn't halt and nothing catchable fires β€” a message arrives at your debug node like any other, with msg.statusCode = 500. (Genuine transport failures β€” DNS, refused connection β€” put the error code in msg.statusCode instead of an HTTP status.) So the standard pattern is a switch node right after every http request node:

Now you can fire every branch on purpose: mock_status=401, 404, 429, 503 β€” each one steers the message into the wiring that's supposed to handle it, and you find out today whether it does.

4. Build the retry loop Node-RED doesn't ship β€” and prove it recovers

n8n has a "Retry On Fail" toggle; Node-RED core has no equivalent β€” retries are a loop you wire yourself: switch (non-2xx) β†’ function (count attempts) β†’ delay (backoff) β†’ back into the http request node. The problem is proving it works: a real API fails when it feels like it. ?mock_seq makes the failure deterministic:

https://mockbird.mockbird.workers.dev/m/demo/products?mock_seq=503,503,200&mock_seq_key=flow1

First request 503, second 503, third (and after) 200 β€” every time, keyed so parallel flows don't share a counter (mock_seq_reset=1 restarts it). The attempt-counting function node is three lines:

// on the switch node's non-2xx output
msg.attempts = (msg.attempts || 0) + 1;
if (msg.attempts > 3) { node.error("gave up after 3 attempts", msg); return null; }
return msg;   // β†’ delay node (2s) β†’ back into the http request node

Inject once and watch the debug sidebar: 503, 503, then the parsed product list β€” your loop demonstrably recovers from a flapping upstream. Every response also carries an x-mockbird-seq header (visible in msg.headers) telling you which step of the sequence you're on. Failed writes in a sequence are not applied, so pointing a POST flow at mock_seq=503,201 can't double-create.

5. The delay node's rate-limit mode, proven against real 429s

The standard way to pace a chatty flow is a delay node in Rate Limit mode (e.g. 1 msg/sec, queue intermediate messages) in front of the http request node. Prove the pacing is actually sufficient against an API that enforces a limit:

https://mockbird.mockbird.workers.dev/m/demo/products?mock_ratelimit=3&mock_ratelimit_key=flow1

That allows 3 requests per rolling 60s window for your key; the 4th answers 429 with real headers your error branch can read from msg.headers:

HTTP/2 429
retry-after: 7
x-ratelimit-limit: 3
x-ratelimit-remaining: 0
x-ratelimit-reset: 1789935660

Fire 5 injects quickly: without the delay node you'll see two 429s in the debug sidebar; with it, the queue drains inside the window. A polite flow reads msg.headers['retry-after'] in the 429 branch and feeds it to a delay node set via msg.delay β€” which you can now test, because the 429 arrives on demand instead of at 3am. More patterns in the rate-limit guide.

6. Timeouts: 120 seconds is a long time for a flow to hang

The http request node's default timeout is the global httpRequestTimeout setting β€” 120 seconds unless you've edited settings.js. A flow polling every 5 seconds against a hung endpoint quietly stacks up in-flight requests for two minutes each. The per-message override is msg.requestTimeout (milliseconds) β€” and ?mock_delay gives you a slow endpoint to test it against:

// change/function node before the http request node
msg.requestTimeout = 2000;
return msg;

// URL: https://mockbird.mockbird.workers.dev/m/demo/products/1?mock_delay=3000

The endpoint takes ~3s; your 2s budget fails first, and msg.statusCode carries the error code for your switch node's error branch. Set mock_delay=1000 and the same flow succeeds β€” both sides of the timeout boundary, tested in a minute.

7. Fetch all pages, driven by a real total

Node-RED sees response headers (msg.headers), so header-driven pagination β€” which some low-code tools struggle with β€” is a small loop. List endpoints here support _page/_limit and return the total in x-total-count:

curl -si "https://mockbird.mockbird.workers.dev/m/demo/products?_page=2&_limit=5" | grep -i x-total-count
# β†’ x-total-count: 30    (body: records 6–10)

Loop shape: a function node initializes msg.page = 1, the http request node's URL is …/products?_page={{{page}}}&_limit=10, and an accumulator function node decides whether to go round again:

const total = parseInt(msg.headers['x-total-count'], 10);
context.rows = (context.rows || []).concat(msg.payload);
if (context.rows.length < total) { msg.page += 1; return [msg, null]; }        // β†’ loop back
const out = { payload: context.rows }; context.rows = []; return [null, out];  // β†’ done (all 30 rows)

Prefer cursor-style paging (Stripe/Slack-shaped next_cursor tokens)? Add ?mock_cursor=1 β€” see the cursor pagination guide.

8. See exactly what your flow sends

When the other end rejects your POST, the question is what actually went over the wire β€” after mustache expansion, after the JSON option, after whatever a change node did upstream. Point the http request node at any unrouted path on the demo and it lands in a catch-all bin:

curl -s -X POST https://mockbird.mockbird.workers.dev/m/demo/ingest/sensor-7 \
  -H 'content-type: application/json' -d '{"temp": 21.4}'
# β†’ {"ok":true, "caught":"POST /ingest/sensor-7", "body":{"temp":21.4}, …}

Every hit β€” method, path, query string, headers, body β€” is also logged in the public demo request inspector: the content-type your node really sent, the exact body encoding, the User-Agent. On your own project the inspector is private. (Testing the other direction β€” Node-RED's http in endpoints β€” works too: a Mockbird webhook can POST HMAC-signed deliveries at your flow's endpoint on every record change.)

9. Bearer auth without standing up an identity provider

The http request node's Use authentication supports basic, digest, and bearer token. Feed the bearer option from the mock JWT login β€” any email/password pair works on the demo:

curl -s -X POST https://mockbird.mockbird.workers.dev/m/demo/auth/login \
  -H 'content-type: application/json' \
  -d '{"email": "flow@example.com", "password": "anything"}'
# β†’ {"token":"eyJhbGciOiJIUzI1NiIs…", "tokenType":"Bearer", "expiresIn":3600, "user":{…}}

On your own project you can set a short expiry (down to 5 seconds) to rehearse token-refresh wiring, and switch the project to protected mode so every endpoint genuinely 401s without a valid token β€” then confirm your flow's re-login branch fires.

10. Your own API instead of the shared demo

One curl (or one click), no signup:

curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H "content-type: application/json" \
  -d '{"preset":"ecommerce"}'
# β†’ {"id":"abc123xyz9", "adminKey":"…", ...}

Presets: blog / ecommerce / saas β€” or define resources by hand, or import an OpenAPI spec, db.json, CSV, or a HAR recording of the real API. An inject node set to repeat every few seconds makes a fine poller against it β€” mind the 10,000 requests/day per-project cap when you pick the interval (a 5s poll is ~17k/day on its own).

Where Node-RED's built-ins are still the right tool

Fair's fair: Node-RED can mock an API with itself β€” an http in β†’ template β†’ http response flow serves a static stub on the same instance, zero external dependencies, and the cookbook shows exactly that. For a fixed happy-path fixture on a dev machine, do that. A hosted mock earns its keep when you want the parts that stub can't give you without building a second product: deterministic failure sequences, enforced rate limits, persistent CRUD with filters and pagination, auth, and a request inspector β€” plus a URL that's reachable when your flows run somewhere your laptop isn't.

Related: the n8n version of this guide, the Make version, the Home Assistant version (RESTful sensors + rest_command), the Power Automate version, testing loading and error states, simulating rate limits, cursor pagination, and mocking third-party APIs generally.

Verification: all demo curls on this page (products list with limit=5, single record by id, POSTβ†’GET-backβ†’DELETE with id 31, mock_status=500, a fresh-key mock_seq=503,503,200 run answering 503/503/200 with the x-mockbird-seq header, mock_ratelimit=3 answering 429 on the 4th hit with retry-after: 7 and x-ratelimit-* headers, a measured ~3.8s response with mock_delay=3000, _page=2&_limit=5 returning records 6–10 with x-total-count: 30, the catch-all bin echo, and the /auth/login token response) were run against production on 20 Sep 2026 before publishing. Node-RED behavior β€” mustache URL templating with the {{ }}-escapes/{{{ }}}-raw distinction, msg.statusCode carrying "the status code of the response, or the error code if the request could not be completed", msg.requestTimeout overriding the global httpRequestTimeout (120-second default), the Return: a parsed JSON object option, and bearer/basic/digest authentication β€” is from the http request node's built-in help and Node-RED's official cookbook and settings documentation as of September 2026. We don't run your Node-RED; if a step here doesn't work, that's a bug: tell us.