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.
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.
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).
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.
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.
?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.)
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.
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.
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).
Three honest options, in order of preference:
| Option | When |
|---|---|
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.
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.
| Tool | Where it's the right call |
|---|---|
| json-server on your LAN | Your 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. |
| Wokwi | Simulating 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 flash | Fine 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 API | Final 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. |
| Mockbird | Everything 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). |
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.