The import dialog took my JSON without a single complaint. The first execution died on module 2 with BundleValidationError: Validation failed for 7 parameter(s).
That gap between "the import accepted it" and "the runtime will actually run it" is where I spent most of a day this week, and almost none of it is documented anywhere I could find. So here it is, in the order it broke.
Context, briefly. I write Make (ex-Integromat) scenarios as blueprint JSON by hand instead of assembling them in the canvas, because I want them in git, diffable, reviewable. The scenarios route an order flow between an ERP and a 3PL. Every decision that can go wrong (is this order complete, which carrier code does a typo'd shipping method mean, is this event a duplicate) lives in a typed FastAPI service with 83 pytest tests, and every response carries the same envelope: decision for the routers to branch on, reason for a human, evidence for the audit trail. The scenario routes; the service decides. To test the whole thing for real, I imported the blueprints into a live Make workspace and exposed the local service through a cloudflared tunnel.
Then the workspace started grading my homework.
1. Import is a parser, not a validator
The blueprints were clean JSON. Modules, routes, mappings, all structurally valid, and the import agreed. The first run did not: Validation failed for 7 parameter(s), naming seven fields I had never typed in my life, all missing from the HTTP module's mapper: serializeUrl, shareCookies, rejectUnauthorized, followRedirect, useQuerystring, gzip, useMtls.
These are the checkboxes the canvas quietly fills in when you drop an HTTP module onto a scenario. Write the module by hand and nobody fills them, and import does not care, because import only checks that the JSON parses into modules and routes. The module's own contract is enforced at execution time.
Fine. I added the seven and ran again.
Validation failed for 1 parameter(s): followAllRedirects.
The first error listed seven missing parameters when there were eight. Runtime validation happens in layers, and each run only surfaces the layer it died in. Budget one execution per layer of complaints; the error list in front of you is not the whole bill.
Here is the mapper that finally runs, straight from the blueprint:
"mapper": {
"url": "https://YOUR-SERVICE-HOST/orders/validate",
"method": "post",
"headers": [{ "name": "Content-Type", "value": "application/json" }],
"data": "{\"order_ref\": \"{{1.order_ref}}\", \"customer\": {{13.json}}, \"shipping_address\": {{14.json}}, \"shipping_method\": \"{{1.shipping_method}}\", \"lines\": {{12.json}}}",
"serializeUrl": false,
"shareCookies": false,
"rejectUnauthorized": true,
"followRedirect": true,
"followAllRedirects": false,
"useQuerystring": false,
"gzip": true,
"useMtls": false
}
Keep that block. It is the difference between a blueprint that imports and a blueprint that runs.
2. {{1.body}} does not exist, and the failure mode is silence
I had written the webhook mappings the way anyone with HTTP reflexes would: the webhook receives a POST, so the payload must live in {{1.body}}. It does not. Make's custom webhook parses the incoming JSON and exposes the fields at the top level of the module output: {{1.order_ref}}, {{1.shipping_method}}. There is no body.
And here is the part that costs hours: referencing a path that does not exist is not an error in Make. It renders as empty. Silently. My service received b'' first (the whole body reference resolved to nothing), and after a partial fix, the literal string null. To the validator those looked like genuinely broken requests, so it did its job and reported them as such, which pointed me at the wrong suspect. I went through the service looking for a bug that was not there. The service was fine. The bug was an absence.
The ten lines that ended the guessing
What broke the loop was not reading the scenario harder. It was logging what actually crossed the wire:
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def log_raw_body(request: Request, call_next):
body = await request.body()
print(f"{request.method} {request.url.path} raw={body!r}")
return await call_next(request)
Behind a tunnel, this is a complete diagnostic rig for a visual tool. Every mapping experiment in the canvas shows up seconds later as the exact bytes it produced. My terminal reads like a fever chart of the session: raw=b'', then raw=b'null', then a perfect payload. Once I could see what Make sent instead of inferring it from downstream symptoms, every remaining bug fell in minutes.
3. Collections do not serialize into text fields
Next, the ops-alert branch. When validation fails, the scenario posts an alert carrying the list of gaps, and I mapped the gaps array straight into the raw JSON body of that request. Make rendered the collection as text, inside the quotes it happened to land in, and produced a body that was no longer JSON. The endpoint said 422, and it was right to.
A collection mapped into a text field does not become JSON. It becomes a string shaped like whatever Make's text rendering of that collection is, which inside a hand-built JSON body is a syntax error.
Two fixes, both in the final blueprints: run each collection through a JSON > Transform to JSON module and embed the result without surrounding quotes ({{12.json}}, not "{{12.json}}"), or keep alert bodies scalar-only (gap_count instead of the gaps array). Look back at the mapper above and you can see both rules at work: webhook scalars quoted, collections embedded bare.
4. The default schedule is a 15-minute tick
The scenario worked. Then it appeared to die. I sent test events and nothing happened. No error, no execution, no log line. Nothing.
Make's default schedule runs a scenario every 15 minutes. A webhook trigger without "Immediately as data arrives" queues events silently until the next tick. To anyone watching, a perfectly healthy integration looks down for up to fourteen minutes at a stretch. One dropdown. Nothing broken. This is the incident titled "the integration stopped working" that resolves itself before anyone finishes triaging it.
What the wire showed when it all worked
Every decision, reason, gap and score above is taken verbatim from the live runs described in this post.
Three routes, executed live through the imported scenario, values as returned:
- A clean order came back
VALIDwith 15 checks passed, the shipping method mappedMAPPED_EXACTtoSM-STD-01, and the order was created at the 3PL. The happy path nobody writes posts about. - A broken order came back
INVALIDwith the reason7 gap(s) found, 8 check(s) passed(the same 15 checks, now split), and routed to the ops alert withgap_count7. The router branched ondecision; the reason string went into the alert text unchanged. - An order with the shipping method typed as "parcel" scored 0.60 on fuzzy match, below the 0.85 floor, so the service returned
UNMAPPEDwith one ranked candidate and the reasonreturning 1 candidate(s) for human review instead of guessing. The scenario put the order on hold for a human.
That last route is the whole argument. A visual flow will route a wrong guess with the same confidence as a right one. The refusal to guess has to come from somewhere with a test suite.
The outage I did not schedule
Mid-session, cloudflared dropped the tunnel for real. Cloudflare error 1033, service unreachable. The Break error handler on the HTTP module (current Make docs call the directive Retry; the blueprint directive is still builtin:Break) parked the execution in Incomplete Executions with its mappings intact. Nothing lost, nothing half-applied, one click away from resolving once the service was reachable again. The exact failure mode this design exists to survive walked in unannounced, and the pattern held. I could not have scripted a better demo, and for once I didn't.
What this does not tell you
The middleware shows what arrives, not what Make intended. A mapping bug that happens to produce valid-shaped JSON with wrong values sails through both the wire log and the schema check. The semantic checks still have to live in the service.
None of this makes Make the villain. The event queue, the retry policy, the incomplete-executions safety net are better than what most hand-rolled webhook consumers ship. That is exactly why the routing belongs there, and the deciding does not.
Hand-writing blueprints is a real trade. The canvas would have filled those eight HTTP parameters for me and I would never have met BundleValidationError. I pay that tax to get scenarios in git with reviewable diffs. If you do not need the diff, click the modules.
And every Transform to JSON module is one more operation on every run, which is the unit Make bills in. Scalar alert bodies are not just simpler. They are cheaper.
The service, both importable blueprints with all eight parameters already in place, and these lessons written down live at github.com/vinimabreu/make-failsafe, mostly so I never rediscover any of this at 2 a.m.
The flow routes, the service decides, and the wire is the only place where both of them tell the truth.

Top comments (2)
The part about rediscovering all this at 2 a.m. — I nearly spat out my coffee laughing. This post is a perfect textbook on real production debugging. Getting paged in the middle of the night when you should be asleep is truly a nightmare we all know too well. Hahaha.🤣
Ha, glad it landed. The 2 a.m. version of me is the real audience for everything I write down. Present me always thinks he will remember why the webhook fields live at the top level. He never does.
The honest confession is that the README exists because pager-me once spent an hour blaming a healthy FastAPI service for a payload Make never sent. Logging the raw bytes at the boundary is the cheapest insurance I know. Thanks for reading, and your Stratagems series is part of why I bother writing these up properly.