โ† All guides

Mock APIs for ESP32 & Arduino โ€” poll all you want, break it on purpose, and see the exact bytes your device sends

Every ESP32 weather display, crypto ticker, and air-quality dashboard has the same dev loop: flash, watch serial, tweak, flash again โ€” while hammering a real API the whole time. Free tiers make that nervous work (OpenWeatherMap's free key allows 60 calls per minute, which one device in a crash-reboot loop can chew through before you've opened the serial monitor), and no real API will serve you a 429, a 503, or a 4-second stall because you asked โ€” which is exactly what you need while writing the error branch, the retry loop, and the watchdog-safe timeout your device will live or die by at 3am.

A hosted, stateful mock inverts all of it: an endpoint that returns whatever you set, changes when you curl new values into it (no reflash), fails on demand and on schedule, logs the exact bytes your device sends, and doesn't care how often firmware polls it. It also speaks plain HTTP as well as HTTPS โ€” relevant when every kilobyte of heap counts. Every curl below was verified against production before publishing; the sketches follow the standard HTTPClient / WiFiClientSecure example patterns that ship with the ESP32 Arduino core.

1. A first GET in 60 seconds (zero setup)

The public demo project has a /health route โ€” try it from your desk, then from the device:

curl 'https://mockbird.mockbird.workers.dev/m/demo/health'
# โ†’ {"status":"ok", "service":"mockbird-demo", โ€ฆ}
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

void setup() {
  Serial.begin(115200);
  WiFi.begin("your-ssid", "your-pass");
  while (WiFi.status() != WL_CONNECTED) delay(250);
}

void loop() {
  WiFiClientSecure client;
  client.setInsecure();              // fine for a mock โ€” see ยง9 before shipping
  HTTPClient http;
  http.begin(client, "https://mockbird.mockbird.workers.dev/m/demo/health");
  int code = http.GET();             // >0 = HTTP status, <0 = transport error
  if (code > 0) Serial.println(http.getString());
  else Serial.printf("transport error %d: %s\n", code, http.errorToString(code).c_str());
  http.end();
  delay(30000);
}

That proves WiFi, TLS, and parsing plumbing end to end. The interesting part is an endpoint you control.

2. An endpoint shaped like the API you'll ship against

Your parsing code cares about the exact JSON shape. Create a project (one curl, no signup) and give it a custom route that returns an OpenWeatherMap-style payload, byte for byte the shape you'll parse in production:

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"esp32-lab","blank":true}'
# โ†’ {"id":"abc123xyz9", "adminKey":"โ€ฆ", โ€ฆ}   โ† save both

curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123xyz9/routes \
  -H 'content-type: application/json' -H 'x-admin-key: YOUR_ADMIN_KEY' \
  -d '{"method":"GET","path":"/weather","status":200,"contentType":"application/json",
       "body":"{\"name\":\"Berlin\",\"main\":{\"temp\":21.4,\"humidity\":60,\"pressure\":1013},\"weather\":[{\"main\":\"Clear\",\"description\":\"clear sky\"}],\"wind\":{\"speed\":3.6}}"}'

curl https://mockbird.mockbird.workers.dev/m/abc123xyz9/weather
# โ†’ {"name":"Berlin","main":{"temp":21.4,"humidity":60,"pressure":1013},
#    "weather":[{"main":"Clear","description":"clear sky"}],"wind":{"speed":3.6}}

The firmware side is ordinary ArduinoJson:

#include <ArduinoJson.h>
// inside loop(), after code == 200:
JsonDocument doc;
DeserializationError err = deserializeJson(doc, http.getString());
if (!err) {
  float temp   = doc["main"]["temp"];          // 21.4
  const char* sky = doc["weather"][0]["description"];  // "clear sky"
  Serial.printf("%.1fยฐC โ€” %s\n", temp, sky);
}

When you cut over to the real API later, this parsing code doesn't change โ€” only the URL and the API key do (ยง12).

3. Change what the device sees โ€” without reflashing

Re-POSTing the same route path overwrites it. So while the device sits on your bench polling every 30 seconds, feed it the edge cases your display code has never met:

curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123xyz9/routes \
  -H 'content-type: application/json' -H 'x-admin-key: YOUR_ADMIN_KEY' \
  -d '{"method":"GET","path":"/weather","status":200,"contentType":"application/json",
       "body":"{\"name\":\"Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch\",\"main\":{\"temp\":-12.5,\"humidity\":98,\"pressure\":989},\"weather\":[{\"main\":\"Snow\",\"description\":\"heavy snow with thunder\"}],\"wind\":{\"speed\":24.1}}"}'
# โ†’ next poll: does your OLED clip the 58-char station name? does "-12.5"
#   render, or did sprintf("%d") truncate it? does the icon map have "Snow"?

