{
 "openapi": "3.0.3",
 "info": {
  "title": "Zap! by mutual",
  "version": "2026.10.14.78",
  "description": "Zap! routes placed KiCad boards: upload a .kicad_pcb (and its .kicad_pro), get the same board back with tracks, vias and copper pours, plus a manufacturability report. Early access: every routed board must be reviewed by an engineer and checked with KiCad's DRC before fabrication.\n\nDo this, in order: (1) POST /v1/check with the upload; if ok is false, show the blockers to the human and stop. (2) POST /v1/jobs with the same upload; keep the id. It is not idempotent: if the request fails without an answer, GET /v1/jobs before resubmitting. (3) GET /v1/jobs/{id} every 5 s, every 10 s after the first minute, until status is succeeded, failed or cancelled (a job is stopped at 60 minutes). (4) GET /v1/jobs/{id}/result and save it as <board>.routed.kicad_pcb. (5) DELETE /v1/jobs/{id}.\n\nAuth: header Authorization: Bearer <api key>; read the key from the environment or a file, never from code, URLs, logs or command-line arguments. Limits: 20 MiB per upload (a gzip-compressed board counts compressed), 10,000 pads, 4,000 footprints, 2 to 16 copper layers, 30 requests in any 60 s per key (429 rate_limited with Retry-After beyond), daily jobs and concurrent jobs per key as issued (default 50 and 2). Errors: JSON {\"error\": code, \"message\": sentence}; GET https://zap.mutual.solutions/v1/errors lists every code with its HTTP status, fix and whether retrying helps. A 403 with the text 'error code: 1010' (no JSON) comes from Cloudflare in front of Zap!: send any User-Agent header, e.g. 'my-agent/1.0'. Agent recipe: https://zap.mutual.solutions/agents.md. MCP server: https://zap.mutual.solutions/downloads/zap_mcp.py.",
  "contact": {
   "name": "mutual",
   "email": "hello@mutual.solutions"
  }
 },
 "servers": [
  {
   "url": "https://zap.mutual.solutions"
  }
 ],
 "security": [
  {
   "apiKey": []
  },
  {
   "oauth2": [
    "zap"
   ]
  }
 ],
 "tags": [
  {
   "name": "flow",
   "description": "check, submit, poll, download, delete"
  },
  {
   "name": "feedback",
   "description": "the user's opinion, sent only with the user's approval"
  },
  {
   "name": "info",
   "description": "no key needed"
  },
  {
   "name": "try",
   "description": "the free trial without a key, used by the /app page"
  },
  {
   "name": "oauth",
   "description": "sign-in with Google, GitHub or Microsoft (guide: Connect with OAuth)"
  }
 ],
 "paths": {
  "/v1/check": {
   "post": {
    "tags": [
     "flow"
    ],
    "operationId": "checkBoard",
    "summary": "Step 1: pre-flight check of an upload (no routing, no job quota)",
    "description": "Send the same multipart upload you will submit. Answers in seconds. If ok is false, do not submit: show every blocker (message, references, positions) to the human. Mention warnings later. One check at a time per key; counts toward the request rate, not the daily jobs.",
    "requestBody": {
     "required": true,
     "description": "multipart/form-data upload. Send a Content-Length (no chunked transfer); at most 20 MiB in total.",
     "content": {
      "multipart/form-data": {
       "schema": {
        "type": "object",
        "required": [
         "board"
        ],
        "properties": {
         "board": {
          "type": "string",
          "format": "binary",
          "description": "The placed board: the .kicad_pcb file KiCad saved (KiCad 6 to 10), with the board outline on Edge.Cuts, every footprint placed on the board, and no tracks or vias (unless keep_existing). May be gzip-compressed (board.kicad_pcb.gz, the same field): the upload limit counts it compressed; unpacked at most 64 MiB (413 board_too_large_unpacked)."
         },
         "project": {
          "type": "string",
          "format": "binary",
          "description": "Recommended: the .kicad_pro saved next to the board (design rules and net classes). Without it Zap! routes and checks to KiCad's default rules."
         },
         "rules": {
          "type": "string",
          "format": "binary",
          "description": "Optional: the .kicad_dru custom rules file (first line starts with '(version')."
         },
         "currents": {
          "type": "string",
          "format": "binary",
          "description": "Optional: currents the human knows, as a JSON file or the same JSON as a text field: {\"nets\": {\"<net name>\": {\"amps\": A, \"peak_amps\": A}}, \"ambient_c\": C}. Names exactly as in the board or a unique last path part ('PoE_5V' for '/PoE_5V'); amps = continuous current 0-100; peak_amps optional, at least amps, recorded only; ambient_c optional, -40 to 125; at most 2,000 nets. State supply nets, not ground. The router sizes those nets' copper from them and the temperature check uses them."
         },
         "router": {
          "type": "string",
          "enum": [
           "stable",
           "next"
          ],
          "default": "stable",
          "description": "Router build: stable (default), or next only when GET /v1/version lists routers.next (otherwise 409 router_unavailable)."
         },
         "keep_existing": {
          "type": "string",
          "enum": [
           "true",
           "false"
          ],
          "default": "false",
          "description": "true: keep the board's own tracks, vias and zones exactly and route only the missing connections around them (a partly routed board). false (default): the board must have no tracks or vias (422 input_has_copper), unless strip_existing is true."
         },
         "strip_existing": {
          "type": "string",
          "enum": [
           "true",
           "false"
          ],
          "default": "false",
          "description": "true: discard the board's own tracks and vias and route everything (a board saved with old routing; the fix for input_has_copper when no KiCad is at hand). Not with keep_existing (422 invalid_option)."
         },
         "fab_floor": {
          "type": "string",
          "enum": [
           "jlc",
           "board"
          ],
          "default": "jlc",
          "description": "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 (fab_floor in the job status)."
         },
         "signal_width_mm": {
          "type": "number",
          "minimum": 0.05,
          "maximum": 5,
          "description": "Signal track width, mm. Default: the board's Default net class width, else 0.2. With fab_floor=jlc at least 0.127 (2 layers) or 0.09 (4+)."
         },
         "via_diameter_mm": {
          "type": "number",
          "minimum": 0.2,
          "maximum": 3,
          "description": "Via diameter, mm; send with via_drill_mm. Default: the board's Default net class via, else 0.6."
         },
         "via_drill_mm": {
          "type": "number",
          "minimum": 0.1,
          "maximum": 2,
          "description": "Via drill, mm, at least 0.1 smaller than the diameter. Default: net class, else 0.3."
         },
         "vias_in_pads": {
          "type": "string",
          "enum": [
           "forbid",
           "allow"
          ],
          "default": "forbid",
          "description": "Vias inside SMD pads; allow needs the board made with filled and capped vias, an extra fab option."
         },
         "ground_pour": {
          "type": "string",
          "enum": [
           "on",
           "off"
          ],
          "default": "on",
          "description": "Ground copper pours on every copper layer."
         },
         "size_rails_from_current": {
          "type": "string",
          "enum": [
           "on",
           "off"
          ],
          "default": "on",
          "description": "Supply track width widened from current (stated, else estimated) where room allows, never below the net-class width; off: the net-class width, which may overheat."
         },
         "effort": {
          "type": "string",
          "enum": [
           "quick",
           "normal",
           "thorough"
          ],
          "default": "normal",
          "description": "Routing effort. quick: about one eighth of the routing work: faster, more unrouted connections. normal (default). thorough: about twice the routing work and keeps the best intermediate result; slower on large boards, usually fewer unrouted connections."
         },
         "minutes": {
          "type": "integer",
          "minimum": 1,
          "maximum": 60,
          "description": "The whole job ends within about this many minutes (1 to 60), checks included, and returns the best board found by then (if no attempt can finish in time, the first to finish is returned later than asked, with time_budget.stopped over_time); default (absent): one round of a few minutes. After the first round the job routes the same board again with more work and other settings while the time allows, and keeps the best by the same ranking; a new attempt starts only if it can finish in time, and routing ends by minute 50 so the checks and report fit in the job's 60 minutes. One job with minutes over 10 runs at a time across the service; others wait in the queue (queue_note). The job's metrics.time_budget says how many attempts ran, which was kept and the time used."
         },
         "consent_training": {
          "type": "string",
          "enum": [
           "true",
           "false"
          ],
          "default": "false",
          "description": "Optional preference only; boards are never used for development without a separate written agreement."
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Findings.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Check"
        },
        "example": {
         "ok": true,
         "summary": "Ready to route. 2 warnings.",
         "board": {
          "file_version": 20260206,
          "kicad_version": 10,
          "returned_as": "a KiCad 10 file, like the one you sent",
          "rules": {
           "project": "uploaded",
           "custom_rules": "none"
          },
          "router": {
           "choice": "stable",
           "build": "r009e659.8f766f9f"
          },
          "footprints": 19,
          "pads": 94,
          "nets": 42,
          "copper_layers": 2,
          "size_mm": [
           88.64,
           78.81
          ]
         },
         "blockers": [],
         "warnings": [
          {
           "code": "footprints_outside_board",
           "message": "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.",
           "footprints": [
            {
             "ref": "R4",
             "at_mm": [
              400.0,
              400.0
             ],
             "side": "front",
             "whole_footprint": true
            }
           ],
           "footprints_total": 1
          },
          {
           "code": "courtyards_overlap",
           "message": "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).",
           "items": [
            {
             "refs": [
              "R4",
              "RV1"
             ],
             "at_mm": [
              118.69,
              121.815
             ]
            }
           ],
           "items_total": 1
          }
         ],
         "check_s": 3.2
        }
       }
      }
     },
     "400": {
      "description": "`invalid_compressed_board`: 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. (retry does not help: Send the board again, compressed whole with gzip or not compressed.); `board_required`: No board was attached: send the placed board in the multipart field 'board' (curl -F board=@myboard.kicad_pcb). (retry does not help: Put the board in the multipart field 'board'.); `invalid_project`: 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. (retry does not help: Attach the right .kicad_pro or leave it out.); `invalid_rules`: 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. (retry does not help: Attach the right .kicad_dru or leave it out.); `bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "invalid_compressed_board": {
          "value": {
           "error": "invalid_compressed_board",
           "message": "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."
          }
         },
         "board_required": {
          "value": {
           "error": "board_required",
           "message": "No board was attached: send the placed board in the multipart field 'board' (curl -F board=@myboard.kicad_pcb)."
          }
         },
         "invalid_project": {
          "value": {
           "error": "invalid_project",
           "message": "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": {
          "value": {
           "error": "invalid_rules",
           "message": "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."
          }
         },
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "`unauthorized`: The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely. (retry does not help: Fix the key; do not retry with the same key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "unauthorized": {
          "value": {
           "error": "unauthorized",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "`router_unavailable`: The next router is not available right now; use stable. (retry does not help: Use router=stable (GET /v1/version lists routers.next when it is offered).); `keep_existing_unavailable`: 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. (retry does not help: Use router=stable, or send keep_existing=false with a board that has no tracks or vias.); `fab_floor_unavailable`: 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. (retry does not help: Send fab_floor=jlc, or use router=next when GET /v1/version lists one.); `options_unavailable`: Routing options are not switched on yet: leave them out to use the defaults (GET /v1/version shows \"options\": true once they are on). (retry does not help: Leave the routing options out for now (GET /v1/version lists options when they are on).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "router_unavailable": {
          "value": {
           "error": "router_unavailable",
           "message": "The next router is not available right now; use stable."
          }
         },
         "keep_existing_unavailable": {
          "value": {
           "error": "keep_existing_unavailable",
           "message": "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."
          }
         },
         "fab_floor_unavailable": {
          "value": {
           "error": "fab_floor_unavailable",
           "message": "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."
          }
         },
         "options_unavailable": {
          "value": {
           "error": "options_unavailable",
           "message": "Routing options are not switched on yet: leave them out to use the defaults (GET /v1/version shows \"options\": true once they are on)."
          }
         }
        }
       }
      }
     },
     "411": {
      "description": "`length_required`: 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). (retry does not help: Send a normal multipart upload with a Content-Length.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "length_required": {
          "value": {
           "error": "length_required",
           "message": "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)."
          }
         }
        }
       }
      }
     },
     "413": {
      "description": "`too_large`: 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. (retry does not help: Send smaller files.); `board_too_large_unpacked`: 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. (retry does not help: Send a smaller board.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "too_large": {
          "value": {
           "error": "too_large",
           "message": "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": {
          "value": {
           "error": "board_too_large_unpacked",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "422": {
      "description": "`invalid_board`: 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). (retry does not help: Fix the board file; resubmitting the same file gives the same answer.); `invalid_currents`: The currents file is not valid: send JSON {\"nets\": {\"<net name>\": {\"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). (retry does not help: Fix the currents JSON (see unknown_nets / ambiguous_nets in the answer).); `invalid_router`: The router field is not valid: send stable or next, or leave it out (which means stable). (retry does not help: Send router=stable, router=next, or leave it out.); `invalid_consent`: The consent_training field is not valid: send true or false, or leave it out (which means false). (retry does not help: Send consent_training=true or false, or leave it out.); `invalid_keep_existing`: The keep_existing field is not valid: send true or false, or leave it out (which means false). (retry does not help: Send keep_existing=true or false, or leave it out.); `invalid_fab_floor`: 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. (retry does not help: Send fab_floor=jlc or fab_floor=board, or leave it out.); `invalid_option`: A routing option is not valid (option names it, fix says what to send): correct it, or leave it out to use the default. (retry does not help: Change the option named in option as fix says, or leave it out to use the default.); `input_has_copper`: 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. (retry does not help: Send strip_existing=true to discard them and route everything, or keep_existing=true to route around them.); `too_many_pads`: The board has more than 10,000 pads, the current limit: contact hello@mutual.solutions if you need a larger board routed. (retry does not help: Not retryable: ask mutual for a larger limit.); `too_many_footprints`: The board has more than 4,000 footprints, the current limit: contact hello@mutual.solutions if you need a larger board routed. (retry does not help: Not retryable: ask mutual for a larger limit.); `board_too_large`: 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. (retry does not help: Not retryable as is: email hello@mutual.solutions to arrange a business (B2B) job.); `unsupported_layer_count`: The board must have 2 to 16 copper layers: set the count in File > Board Setup > Board Stackup > Physical Stackup (Copper layers) and save. (retry does not help: Fix the stackup in KiCad, then submit again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "invalid_board": {
          "value": {
           "error": "invalid_board",
           "message": "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)."
          }
         },
         "invalid_currents": {
          "value": {
           "error": "invalid_currents",
           "message": "The currents file is not valid: send JSON {\"nets\": {\"<net name>\": {\"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": {
          "value": {
           "error": "invalid_router",
           "message": "The router field is not valid: send stable or next, or leave it out (which means stable)."
          }
         },
         "invalid_consent": {
          "value": {
           "error": "invalid_consent",
           "message": "The consent_training field is not valid: send true or false, or leave it out (which means false)."
          }
         },
         "invalid_keep_existing": {
          "value": {
           "error": "invalid_keep_existing",
           "message": "The keep_existing field is not valid: send true or false, or leave it out (which means false)."
          }
         },
         "invalid_fab_floor": {
          "value": {
           "error": "invalid_fab_floor",
           "message": "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."
          }
         },
         "invalid_option": {
          "value": {
           "error": "invalid_option",
           "message": "A routing option is not valid (option names it, fix says what to send): correct it, or leave it out to use the default."
          }
         },
         "input_has_copper": {
          "value": {
           "error": "input_has_copper",
           "message": "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": {
          "value": {
           "error": "too_many_pads",
           "message": "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": {
          "value": {
           "error": "too_many_footprints",
           "message": "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": {
          "value": {
           "error": "board_too_large",
           "message": "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": {
          "value": {
           "error": "unsupported_layer_count",
           "message": "The board must have 2 to 16 copper layers: set the count in File > Board Setup > Board Stackup > Physical Stackup (Copper layers) and save."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.); `check_limit_reached`: 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. (retry does not help: 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.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         },
         "check_limit_reached": {
          "value": {
           "error": "check_limit_reached",
           "message": "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."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     },
     "503": {
      "description": "`check_busy`: Several board checks are running right now: try again in a minute. (retry helps: Wait the seconds in Retry-After, then repeat the check.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "check_busy": {
          "value": {
           "error": "check_busy",
           "message": "Several board checks are running right now: try again in a minute."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     }
    }
   }
  },
  "/v1/jobs": {
   "post": {
    "tags": [
     "flow"
    ],
    "operationId": "createJob",
    "summary": "Step 2: submit a placed board for routing",
    "description": "Uses one of the key's daily jobs. Not idempotent: each accepted call creates a job; after a network failure without an answer, GET /v1/jobs and look for a job created in the last minutes before resubmitting. Then poll GET /v1/jobs/{id}.",
    "requestBody": {
     "required": true,
     "description": "multipart/form-data upload. Send a Content-Length (no chunked transfer); at most 20 MiB in total.",
     "content": {
      "multipart/form-data": {
       "schema": {
        "type": "object",
        "required": [
         "board"
        ],
        "properties": {
         "board": {
          "type": "string",
          "format": "binary",
          "description": "The placed board: the .kicad_pcb file KiCad saved (KiCad 6 to 10), with the board outline on Edge.Cuts, every footprint placed on the board, and no tracks or vias (unless keep_existing). May be gzip-compressed (board.kicad_pcb.gz, the same field): the upload limit counts it compressed; unpacked at most 64 MiB (413 board_too_large_unpacked)."
         },
         "project": {
          "type": "string",
          "format": "binary",
          "description": "Recommended: the .kicad_pro saved next to the board (design rules and net classes). Without it Zap! routes and checks to KiCad's default rules."
         },
         "rules": {
          "type": "string",
          "format": "binary",
          "description": "Optional: the .kicad_dru custom rules file (first line starts with '(version')."
         },
         "currents": {
          "type": "string",
          "format": "binary",
          "description": "Optional: currents the human knows, as a JSON file or the same JSON as a text field: {\"nets\": {\"<net name>\": {\"amps\": A, \"peak_amps\": A}}, \"ambient_c\": C}. Names exactly as in the board or a unique last path part ('PoE_5V' for '/PoE_5V'); amps = continuous current 0-100; peak_amps optional, at least amps, recorded only; ambient_c optional, -40 to 125; at most 2,000 nets. State supply nets, not ground. The router sizes those nets' copper from them and the temperature check uses them."
         },
         "router": {
          "type": "string",
          "enum": [
           "stable",
           "next"
          ],
          "default": "stable",
          "description": "Router build: stable (default), or next only when GET /v1/version lists routers.next (otherwise 409 router_unavailable)."
         },
         "keep_existing": {
          "type": "string",
          "enum": [
           "true",
           "false"
          ],
          "default": "false",
          "description": "true: keep the board's own tracks, vias and zones exactly and route only the missing connections around them (a partly routed board). false (default): the board must have no tracks or vias (422 input_has_copper), unless strip_existing is true."
         },
         "strip_existing": {
          "type": "string",
          "enum": [
           "true",
           "false"
          ],
          "default": "false",
          "description": "true: discard the board's own tracks and vias and route everything (a board saved with old routing; the fix for input_has_copper when no KiCad is at hand). Not with keep_existing (422 invalid_option)."
         },
         "fab_floor": {
          "type": "string",
          "enum": [
           "jlc",
           "board"
          ],
          "default": "jlc",
          "description": "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 (fab_floor in the job status)."
         },
         "signal_width_mm": {
          "type": "number",
          "minimum": 0.05,
          "maximum": 5,
          "description": "Signal track width, mm. Default: the board's Default net class width, else 0.2. With fab_floor=jlc at least 0.127 (2 layers) or 0.09 (4+)."
         },
         "via_diameter_mm": {
          "type": "number",
          "minimum": 0.2,
          "maximum": 3,
          "description": "Via diameter, mm; send with via_drill_mm. Default: the board's Default net class via, else 0.6."
         },
         "via_drill_mm": {
          "type": "number",
          "minimum": 0.1,
          "maximum": 2,
          "description": "Via drill, mm, at least 0.1 smaller than the diameter. Default: net class, else 0.3."
         },
         "vias_in_pads": {
          "type": "string",
          "enum": [
           "forbid",
           "allow"
          ],
          "default": "forbid",
          "description": "Vias inside SMD pads; allow needs the board made with filled and capped vias, an extra fab option."
         },
         "ground_pour": {
          "type": "string",
          "enum": [
           "on",
           "off"
          ],
          "default": "on",
          "description": "Ground copper pours on every copper layer."
         },
         "size_rails_from_current": {
          "type": "string",
          "enum": [
           "on",
           "off"
          ],
          "default": "on",
          "description": "Supply track width widened from current (stated, else estimated) where room allows, never below the net-class width; off: the net-class width, which may overheat."
         },
         "effort": {
          "type": "string",
          "enum": [
           "quick",
           "normal",
           "thorough"
          ],
          "default": "normal",
          "description": "Routing effort. quick: about one eighth of the routing work: faster, more unrouted connections. normal (default). thorough: about twice the routing work and keeps the best intermediate result; slower on large boards, usually fewer unrouted connections."
         },
         "minutes": {
          "type": "integer",
          "minimum": 1,
          "maximum": 60,
          "description": "The whole job ends within about this many minutes (1 to 60), checks included, and returns the best board found by then (if no attempt can finish in time, the first to finish is returned later than asked, with time_budget.stopped over_time); default (absent): one round of a few minutes. After the first round the job routes the same board again with more work and other settings while the time allows, and keeps the best by the same ranking; a new attempt starts only if it can finish in time, and routing ends by minute 50 so the checks and report fit in the job's 60 minutes. One job with minutes over 10 runs at a time across the service; others wait in the queue (queue_note). The job's metrics.time_budget says how many attempts ran, which was kept and the time used."
         },
         "consent_training": {
          "type": "string",
          "enum": [
           "true",
           "false"
          ],
          "default": "false",
          "description": "Optional preference only; boards are never used for development without a separate written agreement."
         }
        }
       }
      }
     }
    },
    "responses": {
     "202": {
      "description": "Accepted and queued. Poll GET /v1/jobs/{id} every 5 s.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "required": [
          "id",
          "status"
         ],
         "properties": {
          "id": {
           "type": "string",
           "pattern": "^[0-9a-f]{32}$"
          },
          "status": {
           "type": "string",
           "enum": [
            "queued"
           ]
          }
         }
        },
        "example": {
         "id": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1",
         "status": "queued"
        }
       }
      }
     },
     "400": {
      "description": "`invalid_compressed_board`: 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. (retry does not help: Send the board again, compressed whole with gzip or not compressed.); `board_required`: No board was attached: send the placed board in the multipart field 'board' (curl -F board=@myboard.kicad_pcb). (retry does not help: Put the board in the multipart field 'board'.); `invalid_project`: 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. (retry does not help: Attach the right .kicad_pro or leave it out.); `invalid_rules`: 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. (retry does not help: Attach the right .kicad_dru or leave it out.); `bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "invalid_compressed_board": {
          "value": {
           "error": "invalid_compressed_board",
           "message": "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."
          }
         },
         "board_required": {
          "value": {
           "error": "board_required",
           "message": "No board was attached: send the placed board in the multipart field 'board' (curl -F board=@myboard.kicad_pcb)."
          }
         },
         "invalid_project": {
          "value": {
           "error": "invalid_project",
           "message": "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": {
          "value": {
           "error": "invalid_rules",
           "message": "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."
          }
         },
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "`unauthorized`: The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely. (retry does not help: Fix the key; do not retry with the same key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "unauthorized": {
          "value": {
           "error": "unauthorized",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "`router_unavailable`: The next router is not available right now; use stable. (retry does not help: Use router=stable (GET /v1/version lists routers.next when it is offered).); `keep_existing_unavailable`: 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. (retry does not help: Use router=stable, or send keep_existing=false with a board that has no tracks or vias.); `fab_floor_unavailable`: 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. (retry does not help: Send fab_floor=jlc, or use router=next when GET /v1/version lists one.); `options_unavailable`: Routing options are not switched on yet: leave them out to use the defaults (GET /v1/version shows \"options\": true once they are on). (retry does not help: Leave the routing options out for now (GET /v1/version lists options when they are on).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "router_unavailable": {
          "value": {
           "error": "router_unavailable",
           "message": "The next router is not available right now; use stable."
          }
         },
         "keep_existing_unavailable": {
          "value": {
           "error": "keep_existing_unavailable",
           "message": "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."
          }
         },
         "fab_floor_unavailable": {
          "value": {
           "error": "fab_floor_unavailable",
           "message": "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."
          }
         },
         "options_unavailable": {
          "value": {
           "error": "options_unavailable",
           "message": "Routing options are not switched on yet: leave them out to use the defaults (GET /v1/version shows \"options\": true once they are on)."
          }
         }
        }
       }
      }
     },
     "411": {
      "description": "`length_required`: 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). (retry does not help: Send a normal multipart upload with a Content-Length.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "length_required": {
          "value": {
           "error": "length_required",
           "message": "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)."
          }
         }
        }
       }
      }
     },
     "413": {
      "description": "`too_large`: 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. (retry does not help: Send smaller files.); `board_too_large_unpacked`: 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. (retry does not help: Send a smaller board.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "too_large": {
          "value": {
           "error": "too_large",
           "message": "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": {
          "value": {
           "error": "board_too_large_unpacked",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "422": {
      "description": "`invalid_board`: 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). (retry does not help: Fix the board file; resubmitting the same file gives the same answer.); `invalid_currents`: The currents file is not valid: send JSON {\"nets\": {\"<net name>\": {\"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). (retry does not help: Fix the currents JSON (see unknown_nets / ambiguous_nets in the answer).); `invalid_router`: The router field is not valid: send stable or next, or leave it out (which means stable). (retry does not help: Send router=stable, router=next, or leave it out.); `invalid_consent`: The consent_training field is not valid: send true or false, or leave it out (which means false). (retry does not help: Send consent_training=true or false, or leave it out.); `invalid_keep_existing`: The keep_existing field is not valid: send true or false, or leave it out (which means false). (retry does not help: Send keep_existing=true or false, or leave it out.); `invalid_fab_floor`: 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. (retry does not help: Send fab_floor=jlc or fab_floor=board, or leave it out.); `invalid_option`: A routing option is not valid (option names it, fix says what to send): correct it, or leave it out to use the default. (retry does not help: Change the option named in option as fix says, or leave it out to use the default.); `input_has_copper`: 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. (retry does not help: Send strip_existing=true to discard them and route everything, or keep_existing=true to route around them.); `too_many_pads`: The board has more than 10,000 pads, the current limit: contact hello@mutual.solutions if you need a larger board routed. (retry does not help: Not retryable: ask mutual for a larger limit.); `too_many_footprints`: The board has more than 4,000 footprints, the current limit: contact hello@mutual.solutions if you need a larger board routed. (retry does not help: Not retryable: ask mutual for a larger limit.); `board_too_large`: 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. (retry does not help: Not retryable as is: email hello@mutual.solutions to arrange a business (B2B) job.); `unsupported_layer_count`: The board must have 2 to 16 copper layers: set the count in File > Board Setup > Board Stackup > Physical Stackup (Copper layers) and save. (retry does not help: Fix the stackup in KiCad, then submit again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "invalid_board": {
          "value": {
           "error": "invalid_board",
           "message": "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)."
          }
         },
         "invalid_currents": {
          "value": {
           "error": "invalid_currents",
           "message": "The currents file is not valid: send JSON {\"nets\": {\"<net name>\": {\"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": {
          "value": {
           "error": "invalid_router",
           "message": "The router field is not valid: send stable or next, or leave it out (which means stable)."
          }
         },
         "invalid_consent": {
          "value": {
           "error": "invalid_consent",
           "message": "The consent_training field is not valid: send true or false, or leave it out (which means false)."
          }
         },
         "invalid_keep_existing": {
          "value": {
           "error": "invalid_keep_existing",
           "message": "The keep_existing field is not valid: send true or false, or leave it out (which means false)."
          }
         },
         "invalid_fab_floor": {
          "value": {
           "error": "invalid_fab_floor",
           "message": "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."
          }
         },
         "invalid_option": {
          "value": {
           "error": "invalid_option",
           "message": "A routing option is not valid (option names it, fix says what to send): correct it, or leave it out to use the default."
          }
         },
         "input_has_copper": {
          "value": {
           "error": "input_has_copper",
           "message": "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": {
          "value": {
           "error": "too_many_pads",
           "message": "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": {
          "value": {
           "error": "too_many_footprints",
           "message": "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": {
          "value": {
           "error": "board_too_large",
           "message": "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": {
          "value": {
           "error": "unsupported_layer_count",
           "message": "The board must have 2 to 16 copper layers: set the count in File > Board Setup > Board Stackup > Physical Stackup (Copper layers) and save."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.); `daily_quota_exceeded`: This key has used all of today's jobs: try again after 00:00 UTC, or ask mutual to raise the quota. (retry does not help: 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.); `too_many_active_jobs`: This key already has the maximum number of jobs queued or running: wait for one to finish, then submit again. (retry helps: Wait until one of your queued or running jobs finishes, then submit again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         },
         "daily_quota_exceeded": {
          "value": {
           "error": "daily_quota_exceeded",
           "message": "This key has used all of today's jobs: try again after 00:00 UTC, or ask mutual to raise the quota."
          }
         },
         "too_many_active_jobs": {
          "value": {
           "error": "too_many_active_jobs",
           "message": "This key already has the maximum number of jobs queued or running: wait for one to finish, then submit again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     },
     "503": {
      "description": "`server_busy`: 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. (retry helps: Wait the seconds in the Retry-After header, then submit again; estimated_wait_min is the expected wait.); `check_busy`: Several board checks are running right now: try again in a minute. (retry helps: Wait the seconds in Retry-After, then repeat the check.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "server_busy": {
          "value": {
           "error": "server_busy",
           "message": "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."
          }
         },
         "check_busy": {
          "value": {
           "error": "check_busy",
           "message": "Several board checks are running right now: try again in a minute."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     }
    }
   },
   "get": {
    "tags": [
     "flow"
    ],
    "operationId": "listJobs",
    "summary": "List this key's last 50 jobs, newest first",
    "description": "Use it to find a job after a submission whose answer was lost.",
    "responses": {
     "200": {
      "description": "Jobs.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "jobs": {
           "type": "array",
           "items": {
            "$ref": "#/components/schemas/Job"
           }
          }
         }
        },
        "example": {
         "jobs": [
          {
           "id": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1",
           "status": "succeeded",
           "created_at": "2026-10-13T02:21:09Z",
           "started_at": "2026-10-13T02:21:09Z",
           "finished_at": "2026-10-13T02:21:16Z",
           "consent_training": false,
           "files_deleted": false,
           "version": {
            "service": "2026.10.14.78",
            "router": "r009e659.8f766f9f",
            "router_choice": "stable"
           },
           "metrics": {
            "unconnected": 0,
            "critical_unconnected": 0,
            "drc_errors": 0,
            "wall_time_s": 6.8,
            "fab": {
             "check": "fab1 1.6",
             "rules": {
              "project": "uploaded",
              "custom_rules": "none"
             },
             "computed": true,
             "thermal": {
              "computed": true,
              "currents": "Estimated from the board's parts. May under-estimate large power stages; confirm high-current nets yourself.",
              "limit_c": 10.0,
              "worst_rise_c": 0.61,
              "off_chart": false,
              "sections_over_limit": 0,
              "nets_over_limit": [],
              "rails_checked": 1,
              "rails_not_checked": 0,
              "hottest": [
               {
                "net": "+5V",
                "kind": "track",
                "layer": "F.Cu",
                "rise_c": 0.61,
                "current_a": 0.6,
                "current_source": "estimated",
                "at_mm": [
                 131.575,
                 104.875
                ]
               }
              ]
             },
             "shorts": 0,
             "drc_errors": 0,
             "drc_errors_by_type": {},
             "jlc_violations": 15,
             "jlc_violations_by_type": {
              "acute_angle_acid_trap": 1,
              "via_in_pad": 14
             },
             "jlc_capabilities": "1-2 layers",
             "counts_capped": [],
             "time_s": 1.4
            }
           }
          }
         ]
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "`unauthorized`: The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely. (retry does not help: Fix the key; do not retry with the same key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "unauthorized": {
          "value": {
           "error": "unauthorized",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/v1/jobs/{id}": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[0-9a-f]{32}$"
     },
     "description": "The job id returned by POST /v1/jobs.",
     "example": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1"
    }
   ],
   "get": {
    "tags": [
     "flow"
    ],
    "operationId": "getJob",
    "summary": "Step 3: job status; metrics once succeeded",
    "description": "Poll every 5 s for the first minute, then every 10 s, until status is succeeded, failed or cancelled. succeeded: download the result (step 4) and report metrics to the human (lower is better; critical_unconnected must be 0 before ordering boards). failed: read error.code; GET /v1/errors says whether retrying helps.",
    "responses": {
     "200": {
      "description": "Job.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Job"
        },
        "examples": {
         "succeeded": {
          "value": {
           "id": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1",
           "status": "succeeded",
           "created_at": "2026-10-13T02:21:09Z",
           "started_at": "2026-10-13T02:21:09Z",
           "finished_at": "2026-10-13T02:21:16Z",
           "consent_training": false,
           "files_deleted": false,
           "version": {
            "service": "2026.10.14.78",
            "router": "r009e659.8f766f9f",
            "router_choice": "stable"
           },
           "metrics": {
            "unconnected": 0,
            "critical_unconnected": 0,
            "drc_errors": 0,
            "wall_time_s": 6.8,
            "fab": {
             "check": "fab1 1.6",
             "rules": {
              "project": "uploaded",
              "custom_rules": "none"
             },
             "computed": true,
             "thermal": {
              "computed": true,
              "currents": "Estimated from the board's parts. May under-estimate large power stages; confirm high-current nets yourself.",
              "limit_c": 10.0,
              "worst_rise_c": 0.61,
              "off_chart": false,
              "sections_over_limit": 0,
              "nets_over_limit": [],
              "rails_checked": 1,
              "rails_not_checked": 0,
              "hottest": [
               {
                "net": "+5V",
                "kind": "track",
                "layer": "F.Cu",
                "rise_c": 0.61,
                "current_a": 0.6,
                "current_source": "estimated",
                "at_mm": [
                 131.575,
                 104.875
                ]
               }
              ]
             },
             "shorts": 0,
             "drc_errors": 0,
             "drc_errors_by_type": {},
             "jlc_violations": 15,
             "jlc_violations_by_type": {
              "acute_angle_acid_trap": 1,
              "via_in_pad": 14
             },
             "jlc_capabilities": "1-2 layers",
             "counts_capped": [],
             "time_s": 1.4
            }
           }
          }
         },
         "running": {
          "value": {
           "id": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1",
           "status": "running",
           "created_at": "2026-10-13T02:21:09Z",
           "started_at": "2026-10-13T02:21:09Z",
           "finished_at": null,
           "consent_training": false,
           "files_deleted": false,
           "version": {
            "service": "2026.10.14.78",
            "router": "r009e659.8f766f9f",
            "router_choice": "stable"
           }
          }
         },
         "queued": {
          "value": {
           "id": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1",
           "status": "queued",
           "created_at": "2026-10-13T02:21:09Z",
           "started_at": null,
           "finished_at": null,
           "consent_training": false,
           "files_deleted": false,
           "version": null,
           "queue_position": 1
          }
         },
         "failed": {
          "value": {
           "id": "2f4634e0aa7c4a6e9d2b1c0f3e5d7a91",
           "status": "failed",
           "created_at": "2026-10-12T18:01:09Z",
           "started_at": "2026-10-12T18:01:09Z",
           "finished_at": "2026-10-12T18:01:13Z",
           "consent_training": false,
           "files_deleted": false,
           "version": {
            "service": "2026.10.14.78",
            "router": "r009e659.8f766f9f",
            "router_choice": "stable"
           },
           "error": {
            "code": "invalid_board",
            "message": "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)."
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "`unauthorized`: The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely. (retry does not help: Fix the key; do not retry with the same key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "unauthorized": {
          "value": {
           "error": "unauthorized",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`not_found`: No job with this id exists for this key: check the id and use the same key that submitted the job. (retry does not help: Check the job id and use the key that submitted it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_found": {
          "value": {
           "error": "not_found",
           "message": "No job with this id exists for this key: check the id and use the same key that submitted the job."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     }
    }
   },
   "delete": {
    "tags": [
     "flow"
    ],
    "operationId": "deleteJob",
    "summary": "Step 5: delete the job's files, or cancel the job",
    "description": "Queued job: cancels it (it never runs and does not count as a daily job). Running job: stops it at once (the unit and every process and file of it are removed, as on a timeout); answers when it has stopped (up to about a minute) with status cancelled. Finished job: deletes the files after you saved the result and the full status (net names and positions become null). The job record stays, without board data. Files are deleted after 7 days anyway.",
    "responses": {
     "200": {
      "description": "Deleted or cancelled.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "string"
          },
          "status": {
           "type": "string",
           "enum": [
            "queued",
            "running",
            "succeeded",
            "failed",
            "cancelled"
           ]
          },
          "files_deleted": {
           "type": "boolean"
          }
         }
        },
        "examples": {
         "cancelled": {
          "value": {
           "id": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1",
           "status": "cancelled",
           "files_deleted": true
          }
         },
         "deleted": {
          "value": {
           "id": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1",
           "status": "succeeded",
           "files_deleted": true
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "`unauthorized`: The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely. (retry does not help: Fix the key; do not retry with the same key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "unauthorized": {
          "value": {
           "error": "unauthorized",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`not_found`: No job with this id exists for this key: check the id and use the same key that submitted the job. (retry does not help: Check the job id and use the key that submitted it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_found": {
          "value": {
           "error": "not_found",
           "message": "No job with this id exists for this key: check the id and use the same key that submitted the job."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/v1/jobs/{id}/result": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[0-9a-f]{32}$"
     },
     "description": "The job id returned by POST /v1/jobs.",
     "example": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1"
    }
   ],
   "get": {
    "tags": [
     "flow"
    ],
    "operationId": "getResult",
    "summary": "Step 4: download the routed board",
    "description": "Only when status is succeeded. Save the bytes as <board>.routed.kicad_pcb next to the input (the header only suggests routed.kicad_pcb); never overwrite the input. It is a KiCad file of the same version as the upload. Copy <board>.kicad_pro (and .kicad_dru) to <board>.routed.kicad_pro so KiCad checks it with the same rules. Save the full GET /v1/jobs/{id} answer before DELETE.",
    "responses": {
     "200": {
      "description": "The routed .kicad_pcb.",
      "content": {
       "application/octet-stream": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "`unauthorized`: The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely. (retry does not help: Fix the key; do not retry with the same key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "unauthorized": {
          "value": {
           "error": "unauthorized",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`not_found`: No job with this id exists for this key: check the id and use the same key that submitted the job. (retry does not help: Check the job id and use the key that submitted it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_found": {
          "value": {
           "error": "not_found",
           "message": "No job with this id exists for this key: check the id and use the same key that submitted the job."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "`not_ready`: The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'. (retry helps: Poll GET /v1/jobs/{id} every 5 s until status is succeeded or failed, then download.); `no_result`: The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}. (retry does not help: The job failed: read error.code in GET /v1/jobs/{id}.); `job_cancelled`: The job was cancelled, so there is no routed board: submit the board again if you still need it. (retry does not help: The job was cancelled (DELETE /v1/jobs/{id}); submit the board again if you still need it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_ready": {
          "value": {
           "error": "not_ready",
           "message": "The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'."
          }
         },
         "no_result": {
          "value": {
           "error": "no_result",
           "message": "The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}."
          }
         },
         "job_cancelled": {
          "value": {
           "error": "job_cancelled",
           "message": "The job was cancelled, so there is no routed board: submit the board again if you still need it."
          }
         }
        }
       }
      }
     },
     "410": {
      "description": "`expired`: The routed board was deleted (7 days after the job, or on request): submit the board again. (retry does not help: The routed board was deleted: submit the board again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "expired": {
          "value": {
           "error": "expired",
           "message": "The routed board was deleted (7 days after the job, or on request): submit the board again."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/v1/jobs/{id}/report.json": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[0-9a-f]{32}$"
     },
     "description": "The job id returned by POST /v1/jobs.",
     "example": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1"
    }
   ],
   "get": {
    "tags": [
     "flow"
    ],
    "operationId": "getReport",
    "summary": "The result report: every finding with where it is and what to do",
    "description": "For a finished job (succeeded or failed). Sections: unconnected (connections still missing, critical first), drc_errors (KiCad DRC errors under the uploaded rules), jlc (JLC capability violations), hot_spots (the 10 hottest copper sections), dangling (track ends and vias that connect nothing), isolated (copper islands). Every item has at_mm (x, y in mm as KiCad shows them), layer and net where KiCad names them, and what_to_do (one sentence with the KiCad command). At most 2,000 items per section (total and truncated say when there are more). image: a picture of the copper layers (SVG) and where it sits in board coordinates. Kept with the routed board: 410 expired after DELETE or 7 days. report_url in the job status opens the same report as a web page (the page asks for the key).",
    "responses": {
     "200": {
      "description": "Report.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Report"
        },
        "example": {
         "id": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1",
         "status": "succeeded",
         "created_at": "2026-10-13T02:21:09Z",
         "finished_at": "2026-10-13T02:21:16Z",
         "version": {
          "service": "2026.10.14.78",
          "router": "r009e659.8f766f9f",
          "router_choice": "stable"
         },
         "report_url": "https://zap.mutual.solutions/app#report=9671312f2b2a4c4ea4d5c1d3b0a7e8f1",
         "summary": {
          "lower_is_better": true,
          "unconnected": 1,
          "critical_unconnected": 0,
          "drc_errors": 0,
          "shorts": 0,
          "shorts_already_in_board": 0,
          "jlc_violations": 1,
          "sections_over_limit": 0,
          "worst_rise_c": 0.61,
          "rise_limit_c": 10.0,
          "heat": "0 over the 10 \u00b0C limit (hottest +0.61 \u00b0C)",
          "dangling": 0,
          "isolated": 0
         },
         "pours": {
          "replaced": [
           {
            "net": "+12V",
            "layers": [
             "F.Cu"
            ]
           }
          ],
          "made": [
           {
            "net": "GND",
            "layers": [
             "F.Cu",
             "B.Cu"
            ]
           }
          ]
         },
         "image": {
          "format": "svg",
          "origin_mm": [
           99.0,
           80.0
          ],
          "size_mm": [
           88.64,
           78.81
          ],
          "layers": [
           {
            "name": "F.Cu",
            "url": "/v1/jobs/9671312f2b2a4c4ea4d5c1d3b0a7e8f1/report/F_Cu.svg"
           },
           {
            "name": "B.Cu",
            "url": "/v1/jobs/9671312f2b2a4c4ea4d5c1d3b0a7e8f1/report/B_Cu.svg"
           }
          ]
         },
         "sections": [
          {
           "kind": "unconnected",
           "title": "Connections still missing (finish them by hand)",
           "total": 1,
           "truncated": false,
           "items": [
            {
             "net": "/LED1",
             "layer": "F.Cu",
             "at_mm": [
              131.5,
              104.8
             ],
             "to_mm": [
              140.2,
              104.8
             ],
             "critical": false,
             "items": [
              "Pad 2 [/LED1] of D1 on F.Cu",
              "Pad 1 [/LED1] of R3 on F.Cu"
             ],
             "what_to_do": "This connection is still missing: route it by hand between the two points listed (Route > Route Single Track, or press X), then run Inspect > Design Rules Checker; critical nets (supply, ground, clock, reset, boot, debug) must be finished before ordering boards."
            }
           ]
          },
          {
           "kind": "jlc",
           "title": "JLC capability violations",
           "total": 1,
           "truncated": false,
           "items": [
            {
             "type": "acute_angle_acid_trap",
             "at_mm": [
              120.1,
              95.3
             ],
             "layer": "F.Cu",
             "net": "+5V",
             "refs": [],
             "description": "tracks meet at 45.0 degrees (ends)",
             "what_to_do": "Two tracks meet at a sharp angle here (an acid trap): drag the corner (D) so they meet at 90 degrees or more."
            }
           ]
          }
         ]
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "`unauthorized`: The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely. (retry does not help: Fix the key; do not retry with the same key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "unauthorized": {
          "value": {
           "error": "unauthorized",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`not_found`: No job with this id exists for this key: check the id and use the same key that submitted the job. (retry does not help: Check the job id and use the key that submitted it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_found": {
          "value": {
           "error": "not_found",
           "message": "No job with this id exists for this key: check the id and use the same key that submitted the job."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "`not_ready`: The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'. (retry helps: Poll GET /v1/jobs/{id} every 5 s until status is succeeded or failed, then download.); `job_cancelled`: The job was cancelled, so there is no routed board: submit the board again if you still need it. (retry does not help: The job was cancelled (DELETE /v1/jobs/{id}); submit the board again if you still need it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_ready": {
          "value": {
           "error": "not_ready",
           "message": "The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'."
          }
         },
         "job_cancelled": {
          "value": {
           "error": "job_cancelled",
           "message": "The job was cancelled, so there is no routed board: submit the board again if you still need it."
          }
         }
        }
       }
      }
     },
     "410": {
      "description": "`expired`: The routed board was deleted (7 days after the job, or on request): submit the board again. (retry does not help: The routed board was deleted: submit the board again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "expired": {
          "value": {
           "error": "expired",
           "message": "The routed board was deleted (7 days after the job, or on request): submit the board again."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/v1/jobs/{id}/report/{layer}.svg": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[0-9a-f]{32}$"
     },
     "description": "The job id returned by POST /v1/jobs.",
     "example": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1"
    },
    {
     "name": "layer",
     "in": "path",
     "required": true,
     "description": "Copper layer with _ for the dot, as listed in report.image.layers: F_Cu, B_Cu, In1_Cu ...",
     "schema": {
      "type": "string",
      "pattern": "^(F|B|In[0-9]{1,2})_Cu$"
     }
    }
   ],
   "get": {
    "tags": [
     "flow"
    ],
    "operationId": "getReportImage",
    "summary": "Picture of one copper layer and the outline of the routed board (SVG)",
    "description": "KiCad's own plot (board area only). Every layer's picture has the same frame: its top-left corner is report.image.origin_mm in board coordinates and it spans report.image.size_mm, so a finding at (x, y) sits at ((x - origin x) / width, (y - origin y) / height) of the picture.",
    "responses": {
     "200": {
      "description": "SVG.",
      "content": {
       "image/svg+xml": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "`unauthorized`: The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely. (retry does not help: Fix the key; do not retry with the same key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "unauthorized": {
          "value": {
           "error": "unauthorized",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`not_found`: No job with this id exists for this key: check the id and use the same key that submitted the job. (retry does not help: Check the job id and use the key that submitted it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_found": {
          "value": {
           "error": "not_found",
           "message": "No job with this id exists for this key: check the id and use the same key that submitted the job."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "`not_ready`: The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'. (retry helps: Poll GET /v1/jobs/{id} every 5 s until status is succeeded or failed, then download.); `no_result`: The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}. (retry does not help: The job failed: read error.code in GET /v1/jobs/{id}.); `job_cancelled`: The job was cancelled, so there is no routed board: submit the board again if you still need it. (retry does not help: The job was cancelled (DELETE /v1/jobs/{id}); submit the board again if you still need it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_ready": {
          "value": {
           "error": "not_ready",
           "message": "The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'."
          }
         },
         "no_result": {
          "value": {
           "error": "no_result",
           "message": "The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}."
          }
         },
         "job_cancelled": {
          "value": {
           "error": "job_cancelled",
           "message": "The job was cancelled, so there is no routed board: submit the board again if you still need it."
          }
         }
        }
       }
      }
     },
     "410": {
      "description": "`expired`: The routed board was deleted (7 days after the job, or on request): submit the board again. (retry does not help: The routed board was deleted: submit the board again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "expired": {
          "value": {
           "error": "expired",
           "message": "The routed board was deleted (7 days after the job, or on request): submit the board again."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/v1/feedback": {
   "post": {
    "tags": [
     "feedback"
    ],
    "operationId": "sendFeedback",
    "summary": "Send the user's opinion about Zap! or a result to the Zap! team (only with the user's approval)",
    "description": "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, saying in one short sentence what happens (only this text, and the picture if any, is used to improve Zap!, never the board file; deleted on request). Show the exact text you would send (their words, or a summary they approve) and the job id. Send it only if they say yes; if they say no, do not ask again in this session. Never include board files or private details they did not approve. Only the text (and attached pictures) is used to improve Zap!; stored with the key id, job id and time, linked to the job's summary numbers (never the board file), kept at most 730 days or until deleted on request to hello@mutual.solutions. At most 20 per key per UTC day.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "additionalProperties": false,
        "required": [
         "message",
         "user_approved"
        ],
        "properties": {
         "message": {
          "type": "string",
          "minLength": 1,
          "maxLength": 4000,
          "description": "Exactly the text the user approved: their words, or a summary they agreed to."
         },
         "user_approved": {
          "type": "boolean",
          "enum": [
           true
          ],
          "description": "true only after the user saw this exact text and said yes. Otherwise 400 feedback_needs_user_approval and nothing is stored."
         },
         "job_id": {
          "type": "string",
          "pattern": "^[0-9a-f]{32}$",
          "description": "Optional: the job the opinion is about; must be one of this key's jobs (else 404 not_found)."
         },
         "kind": {
          "type": "string",
          "enum": [
           "praise",
           "problem",
           "idea",
           "other"
          ],
          "description": "Optional."
         },
         "contact_ok": {
          "type": "boolean",
          "default": false,
          "description": "Optional: true if the user agreed that the Zap! team may reply to the account's email address."
         },
         "snapshot_ids": {
          "type": "array",
          "maxItems": 3,
          "uniqueItems": true,
          "items": {
           "type": "string",
           "pattern": "^sn_[0-9a-f]{16}$"
          },
          "description": "Optional: board pictures (POST /v1/jobs/{id}/snapshot) of this job that the user saw and agreed to attach. Kept with the feedback."
         }
        }
       },
       "example": {
        "message": "The routing near U3 looks messy: three vias where one would do.",
        "user_approved": true,
        "job_id": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1",
        "kind": "problem",
        "contact_ok": true
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "Stored for the Zap! team.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "required": [
          "id",
          "received_at"
         ],
         "properties": {
          "id": {
           "type": "string",
           "pattern": "^fb_[0-9a-f]{16}$"
          },
          "received_at": {
           "type": "string",
           "format": "date-time"
          }
         }
        },
        "example": {
         "id": "fb_3f9a2c71d04b8e65",
         "received_at": "2026-10-14T07:10:03Z"
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).); `feedback_needs_user_approval`: 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. (retry does not help: Ask your user whether to send this exact text; send it again with user_approved true only if they agree.); `feedback_message_length`: 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. (retry does not help: Send 1 to 4,000 characters; shorten only with your user's agreement.); `invalid_feedback`: 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. (retry does not help: Fix the field named in field, then send again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         },
         "feedback_needs_user_approval": {
          "value": {
           "error": "feedback_needs_user_approval",
           "message": "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": {
          "value": {
           "error": "feedback_message_length",
           "message": "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": {
          "value": {
           "error": "invalid_feedback",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "`unauthorized`: The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely. (retry does not help: Fix the key; do not retry with the same key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "unauthorized": {
          "value": {
           "error": "unauthorized",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`not_found`: No job with this id exists for this key: check the id and use the same key that submitted the job. (retry does not help: Check the job id and use the key that submitted it.); `snapshot_not_found`: 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. (retry does not help: Make a new picture with POST /v1/jobs/{id}/snapshot.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_found": {
          "value": {
           "error": "not_found",
           "message": "No job with this id exists for this key: check the id and use the same key that submitted the job."
          }
         },
         "snapshot_not_found": {
          "value": {
           "error": "snapshot_not_found",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.); `feedback_limit_reached`: This key has sent today's feedback messages (20 per UTC day): send more after 00:00 UTC, or email hello@mutual.solutions. (retry does not help: Not today: this key's feedback messages are used up until 00:00 UTC (Retry-After gives the seconds); email hello@mutual.solutions instead.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         },
         "feedback_limit_reached": {
          "value": {
           "error": "feedback_limit_reached",
           "message": "This key has sent today's feedback messages (20 per UTC day): send more after 00:00 UTC, or email hello@mutual.solutions."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/v1/jobs/{id}/snapshot": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[0-9a-f]{32}$"
     },
     "description": "The job id returned by POST /v1/jobs.",
     "example": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1"
    }
   ],
   "post": {
    "tags": [
     "feedback"
    ],
    "operationId": "boardSnapshot",
    "summary": "A picture of one area of the routed board (at most 40 mm square), to show the user and attach to feedback",
    "description": "When the user's feedback is about a spot on the board, offer to attach a picture of just that area, aimed with the report's at_mm positions. Show the picture to the user before sending and attach it only if they say yes. Never the whole board. Drawn on our server from the routed board (kept 7 days); the picture is deleted 24 hours after it was made unless attached to feedback. At most 30 per key per UTC day.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "additionalProperties": false,
        "required": [
         "x_mm",
         "y_mm"
        ],
        "properties": {
         "x_mm": {
          "type": "number",
          "description": "Centre of the area, x in mm as KiCad shows positions."
         },
         "y_mm": {
          "type": "number",
          "description": "Centre of the area, y in mm as KiCad shows positions."
         },
         "size_mm": {
          "type": "number",
          "minimum": 1,
          "maximum": 40,
          "default": 20,
          "description": "Side of the square area, mm."
         },
         "layers": {
          "type": "array",
          "items": {
           "type": "string",
           "pattern": "^(F|B|In[0-9]{1,2})\\.Cu$"
          },
          "description": "Copper layers to draw (default: every copper layer)."
         },
         "show_issues": {
          "type": "boolean",
          "default": true,
          "description": "Mark the report's missing connections and design-rule errors in the area."
         }
        }
       },
       "example": {
        "x_mm": 93.5,
        "y_mm": 69.5,
        "size_mm": 20
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "The picture was made.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "snapshot_id": {
           "type": "string",
           "pattern": "^sn_[0-9a-f]{16}$"
          },
          "png_url": {
           "type": "string",
           "description": "GET it with the key to show the user."
          },
          "expires_at": {
           "type": "string",
           "format": "date-time"
          },
          "region": {
           "type": "object",
           "properties": {
            "x_mm": {
             "type": "number"
            },
            "y_mm": {
             "type": "number"
            },
            "size_mm": {
             "type": "number"
            },
            "layers": {
             "type": "array",
             "items": {
              "type": "string"
             }
            }
           }
          },
          "nets_in_region": {
           "type": "array",
           "items": {
            "type": "string"
           },
           "description": "At most 200 net names."
          },
          "issues_marked": {
           "type": "object",
           "properties": {
            "unconnected": {
             "type": "integer"
            },
            "drc": {
             "type": "integer"
            }
           }
          }
         }
        },
        "example": {
         "snapshot_id": "sn_3f9a2c71d04b8e65",
         "png_url": "/v1/snapshots/sn_3f9a2c71d04b8e65.png",
         "expires_at": "2026-10-15T07:10:03Z",
         "region": {
          "x_mm": 93.5,
          "y_mm": 69.5,
          "size_mm": 20,
          "layers": [
           "F.Cu",
           "B.Cu"
          ]
         },
         "nets_in_region": [
          "GND",
          "+3V3"
         ],
         "issues_marked": {
          "unconnected": 1,
          "drc": 0
         }
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).); `invalid_snapshot`: 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. (retry does not help: Fix the field named in field, then ask again.); `snapshot_too_large`: 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). (retry does not help: Send size_mm of at most 40 and aim at the spot.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         },
         "invalid_snapshot": {
          "value": {
           "error": "invalid_snapshot",
           "message": "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": {
          "value": {
           "error": "snapshot_too_large",
           "message": "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)."
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "`unauthorized`: The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely. (retry does not help: Fix the key; do not retry with the same key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "unauthorized": {
          "value": {
           "error": "unauthorized",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely."
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "`snapshots_off`: 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. (retry does not help: Not with this key: send the feedback as text only.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "snapshots_off": {
          "value": {
           "error": "snapshots_off",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`not_found`: No job with this id exists for this key: check the id and use the same key that submitted the job. (retry does not help: Check the job id and use the key that submitted it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_found": {
          "value": {
           "error": "not_found",
           "message": "No job with this id exists for this key: check the id and use the same key that submitted the job."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "`not_ready`: The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'. (retry helps: Poll GET /v1/jobs/{id} every 5 s until status is succeeded or failed, then download.); `no_result`: The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}. (retry does not help: The job failed: read error.code in GET /v1/jobs/{id}.); `job_cancelled`: The job was cancelled, so there is no routed board: submit the board again if you still need it. (retry does not help: The job was cancelled (DELETE /v1/jobs/{id}); submit the board again if you still need it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_ready": {
          "value": {
           "error": "not_ready",
           "message": "The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'."
          }
         },
         "no_result": {
          "value": {
           "error": "no_result",
           "message": "The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}."
          }
         },
         "job_cancelled": {
          "value": {
           "error": "job_cancelled",
           "message": "The job was cancelled, so there is no routed board: submit the board again if you still need it."
          }
         }
        }
       }
      }
     },
     "410": {
      "description": "`expired`: The routed board was deleted (7 days after the job, or on request): submit the board again. (retry does not help: The routed board was deleted: submit the board again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "expired": {
          "value": {
           "error": "expired",
           "message": "The routed board was deleted (7 days after the job, or on request): submit the board again."
          }
         }
        }
       }
      }
     },
     "422": {
      "description": "`snapshot_outside_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. (retry does not help: Aim x_mm and y_mm at a spot on the board.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "snapshot_outside_board": {
          "value": {
           "error": "snapshot_outside_board",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.); `snapshot_limit_reached`: 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. (retry does not help: 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.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         },
         "snapshot_limit_reached": {
          "value": {
           "error": "snapshot_limit_reached",
           "message": "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."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     },
     "503": {
      "description": "`check_busy`: Several board checks are running right now: try again in a minute. (retry helps: Wait the seconds in Retry-After, then repeat the check.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "check_busy": {
          "value": {
           "error": "check_busy",
           "message": "Several board checks are running right now: try again in a minute."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     }
    }
   }
  },
  "/v1/snapshots/{id}.png": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^sn_[0-9a-f]{16}$"
     }
    }
   ],
   "get": {
    "tags": [
     "feedback"
    ],
    "operationId": "getSnapshot",
    "summary": "The picture (PNG), for the key that made it",
    "responses": {
     "200": {
      "description": "PNG, at most 2 MB.",
      "content": {
       "image/png": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "`unauthorized`: The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely. (retry does not help: Fix the key; do not retry with the same key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "unauthorized": {
          "value": {
           "error": "unauthorized",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`snapshot_not_found`: 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. (retry does not help: Make a new picture with POST /v1/jobs/{id}/snapshot.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "snapshot_not_found": {
          "value": {
           "error": "snapshot_not_found",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     }
    }
   },
   "delete": {
    "tags": [
     "feedback"
    ],
    "operationId": "deleteSnapshot",
    "summary": "Delete a picture the user does not want to attach, at once (else it goes after 24 hours)",
    "description": "Only pictures not attached to feedback; attached ones are deleted with their feedback, on request.",
    "responses": {
     "200": {
      "description": "Deleted.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "string"
          },
          "deleted": {
           "type": "boolean"
          }
         }
        },
        "example": {
         "id": "sn_3f9a2c71d04b8e65",
         "deleted": true
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "401": {
      "description": "`unauthorized`: The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely. (retry does not help: Fix the key; do not retry with the same key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "unauthorized": {
          "value": {
           "error": "unauthorized",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`not_found`: No job with this id exists for this key: check the id and use the same key that submitted the job. (retry does not help: Check the job id and use the key that submitted it.); `snapshot_not_found`: 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. (retry does not help: Make a new picture with POST /v1/jobs/{id}/snapshot.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_found": {
          "value": {
           "error": "not_found",
           "message": "No job with this id exists for this key: check the id and use the same key that submitted the job."
          }
         },
         "snapshot_not_found": {
          "value": {
           "error": "snapshot_not_found",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/v1/version": {
   "get": {
    "tags": [
     "info"
    ],
    "operationId": "version",
    "security": [],
    "summary": "Service version and the routers offered",
    "description": "No key needed.",
    "responses": {
     "200": {
      "description": "Versions.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Version"
        },
        "example": {
         "service": "2026.10.14.78",
         "router": "r009e659.8f766f9f",
         "routers": {
          "stable": "r009e659.8f766f9f",
          "next": null
         }
        }
       }
      }
     }
    }
   }
  },
  "/v1/status": {
   "get": {
    "tags": [
     "info"
    ],
    "operationId": "status",
    "security": [],
    "summary": "Queue and typical wait",
    "description": "No key needed. Jobs waiting and running, the queue limit (above it POST /v1/jobs answers 503 server_busy), the expected wait for a new job and the typical wait and routing time over the last 24 hours (seconds; null when no job finished). Also as a web page: /status.",
    "responses": {
     "200": {
      "description": "Status.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "service": {
           "type": "string"
          },
          "router": {
           "type": "string"
          },
          "jobs_waiting": {
           "type": "integer"
          },
          "jobs_running": {
           "type": "integer"
          },
          "job_slots": {
           "type": "integer"
          },
          "queue_limit": {
           "type": "integer"
          },
          "accepting_jobs": {
           "type": "boolean"
          },
          "estimated_wait_s": {
           "type": "integer"
          },
          "typical_wait_s": {
           "type": "number",
           "nullable": true
          },
          "typical_run_s": {
           "type": "number",
           "nullable": true
          },
          "jobs_finished_24h": {
           "type": "integer"
          }
         }
        },
        "example": {
         "service": "2026.10.14.78",
         "router": "rd3ff129.55786789",
         "jobs_waiting": 0,
         "jobs_running": 1,
         "job_slots": 2,
         "queue_limit": 40,
         "accepting_jobs": true,
         "estimated_wait_s": 0,
         "typical_wait_s": 0.4,
         "typical_run_s": 7.2,
         "jobs_finished_24h": 31
        }
       }
      }
     }
    }
   }
  },
  "/v1/errors": {
   "get": {
    "tags": [
     "info"
    ],
    "operationId": "errors",
    "security": [],
    "summary": "The error catalog",
    "description": "No key needed. Every error code with its HTTP status (null: job failure only), meaning, fix and whether retrying helps; plus the pre-flight check findings.",
    "responses": {
     "200": {
      "description": "Catalog.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/ErrorCatalog"
        },
        "example": {
         "errors": [
          {
           "code": "unauthorized",
           "http_status": 401,
           "where": [
            "any authenticated request"
           ],
           "job_failure": false,
           "meaning": "The API key is missing or not valid.",
           "fix": "Send it as 'Authorization: Bearer <key>' and check it was copied completely.",
           "retryable": false,
           "retry": "Fix the key; do not retry with the same key.",
           "message": "The API key is missing or not valid: send it as 'Authorization: Bearer <key>' and check it was copied completely.",
           "details": {}
          },
          {
           "code": "rate_limited",
           "http_status": 429,
           "where": [
            "any authenticated request",
            "POST /v1/claim"
           ],
           "job_failure": false,
           "meaning": "Too many requests.",
           "fix": "Wait the number of seconds in the Retry-After header, then try again.",
           "retryable": true,
           "retry": "Wait the seconds in the Retry-After header, then repeat the same request.",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again.",
           "details": {}
          },
          {
           "code": "daily_quota_exceeded",
           "http_status": 429,
           "where": [
            "POST /v1/jobs"
           ],
           "job_failure": false,
           "meaning": "This key has used all of today's jobs.",
           "fix": "Try again after 00:00 UTC, or ask mutual to raise the quota.",
           "retryable": false,
           "retry": "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.",
           "message": "This key has used all of today's jobs: try again after 00:00 UTC, or ask mutual to raise the quota.",
           "details": {}
          },
          {
           "code": "check_limit_reached",
           "http_status": 429,
           "where": [
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "This account has used today's pre-flight checks.",
           "fix": "Submit the job directly (it runs the same checks), or check again after 00:00 UTC.",
           "retryable": false,
           "retry": "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.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "server_busy",
           "http_status": 503,
           "where": [
            "POST /v1/jobs"
           ],
           "job_failure": false,
           "meaning": "Zap! is busy.",
           "fix": "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.",
           "retryable": true,
           "retry": "Wait the seconds in the Retry-After header, then submit again; estimated_wait_min is the expected wait.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "too_many_active_jobs",
           "http_status": 429,
           "where": [
            "POST /v1/jobs"
           ],
           "job_failure": false,
           "meaning": "This key already has the maximum number of jobs queued or running.",
           "fix": "Wait for one to finish, then submit again.",
           "retryable": true,
           "retry": "Wait until one of your queued or running jobs finishes, then submit again.",
           "message": "This key already has the maximum number of jobs queued or running: wait for one to finish, then submit again.",
           "details": {}
          },
          {
           "code": "too_large",
           "http_status": 413,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The upload is over 20 MiB in total.",
           "fix": "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.",
           "retryable": false,
           "retry": "Send smaller files.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "board_too_large_unpacked",
           "http_status": 413,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The compressed board unpacks to more than 64 MiB (max_unpacked_bytes).",
           "fix": "Remove embedded 3D models or images (Footprint Properties > 3D Models), save again and send it again.",
           "retryable": false,
           "retry": "Send a smaller board.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "invalid_compressed_board",
           "http_status": 400,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The board looks gzip-compressed but cannot be unpacked (cut short, damaged, or more than one file).",
           "fix": "Compress the .kicad_pcb again with gzip, or send it uncompressed.",
           "retryable": false,
           "retry": "Send the board again, compressed whole with gzip or not compressed.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "length_required",
           "http_status": 411,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The upload has no total length (for example it was sent with Transfer-Encoding: chunked), which Zap! cannot read.",
           "fix": "Send a normal multipart upload with a Content-Length (curl -F, Python requests and zap.py do this).",
           "retryable": false,
           "retry": "Send a normal multipart upload with a Content-Length.",
           "message": "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).",
           "details": {}
          },
          {
           "code": "board_required",
           "http_status": 400,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "No board was attached.",
           "fix": "Send the placed board in the multipart field 'board' (curl -F board=@myboard.kicad_pcb).",
           "retryable": false,
           "retry": "Put the board in the multipart field 'board'.",
           "message": "No board was attached: send the placed board in the multipart field 'board' (curl -F board=@myboard.kicad_pcb).",
           "details": {}
          },
          {
           "code": "invalid_board",
           "http_status": 422,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check",
            "job failure"
           ],
           "job_failure": true,
           "meaning": "The file could not be read as a KiCad board.",
           "fix": "Open the board in KiCad's PCB Editor, use File > Save, and upload that .kicad_pcb (not a schematic, Gerber or zip).",
           "retryable": false,
           "retry": "Fix the board file; resubmitting the same file gives the same answer.",
           "message": "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).",
           "details": {
            "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.",
            "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.",
            "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."
           }
          },
          {
           "code": "invalid_project",
           "http_status": 400,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The project file is not a valid KiCad project.",
           "fix": "Attach the .kicad_pro that KiCad writes next to the board when you use File > Save in the PCB Editor, or leave it out.",
           "retryable": false,
           "retry": "Attach the right .kicad_pro or leave it out.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "invalid_rules",
           "http_status": 400,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The custom rules file is not valid.",
           "fix": "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.",
           "retryable": false,
           "retry": "Attach the right .kicad_dru or leave it out.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "invalid_currents",
           "http_status": 422,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The currents file is not valid.",
           "fix": "Send JSON {\"nets\": {\"<net name>\": {\"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).",
           "retryable": false,
           "retry": "Fix the currents JSON (see unknown_nets / ambiguous_nets in the answer).",
           "message": "The currents file is not valid: send JSON {\"nets\": {\"<net name>\": {\"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).",
           "details": {}
          },
          {
           "code": "invalid_router",
           "http_status": 422,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The router field is not valid.",
           "fix": "Send stable or next, or leave it out (which means stable).",
           "retryable": false,
           "retry": "Send router=stable, router=next, or leave it out.",
           "message": "The router field is not valid: send stable or next, or leave it out (which means stable).",
           "details": {}
          },
          {
           "code": "router_unavailable",
           "http_status": 409,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The next router is not available right now.",
           "fix": "Use stable.",
           "retryable": false,
           "retry": "Use router=stable (GET /v1/version lists routers.next when it is offered).",
           "message": "The next router is not available right now; use stable.",
           "details": {}
          },
          {
           "code": "invalid_consent",
           "http_status": 422,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The consent_training field is not valid.",
           "fix": "Send true or false, or leave it out (which means false).",
           "retryable": false,
           "retry": "Send consent_training=true or false, or leave it out.",
           "message": "The consent_training field is not valid: send true or false, or leave it out (which means false).",
           "details": {}
          },
          {
           "code": "invalid_keep_existing",
           "http_status": 422,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The keep_existing field is not valid.",
           "fix": "Send true or false, or leave it out (which means false).",
           "retryable": false,
           "retry": "Send keep_existing=true or false, or leave it out.",
           "message": "The keep_existing field is not valid: send true or false, or leave it out (which means false).",
           "details": {}
          },
          {
           "code": "keep_existing_unavailable",
           "http_status": 409,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The router you chose cannot keep existing copper.",
           "fix": "Use router=stable, or remove the tracks and vias in KiCad (Edit > Global Deletions, tick Tracks and Vias) and send keep_existing=false.",
           "retryable": false,
           "retry": "Use router=stable, or send keep_existing=false with a board that has no tracks or vias.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "invalid_fab_floor",
           "http_status": 422,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The fab_floor field is not valid.",
           "fix": "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.",
           "retryable": false,
           "retry": "Send fab_floor=jlc or fab_floor=board, or leave it out.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "fab_floor_unavailable",
           "http_status": 409,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The router you chose cannot follow your board's own minimums yet.",
           "fix": "Send fab_floor=jlc, or use router=next when GET /v1/version lists one.",
           "retryable": false,
           "retry": "Send fab_floor=jlc, or use router=next when GET /v1/version lists one.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "invalid_option",
           "http_status": 422,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "A routing option is not valid (option names it, fix says what to send).",
           "fix": "Correct it, or leave it out to use the default.",
           "retryable": false,
           "retry": "Change the option named in option as fix says, or leave it out to use the default.",
           "message": "A routing option is not valid (option names it, fix says what to send): correct it, or leave it out to use the default.",
           "details": {}
          },
          {
           "code": "options_unavailable",
           "http_status": 409,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "Routing options are not switched on yet.",
           "fix": "Leave them out to use the defaults (GET /v1/version shows \"options\": true once they are on).",
           "retryable": false,
           "retry": "Leave the routing options out for now (GET /v1/version lists options when they are on).",
           "message": "Routing options are not switched on yet: leave them out to use the defaults (GET /v1/version shows \"options\": true once they are on).",
           "details": {}
          },
          {
           "code": "input_has_copper",
           "http_status": 422,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check",
            "job failure"
           ],
           "job_failure": true,
           "meaning": "The board already has tracks or vias.",
           "fix": "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.",
           "retryable": false,
           "retry": "Send strip_existing=true to discard them and route everything, or keep_existing=true to route around them.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "too_many_pads",
           "http_status": 422,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The board has more than 10,000 pads, the current limit.",
           "fix": "Contact hello@mutual.solutions if you need a larger board routed.",
           "retryable": false,
           "retry": "Not retryable: ask mutual for a larger limit.",
           "message": "The board has more than 10,000 pads, the current limit: contact hello@mutual.solutions if you need a larger board routed.",
           "details": {}
          },
          {
           "code": "too_many_footprints",
           "http_status": 422,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The board has more than 4,000 footprints, the current limit.",
           "fix": "Contact hello@mutual.solutions if you need a larger board routed.",
           "retryable": false,
           "retry": "Not retryable: ask mutual for a larger limit.",
           "message": "The board has more than 4,000 footprints, the current limit: contact hello@mutual.solutions if you need a larger board routed.",
           "details": {}
          },
          {
           "code": "board_too_large",
           "http_status": 422,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check",
            "job failure"
           ],
           "job_failure": true,
           "meaning": "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).",
           "fix": "For boards this large, email hello@mutual.solutions to arrange a business (B2B) job.",
           "retryable": false,
           "retry": "Not retryable as is: email hello@mutual.solutions to arrange a business (B2B) job.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "unsupported_layer_count",
           "http_status": 422,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check"
           ],
           "job_failure": false,
           "meaning": "The board must have 2 to 16 copper layers.",
           "fix": "Set the count in File > Board Setup > Board Stackup > Physical Stackup (Copper layers) and save.",
           "retryable": false,
           "retry": "Fix the stackup in KiCad, then submit again.",
           "message": "The board must have 2 to 16 copper layers: set the count in File > Board Setup > Board Stackup > Physical Stackup (Copper layers) and save.",
           "details": {}
          },
          {
           "code": "not_found",
           "http_status": 404,
           "where": [
            "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"
           ],
           "job_failure": false,
           "meaning": "No job with this id exists for this key.",
           "fix": "Check the id and use the same key that submitted the job.",
           "retryable": false,
           "retry": "Check the job id and use the key that submitted it.",
           "message": "No job with this id exists for this key: check the id and use the same key that submitted the job.",
           "details": {
            "path": "Not found: check the URL; the endpoints are listed at /docs."
           }
          },
          {
           "code": "not_ready",
           "http_status": 409,
           "where": [
            "GET /v1/jobs/{id}/result",
            "GET /v1/jobs/{id}/report.json",
            "GET /v1/jobs/{id}/report/{layer}.svg",
            "POST /v1/jobs/{id}/snapshot"
           ],
           "job_failure": false,
           "meaning": "The job is still queued or running.",
           "fix": "Poll GET /v1/jobs/{id} until the status is 'succeeded'.",
           "retryable": true,
           "retry": "Poll GET /v1/jobs/{id} every 5 s until status is succeeded or failed, then download.",
           "message": "The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'.",
           "details": {}
          },
          {
           "code": "no_result",
           "http_status": 409,
           "where": [
            "GET /v1/jobs/{id}/result",
            "GET /v1/jobs/{id}/report/{layer}.svg",
            "POST /v1/jobs/{id}/snapshot"
           ],
           "job_failure": false,
           "meaning": "The job failed, so there is no routed board.",
           "fix": "See the error in GET /v1/jobs/{id}.",
           "retryable": false,
           "retry": "The job failed: read error.code in GET /v1/jobs/{id}.",
           "message": "The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}.",
           "details": {}
          },
          {
           "code": "job_cancelled",
           "http_status": 409,
           "where": [
            "GET /v1/jobs/{id}/result",
            "GET /v1/jobs/{id}/report.json",
            "GET /v1/jobs/{id}/report/{layer}.svg",
            "POST /v1/jobs/{id}/snapshot"
           ],
           "job_failure": false,
           "meaning": "The job was cancelled, so there is no routed board.",
           "fix": "Submit the board again if you still need it.",
           "retryable": false,
           "retry": "The job was cancelled (DELETE /v1/jobs/{id}); submit the board again if you still need it.",
           "message": "The job was cancelled, so there is no routed board: submit the board again if you still need it.",
           "details": {}
          },
          {
           "code": "expired",
           "http_status": 410,
           "where": [
            "GET /v1/jobs/{id}/result",
            "GET /v1/jobs/{id}/report.json",
            "GET /v1/jobs/{id}/report/{layer}.svg",
            "POST /v1/jobs/{id}/snapshot"
           ],
           "job_failure": false,
           "meaning": "The routed board was deleted (7 days after the job, or on request).",
           "fix": "Submit the board again.",
           "retryable": false,
           "retry": "The routed board was deleted: submit the board again.",
           "message": "The routed board was deleted (7 days after the job, or on request): submit the board again.",
           "details": {}
          },
          {
           "code": "method_not_allowed",
           "http_status": 405,
           "where": [
            "any address"
           ],
           "job_failure": false,
           "meaning": "This address does not accept that method.",
           "fix": "See /docs for the endpoints.",
           "retryable": false,
           "retry": "Use the method the endpoint documents.",
           "message": "This address does not accept that method: see /docs for the endpoints.",
           "details": {}
          },
          {
           "code": "bad_request",
           "http_status": 400,
           "where": [
            "POST /v1/jobs",
            "POST /v1/check",
            "any request"
           ],
           "job_failure": false,
           "meaning": "The request could not be read.",
           "fix": "Send a multipart/form-data upload as shown in /docs.",
           "retryable": false,
           "retry": "Fix the request shape (see /docs.md).",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs.",
           "details": {}
          },
          {
           "code": "check_busy",
           "http_status": 503,
           "where": [
            "POST /v1/check",
            "POST /v1/jobs",
            "POST /v1/jobs/{id}/snapshot"
           ],
           "job_failure": false,
           "meaning": "Several board checks are running right now.",
           "fix": "Try again in a minute.",
           "retryable": true,
           "retry": "Wait the seconds in Retry-After, then repeat the check.",
           "message": "Several board checks are running right now: try again in a minute.",
           "details": {}
          },
          {
           "code": "internal",
           "http_status": 500,
           "where": [
            "any authenticated request",
            "job failure"
           ],
           "job_failure": true,
           "meaning": "Something went wrong on our side.",
           "fix": "Try again, and if it repeats send the job id to hello@mutual.solutions.",
           "retryable": true,
           "retry": "Retry once; if it repeats, send the job id to hello@mutual.solutions.",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions.",
           "details": {}
          },
          {
           "code": "timeout",
           "http_status": null,
           "where": [
            "job failure"
           ],
           "job_failure": true,
           "meaning": "The job hit the 60-minute limit.",
           "fix": "Tell hello@mutual.solutions the job id; a board this size may need a larger limit.",
           "retryable": false,
           "retry": "Not retryable as is: send the job id to hello@mutual.solutions.",
           "message": "The job hit the 60-minute limit: tell hello@mutual.solutions the job id; a board this size may need a larger limit.",
           "details": {}
          },
          {
           "code": "timeout_or_limit",
           "http_status": null,
           "where": [
            "job failure"
           ],
           "job_failure": true,
           "meaning": "The job hit the time or memory limit (very large outlines can exceed the memory).",
           "fix": "Tell hello@mutual.solutions the job id; a board this size may need a larger limit.",
           "retryable": false,
           "retry": "Not retryable as is: send the job id to hello@mutual.solutions.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "route_failed",
           "http_status": null,
           "where": [
            "job failure"
           ],
           "job_failure": true,
           "meaning": "The router could not produce a result for this board.",
           "fix": "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.",
           "retryable": false,
           "retry": "Fix the board outline / placement; send the job id if it repeats.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "rules_check_failed",
           "http_status": null,
           "where": [
            "job failure"
           ],
           "job_failure": true,
           "meaning": "KiCad could not check the routed board with the rules you sent.",
           "fix": "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.",
           "retryable": false,
           "retry": "Send matching .kicad_pro/.kicad_dru or leave them out, then submit again.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "unsupported_rules",
           "http_status": null,
           "where": [
            "POST /v1/check",
            "job failure"
           ],
           "job_failure": true,
           "meaning": "Your .kicad_dru uses a custom rule condition Zap! cannot apply yet (for example insideCourtyard()).",
           "fix": "Remove or simplify that rule in File > Board Setup > Design Rules > Custom Rules, or leave the .kicad_dru out, and submit again.",
           "retryable": false,
           "retry": "Remove or simplify the custom rule, then submit again.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "output_has_shorts",
           "http_status": null,
           "where": [
            "job failure"
           ],
           "job_failure": true,
           "meaning": "Every routing attempt left a short circuit between different nets, so no board is returned.",
           "fix": "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.",
           "retryable": false,
           "retry": "Send your .kicad_pro; send the job id if it repeats.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "claim_invalid",
           "http_status": 404,
           "where": [
            "POST /v1/claim"
           ],
           "job_failure": false,
           "meaning": "This claim link is not valid.",
           "fix": "Check that you copied the whole link, or ask mutual for a new one.",
           "retryable": false,
           "retry": "Check the whole claim link.",
           "message": "This claim link is not valid: check that you copied the whole link, or ask mutual for a new one.",
           "details": {}
          },
          {
           "code": "claim_used",
           "http_status": 410,
           "where": [
            "POST /v1/claim"
           ],
           "job_failure": false,
           "meaning": "This key was already claimed and is shown only once.",
           "fix": "Ask hello@mutual.solutions for a new key.",
           "retryable": false,
           "retry": "Ask for a new key.",
           "message": "This key was already claimed and is shown only once: ask hello@mutual.solutions for a new key.",
           "details": {}
          },
          {
           "code": "claim_expired",
           "http_status": 410,
           "where": [
            "POST /v1/claim"
           ],
           "job_failure": false,
           "meaning": "This claim link has expired (links last 72 hours).",
           "fix": "Ask hello@mutual.solutions for a new one.",
           "retryable": false,
           "retry": "Ask for a new claim link.",
           "message": "This claim link has expired (links last 72 hours): ask hello@mutual.solutions for a new one.",
           "details": {}
          },
          {
           "code": "feedback_needs_user_approval",
           "http_status": 400,
           "where": [
            "POST /v1/feedback"
           ],
           "job_failure": false,
           "meaning": "Feedback is sent only with the user's approval (user_approved is not true).",
           "fix": "Ask your user whether to send this exact text; send only if they agree.",
           "retryable": false,
           "retry": "Ask your user whether to send this exact text; send it again with user_approved true only if they agree.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "feedback_message_length",
           "http_status": 400,
           "where": [
            "POST /v1/feedback"
           ],
           "job_failure": false,
           "meaning": "The feedback message is empty or longer than 4,000 characters.",
           "fix": "Send the user's words in 1 to 4,000 characters, shortened only with their agreement.",
           "retryable": false,
           "retry": "Send 1 to 4,000 characters; shorten only with your user's agreement.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "invalid_feedback",
           "http_status": 400,
           "where": [
            "POST /v1/feedback"
           ],
           "job_failure": false,
           "meaning": "The feedback request is not valid (field names the first problem).",
           "fix": "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.",
           "retryable": false,
           "retry": "Fix the field named in field, then send again.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "feedback_limit_reached",
           "http_status": 429,
           "where": [
            "POST /v1/feedback"
           ],
           "job_failure": false,
           "meaning": "This key has sent today's feedback messages (20 per UTC day).",
           "fix": "Send more after 00:00 UTC, or email hello@mutual.solutions.",
           "retryable": false,
           "retry": "Not today: this key's feedback messages are used up until 00:00 UTC (Retry-After gives the seconds); email hello@mutual.solutions instead.",
           "message": "This key has sent today's feedback messages (20 per UTC day): send more after 00:00 UTC, or email hello@mutual.solutions.",
           "details": {}
          },
          {
           "code": "snapshots_off",
           "http_status": 403,
           "where": [
            "POST /v1/jobs/{id}/snapshot"
           ],
           "job_failure": false,
           "meaning": "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).",
           "fix": "Send the feedback as text only.",
           "retryable": false,
           "retry": "Not with this key: send the feedback as text only.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "invalid_snapshot",
           "http_status": 400,
           "where": [
            "POST /v1/jobs/{id}/snapshot"
           ],
           "job_failure": false,
           "meaning": "The picture request is not valid (field names the first problem).",
           "fix": "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.",
           "retryable": false,
           "retry": "Fix the field named in field, then ask again.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "snapshot_too_large",
           "http_status": 400,
           "where": [
            "POST /v1/jobs/{id}/snapshot"
           ],
           "job_failure": false,
           "meaning": "The picture area is larger than 40 mm (pictures show one spot, never the whole board).",
           "fix": "Send size_mm of at most 40, centred on the spot (the report's at_mm positions help).",
           "retryable": false,
           "retry": "Send size_mm of at most 40 and aim at the spot.",
           "message": "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).",
           "details": {}
          },
          {
           "code": "snapshot_outside_board",
           "http_status": 422,
           "where": [
            "POST /v1/jobs/{id}/snapshot"
           ],
           "job_failure": false,
           "meaning": "The picture area misses the board (board_mm gives the board's extent).",
           "fix": "Aim x_mm and y_mm at a spot on the board, for example a position from the report.",
           "retryable": false,
           "retry": "Aim x_mm and y_mm at a spot on the board.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "snapshot_limit_reached",
           "http_status": 429,
           "where": [
            "POST /v1/jobs/{id}/snapshot"
           ],
           "job_failure": false,
           "meaning": "This key has made today's board pictures (30 per UTC day).",
           "fix": "Make more after 00:00 UTC, or send the feedback as text only.",
           "retryable": false,
           "retry": "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.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "snapshot_not_found",
           "http_status": 404,
           "where": [
            "GET /v1/snapshots/{id}.png",
            "DELETE /v1/snapshots/{id}.png",
            "POST /v1/feedback"
           ],
           "job_failure": false,
           "meaning": "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).",
           "fix": "Make a new one with POST /v1/jobs/{id}/snapshot.",
           "retryable": false,
           "retry": "Make a new picture with POST /v1/jobs/{id}/snapshot.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "tryit_limit_reached",
           "http_status": 429,
           "where": [
            "POST /v1/try/jobs"
           ],
           "job_failure": false,
           "meaning": "Your network address has used today's free trial jobs.",
           "fix": "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.",
           "retryable": false,
           "retry": "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.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "tryit_board_too_large",
           "http_status": 422,
           "where": [
            "POST /v1/try/jobs"
           ],
           "job_failure": false,
           "meaning": "The board is too large for the free trial (more pads than max_pads in this answer, or a very large outline).",
           "fix": "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.",
           "retryable": false,
           "retry": "Not retryable: send a smaller board, or use an API key.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "tryit_layers_unsupported",
           "http_status": 422,
           "where": [
            "POST /v1/try/jobs"
           ],
           "job_failure": false,
           "meaning": "The free trial routes only boards with 2 or 4 copper layers.",
           "fix": "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.",
           "retryable": false,
           "retry": "Not retryable: send a board with 2 or 4 copper layers, or use an API key.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "tryit_busy",
           "http_status": 503,
           "where": [
            "POST /v1/try/jobs"
           ],
           "job_failure": false,
           "meaning": "The free trial is busy right now (another visitor's board is routing, or today's trial jobs are used up).",
           "fix": "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.",
           "retryable": true,
           "retry": "Wait the seconds in the Retry-After header, then submit again.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "tryit_human_check_failed",
           "http_status": 403,
           "where": [
            "POST /v1/try/jobs"
           ],
           "job_failure": false,
           "meaning": "The human check did not pass or has expired.",
           "fix": "Complete it again on the page, then press the button once more.",
           "retryable": true,
           "retry": "Complete the human check again, then submit.",
           "message": "The human check did not pass or has expired: complete it again on the page, then press the button once more.",
           "details": {}
          },
          {
           "code": "tryit_not_found",
           "http_status": 404,
           "where": [
            "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"
           ],
           "job_failure": false,
           "meaning": "No free trial job matches this id and token.",
           "fix": "The token is given only once, in the answer to the upload, so check the job from the same page.",
           "retryable": false,
           "retry": "Use the id and token from the upload's answer.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "invalid_access_request",
           "http_status": 422,
           "where": [
            "POST /v1/access-requests"
           ],
           "job_failure": false,
           "meaning": "The request is incomplete or not valid (field names the first problem).",
           "fix": "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.",
           "retryable": false,
           "retry": "Fix the field named in field, then send again.",
           "message": "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.",
           "details": {}
          },
          {
           "code": "access_requests_closed",
           "http_status": 503,
           "where": [
            "POST /v1/access-requests"
           ],
           "job_failure": false,
           "meaning": "We are not taking more access requests through this form right now.",
           "fix": "Email hello@mutual.solutions instead.",
           "retryable": false,
           "retry": "Email hello@mutual.solutions instead.",
           "message": "We are not taking more access requests through this form right now: email hello@mutual.solutions instead.",
           "details": {}
          }
         ],
         "check_findings": [
          {
           "code": "very_large_board",
           "severity": "warning",
           "message": "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.",
           "meaning": "The board outline is very large for its copper layers, so the job may hit the memory limit and fail with timeout_or_limit.",
           "fix": "If it does, split the panel into single boards in KiCad and route them one by one."
          },
          {
           "code": "rules_finer_than_jlc",
           "severity": "warning",
           "message": "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).",
           "meaning": "Your net classes ask for finer copper than JLC can make and fab_floor is jlc, so some nets may stay unrouted.",
           "fix": "Use fab_floor=board if your fab supports it (sizes: File > Board Setup > Design Rules > Net Classes)."
          },
          {
           "code": "courtyards_overlap",
           "severity": "warning",
           "message": "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).",
           "meaning": "Some footprint courtyards overlap.",
           "fix": "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)."
          },
          {
           "code": "malformed_courtyard",
           "severity": "warning",
           "message": "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).",
           "meaning": "Some footprints have a broken courtyard outline.",
           "fix": "Fix the footprint's F.Courtyard / B.Courtyard drawing in the Footprint Editor (Tools > Load Footprint from Board)."
          },
          {
           "code": "no_project",
           "severity": "warning",
           "message": "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.",
           "meaning": "No .kicad_pro was sent.",
           "fix": "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."
          },
          {
           "code": "unknown_net_class",
           "severity": "warning",
           "message": "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.",
           "meaning": "Your .kicad_pro assigns nets to net classes it does not define, so those nets fall back to the Default class.",
           "fix": "Define them in File > Board Setup > Design Rules > Net Classes."
          },
          {
           "code": "footprints_outside_board",
           "severity": "warning",
           "message": "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.",
           "meaning": "Some footprints are outside the board outline (listed in footprints, with positions).",
           "fix": "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."
          },
          {
           "code": "existing_pours_replaced",
           "severity": "warning",
           "message": "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.",
           "meaning": "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).",
           "fix": "Compare the routed board's pours with your design, or send keep_existing=true to keep your pours, tracks and vias exactly."
          },
          {
           "code": "check_incomplete",
           "severity": "warning",
           "message": "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.",
           "meaning": "Part of the check could not run, so some problems may only show when the job runs.",
           "fix": "Run Inspect > Design Rules Checker in KiCad yourself, or check again in a minute."
          }
         ],
         "network_edge": [
          {
           "http_status": [
            403
           ],
           "body_contains": "error code: 1010",
           "meaning": "Cloudflare, the network edge in front of Zap!, blocked the client's name (User-Agent); Python's built-in urllib sends a default name that is blocked.",
           "fix": "Send any User-Agent header, e.g. 'my-agent/1.0', and repeat.",
           "retryable": true
          },
          {
           "http_status": [
            502,
            503,
            504
           ],
           "body_contains": "<html",
           "meaning": "Short maintenance at the network edge (usually under a minute).",
           "fix": "Wait 30 to 60 s; repeat GET requests; for POST /v1/jobs list your jobs first.",
           "retryable": true
          }
         ]
        }
       }
      }
     }
    }
   }
  },
  "/v1/health": {
   "get": {
    "tags": [
     "info"
    ],
    "operationId": "health",
    "security": [],
    "summary": "Service check",
    "description": "No key needed.",
    "responses": {
     "200": {
      "description": "Up.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "ok": {
           "type": "boolean"
          }
         }
        },
        "example": {
         "ok": true
        }
       }
      }
     }
    }
   }
  },
  "/v1/claim": {
   "post": {
    "tags": [
     "info"
    ],
    "operationId": "claimKey",
    "security": [],
    "summary": "Claim an API key with a one-time token (used by the /claim page, not by integrations)",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "token"
        ],
        "properties": {
         "token": {
          "type": "string"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "The key, shown once: store it at once.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "key": {
           "type": "string"
          },
          "key_id": {
           "type": "string"
          },
          "name": {
           "type": "string"
          },
          "daily_jobs": {
           "type": "integer"
          },
          "concurrent_jobs": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`claim_invalid`: This claim link is not valid: check that you copied the whole link, or ask mutual for a new one. (retry does not help: Check the whole claim link.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "claim_invalid": {
          "value": {
           "error": "claim_invalid",
           "message": "This claim link is not valid: check that you copied the whole link, or ask mutual for a new one."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "410": {
      "description": "`claim_used`: This key was already claimed and is shown only once: ask hello@mutual.solutions for a new key. (retry does not help: Ask for a new key.); `claim_expired`: This claim link has expired (links last 72 hours): ask hello@mutual.solutions for a new one. (retry does not help: Ask for a new claim link.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "claim_used": {
          "value": {
           "error": "claim_used",
           "message": "This key was already claimed and is shown only once: ask hello@mutual.solutions for a new key."
          }
         },
         "claim_expired": {
          "value": {
           "error": "claim_expired",
           "message": "This claim link has expired (links last 72 hours): ask hello@mutual.solutions for a new one."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     }
    }
   }
  },
  "/v1/try/jobs": {
   "post": {
    "tags": [
     "try"
    ],
    "operationId": "tryCreateJob",
    "security": [],
    "summary": "Route one small board without a key",
    "description": "No key. Same fields and checks as POST /v1/jobs (router is always stable), plus the trial's limits: at most 300 pads, 2 or 4 copper layers, 2 jobs per network address per UTC day. Send the header X-Zap-Try: 1. The answer's token is given once: send it as X-Zap-Try-Token to read the job. The routed board, its report and pictures are deleted right after the first download of the board (fetch the report first), or after 7 days.",
    "parameters": [
     {
      "name": "X-Zap-Try",
      "in": "header",
      "required": true,
      "schema": {
       "type": "string",
       "enum": [
        "1"
       ]
      }
     }
    ],
    "requestBody": {
     "required": true,
     "content": {
      "multipart/form-data": {
       "schema": {
        "type": "object",
        "required": [
         "board"
        ],
        "properties": {
         "board": {
          "type": "string",
          "format": "binary",
          "description": "The placed board: the .kicad_pcb file KiCad saved (KiCad 6 to 10), with the board outline on Edge.Cuts, every footprint placed on the board, and no tracks or vias (unless keep_existing). May be gzip-compressed (board.kicad_pcb.gz, the same field): the upload limit counts it compressed; unpacked at most 64 MiB (413 board_too_large_unpacked)."
         },
         "project": {
          "type": "string",
          "format": "binary",
          "description": "Recommended: the .kicad_pro saved next to the board (design rules and net classes). Without it Zap! routes and checks to KiCad's default rules."
         },
         "rules": {
          "type": "string",
          "format": "binary",
          "description": "Optional: the .kicad_dru custom rules file (first line starts with '(version')."
         },
         "currents": {
          "type": "string",
          "format": "binary",
          "description": "Optional: currents the human knows, as a JSON file or the same JSON as a text field: {\"nets\": {\"<net name>\": {\"amps\": A, \"peak_amps\": A}}, \"ambient_c\": C}. Names exactly as in the board or a unique last path part ('PoE_5V' for '/PoE_5V'); amps = continuous current 0-100; peak_amps optional, at least amps, recorded only; ambient_c optional, -40 to 125; at most 2,000 nets. State supply nets, not ground. The router sizes those nets' copper from them and the temperature check uses them."
         },
         "keep_existing": {
          "type": "string",
          "enum": [
           "true",
           "false"
          ],
          "default": "false",
          "description": "true: keep the board's own tracks, vias and zones exactly and route only the missing connections around them (a partly routed board). false (default): the board must have no tracks or vias (422 input_has_copper), unless strip_existing is true."
         },
         "strip_existing": {
          "type": "string",
          "enum": [
           "true",
           "false"
          ],
          "default": "false",
          "description": "true: discard the board's own tracks and vias and route everything (a board saved with old routing; the fix for input_has_copper when no KiCad is at hand). Not with keep_existing (422 invalid_option)."
         },
         "fab_floor": {
          "type": "string",
          "enum": [
           "jlc",
           "board"
          ],
          "default": "jlc",
          "description": "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 (fab_floor in the job status)."
         },
         "signal_width_mm": {
          "type": "number",
          "minimum": 0.05,
          "maximum": 5,
          "description": "Signal track width, mm. Default: the board's Default net class width, else 0.2. With fab_floor=jlc at least 0.127 (2 layers) or 0.09 (4+)."
         },
         "via_diameter_mm": {
          "type": "number",
          "minimum": 0.2,
          "maximum": 3,
          "description": "Via diameter, mm; send with via_drill_mm. Default: the board's Default net class via, else 0.6."
         },
         "via_drill_mm": {
          "type": "number",
          "minimum": 0.1,
          "maximum": 2,
          "description": "Via drill, mm, at least 0.1 smaller than the diameter. Default: net class, else 0.3."
         },
         "vias_in_pads": {
          "type": "string",
          "enum": [
           "forbid",
           "allow"
          ],
          "default": "forbid",
          "description": "Vias inside SMD pads; allow needs the board made with filled and capped vias, an extra fab option."
         },
         "ground_pour": {
          "type": "string",
          "enum": [
           "on",
           "off"
          ],
          "default": "on",
          "description": "Ground copper pours on every copper layer."
         },
         "size_rails_from_current": {
          "type": "string",
          "enum": [
           "on",
           "off"
          ],
          "default": "on",
          "description": "Supply track width widened from current (stated, else estimated) where room allows, never below the net-class width; off: the net-class width, which may overheat."
         },
         "effort": {
          "type": "string",
          "enum": [
           "quick",
           "normal",
           "thorough"
          ],
          "default": "normal",
          "description": "Routing effort. quick: about one eighth of the routing work: faster, more unrouted connections. normal (default). thorough: about twice the routing work and keeps the best intermediate result; slower on large boards, usually fewer unrouted connections."
         },
         "minutes": {
          "type": "integer",
          "minimum": 1,
          "maximum": 60,
          "description": "The whole job ends within about this many minutes (1 to 60), checks included, and returns the best board found by then (if no attempt can finish in time, the first to finish is returned later than asked, with time_budget.stopped over_time); default (absent): one round of a few minutes. After the first round the job routes the same board again with more work and other settings while the time allows, and keeps the best by the same ranking; a new attempt starts only if it can finish in time, and routing ends by minute 50 so the checks and report fit in the job's 60 minutes. One job with minutes over 10 runs at a time across the service; others wait in the queue (queue_note). The job's metrics.time_budget says how many attempts ran, which was kept and the time used."
         },
         "consent_training": {
          "type": "string",
          "enum": [
           "true",
           "false"
          ],
          "default": "false",
          "description": "Optional preference only; boards are never used for development without a separate written agreement."
         },
         "cf_turnstile_response": {
          "type": "string",
          "description": "The human check's token, when GET /v1/version lists try_it.turnstile_sitekey."
         }
        }
       }
      }
     }
    },
    "responses": {
     "202": {
      "description": "Accepted and queued.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "string"
          },
          "status": {
           "type": "string",
           "enum": [
            "queued"
           ]
          },
          "token": {
           "type": "string"
          }
         }
        },
        "example": {
         "id": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1",
         "status": "queued",
         "token": "<43 characters, shown once>"
        }
       }
      }
     },
     "400": {
      "description": "`invalid_compressed_board`: 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. (retry does not help: Send the board again, compressed whole with gzip or not compressed.); `board_required`: No board was attached: send the placed board in the multipart field 'board' (curl -F board=@myboard.kicad_pcb). (retry does not help: Put the board in the multipart field 'board'.); `invalid_project`: 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. (retry does not help: Attach the right .kicad_pro or leave it out.); `invalid_rules`: 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. (retry does not help: Attach the right .kicad_dru or leave it out.); `bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "invalid_compressed_board": {
          "value": {
           "error": "invalid_compressed_board",
           "message": "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."
          }
         },
         "board_required": {
          "value": {
           "error": "board_required",
           "message": "No board was attached: send the placed board in the multipart field 'board' (curl -F board=@myboard.kicad_pcb)."
          }
         },
         "invalid_project": {
          "value": {
           "error": "invalid_project",
           "message": "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": {
          "value": {
           "error": "invalid_rules",
           "message": "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."
          }
         },
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "403": {
      "description": "`tryit_human_check_failed`: The human check did not pass or has expired: complete it again on the page, then press the button once more. (retry helps: Complete the human check again, then submit.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "tryit_human_check_failed": {
          "value": {
           "error": "tryit_human_check_failed",
           "message": "The human check did not pass or has expired: complete it again on the page, then press the button once more."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "`keep_existing_unavailable`: 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. (retry does not help: Use router=stable, or send keep_existing=false with a board that has no tracks or vias.); `fab_floor_unavailable`: 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. (retry does not help: Send fab_floor=jlc, or use router=next when GET /v1/version lists one.); `options_unavailable`: Routing options are not switched on yet: leave them out to use the defaults (GET /v1/version shows \"options\": true once they are on). (retry does not help: Leave the routing options out for now (GET /v1/version lists options when they are on).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "keep_existing_unavailable": {
          "value": {
           "error": "keep_existing_unavailable",
           "message": "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."
          }
         },
         "fab_floor_unavailable": {
          "value": {
           "error": "fab_floor_unavailable",
           "message": "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."
          }
         },
         "options_unavailable": {
          "value": {
           "error": "options_unavailable",
           "message": "Routing options are not switched on yet: leave them out to use the defaults (GET /v1/version shows \"options\": true once they are on)."
          }
         }
        }
       }
      }
     },
     "411": {
      "description": "`length_required`: 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). (retry does not help: Send a normal multipart upload with a Content-Length.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "length_required": {
          "value": {
           "error": "length_required",
           "message": "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)."
          }
         }
        }
       }
      }
     },
     "413": {
      "description": "`too_large`: 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. (retry does not help: Send smaller files.); `board_too_large_unpacked`: 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. (retry does not help: Send a smaller board.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "too_large": {
          "value": {
           "error": "too_large",
           "message": "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": {
          "value": {
           "error": "board_too_large_unpacked",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "422": {
      "description": "`invalid_board`: 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). (retry does not help: Fix the board file; resubmitting the same file gives the same answer.); `invalid_currents`: The currents file is not valid: send JSON {\"nets\": {\"<net name>\": {\"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). (retry does not help: Fix the currents JSON (see unknown_nets / ambiguous_nets in the answer).); `invalid_consent`: The consent_training field is not valid: send true or false, or leave it out (which means false). (retry does not help: Send consent_training=true or false, or leave it out.); `invalid_keep_existing`: The keep_existing field is not valid: send true or false, or leave it out (which means false). (retry does not help: Send keep_existing=true or false, or leave it out.); `invalid_fab_floor`: 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. (retry does not help: Send fab_floor=jlc or fab_floor=board, or leave it out.); `invalid_option`: A routing option is not valid (option names it, fix says what to send): correct it, or leave it out to use the default. (retry does not help: Change the option named in option as fix says, or leave it out to use the default.); `input_has_copper`: 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. (retry does not help: Send strip_existing=true to discard them and route everything, or keep_existing=true to route around them.); `too_many_pads`: The board has more than 10,000 pads, the current limit: contact hello@mutual.solutions if you need a larger board routed. (retry does not help: Not retryable: ask mutual for a larger limit.); `too_many_footprints`: The board has more than 4,000 footprints, the current limit: contact hello@mutual.solutions if you need a larger board routed. (retry does not help: Not retryable: ask mutual for a larger limit.); `board_too_large`: 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. (retry does not help: Not retryable as is: email hello@mutual.solutions to arrange a business (B2B) job.); `unsupported_layer_count`: The board must have 2 to 16 copper layers: set the count in File > Board Setup > Board Stackup > Physical Stackup (Copper layers) and save. (retry does not help: Fix the stackup in KiCad, then submit again.); `tryit_board_too_large`: 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. (retry does not help: Not retryable: send a smaller board, or use an API key.); `tryit_layers_unsupported`: 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. (retry does not help: Not retryable: send a board with 2 or 4 copper layers, or use an API key.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "invalid_board": {
          "value": {
           "error": "invalid_board",
           "message": "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)."
          }
         },
         "invalid_currents": {
          "value": {
           "error": "invalid_currents",
           "message": "The currents file is not valid: send JSON {\"nets\": {\"<net name>\": {\"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_consent": {
          "value": {
           "error": "invalid_consent",
           "message": "The consent_training field is not valid: send true or false, or leave it out (which means false)."
          }
         },
         "invalid_keep_existing": {
          "value": {
           "error": "invalid_keep_existing",
           "message": "The keep_existing field is not valid: send true or false, or leave it out (which means false)."
          }
         },
         "invalid_fab_floor": {
          "value": {
           "error": "invalid_fab_floor",
           "message": "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."
          }
         },
         "invalid_option": {
          "value": {
           "error": "invalid_option",
           "message": "A routing option is not valid (option names it, fix says what to send): correct it, or leave it out to use the default."
          }
         },
         "input_has_copper": {
          "value": {
           "error": "input_has_copper",
           "message": "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": {
          "value": {
           "error": "too_many_pads",
           "message": "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": {
          "value": {
           "error": "too_many_footprints",
           "message": "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": {
          "value": {
           "error": "board_too_large",
           "message": "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": {
          "value": {
           "error": "unsupported_layer_count",
           "message": "The board must have 2 to 16 copper layers: set the count in File > Board Setup > Board Stackup > Physical Stackup (Copper layers) and save."
          }
         },
         "tryit_board_too_large": {
          "value": {
           "error": "tryit_board_too_large",
           "message": "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": {
          "value": {
           "error": "tryit_layers_unsupported",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.); `tryit_limit_reached`: 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. (retry does not help: 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.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         },
         "tryit_limit_reached": {
          "value": {
           "error": "tryit_limit_reached",
           "message": "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."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "500": {
      "description": "`internal`: Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. (retry helps: Retry once; if it repeats, send the job id to hello@mutual.solutions.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "internal": {
          "value": {
           "error": "internal",
           "message": "Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions."
          }
         }
        }
       }
      }
     },
     "503": {
      "description": "`server_busy`: 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. (retry helps: Wait the seconds in the Retry-After header, then submit again; estimated_wait_min is the expected wait.); `check_busy`: Several board checks are running right now: try again in a minute. (retry helps: Wait the seconds in Retry-After, then repeat the check.); `tryit_busy`: 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. (retry helps: Wait the seconds in the Retry-After header, then submit again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "server_busy": {
          "value": {
           "error": "server_busy",
           "message": "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."
          }
         },
         "check_busy": {
          "value": {
           "error": "check_busy",
           "message": "Several board checks are running right now: try again in a minute."
          }
         },
         "tryit_busy": {
          "value": {
           "error": "tryit_busy",
           "message": "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."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     }
    }
   }
  },
  "/v1/try/jobs/{id}": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[0-9a-f]{32}$"
     },
     "description": "The job id returned by POST /v1/try/jobs.",
     "example": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1"
    },
    {
     "name": "X-Zap-Try-Token",
     "in": "header",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[A-Za-z0-9_-]{43}$"
     },
     "description": "The token from the answer to POST /v1/try/jobs (given once)."
    }
   ],
   "get": {
    "tags": [
     "try"
    ],
    "operationId": "tryGetJob",
    "security": [],
    "summary": "Status of a trial job (as GET /v1/jobs/{id})",
    "responses": {
     "200": {
      "description": "Job.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Job"
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`tryit_not_found`: 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. (retry does not help: Use the id and token from the upload's answer.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "tryit_not_found": {
          "value": {
           "error": "tryit_not_found",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     }
    }
   }
  },
  "/v1/try/jobs/{id}/result": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[0-9a-f]{32}$"
     },
     "description": "The job id returned by POST /v1/try/jobs.",
     "example": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1"
    },
    {
     "name": "X-Zap-Try-Token",
     "in": "header",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[A-Za-z0-9_-]{43}$"
     },
     "description": "The token from the answer to POST /v1/try/jobs (given once)."
    }
   ],
   "get": {
    "tags": [
     "try"
    ],
    "operationId": "tryGetResult",
    "security": [],
    "summary": "Download the routed board once (its files are deleted right after)",
    "responses": {
     "200": {
      "description": "The routed .kicad_pcb.",
      "content": {
       "application/octet-stream": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`tryit_not_found`: 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. (retry does not help: Use the id and token from the upload's answer.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "tryit_not_found": {
          "value": {
           "error": "tryit_not_found",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "`not_ready`: The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'. (retry helps: Poll GET /v1/jobs/{id} every 5 s until status is succeeded or failed, then download.); `no_result`: The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}. (retry does not help: The job failed: read error.code in GET /v1/jobs/{id}.); `job_cancelled`: The job was cancelled, so there is no routed board: submit the board again if you still need it. (retry does not help: The job was cancelled (DELETE /v1/jobs/{id}); submit the board again if you still need it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_ready": {
          "value": {
           "error": "not_ready",
           "message": "The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'."
          }
         },
         "no_result": {
          "value": {
           "error": "no_result",
           "message": "The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}."
          }
         },
         "job_cancelled": {
          "value": {
           "error": "job_cancelled",
           "message": "The job was cancelled, so there is no routed board: submit the board again if you still need it."
          }
         }
        }
       }
      }
     },
     "410": {
      "description": "`expired`: The routed board was deleted (7 days after the job, or on request): submit the board again. (retry does not help: The routed board was deleted: submit the board again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "expired": {
          "value": {
           "error": "expired",
           "message": "The routed board was deleted (7 days after the job, or on request): submit the board again."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     }
    }
   }
  },
  "/v1/try/jobs/{id}/report.json": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[0-9a-f]{32}$"
     },
     "description": "The job id returned by POST /v1/try/jobs.",
     "example": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1"
    },
    {
     "name": "X-Zap-Try-Token",
     "in": "header",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[A-Za-z0-9_-]{43}$"
     },
     "description": "The token from the answer to POST /v1/try/jobs (given once)."
    }
   ],
   "get": {
    "tags": [
     "try"
    ],
    "operationId": "tryGetReport",
    "security": [],
    "summary": "The result report (as for a keyed job)",
    "responses": {
     "200": {
      "description": "Report.",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Report"
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`tryit_not_found`: 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. (retry does not help: Use the id and token from the upload's answer.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "tryit_not_found": {
          "value": {
           "error": "tryit_not_found",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "`not_ready`: The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'. (retry helps: Poll GET /v1/jobs/{id} every 5 s until status is succeeded or failed, then download.); `job_cancelled`: The job was cancelled, so there is no routed board: submit the board again if you still need it. (retry does not help: The job was cancelled (DELETE /v1/jobs/{id}); submit the board again if you still need it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_ready": {
          "value": {
           "error": "not_ready",
           "message": "The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'."
          }
         },
         "job_cancelled": {
          "value": {
           "error": "job_cancelled",
           "message": "The job was cancelled, so there is no routed board: submit the board again if you still need it."
          }
         }
        }
       }
      }
     },
     "410": {
      "description": "`expired`: The routed board was deleted (7 days after the job, or on request): submit the board again. (retry does not help: The routed board was deleted: submit the board again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "expired": {
          "value": {
           "error": "expired",
           "message": "The routed board was deleted (7 days after the job, or on request): submit the board again."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     }
    }
   }
  },
  "/v1/try/jobs/{id}/report/{layer}.svg": {
   "parameters": [
    {
     "name": "id",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[0-9a-f]{32}$"
     },
     "description": "The job id returned by POST /v1/try/jobs.",
     "example": "9671312f2b2a4c4ea4d5c1d3b0a7e8f1"
    },
    {
     "name": "X-Zap-Try-Token",
     "in": "header",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^[A-Za-z0-9_-]{43}$"
     },
     "description": "The token from the answer to POST /v1/try/jobs (given once)."
    },
    {
     "name": "layer",
     "in": "path",
     "required": true,
     "schema": {
      "type": "string",
      "pattern": "^(F|B|In[0-9]{1,2})_Cu$"
     }
    }
   ],
   "get": {
    "tags": [
     "try"
    ],
    "operationId": "tryGetReportImage",
    "security": [],
    "summary": "Picture of one copper layer (SVG)",
    "responses": {
     "200": {
      "description": "SVG.",
      "content": {
       "image/svg+xml": {
        "schema": {
         "type": "string",
         "format": "binary"
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "404": {
      "description": "`not_found`: No job with this id exists for this key: check the id and use the same key that submitted the job. (retry does not help: Check the job id and use the key that submitted it.); `tryit_not_found`: 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. (retry does not help: Use the id and token from the upload's answer.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_found": {
          "value": {
           "error": "not_found",
           "message": "No job with this id exists for this key: check the id and use the same key that submitted the job."
          }
         },
         "tryit_not_found": {
          "value": {
           "error": "tryit_not_found",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "409": {
      "description": "`not_ready`: The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'. (retry helps: Poll GET /v1/jobs/{id} every 5 s until status is succeeded or failed, then download.); `no_result`: The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}. (retry does not help: The job failed: read error.code in GET /v1/jobs/{id}.); `job_cancelled`: The job was cancelled, so there is no routed board: submit the board again if you still need it. (retry does not help: The job was cancelled (DELETE /v1/jobs/{id}); submit the board again if you still need it.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "not_ready": {
          "value": {
           "error": "not_ready",
           "message": "The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'."
          }
         },
         "no_result": {
          "value": {
           "error": "no_result",
           "message": "The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}."
          }
         },
         "job_cancelled": {
          "value": {
           "error": "job_cancelled",
           "message": "The job was cancelled, so there is no routed board: submit the board again if you still need it."
          }
         }
        }
       }
      }
     },
     "410": {
      "description": "`expired`: The routed board was deleted (7 days after the job, or on request): submit the board again. (retry does not help: The routed board was deleted: submit the board again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "expired": {
          "value": {
           "error": "expired",
           "message": "The routed board was deleted (7 days after the job, or on request): submit the board again."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     }
    }
   }
  },
  "/v1/access-requests": {
   "post": {
    "tags": [
     "info"
    ],
    "operationId": "requestAccess",
    "security": [],
    "summary": "Ask for an API key (the /request-access page); stored for review by hand, no email is sent",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "email",
         "use_case",
         "consent"
        ],
        "properties": {
         "email": {
          "type": "string",
          "maxLength": 254
         },
         "name": {
          "type": "string",
          "maxLength": 200
         },
         "use_case": {
          "type": "string",
          "minLength": 1,
          "maxLength": 2000
         },
         "consent": {
          "type": "boolean",
          "enum": [
           true
          ],
          "description": "mutual may email this address about access."
         }
        }
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "Stored.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "id": {
           "type": "string"
          },
          "status": {
           "type": "string",
           "enum": [
            "pending"
           ]
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "`bad_request`: The request could not be read: send a multipart/form-data upload as shown in /docs. (retry does not help: Fix the request shape (see /docs.md).)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "bad_request": {
          "value": {
           "error": "bad_request",
           "message": "The request could not be read: send a multipart/form-data upload as shown in /docs."
          }
         }
        }
       }
      }
     },
     "405": {
      "description": "`method_not_allowed`: This address does not accept that method: see /docs for the endpoints. (retry does not help: Use the method the endpoint documents.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "method_not_allowed": {
          "value": {
           "error": "method_not_allowed",
           "message": "This address does not accept that method: see /docs for the endpoints."
          }
         }
        }
       }
      }
     },
     "411": {
      "description": "`length_required`: 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). (retry does not help: Send a normal multipart upload with a Content-Length.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "length_required": {
          "value": {
           "error": "length_required",
           "message": "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)."
          }
         }
        }
       }
      }
     },
     "413": {
      "description": "`too_large`: 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. (retry does not help: Send smaller files.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "too_large": {
          "value": {
           "error": "too_large",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "422": {
      "description": "`invalid_access_request`: 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. (retry does not help: Fix the field named in field, then send again.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "invalid_access_request": {
          "value": {
           "error": "invalid_access_request",
           "message": "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."
          }
         }
        }
       }
      }
     },
     "429": {
      "description": "`rate_limited`: Too many requests: wait the number of seconds in the Retry-After header, then try again. (retry helps: Wait the seconds in the Retry-After header, then repeat the same request.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "rate_limited": {
          "value": {
           "error": "rate_limited",
           "message": "Too many requests: wait the number of seconds in the Retry-After header, then try again."
          }
         }
        }
       }
      },
      "headers": {
       "Retry-After": {
        "schema": {
         "type": "integer"
        },
        "description": "Seconds to wait before repeating the request."
       }
      }
     },
     "503": {
      "description": "`access_requests_closed`: We are not taking more access requests through this form right now: email hello@mutual.solutions instead. (retry does not help: Email hello@mutual.solutions instead.)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/Error"
        },
        "examples": {
         "access_requests_closed": {
          "value": {
           "error": "access_requests_closed",
           "message": "We are not taking more access requests through this form right now: email hello@mutual.solutions instead."
          }
         }
        }
       }
      }
     }
    }
   }
  },
  "/oauth/token": {
   "post": {
    "tags": [
     "oauth"
    ],
    "operationId": "oauthToken",
    "security": [],
    "summary": "Get or renew an access token",
    "requestBody": {
     "required": true,
     "content": {
      "application/x-www-form-urlencoded": {
       "schema": {
        "type": "object",
        "properties": {
         "grant_type": {
          "type": "string",
          "description": "authorization_code, refresh_token, client_credentials or urn:ietf:params:oauth:grant-type:device_code"
         },
         "code": {
          "type": "string",
          "description": "authorization_code"
         },
         "redirect_uri": {
          "type": "string",
          "description": "authorization_code: exactly as in the authorization request"
         },
         "code_verifier": {
          "type": "string",
          "description": "authorization_code: the PKCE verifier (S256)"
         },
         "refresh_token": {
          "type": "string",
          "description": "refresh_token"
         },
         "device_code": {
          "type": "string",
          "description": "device_code"
         },
         "client_id": {
          "type": "string",
          "description": "public clients; confidential ones may use HTTP Basic instead"
         },
         "client_secret": {
          "type": "string",
          "description": "client_secret_post"
         },
         "resource": {
          "type": "string",
          "description": "RFC 8707: https://zap.mutual.solutions/mcp or https://zap.mutual.solutions/v1"
         },
         "scope": {
          "type": "string",
          "description": "zap"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Tokens.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "access_token": {
           "type": "string",
           "description": "zat_..., 1 hour"
          },
          "token_type": {
           "type": "string",
           "enum": [
            "Bearer"
           ]
          },
          "expires_in": {
           "type": "integer"
          },
          "scope": {
           "type": "string",
           "enum": [
            "zap"
           ]
          },
          "refresh_token": {
           "type": "string",
           "description": "zrt_...; works once: each use returns a new one (not for client credentials)"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "invalid_request, invalid_grant (wrong or reused code, wrong PKCE verifier, used or revoked refresh token), invalid_target (other resource), unauthorized_client, authorization_pending, slow_down, expired_token, access_denied",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/OAuthError"
        }
       }
      }
     },
     "401": {
      "description": "invalid_client",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/OAuthError"
        }
       }
      }
     },
     "429": {
      "description": "too many requests (Retry-After)",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/OAuthError"
        }
       }
      }
     }
    }
   }
  },
  "/oauth/register": {
   "post": {
    "tags": [
     "oauth"
    ],
    "operationId": "oauthRegister",
    "security": [],
    "summary": "Register an app (RFC 7591)",
    "description": "redirect_uris: http://127.0.0.1, http://[::1] or http://localhost (any port), or https; others are dropped, and with none left the registration is refused. grant_types: authorization_code, refresh_token. token_endpoint_auth_method: none (public), client_secret_basic or client_secret_post (the secret is in this answer only).",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "required": [
         "redirect_uris"
        ],
        "properties": {
         "redirect_uris": {
          "type": "array",
          "items": {
           "type": "string"
          },
          "minItems": 1,
          "maxItems": 10
         },
         "client_name": {
          "type": "string",
          "maxLength": 100
         },
         "token_endpoint_auth_method": {
          "type": "string"
         },
         "grant_types": {
          "type": "array",
          "items": {
           "type": "string"
          }
         },
         "response_types": {
          "type": "array",
          "items": {
           "type": "string"
          }
         },
         "scope": {
          "type": "string"
         }
        }
       }
      }
     }
    },
    "responses": {
     "201": {
      "description": "Registered.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "client_id": {
           "type": "string"
          },
          "client_secret": {
           "type": "string"
          },
          "redirect_uris": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "invalid_redirect_uri, invalid_client_metadata",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/OAuthError"
        }
       }
      }
     },
     "429": {
      "description": "too many registrations",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/OAuthError"
        }
       }
      }
     }
    }
   }
  },
  "/oauth/revoke": {
   "post": {
    "tags": [
     "oauth"
    ],
    "operationId": "oauthRevoke",
    "security": [],
    "summary": "Revoke a token (RFC 7009)",
    "description": "token=..., client authentication as on /oauth/token. A refresh token ends its whole sign-in.",
    "requestBody": {
     "required": true,
     "content": {
      "application/x-www-form-urlencoded": {
       "schema": {
        "type": "object",
        "properties": {
         "grant_type": {
          "type": "string",
          "description": "authorization_code, refresh_token, client_credentials or urn:ietf:params:oauth:grant-type:device_code"
         },
         "code": {
          "type": "string",
          "description": "authorization_code"
         },
         "redirect_uri": {
          "type": "string",
          "description": "authorization_code: exactly as in the authorization request"
         },
         "code_verifier": {
          "type": "string",
          "description": "authorization_code: the PKCE verifier (S256)"
         },
         "refresh_token": {
          "type": "string",
          "description": "refresh_token"
         },
         "device_code": {
          "type": "string",
          "description": "device_code"
         },
         "client_id": {
          "type": "string",
          "description": "public clients; confidential ones may use HTTP Basic instead"
         },
         "client_secret": {
          "type": "string",
          "description": "client_secret_post"
         },
         "resource": {
          "type": "string",
          "description": "RFC 8707: https://zap.mutual.solutions/mcp or https://zap.mutual.solutions/v1"
         },
         "scope": {
          "type": "string",
          "description": "zap"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Done (also for unknown tokens).",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "400": {
      "description": "invalid_request",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/OAuthError"
        }
       }
      }
     },
     "401": {
      "description": "invalid_client",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/OAuthError"
        }
       }
      }
     }
    }
   }
  },
  "/oauth/device_authorization": {
   "post": {
    "tags": [
     "oauth"
    ],
    "operationId": "oauthDevice",
    "security": [],
    "summary": "Start a device sign-in (RFC 8628; zap.py login)",
    "requestBody": {
     "required": true,
     "content": {
      "application/x-www-form-urlencoded": {
       "schema": {
        "type": "object",
        "properties": {
         "grant_type": {
          "type": "string",
          "description": "authorization_code, refresh_token, client_credentials or urn:ietf:params:oauth:grant-type:device_code"
         },
         "code": {
          "type": "string",
          "description": "authorization_code"
         },
         "redirect_uri": {
          "type": "string",
          "description": "authorization_code: exactly as in the authorization request"
         },
         "code_verifier": {
          "type": "string",
          "description": "authorization_code: the PKCE verifier (S256)"
         },
         "refresh_token": {
          "type": "string",
          "description": "refresh_token"
         },
         "device_code": {
          "type": "string",
          "description": "device_code"
         },
         "client_id": {
          "type": "string",
          "description": "public clients; confidential ones may use HTTP Basic instead"
         },
         "client_secret": {
          "type": "string",
          "description": "client_secret_post"
         },
         "resource": {
          "type": "string",
          "description": "RFC 8707: https://zap.mutual.solutions/mcp or https://zap.mutual.solutions/v1"
         },
         "scope": {
          "type": "string",
          "description": "zap"
         }
        }
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "Codes.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object",
         "properties": {
          "device_code": {
           "type": "string"
          },
          "user_code": {
           "type": "string"
          },
          "verification_uri": {
           "type": "string"
          },
          "verification_uri_complete": {
           "type": "string"
          },
          "expires_in": {
           "type": "integer"
          },
          "interval": {
           "type": "integer"
          }
         }
        }
       }
      }
     },
     "400": {
      "description": "invalid_request, unauthorized_client",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/OAuthError"
        }
       }
      }
     },
     "429": {
      "description": "too many sign-ins from this address",
      "content": {
       "application/json": {
        "schema": {
         "$ref": "#/components/schemas/OAuthError"
        }
       }
      }
     }
    }
   }
  },
  "/.well-known/oauth-authorization-server": {
   "get": {
    "tags": [
     "oauth"
    ],
    "operationId": "oauthServerMetadata",
    "security": [],
    "summary": "Server metadata (RFC 8414)",
    "responses": {
     "200": {
      "description": "Metadata.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/.well-known/oauth-protected-resource/v1": {
   "get": {
    "tags": [
     "oauth"
    ],
    "operationId": "oauthResourceApi",
    "security": [],
    "summary": "Resource metadata of the API (RFC 9728)",
    "responses": {
     "200": {
      "description": "Metadata.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/.well-known/oauth-protected-resource/mcp": {
   "get": {
    "tags": [
     "oauth"
    ],
    "operationId": "oauthResourceMcp",
    "security": [],
    "summary": "Resource metadata of the MCP server (RFC 9728)",
    "responses": {
     "200": {
      "description": "Metadata.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     }
    }
   }
  },
  "/mcp": {
   "post": {
    "tags": [
     "oauth"
    ],
    "operationId": "mcp",
    "security": [
     {
      "oauth2": [
       "zap"
      ]
     }
    ],
    "summary": "Remote MCP server (Streamable HTTP, JSON answers; protocol 2026-07-28 and 2025-03-26 to 2025-11-25)",
    "description": "Needs an access token for the audience https://zap.mutual.solutions/mcp (not an API key). Tools: check_board, submit_job, get_job, list_jobs, get_report, get_result, delete_job; boards as file text.",
    "requestBody": {
     "required": true,
     "content": {
      "application/json": {
       "schema": {
        "type": "object",
        "description": "One JSON-RPC 2.0 message."
       }
      }
     }
    },
    "responses": {
     "200": {
      "description": "JSON-RPC answer.",
      "content": {
       "application/json": {
        "schema": {
         "type": "object"
        }
       }
      }
     },
     "202": {
      "description": "Notification accepted."
     },
     "401": {
      "description": "No or expired token: WWW-Authenticate names the resource metadata."
     }
    }
   }
  }
 },
 "components": {
  "securitySchemes": {
   "apiKey": {
    "type": "http",
    "scheme": "bearer",
    "description": "Authorization: Bearer <api key> (starts with mrk_). Keep it out of code, URLs, logs and command lines."
   },
   "oauth2": {
    "type": "oauth2",
    "description": "OAuth 2.1 access token (zat_..., 1 hour) for the audience https://zap.mutual.solutions/v1. Also: device code (RFC 8628, POST /oauth/device_authorization; zap.py login) and dynamic registration (RFC 7591, POST /oauth/register). PKCE S256 is required.",
    "flows": {
     "authorizationCode": {
      "authorizationUrl": "https://zap.mutual.solutions/oauth/authorize",
      "tokenUrl": "https://zap.mutual.solutions/oauth/token",
      "refreshUrl": "https://zap.mutual.solutions/oauth/token",
      "scopes": {
       "zap": "check and route boards, read and delete this account's jobs"
      }
     },
     "clientCredentials": {
      "tokenUrl": "https://zap.mutual.solutions/oauth/token",
      "scopes": {
       "zap": "check and route boards, read and delete this account's jobs"
      }
     }
    }
   }
  },
  "schemas": {
   "Error": {
    "type": "object",
    "required": [
     "error",
     "message"
    ],
    "properties": {
     "error": {
      "type": "string",
      "enum": [
       "access_requests_closed",
       "bad_request",
       "board_required",
       "board_too_large",
       "board_too_large_unpacked",
       "check_busy",
       "check_limit_reached",
       "claim_expired",
       "claim_invalid",
       "claim_used",
       "daily_quota_exceeded",
       "expired",
       "fab_floor_unavailable",
       "feedback_limit_reached",
       "feedback_message_length",
       "feedback_needs_user_approval",
       "input_has_copper",
       "internal",
       "invalid_access_request",
       "invalid_board",
       "invalid_compressed_board",
       "invalid_consent",
       "invalid_currents",
       "invalid_fab_floor",
       "invalid_feedback",
       "invalid_keep_existing",
       "invalid_option",
       "invalid_project",
       "invalid_router",
       "invalid_rules",
       "invalid_snapshot",
       "job_cancelled",
       "keep_existing_unavailable",
       "length_required",
       "method_not_allowed",
       "no_result",
       "not_found",
       "not_ready",
       "options_unavailable",
       "rate_limited",
       "router_unavailable",
       "server_busy",
       "snapshot_limit_reached",
       "snapshot_not_found",
       "snapshot_outside_board",
       "snapshot_too_large",
       "snapshots_off",
       "too_large",
       "too_many_active_jobs",
       "too_many_footprints",
       "too_many_pads",
       "tryit_board_too_large",
       "tryit_busy",
       "tryit_human_check_failed",
       "tryit_layers_unsupported",
       "tryit_limit_reached",
       "tryit_not_found",
       "unauthorized",
       "unsupported_layer_count"
      ],
      "description": "Stable code; GET /v1/errors gives the fix and whether retrying helps."
     },
     "message": {
      "type": "string",
      "description": "One sentence: cause, then what to change."
     },
     "unknown_nets": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "invalid_currents: names not found in the board."
     },
     "unknown_nets_total": {
      "type": "integer"
     },
     "ambiguous_nets": {
      "type": "object",
      "additionalProperties": {
       "type": "array",
       "items": {
        "type": "string"
       }
      },
      "description": "invalid_currents: names matching several board nets, with the candidates."
     },
     "memory": {
      "$ref": "#/components/schemas/Memory"
     }
    }
   },
   "Version": {
    "type": "object",
    "properties": {
     "service": {
      "type": "string",
      "example": "2026.10.14.78"
     },
     "router": {
      "type": "string",
      "description": "Router build id (r<commit>.<8 hex>).",
      "example": "r009e659.8f766f9f"
     },
     "router_choice": {
      "type": "string",
      "enum": [
       "stable",
       "next"
      ],
      "description": "In job status: the router chosen."
     },
     "routers": {
      "type": "object",
      "description": "In GET /v1/version: routers offered now.",
      "properties": {
       "stable": {
        "type": "string"
       },
       "next": {
        "type": "string",
        "nullable": true
       }
      }
     },
     "try_it": {
      "type": "object",
      "description": "Present only while the free trial is offered.",
      "properties": {
       "max_pads": {
        "type": "integer"
       },
       "copper_layers": {
        "type": "array",
        "items": {
         "type": "integer"
        }
       },
       "jobs_per_day": {
        "type": "integer"
       },
       "turnstile_sitekey": {
        "type": "string",
        "nullable": true
       }
      }
     }
    }
   },
   "Job": {
    "type": "object",
    "required": [
     "id",
     "status"
    ],
    "properties": {
     "id": {
      "type": "string"
     },
     "status": {
      "type": "string",
      "enum": [
       "queued",
       "running",
       "succeeded",
       "failed",
       "cancelled"
      ],
      "description": "Poll until succeeded, failed or cancelled (cancelled: stopped with DELETE)."
     },
     "created_at": {
      "type": "string",
      "format": "date-time"
     },
     "started_at": {
      "type": "string",
      "format": "date-time",
      "nullable": true
     },
     "finished_at": {
      "type": "string",
      "format": "date-time",
      "nullable": true
     },
     "consent_training": {
      "type": "boolean"
     },
     "keep_existing": {
      "type": "boolean",
      "description": "The board's own copper was kept (upload field keep_existing)."
     },
     "options": {
      "type": "object",
      "description": "The routing options the job was sent that differ from the defaults (upload fields).",
      "additionalProperties": true
     },
     "fab_floor": {
      "type": "string",
      "enum": [
       "jlc",
       "board"
      ],
      "description": "Smallest copper sizes the job routed to (see the upload field)."
     },
     "files_deleted": {
      "type": "boolean",
      "description": "true once the routed board was deleted (7 days, or DELETE)."
     },
     "report_url": {
      "type": "string",
      "description": "Finished jobs: the result report as a web page (asks for the key)."
     },
     "report_json": {
      "type": "string",
      "description": "Finished jobs: path of GET /v1/jobs/{id}/report.json."
     },
     "queue_position": {
      "type": "integer",
      "description": "While queued: 1 means next to run."
     },
     "queue_note": {
      "type": "string",
      "description": "While queued, only when the job waits for a reason other than its place: a job with minutes over 10 waits while another such job runs."
     },
     "estimated_wait_s": {
      "type": "integer",
      "description": "While queued: rough seconds until it starts (from the last 24 h of jobs)."
     },
     "version": {
      "allOf": [
       {
        "$ref": "#/components/schemas/Version"
       }
      ],
      "nullable": true,
      "description": "null while queued."
     },
     "metrics": {
      "$ref": "#/components/schemas/Metrics"
     },
     "error": {
      "type": "object",
      "description": "When failed.",
      "properties": {
       "code": {
        "type": "string",
        "enum": [
         "board_too_large",
         "input_has_copper",
         "internal",
         "invalid_board",
         "output_has_shorts",
         "route_failed",
         "rules_check_failed",
         "timeout",
         "timeout_or_limit",
         "unsupported_rules"
        ],
        "description": "See GET /v1/errors for the fix and whether retrying helps."
       },
       "message": {
        "type": "string"
       },
       "memory": {
        "$ref": "#/components/schemas/Memory"
       }
      }
     }
    }
   },
   "Metrics": {
    "type": "object",
    "description": "When succeeded. Lower is better for every number; 0 is best.",
    "properties": {
     "unconnected": {
      "type": "integer",
      "nullable": true,
      "description": "Connections still missing; the human finishes them in KiCad."
     },
     "critical_unconnected": {
      "type": "integer",
      "nullable": true,
      "description": "Of those, on supply, ground, clock, reset, boot or debug nets. Must be 0 before ordering boards."
     },
     "drc_errors": {
      "type": "integer",
      "nullable": true,
      "description": "KiCad DRC errors under the uploaded rules, including ones caused by the placement."
     },
     "wall_time_s": {
      "type": "number",
      "description": "Server time for the job, seconds."
     },
     "existing_copper": {
      "$ref": "#/components/schemas/ExistingCopper"
     },
     "time_budget": {
      "$ref": "#/components/schemas/TimeBudget"
     },
     "fab": {
      "$ref": "#/components/schemas/Fab"
     }
    }
   },
   "ExistingCopper": {
    "type": "object",
    "description": "keep_existing jobs only. Counts, not findings: *_kept should equal what you uploaded and *_removed should be 0 (the router never removes your copper; any other value is a fault to report); *_added is the router's own copper.",
    "properties": {
     "tracks_kept": {
      "type": "integer",
      "nullable": true
     },
     "vias_kept": {
      "type": "integer",
      "nullable": true
     },
     "zones_kept": {
      "type": "integer",
      "nullable": true
     },
     "tracks_removed": {
      "type": "integer",
      "nullable": true
     },
     "vias_removed": {
      "type": "integer",
      "nullable": true
     },
     "zones_removed": {
      "type": "integer",
      "nullable": true
     },
     "tracks_added": {
      "type": "integer",
      "nullable": true
     },
     "vias_added": {
      "type": "integer",
      "nullable": true
     }
    }
   },
   "Fab": {
    "type": "object",
    "nullable": true,
    "description": "Manufacturability report of the returned board. null for jobs before 2026.10.13.8.",
    "properties": {
     "check": {
      "type": "string",
      "nullable": true,
      "description": "Check version, e.g. 'fab1 1.6'."
     },
     "rules": {
      "type": "object",
      "properties": {
       "project": {
        "type": "string",
        "enum": [
         "uploaded",
         "kicad_defaults"
        ]
       },
       "custom_rules": {
        "type": "string",
        "enum": [
         "uploaded",
         "none"
        ]
       }
      }
     },
     "computed": {
      "type": "boolean"
     },
     "reason": {
      "type": "string",
      "enum": [
       "time limit",
       "check failed",
       "check unavailable"
      ],
      "description": "Only when computed is false."
     },
     "thermal": {
      "$ref": "#/components/schemas/Thermal"
     },
     "shorts": {
      "type": "integer",
      "description": "Shorts between nets that KiCad finds in the returned board. The router never adds one (a board with a routed short is never returned), so any here were already in the uploaded board: see shorts_already_in_board."
     },
     "shorts_already_in_board": {
      "type": "integer",
      "description": "Of the shorts, how many were already in the uploaded board (for example a copper graphic over a pad of another net); fix them in KiCad. From 2026.10.13.22."
     },
     "drc_errors": {
      "type": "integer"
     },
     "drc_errors_by_type": {
      "type": "object",
      "additionalProperties": {
       "type": "integer"
      }
     },
     "jlc_violations": {
      "type": "integer",
      "description": "Features JLCPCB's standard process cannot make. 0 is the goal."
     },
     "jlc_violations_by_type": {
      "type": "object",
      "additionalProperties": {
       "type": "integer"
      },
      "description": "e.g. via_in_pad (needs filled and capped vias), acute_angle_acid_trap, silk_over_copper, clearance."
     },
     "jlc_capabilities": {
      "type": "string",
      "enum": [
       "1-2 layers",
       "multilayer"
      ]
     },
     "counts_capped": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Types KiCad stopped counting at 199 (minimums)."
     },
     "jlc_items": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/JlcItem"
      },
      "nullable": true,
      "description": "Where each JLC violation is (every one, at most 2,000, sorted by type; before 2026.10.13.22 at most 200, and via_in_pad and acute_angle_acid_trap listed only their first 10). null once the routed board is deleted; absent for jobs before 2026.10.13.20."
     },
     "jlc_items_total": {
      "type": "integer",
      "description": "How many items the list would hold."
     },
     "jlc_items_truncated": {
      "type": "boolean",
      "description": "true when jlc_items shows fewer than jlc_violations (more than 2,000)."
     },
     "dangling_total": {
      "type": "integer",
      "description": "Track ends and vias that connect nothing (listed in the report)."
     },
     "isolated_total": {
      "type": "integer",
      "description": "Copper islands with no connection (listed in the report)."
     },
     "time_s": {
      "type": "number"
     }
    }
   },
   "Thermal": {
    "type": "object",
    "description": "Temperature check (IPC-2152, 10 C rise limit).",
    "properties": {
     "computed": {
      "type": "boolean"
     },
     "reason": {
      "type": "string",
      "enum": [
       "time limit",
       "no supply rail with a source and loads was recognised",
       "check failed"
      ],
      "description": "Only when computed is false."
     },
     "currents": {
      "type": "string",
      "description": "How the currents were obtained (label to show the human)."
     },
     "limit_c": {
      "type": "number",
      "example": 10.0
     },
     "worst_rise_c": {
      "type": "number",
      "description": "Hottest copper section, degrees C above ambient; 1000 or more means off the chart (see off_chart). Lower is better."
     },
     "off_chart": {
      "type": "boolean",
      "description": "true: the worst section is beyond the IPC-2152 chart, far too thin; not a real temperature."
     },
     "sections_over_limit": {
      "type": "integer",
      "description": "Track, via and pour sections over limit_c. 0 is the goal."
     },
     "sections_over_by_current_source": {
      "type": "object",
      "description": "Present only when currents were sent.",
      "properties": {
       "stated": {
        "type": "integer"
       },
       "estimated": {
        "type": "integer"
       }
      }
     },
     "nets_over_limit": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "nullable": true,
      "description": "null once the routed board is deleted."
     },
     "rails_checked": {
      "type": "integer"
     },
     "rails_not_checked": {
      "type": "integer"
     },
     "hottest": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Hotspot"
      },
      "nullable": true,
      "description": "Up to 5 hottest sections; null once the routed board is deleted."
     },
     "stated_currents": {
      "type": "object",
      "description": "Present only when currents were sent.",
      "properties": {
       "nets": {
        "type": "integer"
       },
       "applied": {
        "type": "boolean"
       }
      }
     },
     "stated_not_checked": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "nullable": true,
      "description": "Stated nets the check could not use (no source or load part found): check them by hand."
     },
     "ambient_c": {
      "type": "number",
      "description": "Present only when ambient_c was sent."
     },
     "worst_temp_c": {
      "type": "number",
      "nullable": true,
      "description": "Present only when ambient_c was sent: ambient_c + worst_rise_c; null when off the chart."
     }
    }
   },
   "Hotspot": {
    "type": "object",
    "properties": {
     "net": {
      "type": "string"
     },
     "kind": {
      "type": "string",
      "enum": [
       "track",
       "via",
       "pour"
      ]
     },
     "layer": {
      "type": "string",
      "description": "KiCad layer name, e.g. F.Cu; empty for a via."
     },
     "rise_c": {
      "type": "number",
      "description": "Rise above ambient, degrees C (lower is better)."
     },
     "current_a": {
      "type": "number"
     },
     "current_source": {
      "type": "string",
      "enum": [
       "stated",
       "estimated"
      ]
     },
     "at_mm": {
      "type": "array",
      "items": {
       "type": "number"
      },
      "minItems": 2,
      "maxItems": 2,
      "description": "x, y in mm as KiCad shows them."
     }
    }
   },
   "JlcItem": {
    "type": "object",
    "properties": {
     "type": {
      "type": "string",
      "description": "Same names as jlc_violations_by_type."
     },
     "at_mm": {
      "type": "array",
      "items": {
       "type": "number"
      },
      "minItems": 2,
      "maxItems": 2,
      "description": "x, y in mm as KiCad shows them."
     },
     "layer": {
      "type": "string",
      "nullable": true
     },
     "net": {
      "type": "string",
      "nullable": true
     },
     "refs": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "Footprints involved, when KiCad names them."
     },
     "description": {
      "type": "string",
      "description": "KiCad's own description of the violation."
     }
    }
   },
   "Check": {
    "type": "object",
    "required": [
     "ok",
     "summary",
     "board",
     "blockers",
     "warnings"
    ],
    "properties": {
     "ok": {
      "type": "boolean",
      "description": "true when nothing would stop the job: then submit with POST /v1/jobs."
     },
     "summary": {
      "type": "string"
     },
     "board": {
      "type": "object",
      "properties": {
       "file_version": {
        "type": "integer",
        "nullable": true
       },
       "kicad_version": {
        "type": "integer",
        "nullable": true
       },
       "returned_as": {
        "type": "string",
        "nullable": true
       },
       "footprints": {
        "type": "integer"
       },
       "pads": {
        "type": "integer"
       },
       "nets": {
        "type": "integer"
       },
       "copper_layers": {
        "type": "integer"
       },
       "size_mm": {
        "type": "array",
        "items": {
         "type": "number"
        }
       },
       "rules": {
        "type": "object",
        "properties": {
         "project": {
          "type": "string",
          "enum": [
           "uploaded",
           "kicad_defaults"
          ]
         },
         "custom_rules": {
          "type": "string",
          "enum": [
           "uploaded",
           "none"
          ]
         }
        }
       },
       "router": {
        "type": "object",
        "properties": {
         "choice": {
          "type": "string"
         },
         "build": {
          "type": "string"
         }
        }
       },
       "keep_existing": {
        "type": "boolean"
       },
       "existing_copper": {
        "type": "object",
        "description": "keep_existing=true only: the copper the job would keep.",
        "properties": {
         "tracks": {
          "type": "integer"
         },
         "vias": {
          "type": "integer"
         },
         "zones": {
          "type": "integer"
         }
        }
       },
       "fab_floor": {
        "type": "string",
        "enum": [
         "jlc",
         "board"
        ]
       },
       "currents": {
        "type": "object",
        "description": "Present when a currents file was sent.",
        "properties": {
         "nets": {
          "type": "integer",
          "description": "Nets in the file."
         },
         "accepted": {
          "type": "boolean",
          "description": "false: the job would refuse the file (see the invalid_currents blocker)."
         }
        }
       }
      }
     },
     "blockers": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Finding"
      },
      "description": "Each would stop the job: show them to the human and do not submit."
     },
     "warnings": {
      "type": "array",
      "items": {
       "$ref": "#/components/schemas/Finding"
      },
      "description": "Would weaken the result: mention them to the human."
     },
     "check_s": {
      "type": "number"
     }
    }
   },
   "Finding": {
    "type": "object",
    "required": [
     "code",
     "message"
    ],
    "properties": {
     "code": {
      "type": "string",
      "description": "A request error code (GET /v1/errors) or a check finding code."
     },
     "message": {
      "type": "string",
      "description": "Cause, then what to change and where in KiCad."
     },
     "detail": {
      "type": "string",
      "description": "A more specific case of the code, e.g. no_outline for invalid_board."
     },
     "footprints": {
      "type": "array",
      "items": {
       "type": "object",
       "properties": {
        "ref": {
         "type": "string"
        },
        "at_mm": {
         "type": "array",
         "items": {
          "type": "number"
         }
        },
        "side": {
         "type": "string",
         "enum": [
          "front",
          "back"
         ]
        },
        "whole_footprint": {
         "type": "boolean"
        }
       }
      }
     },
     "items": {
      "type": "array",
      "items": {
       "type": "object",
       "properties": {
        "refs": {
         "type": "array",
         "items": {
          "type": "string"
         }
        },
        "at_mm": {
         "type": "array",
         "items": {
          "type": "number"
         }
        }
       }
      }
     },
     "unknown_nets": {
      "type": "array",
      "items": {
       "type": "string"
      }
     },
     "ambiguous_nets": {
      "type": "object",
      "additionalProperties": {
       "type": "array",
       "items": {
        "type": "string"
       }
      }
     },
     "net_classes": {
      "type": "array",
      "items": {
       "type": "string"
      }
     },
     "rule": {
      "type": "string"
     },
     "function": {
      "type": "string",
      "nullable": true
     },
     "memory": {
      "$ref": "#/components/schemas/Memory"
     }
    }
   },
   "Memory": {
    "type": "object",
    "description": "board_too_large: the predicted memory of one routing attempt and the board numbers behind it.",
    "properties": {
     "predicted_gb": {
      "type": "number",
      "description": "Predicted peak memory of one routing attempt, GB."
     },
     "budget_gb": {
      "type": "number",
      "description": "The job's memory budget for routing, GB."
     },
     "width_mm": {
      "type": "number",
      "description": "Board outline width, mm (absent when not known)."
     },
     "height_mm": {
      "type": "number",
      "description": "Board outline height, mm (absent when not known)."
     },
     "copper_layers": {
      "type": "integer"
     },
     "grid_cells": {
      "type": "integer",
      "description": "Router grid cells: outline area x copper layers at a 50 um grid."
     }
    }
   },
   "ErrorCatalog": {
    "type": "object",
    "properties": {
     "errors": {
      "type": "array",
      "items": {
       "type": "object",
       "properties": {
        "code": {
         "type": "string"
        },
        "http_status": {
         "type": "integer",
         "nullable": true,
         "description": "null: only a job failure (error.code in job status)."
        },
        "where": {
         "type": "array",
         "items": {
          "type": "string"
         }
        },
        "job_failure": {
         "type": "boolean"
        },
        "meaning": {
         "type": "string"
        },
        "fix": {
         "type": "string"
        },
        "retryable": {
         "type": "boolean"
        },
        "retry": {
         "type": "string",
         "description": "What to do before repeating, or why not to."
        },
        "message": {
         "type": "string"
        },
        "details": {
         "type": "object",
         "additionalProperties": {
          "type": "string"
         }
        }
       }
      }
     },
     "check_findings": {
      "type": "array",
      "items": {
       "type": "object",
       "properties": {
        "code": {
         "type": "string"
        },
        "severity": {
         "type": "string",
         "enum": [
          "blocker",
          "warning"
         ]
        },
        "message": {
         "type": "string"
        },
        "meaning": {
         "type": "string"
        },
        "fix": {
         "type": "string"
        }
       }
      }
     },
     "network_edge": {
      "type": "array",
      "description": "Answers from Cloudflare in front of Zap!, not from Zap! (no JSON body).",
      "items": {
       "type": "object",
       "properties": {
        "http_status": {
         "type": "array",
         "items": {
          "type": "integer"
         }
        },
        "body_contains": {
         "type": "string"
        },
        "meaning": {
         "type": "string"
        },
        "fix": {
         "type": "string"
        },
        "retryable": {
         "type": "boolean"
        }
       }
      }
     }
    }
   },
   "Report": {
    "type": "object",
    "properties": {
     "id": {
      "type": "string"
     },
     "status": {
      "type": "string",
      "enum": [
       "succeeded",
       "failed"
      ]
     },
     "created_at": {
      "type": "string"
     },
     "finished_at": {
      "type": "string"
     },
     "version": {
      "$ref": "#/components/schemas/Version"
     },
     "report_url": {
      "type": "string",
      "description": "The same report as a web page (asks for the key)."
     },
     "error": {
      "type": "object",
      "description": "Failed jobs: code, message and what_to_do.",
      "properties": {
       "code": {
        "type": "string"
       },
       "message": {
        "type": "string"
       },
       "what_to_do": {
        "type": "string"
       }
      }
     },
     "summary": {
      "type": "object",
      "description": "Counts; lower is better for every one. heat: one line, the copper sections over the temperature-rise limit and the hottest rise, for example \"0 over the 10 \u00b0C limit (hottest +0.31 \u00b0C)\"; rise_limit_c: that limit.",
      "additionalProperties": true
     },
     "existing_copper": {
      "$ref": "#/components/schemas/ExistingCopper"
     },
     "time_budget": {
      "$ref": "#/components/schemas/TimeBudget"
     },
     "pours": {
      "type": "object",
      "description": "Jobs without keep_existing: the board's own copper pours, which Zap! replaced, and the pours it made (it chooses their nets itself). Each [{net, layers}]; net \"\" is a pour with no net.",
      "properties": {
       "replaced": {
        "type": "array",
        "items": {
         "type": "object",
         "properties": {
          "net": {
           "type": "string"
          },
          "layers": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       },
       "made": {
        "type": "array",
        "items": {
         "type": "object",
         "properties": {
          "net": {
           "type": "string"
          },
          "layers": {
           "type": "array",
           "items": {
            "type": "string"
           }
          }
         }
        }
       }
      }
     },
     "settings": {
      "type": "object",
      "nullable": true,
      "description": "Settings used: each value the job ran with and its source (yours, board, default).",
      "properties": {
       "no_project": {
        "type": "boolean"
       },
       "items": {
        "type": "array",
        "items": {
         "type": "object",
         "properties": {
          "setting": {
           "type": "string"
          },
          "value": {},
          "source": {
           "type": "string",
           "enum": [
            "yours",
            "board",
            "default"
           ]
          }
         }
        }
       }
      }
     },
     "image": {
      "type": "object",
      "nullable": true,
      "description": "One picture per copper layer, all in one frame.",
      "properties": {
       "format": {
        "type": "string"
       },
       "origin_mm": {
        "type": "array",
        "items": {
         "type": "number"
        }
       },
       "size_mm": {
        "type": "array",
        "items": {
         "type": "number"
        }
       },
       "layers": {
        "type": "array",
        "items": {
         "type": "object",
         "properties": {
          "name": {
           "type": "string"
          },
          "url": {
           "type": "string"
          }
         }
        }
       },
       "note": {
        "type": "string"
       }
      }
     },
     "sections": {
      "type": "array",
      "items": {
       "type": "object",
       "properties": {
        "kind": {
         "type": "string",
         "enum": [
          "unconnected",
          "drc_errors",
          "jlc",
          "hot_spots",
          "dangling",
          "isolated"
         ]
        },
        "title": {
         "type": "string"
        },
        "total": {
         "type": "integer"
        },
        "truncated": {
         "type": "boolean"
        },
        "problems": {
         "type": "integer",
         "description": "Items that need a change (lower is better); hot_spots also lists the hottest sections within the limit."
        },
        "items": {
         "type": "array",
         "items": {
          "$ref": "#/components/schemas/ReportItem"
         }
        }
       }
      }
     }
    }
   },
   "ReportItem": {
    "type": "object",
    "properties": {
     "type": {
      "type": "string",
      "description": "KiCad's violation type, the fab check's own type (via_in_pad, acute_angle_acid_trap), or hot_spot."
     },
     "at_mm": {
      "type": "array",
      "items": {
       "type": "number",
       "nullable": true
      },
      "description": "x, y in mm as KiCad shows them."
     },
     "to_mm": {
      "type": "array",
      "items": {
       "type": "number",
       "nullable": true
      },
      "description": "unconnected: the other end."
     },
     "layer": {
      "type": "string",
      "nullable": true
     },
     "net": {
      "type": "string",
      "nullable": true
     },
     "critical": {
      "type": "boolean",
      "description": "unconnected: on a supply, ground, clock, reset, boot or debug net."
     },
     "reason": {
      "type": "string",
      "description": "unconnected: why the router left it (e.g. its net class is finer than JLC's minimums)."
     },
     "refs": {
      "type": "array",
      "items": {
       "type": "string"
      }
     },
     "description": {
      "type": "string",
      "nullable": true
     },
     "items": {
      "type": "array",
      "items": {
       "type": "string"
      },
      "description": "KiCad's descriptions of the items involved."
     },
     "rise_c": {
      "type": "number"
     },
     "current_a": {
      "type": "number"
     },
     "current_source": {
      "type": "string"
     },
     "over_limit": {
      "type": "boolean"
     },
     "what_to_do": {
      "type": "string",
      "description": "One sentence: the cause, then the KiCad command that fixes it."
     }
    }
   },
   "TimeBudget": {
    "type": "object",
    "description": "Jobs sent with minutes only: the attempts the job made and the one it kept.",
    "properties": {
     "minutes": {
      "type": "integer",
      "description": "The minutes asked for."
     },
     "attempts": {
      "type": "integer",
      "description": "Routing attempts started, the first round's 5 included."
     },
     "attempts_finished": {
      "type": "integer",
      "description": "Of those, attempts that produced a board and were ranked."
     },
     "rounds": {
      "type": "integer",
      "description": "Rounds run, the first one included."
     },
     "routing_s": {
      "type": "number",
      "description": "Seconds from the job's start until routing ended (the checks and report come after)."
     },
     "winner_attempt": {
      "type": "integer",
      "description": "Number of the attempt returned (1 to attempts)."
     },
     "winner": {
      "type": "string",
      "enum": [
       "standard",
       "work_x2",
       "work_x4",
       "alternative"
      ],
      "description": "Its kind: one of the 5 standard settings, one of them with twice or four times the work, or an alternative setting."
     },
     "winner_setting": {
      "type": "integer",
      "description": "Which setting of that kind (standard 1 to 5, alternative 1 to 20)."
     },
     "stopped": {
      "type": "string",
      "enum": [
       "complete",
       "time",
       "settings_used_up",
       "memory"
      ],
      "description": "Why no further attempt started: complete (no missing connection and no short or clearance error left), time (the next one could not finish in time), settings_used_up, memory (an attempt reached the job's memory limit)."
     },
     "summary": {
      "type": "string",
      "description": "The same in one plain sentence."
     }
    }
   },
   "OAuthError": {
    "type": "object",
    "required": [
     "error"
    ],
    "properties": {
     "error": {
      "type": "string",
      "description": "RFC 6749 / 7591 / 8628 error code, e.g. invalid_grant, invalid_client, authorization_pending, slow_down, expired_token, access_denied, invalid_redirect_uri"
     },
     "error_description": {
      "type": "string"
     }
    }
   }
  }
 }
}