guide: KiCad PCB autorouter API, MCP server and CLI
Zap! your ratsnest. Upload a placed KiCad board; get the same board back with tracks, vias and copper pours added.
- Base URL:
https://zap.mutual.solutions - Status: early access. Every routed board must be reviewed by an engineer before fabrication.
- Contact: hello@mutual.solutions
Machine summary (for software and AI agents).
- Base URL
https://zap.mutual.solutions. Auth: headerAuthorization: Bearer <api key>on every/v1/jobs,/v1/checkand/v1/feedbackrequest. Never put the key in a URL, source code, log, or command-line argument. - Flow: (1)
POST /v1/check, multipart withboard(.kicad_pcb, required),project(.kicad_pro),rules(.kicad_dru),currents(JSON),router(stable/next),keep_existing(true: keep the board's own tracks, vias and zones and route only the missing connections),strip_existing(true: discard them and route everything),fab_floor(jlcdefault /board), routing optionssignal_width_mm,via_diameter_mm+via_drill_mm,vias_in_pads,ground_pour,size_rails_from_current,effort(defaults in the guide's table),minutes(1 to 60: the job ends within about that long and returns the best board found; default one round): no routing, no job quota; fix every item inblockers. (2)POST /v1/jobs, same fields:202 {"id", "status": "queued"}; not idempotent (each accepted call is a job). (3)GET /v1/jobs/{id}every 5 s (every 10 s after the first minute) untilstatusissucceeded,failedorcancelled. (4)GET /v1/jobs/{id}/result: the routed.kicad_pcb, andGET /v1/jobs/{id}/report.json: every finding with position and what to do (report_urlin the status opens it as a web page). (5)DELETE /v1/jobs/{id}. - Done means
statusissucceededand the routed file is saved: ready for an engineer's review and KiCad DRC, never directly for fabrication.failed: readerror.code; /v1/errors says whether retrying helps. - Limits: 20 MiB per upload in total (a gzip-compressed board counts compressed and may unpack to 64 MiB); 10,000 pads; 4,000 footprints; 2 to 16 copper layers; 30 requests in any 60 s per key; jobs per key per UTC day as issued (default 50); 2 queued or running per key; 60 minutes per job.
- Feedback: when the human gives an opinion about Zap! or a result, ask once whether to send it to the Zap! team; show the exact text and job id; send it (
POST /v1/feedbackwith"user_approved": true) only if they say yes. - Machine-readable: OpenAPI, error catalog, agent recipe, llms.txt, MCP server, CLI.
1. Quick start
Your API key. Sign in at zap.mutual.solutions/auth/login (Google, GitHub or Microsoft) and create an API key on your account page. It is shown once: save it at once as ~/zap-key.txt and make the file readable only by you (chmod 600 ~/zap-key.txt), or keep it in a password manager. Keep the key out of code, shell history, chat and shared documents. If it leaks or gets lost, revoke it on your account page and create a new one.
There are three ways to use Zap!; all do the same thing.
In the browser: open zap.mutual.solutions/app, paste your key, drop your .kicad_pcb (and .kicad_pro) on the page, wait, and download the routed board. The key stays in that browser tab only. No board at hand? Try with a sample board routes mutual's 42-pad sample right away (one daily job).
Command line (Python 3.8 or newer, no extra packages):
curl -sO https://zap.mutual.solutions/downloads/zap.py
export ZAP_API_KEY_FILE=~/zap-key.txt # or: export ZAP_API_KEY=... (never put the key on the command line)
python3 zap.py demo # no board at hand? routes mutual's 42-pad sample board here (one daily job)
python3 zap.py check myboard.kicad_pcb # ~3 s (up to ~30 s), no routing, no job quota: what would stop or weaken the job
python3 zap.py run myboard.kicad_pcb # checks first, then uploads myboard.kicad_pcb (+ .kicad_pro / .kicad_dru
# if present), waits, saves myboard.routed.kicad_pcb and the full report
# myboard.routed.zap-report.json, then deletes the server copy (--keep: 7 days)
python3 zap.py --help # run, check, submit, status, wait, download, delete, list, version
HTTP API with curl:
URL=https://zap.mutual.solutions
# the key goes to curl as a header file written by bash's built-in printf: never on a command line
zcurl() { curl -s -H @<(printf 'Authorization: Bearer %s\n' "$(cat zap-key.txt)") "$@"; }
# 1. Check, then submit a placed board and keep its job id (leave out "-F project=..." if you have no .kicad_pro)
zcurl -F board=@myboard.kicad_pcb -F project=@myboard.kicad_pro $URL/v1/check # "ok": true means ready
ID=$(zcurl -F board=@myboard.kicad_pcb -F project=@myboard.kicad_pro \
$URL/v1/jobs | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r.get("id") or r)')
echo "$ID" # a 32-character job id; anything else is the error message
# a board over 20 MiB: gzip -k myboard.kicad_pcb, then send -F board=@myboard.kicad_pcb.gz (up to 64 MiB unpacked)
# 2. Wait until the job has finished (every 5 s)
while :; do
ST=$(zcurl $URL/v1/jobs/$ID | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("status") or d.get("error"))')
case "$ST" in queued|running) sleep 5;; *) break;; esac
done
zcurl $URL/v1/jobs/$ID > myboard.status.json
# {"id":"...","status":"succeeded", ..., "metrics":{"unconnected":0,"critical_unconnected":0,"drc_errors":0,"wall_time_s":5.1,"fab":{...}}}
# 3. Download the routed board, then delete the server copy
zcurl -f -o myboard.routed.kicad_pcb $URL/v1/jobs/$ID/result && cp myboard.kicad_pro myboard.routed.kicad_pro
zcurl -X DELETE $URL/v1/jobs/$ID
Python (needs pip install requests):
import os, time, requests
URL = "https://zap.mutual.solutions"
H = {"Authorization": f"Bearer {open('zap-key.txt').read().strip()}"}
files = {"board": open("myboard.kicad_pcb", "rb")}
if os.path.exists("myboard.kicad_pro"): # optional: your design rules
files["project"] = open("myboard.kicad_pro", "rb")
job = requests.post(f"{URL}/v1/jobs", headers=H, files=files).json()
if "id" not in job:
raise SystemExit(job["message"])
while True:
st = requests.get(f"{URL}/v1/jobs/{job['id']}", headers=H).json()
if st["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(5)
if st["status"] == "succeeded":
r = requests.get(f"{URL}/v1/jobs/{job['id']}/result", headers=H)
open("myboard.routed.kicad_pcb", "wb").write(r.content)
print(st["metrics"])
else:
print(st["error"])
2. What to upload
| field | file | required | notes |
|---|---|---|---|
board |
.kicad_pcb |
yes | Board saved by KiCad 6, 7, 8, 9 or 10 (a KiCad 5 board: open it in a newer KiCad and save it first), with every footprint placed and the board outline drawn on Edge.Cuts. It must not contain tracks or vias yet, unless you send strip_existing=true (discard them and route everything) or keep_existing=true (keep them and route only the missing connections). May be gzip-compressed (board.kicad_pcb.gz in the same field, up to 64 MiB unpacked): large boards then fit the upload limit, and zap.py, the MCP tool and the web page compress every board for you. |
project |
.kicad_pro |
recommended | Your design rules and net classes (track width, clearance, via size). Without it, Zap! routes and checks to KiCad's default rules (0.2 mm clearance and track width, 0.5 mm from the board edge). It must come from the same KiCad project as the board. |
rules |
.kicad_dru |
optional | Custom design rules, if you use them. |
currents |
.json file (or the same JSON as a text field) |
optional | Currents you know for some nets; see "Currents you know" below. |
fab_floor |
jlc / board |
optional | jlc (default): never go below JLC's capability; nets that need finer copper are left unrouted and listed with the reason. board: follow your board's own minimums, for fabs finer than JLC; JLC findings are still reported. Recorded per job. The check warns (rules_finer_than_jlc) when your net classes are finer than JLC and fab_floor is jlc. |
router |
stable / next |
optional | Default stable. next is a newer router build, offered only when GET /v1/version lists one under routers.next; otherwise the job is refused with router_unavailable. Same limits and quota for both. |
keep_existing |
true / false |
optional | Default false. true for a partly routed board: Zap! keeps your tracks, vias and zones exactly as they are and routes only the missing connections around them. Any other value is refused (invalid_keep_existing). |
strip_existing |
true / false |
optional | Default false. true for a board saved with old or unwanted routing: Zap! discards its tracks and vias and routes everything, as if you had deleted them in KiCad (Edit > Global Deletions). Not together with keep_existing. |
consent_training |
true / false |
optional | Default false. Any other value is refused (invalid_consent). See section 7. |
Currents you know. If you know how much current some nets carry (a motor supply, a battery feed, a regulator output), send them; Zap! then sizes the copper of those nets for your values instead of its own estimate, and the temperature check uses them too:
{"nets": {"+5V": {"amps": 3.0}, "VBAT": {"amps": 2.5, "peak_amps": 6.0}}, "ambient_c": 40}
Net names as in your board (KiCad shows them in the PCB editor's Net Inspector): the full name, or just its last part when that is unique on the board (PoE_5V for /PoE_5V). State supply nets (the rail a regulator or connector feeds), not ground: a stated ground current is spread evenly over every part on it, which says little. amps is the continuous (RMS) current, 0 to 100 A, and is what the 10 °C check uses; peak_amps (optional, at least amps) is recorded only; ambient_c (optional, -40 to 125) adds the hottest copper's absolute temperature. At most 2,000 nets. Nets you leave out keep the estimate from the board's parts. In the browser, drop the .json with your board; on the command line, python3 zap.py run myboard.kicad_pcb --currents currents.json.
Routing options and defaults. Each setting comes from your option if you send one, else from your board's rules (.kicad_pro, .kicad_dru), else from the default below. Without a .kicad_pro the board's rules are KiCad's defaults, and the report says "No project file: defaults used". Every report lists the settings used and where each came from. The defaults belong to the router build in GET /v1/version; a changed default is listed in the release notes.
| setting | upload field, values | default when you send nothing |
|---|---|---|
| signal track width | signal_width_mm, 0.05 to 5 mm |
the board's Default net class width, else 0.2 mm |
| via diameter and drill | via_diameter_mm + via_drill_mm, mm, drill at least 0.1 mm smaller |
the board's Default net class via, else 0.6 / 0.3 mm; other net classes keep their own |
| clearance, other net classes | (from the board) | the board's net classes, else 0.2 mm |
| smallest sizes | fab_floor: jlc, board |
jlc: never below JLC (2 layers 0.127 mm track, 0.4 / 0.3 mm via; 4+ layers 0.09 mm, 0.25 / 0.15 mm); options below that are refused |
| supply track width | size_rails_from_current: on, off |
on: widened from current (your currents file, else estimated from the parts) where room allows, never below the net-class width; rails can stay at the net-class width (a 2.94 A rail stayed at 0.2 mm on our 840-pad test board without a project file). off: the net-class width, which may overheat. The report counts every copper section over 10 °C and lists the 10 hottest with positions |
| via cost | (not adjustable yet) | normal |
| vias in SMD pads | vias_in_pads: forbid, allow |
forbid; allow needs the board made with filled and capped vias, an extra fab option |
| ground pour | ground_pour: on, off |
on, on every copper layer |
| ground stitching vias | (not adjustable yet) | on |
| silkscreen and solder mask after routing | fab_fix: on, off |
on: silkscreen is clipped where it covers exposed copper, and solder-mask margins are set so JLC's 0.1 mm mask dam fits between pads; copper is not changed. The report counts what changed (fab_fix). off keeps your silk and mask exactly |
| copper layers | (from the board) | all copper layers; on 4 or more layers In1 (and the second-to-last inner layer on 6 or more) are kept as planes |
| effort | effort: quick, normal, thorough |
normal. thorough: about twice the routing work and keeps the best intermediate result; slower on large boards, usually fewer unrouted connections. quick: about one eighth of the routing work: faster, more unrouted connections |
| time budget | minutes, 1 to 60 |
one round of 5 attempts, usually a few minutes (see "Time budget" below) |
| existing copper | keep_existing or strip_existing: true, false |
false: a board with tracks or vias is refused (input_has_copper) |
| router | router: stable, next |
stable |
A value outside these is refused with invalid_option (option names the field, fix says what to send).
Time budget (minutes). minutes: let Zap! keep trying for up to N minutes (1 to 60) and return the best result; default is one round of a few minutes. The whole job, checks and report included, ends within about N minutes: on a board whose first round alone would take longer, each attempt stops searching in time and finishes the board it has, and the connections it could not make are listed as unconnected. Finishing a board still takes time after the search stops (a few minutes on some boards): if no attempt has finished within N minutes, the job returns the first one to finish, later than asked, and metrics.time_budget.stopped says over_time. After the first round (5 settings at once), the job routes the same board again while the time allows, up to 6 attempts at once: the same 5 settings with twice the work, then with four times the work if the time left covers it, then effort=thorough and other settings. It returns the best by the usual ranking (fewest missing connections, then shorts and rule errors, then vias). A new attempt starts only if it can finish in time, judged from this board's first round, and routing ends by minute 50 at the latest, so the checks and the report fit in the job's 60 minutes. The job stops early once an attempt has no missing connection and no short or clearance error. One job with minutes over 10 runs at a time across the service: another one waits in the queue (its status has a queue_note saying so), while jobs without minutes, or with 10 or fewer, are not held back. The result's metrics.time_budget says how many attempts ran, which one was kept and the time used (section 3). Command line: --minutes 20. The free trial without a key routes one round only.
Before uploading:
- Footprints are kept exactly where you put them. Zap! does not move parts.
- Existing copper pours are replaced by Zap!'s own ground and supply pours, whose nets Zap! chooses, unless you send
keep_existing=true. The check names each pour that will be replaced, with its net and layers (existing_pours_replaced), and the report lists the pours replaced and the ones Zap! made (pours). - Old tracks and vias. A board that still has tracks or vias is refused (
input_has_copper). Sendstrip_existing=true(browser: tick "discard them and route everything"; command line:--strip-existing) to route it from scratch, orkeep_existing=trueto keep them. - Partly routed boards. Route the nets you care about by hand (for example a high-speed pair or a power path), then send the board with
keep_existing=true(browser: tick "My board is partly routed"; command line:--keep-existing). Your tracks, vias and zones come back unchanged; Zap! adds only what is missing. The job status says how many of each were kept and added (metrics.existing_copper). - Lock in your net classes and rules in KiCad first. Zap! routes to the rules you send.
Check before you route. POST /v1/check takes the same fields as POST /v1/jobs, routes nothing, answers in about 3 seconds for most boards (about 30 seconds for the largest, 10,000 pads) and does not count against your daily jobs (it does count against the request rate; one check at a time per key). The browser page runs it as soon as you have a key and a board, and zap.py run runs it before submitting. The answer:
{"ok": true, "summary": "Ready to route. 2 warnings.",
"board": {"kicad_version": 8, "file_version": 20240108, "returned_as": "a KiCad 8 file, like the one you sent",
"footprints": 19, "pads": 94, "nets": 42, "copper_layers": 2, "size_mm": [88.64, 78.81],
"rules": {"project": "uploaded", "custom_rules": "none"}, "router": {"choice": "stable", "build": "..."}},
"blockers": [],
"warnings": [{"code": "footprints_outside_board", "message": "...", "footprints": [{"ref": "R4", "at_mm": [400, 400], "side": "front", "whole_footprint": true}]},
{"code": "courtyards_overlap", "message": "...", "items": [{"refs": ["R4", "RV1"], "at_mm": [118.69, 121.82]}]}]}
blockers would stop the job: every refusal code in section 5 (for example input_has_copper, invalid_board with detail no_outline, invalid_currents, board_too_large with the predicted memory of one routing attempt, the outline, copper layers and grid cells in memory) and unsupported_rules (the custom rule and the function the router cannot apply). warnings weaken the result: footprints_outside_board (the references and positions of footprints off the outline: Zap! routes the rest and leaves their pads unconnected), no_project (KiCad's default rules will be used), unknown_net_class (net classes assigned but not defined), rules_finer_than_jlc (net classes finer than JLC while fab_floor is jlc: some nets may stay unrouted), courtyards_overlap and malformed_courtyard (references and positions, from KiCad's own check under your rules), check_incomplete (part of the check could not run), and very_large_board (an outline so large the job may hit the memory limit). Every message says what to change and where in KiCad. Positions are in mm as KiCad shows them.
3. What you get back
routed.kicad_pcb: your board with tracks, vias and copper pours added, in the same KiCad file version you uploaded, so it opens in the KiCad you use (tested with boards from KiCad 6, 7, 8, 9 and 10). Copper pours are filled by KiCad's own zone filler under your rules, so they keep clear of keepout areas and the board edge.- Zap! never returns a board in which routed copper shorts two different nets: such a job fails with
output_has_shorts. - Zap! never routes below JLC's minimum sizes (1-2 layers: 0.127 mm tracks and gaps, 0.3 mm via drill, 0.4 mm via; 4 or more layers: 0.09 mm, 0.15 mm, 0.25 mm) and never puts a via inside an SMD pad. If your net classes ask for finer copper, those nets may stay unrouted; the report lists them with that reason.
- Summary numbers, measured with KiCad's own design-rule check on the returned file after the pours are filled. All are counts; lower is better, 0 is best:
| metric | meaning |
|---|---|
unconnected |
connections still missing |
critical_unconnected |
of those, connections on supply, ground, clock, reset, boot or debug nets (never more than unconnected) |
drc_errors |
design-rule errors under the rules you uploaded, including problems caused by the placement itself (for example overlapping courtyards) |
wall_time_s |
time the job took on our server, in seconds |
time_budget |
jobs sent with minutes only: attempts (started, the first round's 5 included), attempts_finished, rounds, routing_s (seconds from the job's start until routing ended), winner_attempt with winner (standard, work_x2, work_x4 or alternative) and winner_setting, stopped (complete, time, settings_used_up or memory), and summary, the same in one sentence. Counts, not findings |
If unconnected is not 0, finish those connections by hand in KiCad. Review the routed board and run KiCad's DRC (Inspect > Design Rules Checker) before ordering boards.
Your own copper (metrics.existing_copper, keep_existing=true jobs only). Counts, not problems: tracks_kept, vias_kept, zones_kept should match what you uploaded and tracks_removed, vias_removed, zones_removed should be 0 (Zap! never removes your copper; anything else is a fault, please tell us); tracks_added and vias_added are what the router added.
Fab check (metrics.fab). Every routed board is also checked for overheating and manufacturability, under the same rules as the numbers above. The check only reports; it never changes which routing Zap! returns.
| field | meaning |
|---|---|
check |
version of the check, for example fab1 1.6 |
rules |
which rules were used: {"project": "uploaded" or "kicad_defaults", "custom_rules": "uploaded" or "none"} |
computed |
false with a reason (time limit, check failed) if the check did not run; the routed board is returned anyway |
thermal.worst_rise_c |
the hottest copper section, in °C above ambient, per IPC-2152 (lower is better; the limit is thermal.limit_c, 10 °C; 1000 means far beyond the chart) |
thermal.sections_over_limit |
track, via and copper-pour sections above the limit (lower is better, 0 is best) |
thermal.nets_over_limit |
the nets those sections are on (kept with the routed board; null once it is deleted) |
thermal.hottest |
up to 10 hottest sections: net, kind (track, via or pour), layer, rise_c, current_a, current_source (stated: your value, estimated: from the board's parts), at_mm (x, y); kept with the routed board, null once it is deleted |
thermal.stated_currents |
present only when you sent currents: {"nets": N, "applied": true}: the router sized copper from your values and this check used them on those nets (the estimate on all others) |
thermal.stated_not_checked |
stated nets the check could not use because it found no source or no load part on them; check those by hand (kept with the routed board, null once it is deleted) |
thermal.sections_over_by_current_source |
present only when you sent currents: sections over the limit split by where the current came from: {"stated": N, "estimated": N} |
thermal.off_chart |
true when the worst rise is beyond the IPC-2152 chart (worst_rise_c 1000 or more): far too hot, not a real temperature |
thermal.ambient_c, thermal.worst_temp_c |
present only when you sent ambient_c: your ambient, and ambient plus the worst rise (null when off the chart) |
thermal.currents |
how the currents were found: Estimated from the board's parts. May under-estimate large power stages; confirm high-current nets yourself. |
thermal.rails_checked, thermal.rails_not_checked |
power nets the estimate could solve, and those it could not (no source or no loads found) |
shorts |
places where copper of two different nets touches (0 is best). Zap! never adds one, so any here were already in your uploaded board |
shorts_already_in_board |
of those, how many were already in the board you uploaded (for example a copper graphic over a pad of another net): fix them in KiCad |
drc_errors, drc_errors_by_type |
KiCad DRC errors under your rules (the same count as drc_errors above) |
jlc_violations, jlc_violations_by_type |
features JLCPCB's standard process cannot make (lower is better): track, gap, via, annular ring and drill sizes below its minimums for jlc_capabilities (1-2 layers or multilayer), board-edge and hole clearances, solder-mask slivers, copper necks and slivers, plus three checks of our own: via_in_pad (a via in an SMD pad, which needs filled and capped vias), acute_angle_acid_trap (tracks meeting at less than 90 degrees) and silk_over_copper |
jlc_items |
where each JLC violation is: type, at_mm (x, y), layer, net, refs (footprints involved), description; every one (at most 2,000), sorted by type. Kept with the routed board, null once it is deleted |
jlc_items_total, jlc_items_truncated |
how many items the list would hold, and true when it shows fewer than jlc_violations |
counts_capped |
violation types KiCad stopped counting at 199; those counts are minimums |
time_s |
seconds the check took |
The temperature part estimates each power net's current from the parts on your board: regulators, inductors, fuses, ferrite beads and the chips each rail feeds. It has no datasheet currents, so it can under-estimate large power stages (a processor core supply, a motor bridge, Power over Ethernet, a module's supply connector): a low number there does not prove the copper is wide enough. Compare every high-current net with your real load current, and widen the copper or add a pour where needed. A high number means "look at this net". If the job has already used nearly all of its 60 minutes, the temperature part is skipped (thermal.computed is false, reason time limit); if no supply rail with a source and loads is recognised, it says so in reason. Jobs from before release 2026.10.13.8 have fab: null.
The result report
Every finished job has a report: GET /v1/jobs/{id}/report.json (with your key), or the web page at report_url in the job status (it asks for the key). It lists, each with its position in mm as KiCad shows them (so you can find it with KiCad's cursor readout), its layer and net where KiCad names them, and what to do (one sentence with the KiCad command):
- connections still missing (critical ones first), with both ends;
- design-rule errors under your rules;
- JLC capability violations;
- the 10 hottest copper sections;
- track ends and vias that connect nothing, and copper islands with no connection.
It also has a picture of each copper layer with the board outline (image.layers, SVGs from KiCad's own plot, all in one frame), with the findings marked on it in the web page. Lists hold at most 2,000 items each (total and truncated say when there are more). The report is kept with the routed board: deleted after 7 days or with DELETE. zap.py run saves it next to the routed board as <board>.routed.zap-report.json and one <board>.routed.<layer>.svg per copper layer; zap.py report JOB_ID fetches it.
4. Endpoints
All /v1/jobs endpoints, POST /v1/check, POST /v1/feedback and /v1/snapshots need the header Authorization: Bearer <api key> (the word Bearer in any letter case). A machine-readable description is at /openapi.json.
| method and path | what it does | success |
|---|---|---|
POST /v1/check |
pre-flight check of the same upload: no routing, no job quota (section 2) | 200 {"ok", "summary", "board", "blockers", "warnings"} |
POST /v1/jobs |
submit a board (multipart upload, fields in section 2) | 202 {"id", "status": "queued"} |
GET /v1/jobs |
your last 50 jobs (id, status, times) | 200 {"jobs": [...]} |
GET /v1/jobs/{id} |
status: queued, running, succeeded, failed or cancelled; metrics when succeeded; queue_position while queued; version (which Zap! and router build ran it) |
200 |
GET /v1/jobs/{id}/result |
download the routed board | 200; 409 if not finished or failed; 410 after deletion |
GET /v1/jobs/{id}/report.json |
the result report: every finding with position, layer, net and what to do (section 3) | 200; 409 if not finished or cancelled; 410 after deletion |
GET /v1/jobs/{id}/report/{layer}.svg |
picture of one copper layer (F_Cu, B_Cu, In1_Cu ...) with the board outline |
200 (SVG) |
DELETE /v1/jobs/{id} |
queued: cancel it (never runs, not counted as a daily job); running: stop it now (status cancelled, nothing kept); finished: delete the files |
200 {"id", "status", "files_deleted": true} |
POST /v1/feedback |
your opinion about Zap! or a result, sent to the Zap! team with your approval (section 9) | 201 {"id", "received_at"} |
POST /v1/jobs/{id}/snapshot |
a picture of one area (at most 40 mm square) of the routed board, to see and, if you agree, attach to feedback (section 9) | 201 {"snapshot_id", "png_url", "expires_at", "region", "nets_in_region", "issues_marked"} |
GET /v1/snapshots/{id}.png |
that picture (PNG), for the key that made it | 200 (PNG) |
DELETE /v1/snapshots/{id}.png |
delete a picture you do not want to attach, at once | 200 {"id", "deleted": true} |
GET /v1/health |
service check, no key needed | 200 {"ok": true} |
GET /v1/status |
queue, expected and typical wait, routing time, router build, no key needed (web page: /status) |
200 {"jobs_waiting", "jobs_running", "accepting_jobs", "estimated_wait_s", "typical_wait_s", "typical_run_s", ...} |
GET /v1/version |
service version and the routers offered, no key needed | 200 {"service", "router", "routers": {"stable": "<build>", "next": "<build>" or null}} |
Job status fields (GET /v1/jobs/{id}):
| field | meaning |
|---|---|
id |
job id |
status |
queued, running, succeeded, failed or cancelled (stopped with DELETE) |
created_at, started_at, finished_at |
UTC times (null until reached) |
queue_position |
while queued: 1 means next to run |
queue_note |
while queued, only when the job waits for another reason than its place: a job with minutes over 10 waits while another such job runs |
consent_training |
the preference you sent (section 7) |
keep_existing |
true if the job kept your own tracks, vias and zones (section 2) |
files_deleted |
true once the routed board has been deleted |
report_url, report_json |
finished jobs: the result report as a web page (asks for the key) and the path of its JSON |
version |
{"service", "router", "router_choice"}: which Zap! release and router build ran the job, and whether you chose stable or next (null while queued) |
metrics |
when succeeded: the numbers in section 3 |
error |
when failed: {"code", "message"} (section 5) |
Server time per job (wall_time_s), measured 2026-10-13 with router rc5e1a55, Zap! 2026.10.13.33. Layer count and density drive the time more than pad count; queue time comes on top; the limit is 3,600 s.
| copper layers | pads | board size | server time, s (lower is better) |
|---|---|---|---|
| 2 | 94 | 89 x 79 mm | 8 to 14 |
| 2 | 227 | 115 x 75 mm | 24 |
| 2 | 266 | 320 x 81 mm | 90 |
| 2 | 840 | 222 x 132 mm | 512 |
| 2 | 1,880 | 461 x 329 mm | 424 |
| 4 | 322 | 62 x 28 mm | 129 |
| 4 | 347 | 50 x 50 mm | 382 |
| 4 | 707 | 285 x 48 mm | 740 |
| 6 | 767 | 55 x 95 mm | 1,374 |
| 6 | 1,894 | 119 x 29 mm | 2,107 |
Connect with OAuth
Sign in with Google, GitHub or Microsoft instead of an API key. API keys keep working unchanged. Signing in creates your Zap! account for your verified email address (50 jobs a day, 2 at a time, 30 requests a minute; for more, write to hello@mutual.solutions). Two ways to use the account: create an API key on your account page and use it like any key (zap.py, the MCP tool, the REST API), or connect an MCP client with OAuth sign-in as below, with no key.
| Client | Flow | Token for | Access token | Renewal |
|---|---|---|---|---|
| MCP client (Claude Code, Cursor, Codex) | authorization code + PKCE (S256), self-registration | https://zap.mutual.solutions/mcp |
1 h | refresh token, new one at each use; 30 days unused or 90 days after sign-in: sign in again |
| Command line, headless agent | device code: zap.py login |
https://zap.mutual.solutions/v1 |
1 h | as above |
| Your server (backend) | client credentials | https://zap.mutual.solutions/v1 |
1 h | ask for a new token |
Every token has your account's limits (jobs per UTC day, jobs at once, requests per minute), the same as an API key. A token works only where it is for: an MCP token is refused on /v1, and the reverse. Your account page, /account, lists connected apps (revoke any) and your developer credentials.
MCP (remote server https://zap.mutual.solutions/mcp; tools check_board, submit_job, get_job, list_jobs, get_report, get_result, delete_job). Boards travel as file text (board, project, rules; 20 MiB in total); the routed board comes back as an embedded resource (up to 8 MiB; larger: zap.py download after zap.py login).
# Claude Code: add, then type /mcp in Claude Code, choose zap and sign in (a browser opens)
claude mcp add --transport http zap https://zap.mutual.solutions/mcp
// Cursor: ~/.cursor/mcp.json; Cursor then offers to sign in to the server
{"mcpServers": {"zap": {"url": "https://zap.mutual.solutions/mcp"}}}
# Codex: ~/.codex/config.toml gets the two lines below, then sign in
# [mcp_servers.zap]
# url = "https://zap.mutual.solutions/mcp"
codex mcp login zap
Command line.
python3 zap.py login # prints https://zap.mutual.solutions/device and a code: open the address, type the code, approve
python3 zap.py run myboard.kicad_pcb
python3 zap.py logout # ends the sign-in on the server
Without an API key, zap.py uses the saved sign-in (~/.config/zap/oauth.json, readable only by you). After the approval it prints the account that approved; if that is not yours, run zap.py logout.
Server to server. Create a client id and secret on /account/credentials (the secret is shown once), then:
# the id and secret go to curl through stdin (bash's built-in printf): never on a command line
printf 'user = "%s:%s"\n' "$ZAP_CLIENT_ID" "$ZAP_CLIENT_SECRET" | \
curl -s -K - -d grant_type=client_credentials https://zap.mutual.solutions/oauth/token
# {"access_token": "zat_...", "token_type": "Bearer", "expires_in": 3600, "scope": "zap"}
Send it as Authorization: Bearer <access_token> on /v1/..., like an API key; after a 401, get a new token.
| Endpoint | Address |
|---|---|
| Server metadata (RFC 8414) | GET /.well-known/oauth-authorization-server |
| Resource metadata (RFC 9728) | GET /.well-known/oauth-protected-resource/mcp, .../v1 |
| Register an app (RFC 7591) | POST /oauth/register; redirect URIs: http://127.0.0.1, http://[::1] or http://localhost (any port), or https:// |
| Authorize, token, revoke (RFC 7009) | GET /oauth/authorize, POST /oauth/token, POST /oauth/revoke |
| Device code (RFC 8628) | POST /oauth/device_authorization, page /device |
| Scope | zap (the only one) |
OAuth endpoints answer in OAuth's error format ({"error": "invalid_grant", "error_description": "..."}), not the table below. A 401 on /v1 or /mcp carries WWW-Authenticate: Bearer resource_metadata="...". Sign-in data: your email address, the provider's account id, and hashes of your tokens; kept on the Zap! server and in its daily backup copies, which are deleted after 14 days (privacy).
5. Errors
Every error is JSON with a stable code and one sentence saying what to fix: {"error": "<code>", "message": "..."}. A failed job reports {"error": {"code": "...", "message": "..."}} in its status. Codes do not change; messages may get clearer.
| code | HTTP | from | retry helps | what it means and what to do |
|---|---|---|---|---|
unauthorized |
401 | any authenticated request | no: Fix the key; do not retry with the same key. | The API key is missing or not valid: send it as 'Authorization: Bearer |
rate_limited |
429 | any authenticated request, POST /v1/claim |
yes: Wait the seconds in the Retry-After header, then repeat the same request. | Too many requests: wait the number of seconds in the Retry-After header, then try again. |
daily_quota_exceeded |
429 | POST /v1/jobs |
no: Stop and tell the human: the key's daily jobs are used up until 00:00 UTC (Retry-After gives the seconds); mutual can raise the quota. | This key has used all of today's jobs: try again after 00:00 UTC, or ask mutual to raise the quota. |
check_limit_reached |
429 | POST /v1/check |
no: Not today: this account's pre-flight checks are used up until 00:00 UTC (Retry-After gives the seconds); submit the job directly, it runs the same checks. | This account has used today's pre-flight checks: submit the job directly (it runs the same checks), or check again after 00:00 UTC. |
server_busy |
503 | POST /v1/jobs |
yes: Wait the seconds in the Retry-After header, then submit again; estimated_wait_min is the expected wait. | Zap! is busy: too many jobs are waiting right now (jobs_waiting, estimated_wait_min); submit again after the Retry-After seconds, or see https://zap.mutual.solutions/status. |
too_many_active_jobs |
429 | POST /v1/jobs |
yes: Wait until one of your queued or running jobs finishes, then submit again. | This key already has the maximum number of jobs queued or running: wait for one to finish, then submit again. |
too_large |
413 | POST /v1/jobs, POST /v1/check |
no: Send smaller files. | The upload is over 20 MiB in total: send the board gzip-compressed (board.kicad_pcb.gz in the same board field, up to 64 MiB unpacked; zap.py, the MCP tool and the web page do this for you) and only the .kicad_pcb (and .kicad_pro); if the board itself is that large, remove embedded 3D models or images (Footprint Properties > 3D Models) and save again. |
board_too_large_unpacked |
413 | POST /v1/jobs, POST /v1/check |
no: Send a smaller board. | The compressed board unpacks to more than 64 MiB (max_unpacked_bytes): remove embedded 3D models or images (Footprint Properties > 3D Models), save again and send it again. |
invalid_compressed_board |
400 | POST /v1/jobs, POST /v1/check |
no: Send the board again, compressed whole with gzip or not compressed. | The board looks gzip-compressed but cannot be unpacked (cut short, damaged, or more than one file): compress the .kicad_pcb again with gzip, or send it uncompressed. |
length_required |
411 | POST /v1/jobs, POST /v1/check |
no: Send a normal multipart upload with a Content-Length. | The upload has no total length (for example it was sent with Transfer-Encoding: chunked), which Zap! cannot read: send a normal multipart upload with a Content-Length (curl -F, Python requests and zap.py do this). |
board_required |
400 | POST /v1/jobs, POST /v1/check |
no: Put the board in the multipart field 'board'. | No board was attached: send the placed board in the multipart field 'board' (curl -F board=@myboard.kicad_pcb). |
invalid_board |
422 | POST /v1/jobs, POST /v1/check, job failure |
no: Fix the board file; resubmitting the same file gives the same answer. | The file could not be read as a KiCad board: open the board in KiCad's PCB Editor, use File > Save, and upload that .kicad_pcb (not a schematic, Gerber or zip). With detail blank_lines: The file has more than 10,000 blank lines, which KiCad never writes: save it again in KiCad (File > Save) and upload that .kicad_pcb. With detail incomplete: The file is incomplete (its brackets do not close, so it was probably cut off while copying or uploading): save it again in KiCad (File > Save) and upload the complete .kicad_pcb. With detail no_outline: The board has no outline: draw it on the Edge.Cuts layer in KiCad (select Edge.Cuts, then Place > Draw Lines or Draw Rectangle), save, and upload again. |
invalid_project |
400 | POST /v1/jobs, POST /v1/check |
no: Attach the right .kicad_pro or leave it out. | The project file is not a valid KiCad project: attach the .kicad_pro that KiCad writes next to the board when you use File > Save in the PCB Editor, or leave it out. |
invalid_rules |
400 | POST /v1/jobs, POST /v1/check |
no: Attach the right .kicad_dru or leave it out. | The custom rules file is not valid: attach the .kicad_dru that KiCad saves from File > Board Setup > Design Rules > Custom Rules (its first line starts with '(version'), or leave it out. |
invalid_currents |
422 | POST /v1/jobs, POST /v1/check |
no: Fix the currents JSON (see unknown_nets / ambiguous_nets in the answer). | The currents file is not valid: send JSON {"nets": {" |
invalid_router |
422 | POST /v1/jobs, POST /v1/check |
no: Send router=stable, router=next, or leave it out. | The router field is not valid: send stable or next, or leave it out (which means stable). |
router_unavailable |
409 | POST /v1/jobs, POST /v1/check |
no: Use router=stable (GET /v1/version lists routers.next when it is offered). | The next router is not available right now; use stable. |
invalid_consent |
422 | POST /v1/jobs, POST /v1/check |
no: Send consent_training=true or false, or leave it out. | The consent_training field is not valid: send true or false, or leave it out (which means false). |
invalid_keep_existing |
422 | POST /v1/jobs, POST /v1/check |
no: Send keep_existing=true or false, or leave it out. | The keep_existing field is not valid: send true or false, or leave it out (which means false). |
keep_existing_unavailable |
409 | POST /v1/jobs, POST /v1/check |
no: Use router=stable, or send keep_existing=false with a board that has no tracks or vias. | The router you chose cannot keep existing copper: use router=stable, or remove the tracks and vias in KiCad (Edit > Global Deletions, tick Tracks and Vias) and send keep_existing=false. |
invalid_fab_floor |
422 | POST /v1/jobs, POST /v1/check |
no: Send fab_floor=jlc or fab_floor=board, or leave it out. | The fab_floor field is not valid: send jlc (the default: never below JLC's capability) or board (your board's own minimums, for fabs finer than JLC), or leave it out. |
fab_floor_unavailable |
409 | POST /v1/jobs, POST /v1/check |
no: Send fab_floor=jlc, or use router=next when GET /v1/version lists one. | The router you chose cannot follow your board's own minimums yet: send fab_floor=jlc, or use router=next when GET /v1/version lists one. |
invalid_option |
422 | POST /v1/jobs, POST /v1/check |
no: Change the option named in option as fix says, or leave it out to use the default. | A routing option is not valid (option names it, fix says what to send): correct it, or leave it out to use the default. |
options_unavailable |
409 | POST /v1/jobs, POST /v1/check |
no: Leave the routing options out for now (GET /v1/version lists options when they are on). | Routing options are not switched on yet: leave them out to use the defaults (GET /v1/version shows "options": true once they are on). |
input_has_copper |
422 | POST /v1/jobs, POST /v1/check, job failure |
no: Send strip_existing=true to discard them and route everything, or keep_existing=true to route around them. | The board already has tracks or vias: send strip_existing=true to discard them and route everything, keep_existing=true to keep them and route only the missing connections, or remove them in KiCad (Edit > Global Deletions, tick Tracks and Vias) and upload again. |
too_many_pads |
422 | POST /v1/jobs, POST /v1/check |
no: Not retryable: ask mutual for a larger limit. | The board has more than 10,000 pads, the current limit: contact hello@mutual.solutions if you need a larger board routed. |
too_many_footprints |
422 | POST /v1/jobs, POST /v1/check |
no: Not retryable: ask mutual for a larger limit. | The board has more than 4,000 footprints, the current limit: contact hello@mutual.solutions if you need a larger board routed. |
board_too_large |
422 | POST /v1/jobs, POST /v1/check, job failure |
no: Not retryable as is: email hello@mutual.solutions to arrange a business (B2B) job. | The board needs more memory per routing attempt than the self-serve limit (memory gives the predicted GB, the outline, the copper layers and the grid cells): for boards this large, email hello@mutual.solutions to arrange a business (B2B) job. |
unsupported_layer_count |
422 | POST /v1/jobs, POST /v1/check |
no: Fix the stackup in KiCad, then submit again. | The board must have 2 to 16 copper layers: set the count in File > Board Setup > Board Stackup > Physical Stackup (Copper layers) and save. |
not_found |
404 | GET /v1/jobs/{id}, DELETE /v1/jobs/{id}, GET /v1/jobs/{id}/result, GET /v1/jobs/{id}/report.json, GET /v1/jobs/{id}/report/{layer}.svg, POST /v1/feedback, any unknown address, POST /v1/jobs/{id}/snapshot |
no: Check the job id and use the key that submitted it. | No job with this id exists for this key: check the id and use the same key that submitted the job. With detail path: Not found: check the URL; the endpoints are listed at /docs. |
not_ready |
409 | GET /v1/jobs/{id}/result, GET /v1/jobs/{id}/report.json, GET /v1/jobs/{id}/report/{layer}.svg, POST /v1/jobs/{id}/snapshot |
yes: Poll GET /v1/jobs/{id} every 5 s until status is succeeded or failed, then download. | The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'. |
no_result |
409 | GET /v1/jobs/{id}/result, GET /v1/jobs/{id}/report/{layer}.svg, POST /v1/jobs/{id}/snapshot |
no: The job failed: read error.code in GET /v1/jobs/{id}. | The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}. |
job_cancelled |
409 | GET /v1/jobs/{id}/result, GET /v1/jobs/{id}/report.json, GET /v1/jobs/{id}/report/{layer}.svg, POST /v1/jobs/{id}/snapshot |
no: The job was cancelled (DELETE /v1/jobs/{id}); submit the board again if you still need it. | The job was cancelled, so there is no routed board: submit the board again if you still need it. |
expired |
410 | GET /v1/jobs/{id}/result, GET /v1/jobs/{id}/report.json, GET /v1/jobs/{id}/report/{layer}.svg, POST /v1/jobs/{id}/snapshot |
no: The routed board was deleted: submit the board again. | The routed board was deleted (7 days after the job, or on request): submit the board again. |
method_not_allowed |
405 | any address | no: Use the method the endpoint documents. | This address does not accept that method: see /docs for the endpoints. |
bad_request |
400 | POST /v1/jobs, POST /v1/check, any request |
no: Fix the request shape (see /docs.md). | The request could not be read: send a multipart/form-data upload as shown in /docs. |
check_busy |
503 | POST /v1/check, POST /v1/jobs, POST /v1/jobs/{id}/snapshot |
yes: Wait the seconds in Retry-After, then repeat the check. | Several board checks are running right now: try again in a minute. |
internal |
500 | any authenticated request, job failure | yes: Retry once; if it repeats, send the job id to hello@mutual.solutions. | Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. |
timeout |
(job) | job failure | no: Not retryable as is: send the job id to hello@mutual.solutions. | The job hit the 60-minute limit: tell hello@mutual.solutions the job id; a board this size may need a larger limit. |
timeout_or_limit |
(job) | job failure | no: Not retryable as is: send the job id to hello@mutual.solutions. | The job hit the time or memory limit (very large outlines can exceed the memory): tell hello@mutual.solutions the job id; a board this size may need a larger limit. |
route_failed |
(job) | job failure | no: Fix the board outline / placement; send the job id if it repeats. | The router could not produce a result for this board: check in KiCad that the outline on Edge.Cuts is closed and every footprint is inside it (Inspect > Design Rules Checker shows both), then send the job id to hello@mutual.solutions if it repeats. |
rules_check_failed |
(job) | job failure | no: Send matching .kicad_pro/.kicad_dru or leave them out, then submit again. | KiCad could not check the routed board with the rules you sent: attach the .kicad_pro (and .kicad_dru) saved with this board (File > Save; rules in File > Board Setup > Design Rules), or leave them out to use KiCad's defaults. |
unsupported_rules |
(job) | POST /v1/check, job failure |
no: Remove or simplify the custom rule, then submit again. | Your .kicad_dru uses a custom rule condition Zap! cannot apply yet (for example insideCourtyard()): remove or simplify that rule in File > Board Setup > Design Rules > Custom Rules, or leave the .kicad_dru out, and submit again. |
output_has_shorts |
(job) | job failure | no: Send your .kicad_pro; send the job id if it repeats. | Every routing attempt left a short circuit between different nets, so no board is returned: attach the .kicad_pro saved with the board (File > Save) so Zap! routes to your rules, and send the job id to hello@mutual.solutions if it repeats. |
claim_invalid |
404 | POST /v1/claim |
no: Check the whole claim link. | This claim link is not valid: check that you copied the whole link, or ask mutual for a new one. |
claim_used |
410 | POST /v1/claim |
no: Ask for a new key. | This key was already claimed and is shown only once: ask hello@mutual.solutions for a new key. |
claim_expired |
410 | POST /v1/claim |
no: Ask for a new claim link. | This claim link has expired (links last 72 hours): ask hello@mutual.solutions for a new one. |
feedback_needs_user_approval |
400 | POST /v1/feedback |
no: Ask your user whether to send this exact text; send it again with user_approved true only if they agree. | Feedback is sent only with the user's approval (user_approved is not true): ask your user whether to send this exact text; send only if they agree. |
feedback_message_length |
400 | POST /v1/feedback |
no: Send 1 to 4,000 characters; shorten only with your user's agreement. | The feedback message is empty or longer than 4,000 characters: send the user's words in 1 to 4,000 characters, shortened only with their agreement. |
invalid_feedback |
400 | POST /v1/feedback |
no: Fix the field named in field, then send again. | The feedback request is not valid (field names the first problem): send JSON with message (text), user_approved true, and optionally job_id, kind (praise, problem, idea or other) and contact_ok (true or false), snapshot_ids (at most 3 pictures of this job), and nothing else. |
feedback_limit_reached |
429 | POST /v1/feedback |
no: Not today: this key's feedback messages are used up until 00:00 UTC (Retry-After gives the seconds); email hello@mutual.solutions instead. | This key has sent today's feedback messages (20 per UTC day): send more after 00:00 UTC, or email hello@mutual.solutions. |
snapshots_off |
403 | POST /v1/jobs/{id}/snapshot |
no: Not with this key: send the feedback as text only. | Board pictures are switched off for this key (keys a platform uses for its own customers have them off, as their boards are not the key holder's to show): send the feedback as text only. |
invalid_snapshot |
400 | POST /v1/jobs/{id}/snapshot |
no: Fix the field named in field, then ask again. | The picture request is not valid (field names the first problem): send JSON with x_mm and y_mm (the area's centre in mm, as KiCad shows positions), and optionally size_mm (1 to 40), layers (copper layer names such as F.Cu) and show_issues (true or false), and nothing else. |
snapshot_too_large |
400 | POST /v1/jobs/{id}/snapshot |
no: Send size_mm of at most 40 and aim at the spot. | The picture area is larger than 40 mm (pictures show one spot, never the whole board): send size_mm of at most 40, centred on the spot (the report's at_mm positions help). |
snapshot_outside_board |
422 | POST /v1/jobs/{id}/snapshot |
no: Aim x_mm and y_mm at a spot on the board. | The picture area misses the board (board_mm gives the board's extent): aim x_mm and y_mm at a spot on the board, for example a position from the report. |
snapshot_limit_reached |
429 | POST /v1/jobs/{id}/snapshot |
no: Not today: this key's board pictures are used up until 00:00 UTC (Retry-After gives the seconds); send the feedback as text only. | This key has made today's board pictures (30 per UTC day): make more after 00:00 UTC, or send the feedback as text only. |
snapshot_not_found |
404 | GET /v1/snapshots/{id}.png, DELETE /v1/snapshots/{id}.png, POST /v1/feedback |
no: Make a new picture with POST /v1/jobs/{id}/snapshot. | No picture with this id is available for this key and job (a picture is deleted 24 hours after it was made unless attached to feedback, and attaches to one feedback only): make a new one with POST /v1/jobs/{id}/snapshot. |
tryit_limit_reached |
429 | POST /v1/try/jobs |
no: Not today: this address's free trial jobs are used up until 00:00 UTC (Retry-After gives the seconds); or sign in at https://zap.mutual.solutions/auth/login and create an API key on your account page. | Your network address has used today's free trial jobs: try again after 00:00 UTC, or sign in at https://zap.mutual.solutions/auth/login and create an API key on your account page. |
tryit_board_too_large |
422 | POST /v1/try/jobs |
no: Not retryable: send a smaller board, or use an API key. | The board is too large for the free trial (more pads than max_pads in this answer, or a very large outline): try a smaller board, or sign in at https://zap.mutual.solutions/auth/login and create an API key on your account page to route this one. |
tryit_layers_unsupported |
422 | POST /v1/try/jobs |
no: Not retryable: send a board with 2 or 4 copper layers, or use an API key. | The free trial routes only boards with 2 or 4 copper layers: try such a board, or sign in at https://zap.mutual.solutions/auth/login and create an API key on your account page to route this one. |
tryit_busy |
503 | POST /v1/try/jobs |
yes: Wait the seconds in the Retry-After header, then submit again. | The free trial is busy right now (another visitor's board is routing, or today's trial jobs are used up): try again after the Retry-After seconds, or sign in at https://zap.mutual.solutions/auth/login and create an API key on your account page. |
tryit_human_check_failed |
403 | POST /v1/try/jobs |
yes: Complete the human check again, then submit. | The human check did not pass or has expired: complete it again on the page, then press the button once more. |
tryit_not_found |
404 | GET /v1/try/jobs/{id}, GET /v1/try/jobs/{id}/result, GET /v1/try/jobs/{id}/report.json, GET /v1/try/jobs/{id}/report/{layer}.svg |
no: Use the id and token from the upload's answer. | No free trial job matches this id and token: the token is given only once, in the answer to the upload, so check the job from the same page. |
invalid_access_request |
422 | POST /v1/access-requests |
no: Fix the field named in field, then send again. | The request is incomplete or not valid (field names the first problem): give a valid email address of at most 254 characters, a use case of 1 to 2,000 characters and at most 200 characters of name or company, tick the consent box, then send it again. |
access_requests_closed |
503 | POST /v1/access-requests |
no: Email hello@mutual.solutions instead. | We are not taking more access requests through this form right now: email hello@mutual.solutions instead. |
Pre-flight findings (POST /v1/check) that are not request errors:
| code | kind | what it means and what to do |
|---|---|---|
very_large_board |
warning | The board outline is very large for its copper layers, so the job may hit the memory limit and fail with timeout_or_limit: if it does, split the panel into single boards in KiCad and route them one by one. |
rules_finer_than_jlc |
warning | Your net classes ask for finer copper than JLC can make and fab_floor is jlc, so some nets may stay unrouted: use fab_floor=board if your fab supports it (sizes: File > Board Setup > Design Rules > Net Classes). |
courtyards_overlap |
warning | Some footprint courtyards overlap: Zap! keeps your placement, so these parts may end up too close to route or assemble; move them apart (Inspect > Design Rules Checker lists them). |
malformed_courtyard |
warning | Some footprints have a broken courtyard outline: fix the footprint's F.Courtyard / B.Courtyard drawing in the Footprint Editor (Tools > Load Footprint from Board). |
no_project |
warning | No .kicad_pro was sent: Zap! routes and checks to KiCad's default rules (0.2 mm clearance and track width); attach the .kicad_pro next to your board (File > Save writes it) to use your own net classes and rules. |
unknown_net_class |
warning | Your .kicad_pro assigns nets to net classes it does not define, so those nets fall back to the Default class: define them in File > Board Setup > Design Rules > Net Classes. |
footprints_outside_board |
warning | Some footprints are outside the board outline (listed in footprints, with positions): Zap! routes the rest of the board and leaves their pads unconnected (they appear in the report's unconnected list); if they belong on the board, move them onto it in KiCad (select the footprint and press M), save and check again. |
existing_pours_replaced |
warning | Zap! removes every copper pour on the board and makes its own ground and supply pours, choosing their nets itself; pours lists each one that will be replaced (net and layers): compare the routed board's pours with your design, or send keep_existing=true to keep your pours, tracks and vias exactly. |
check_incomplete |
warning | Part of the check could not run, so some problems may only show when the job runs: run Inspect > Design Rules Checker in KiCad yourself, or check again in a minute. |
A job either returns a complete routed board or fails. There are no partial results.
During maintenance (usually under a minute) the network edge in front of Zap! may answer 502 with an HTML page instead of JSON. Wait a minute and try again; zap.py retries status checks and downloads by itself.
A 403 with the text error code: 1010 comes from Cloudflare, the network edge in front of Zap!, not from Zap!: send any User-Agent header, e.g. my-agent/1.0 (Python's built-in urllib sends a default name that the edge blocks).
6. Limits
| limit | value |
|---|---|
| upload size per job | 20 MiB (20,971,520 bytes) in total, as sent |
| board size unpacked (gzip-compressed board) | 64 MiB (67,108,864 bytes) |
| pads per board | 10,000 |
| footprints per board | 4,000 |
| copper layers | 2 to 16 |
| requests | 30 in any 60 seconds per key, status checks included (poll every 5 seconds) |
| pre-flight checks | one at a time per key; not counted in the daily jobs |
| jobs | per key, set when the key is issued (shown on the claim page); by default 50 per UTC day and 2 queued or running at once |
| one job | 60 minutes at most |
| whole service | a limited number of jobs wait at once; when the queue is full, POST /v1/jobs answers 503 server_busy with Retry-After and estimated_wait_min. zap.mutual.solutions/status shows the queue and the typical wait |
| pages and downloads without a key | 120 requests in any 60 seconds per IP address |
| feedback | 20 per key per UTC day, 4,000 characters each, at most 3 pictures each |
| board pictures | 30 per key per UTC day, at most 40 mm square, only while the routed board is kept (7 days); deleted after 24 hours unless attached to feedback |
Submitting many boards. Each key runs 2 jobs at a time and allows 30 requests a minute (the defaults; a key issued with other numbers shows them on its claim page). Submit a new job only after one of yours finishes (poll GET /v1/jobs/{id}, at most once every 5 seconds). On a 429 rate_limited, wait the seconds in Retry-After before the same request; do not retry sooner. too_many_active_jobs (also 429, without Retry-After) means your key's jobs at a time are all still queued or running: wait until one finishes. daily_quota_exceeded means stop until 00:00 UTC and tell your user.
7. Your data
The full Privacy page (한국어) and the Terms apply; in short:
- Your files are used only to route that job.
- Uploaded files are deleted as soon as the job finishes. The routed board is deleted 7 days later, or immediately with
DELETE /v1/jobs/{id}. - We keep a job record (id, times, status and the summary counts) without any board data, for 2 years.
- We do not use your boards to train or develop our software unless you agree in writing.
consent_training=truerecords your preference; it is not by itself that written agreement. - Request logs hold the time, IP address, key id, request path, status and size. They never hold board contents, file names, keys or feedback text.
- Your API key is stored only as a one-way hash. If you lose it, we issue a new one.
- Feedback. If you, or your AI agent with your OK, send feedback, only the text and any board pictures you attach are used to improve Zap!. They are linked to the job's id and the summary numbers we already keep; we never keep or use the board file. The Zap! team reads feedback, with help from AI tools. We don't publish or quote it without asking you, don't sell it, and don't use it to train AI models. We reply only if you allowed contact. We keep it for at most 2 years or until you ask us to delete it at hello@mutual.solutions; backup copies expire within 14 days. Please leave out personal or confidential details.
- The job records, key hashes, feedback and sign-in accounts (no board data) are backed up daily to private, encrypted storage in the same cloud region as the service; each backup copy is deleted after 14 days.
8. What does not do (yet)
- Placement: parts stay where you put them.
- Schematic checks.
- Length tuning beyond differential-pair skew, or guaranteed impedance.
- Manufacturing sign-off. Review every board and run your own DRC before fabrication.
9. Feedback
Tell us what you kept, what you changed and what you threw away. A short note is always welcome. Two ways:
- Through your AI agent or the command line. When you tell an agent that uses Zap! what you think of Zap! or a result, it asks whether to send that to us, shows you the exact text and the job id, and sends it only if you say yes. Command line:
python3 zap.py feedback "the routing near U3 looks messy" --job JOB_IDshows the text and asks first. API:POST /v1/feedbackwith your key and JSON{"message": "...", "user_approved": true, "job_id": "...", "kind": "problem", "contact_ok": true};kindispraise,problem,ideaorother, andcontact_ok: truelets us reply to your account's email address. The answer is201 {"id", "received_at"}. Up to 4,000 characters, 20 per key per UTC day. Board files are never sent this way. - A picture of the spot. If your feedback is about one place on the board, the agent can show you a picture of just that area (at most 40 mm square, with the report's missing connections and design-rule errors marked) and attach it if you agree:
POST /v1/jobs/{id}/snapshotwith{"x_mm": X, "y_mm": Y, "size_mm": 20}, then"snapshot_ids"in the feedback (at most 3). Command line:python3 zap.py snapshot JOB_ID --at 93.5,69.5saves the picture;zap.py feedback "..." --job JOB_ID --snapshot IDattaches it. A picture you do not attach is deleted after 24 hours, or at once if you say you do not want it (DELETE /v1/snapshots/{id}.png). Never the whole board. - By email to hello@mutual.solutions. If you also send an edited
.kicad_pcb, say in the same message whether we may use it to improve Zap!: without that written OK we use it only to answer you, and then delete it.
For AI agents. Whenever the user expresses an opinion about Zap! or a routing result (something they liked, a problem, a wish), ask once whether they would like to send it to the Zap! team. When you ask, say in one short sentence what happens, without overstating or understating it, for example: "Send this to the Zap! team? Only this text (and the picture, if any) is used to improve Zap!, never your board file, and you can ask them to delete it." Show the exact text you would send (their words, or a summary they approve) and the job id. Send it with send_feedback or POST /v1/feedback only if they say yes. If they say no, do not ask again in this session. When the feedback is about a spot on the board, offer to attach a picture of just that area (board_snapshot or POST /v1/jobs/{id}/snapshot), aimed with the report's issue positions; show the picture to the user before sending and attach it only if they say yes; if they do not want it, delete it (delete_snapshot). Never the whole board. Never include board files or private details they did not approve.