Negative temperatures, 100% humidity, absurdly long place names, weather conditions your icon lookup table forgot โ€” each is one curl, zero reflashes. This is the drill that usually takes a season of real weather to run.

4. The timeout drill your watchdog will thank you for

A blocking HTTP request in loop() with no timeout is the classic ESP32 watchdog-reset story. ?mock_delay=4000 makes the endpoint stall 4 seconds so you can watch your limits actually engage:

http.setConnectTimeout(3000);   // ms, TCP connect
http.setTimeout(3000);          // ms, read
http.begin(client, ".../m/abc123xyz9/weather?mock_delay=4000");
int code = http.GET();          // returns a NEGATIVE transport error after ~3 s
                                // instead of blocking long enough to trip the watchdog

Verified from the desk first โ€” the stall is real, and a client-side deadline beats it:

curl --max-time 2 '.../m/abc123xyz9/weather?mock_delay=4000'
# โ†’ exits 28 (timeout) after 2.0 s

curl -s -o /dev/null -w '%{time_total}\n' '.../m/abc123xyz9/weather?mock_delay=4000'
# โ†’ 4.56 s when nobody enforces a deadline

If your firmware survives a poll cycle against mock_delay=4000 without a watchdog reset and degrades gracefully (keeps the last reading on screen, shows a stale-data indicator), that code path is tested โ€” not hoped-for.

5. Deterministic fail-then-recover: test the retry loop properly

?mock_seq=503,503,200 serves exactly that status sequence โ€” first request 503, second 503, third and later the real response:

for i in 1 2 3 4; do curl -s -o /dev/null -w '%{http_code} ' \
  '.../m/abc123xyz9/weather?mock_seq=503,503,200'; done
# โ†’ 503 503 200 200

Point your firmware's retry/backoff logic at that URL and you get a repeatable answer to questions that are otherwise pure superstition: does it retry at all? Does it back off, or hammer? Does it give up after N attempts and keep the display alive? Restart the drill any time with &mock_seq_reset=1; every response carries an x-mockbird-seq: pos/len header so logs show where in the sequence each request landed. (For random rather than scripted failures, ?mock_chaos=0.3 fails ~30% of requests.)

6. What does your device do when the API says 429?

Real APIs rate-limit exactly when you least want to discover your handling is wrong. ?mock_ratelimit=3 allows 3 requests per 60 seconds per client, then serves real 429s with a real Retry-After:

for i in 1 2 3 4 5; do curl -s -o /dev/null -w '%{http_code} ' \
  '.../m/abc123xyz9/weather?mock_ratelimit=3'; done
# โ†’ 200 200 200 429 429

curl -sD- -o /dev/null '.../m/abc123xyz9/weather?mock_ratelimit=3'
# โ†’ HTTP/2 429
#   retry-after: 56
#   x-ratelimit-limit: 3
#   x-ratelimit-remaining: 0

The good-citizen firmware move: on 429, read Retry-After (http.header("Retry-After") after listing it via collectHeaders), sleep at least that long, and don't count it as a hard failure. Now you can prove yours does that โ€” before a real vendor's abuse detection proves it doesn't.

7. Telemetry uplink โ€” see the exact bytes your device sent

The other half of most firmware is POSTing readings somewhere. Debugging that by staring at the serial monitor is guesswork โ€” a request bin shows you the wire truth. Add a catch-all route:

curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123xyz9/routes \
  -H 'content-type: application/json' -H 'x-admin-key: YOUR_ADMIN_KEY' \
  -d '{"method":"ANY","path":"/ingest/*","status":202,"contentType":"application/json",
       "body":"{\"received\":true,\"at\":\"{{now}}\"}"}'

Firmware side (same pattern as GET, plus a body):

http.begin(client, ".../m/abc123xyz9/ingest/esp32-01");
http.addHeader("Content-Type", "application/json");
http.addHeader("X-Device-Id", "esp32-01");
char body[96];
snprintf(body, sizeof(body), "{\"temp\":%.1f,\"heap\":%u,\"rssi\":%d}",
         readTemp(), ESP.getFreeHeap(), WiFi.RSSI());
int code = http.POST(body);   // โ†’ 202

Then open the request inspector (dashboard, or the API):

curl '.../api/projects/abc123xyz9/requests' -H 'x-admin-key: YOUR_ADMIN_KEY'
# โ†’ POST /ingest/esp32-01  202
#   body: {"temp":21.4,"heap":234512,"rssi":-61}
#   headers: {"content-type":"application/json","x-device-id":"esp32-01", โ€ฆ}

