by mutual

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.

Machine summary (for software and AI agents).


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:

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

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):

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 ' and check it was copied completely.
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": {"": {"amps": A}}} with net names exactly as KiCad shows them (Inspect > Net Inspector), amps from 0 to 100 and at most 2,000 nets (names not found in the board are listed in unknown_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:

8. What does not do (yet)

9. Feedback

Tell us what you kept, what you changed and what you threw away. A short note is always welcome. Two ways:

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.