If your hand-built snprintf JSON is missing a quote, has a locale-surprise comma in a float, or truncated at 96 bytes โ€” it's right there, exactly as sent. This is the ten-minute fix for the class of bug that otherwise eats an evening.

8. Remote config your device polls

Flip device behavior from your desk by giving the project a real (persistent, editable) resource:

curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123xyz9/resources \
  -H 'content-type: application/json' -H 'x-admin-key: YOUR_ADMIN_KEY' \
  -d '{"name":"settings","fields":[{"name":"pollSeconds","type":"number"},
       {"name":"displayMode","type":"word"},{"name":"otaUrl","type":"url"}],"seed":1}'

curl -X PUT https://mockbird.mockbird.workers.dev/m/abc123xyz9/settings/1 \
  -H 'content-type: application/json' \
  -d '{"pollSeconds":30,"displayMode":"detail","otaUrl":"https://example.com/fw-1.2.bin"}'
# โ†’ {"id":1, "pollSeconds":30, "displayMode":"detail", "otaUrl":"โ€ฆ"}

The device GETs /settings/1 once a minute and obeys. Change pollSeconds or displayMode with a PUT and watch the device pick it up on the next cycle โ€” a remote-config loop prototyped in two curls, and a pattern you can keep even in production (records persist; writes are real).

9. HTTPS, plain HTTP, and small heaps

Three honest options, in order of preference:

OptionWhen
WiFiClientSecure + setCACert(root_ca)Production firmware against the real API. Pin the CA; budget ~40 KB heap for the TLS handshake.
WiFiClientSecure + setInsecure()Development against a mock. The connection is still TLS but skips certificate validation. Against an endpoint serving fake data you wrote yourself, the threat model is thin โ€” just don't let it ship.
Plain http://Mockbird serves plain HTTP too (verified: curl -sI http://mockbird.mockbird.workers.dev/m/demo/health โ†’ 200, no redirect). On an ESP8266 or a heap-starved ESP32, skipping TLS entirely during development frees real memory and removes a whole failure class while you debug logic. Mock data only โ€” never a real credential over plain HTTP.

A nice side effect of developing with TLS via setInsecure(): when you switch to the real API you only swap the URL and add the CA cert โ€” the memory profile of the handshake was already part of every dev cycle, so there's no launch-day heap surprise.

10. The MicroPython version

import urequests

r = urequests.get("https://mockbird.mockbird.workers.dev/m/abc123xyz9/weather")
data = r.json()
print(data["main"]["temp"], data["weather"][0]["description"])
r.close()          # always โ€” sockets are scarce on-device

payload = {"temp": 21.4, "heap": 100000, "rssi": -61}
r = urequests.post("https://mockbird.mockbird.workers.dev/m/abc123xyz9/ingest/esp32-01",
                   json=payload)
print(r.status_code)   # 202
r.close()

All the drills above are URL parameters, so they work identically โ€” ?mock_delay against your socket timeout, ?mock_seq against your retry loop, ?mock_ratelimit against your backoff.

11. Honest notes โ€” when a mock is the wrong tool

ToolWhere it's the right call
json-server on your LANYour bench device can reach your laptop, and for a static happy-path payload it works. But there's no failure injection, no rate-limit simulation, no request inspection โ€” and it dies when the laptop sleeps, which devices notice at the worst times. A hosted mock also keeps working when the device is deployed on someone else's network.
WokwiSimulating the hardware โ€” no board on your desk, instant iteration. Its simulated ESP32 has network access, so pointing Wokwi firmware at a mock endpoint composes nicely: fake board, controllable API.
Hardcoded JSON string in flashFine for a first parse test. But it can't exercise WiFi, DNS, TLS, timeouts, retries, or POSTs โ€” which is where device firmware actually breaks.
The real APIFinal validation, always. Schemas drift; a mock proves your parsing and error handling, not the vendor's current payload. Do one real-API soak test before shipping.
MockbirdEverything in between: payloads you edit from curl, failures you script, limits you simulate, exact wire bytes you inspect โ€” HTTPS or plain HTTP, no signup, free, 10,000 requests/project/day (a 30-second poll is 2,880/day โ€” comfortable).

12. Cutover day

Everything above hangs off one base URL in your firmware. Keep it in a single #define API_BASE, and cutover is: swap the define to the real host, add the real API key header, replace setInsecure() with setCACert(...). Your parsing code already matches the real shape (ยง2), your timeout and retry paths are tested (ยง4โ€“5), and your 429 handling is polite (ยง6). The mock project keeps earning its keep after launch, too โ€” it's the target you point CI and the next firmware branch at, instead of the vendor's quota.

Related: the Home Assistant version of this guide, the Apple Shortcuts version, mock weather API, testing loading and error states, simulating rate limits, and request bins.

Create a project in one click ยท Docs ยท More guides