HTTP API
Everything the web app does is a plain HTTP API you can call from scripts or CI: submitting build/test jobs and driving your pods (power, capture, signal generation, flashing, the waveform library). All paths below are under your server's /api base (e.g. https://www.embeddedci.com/api).
Authentication
Create a personal API key and send it as an Authorization: ApiKey header. A key authenticates as you, scoped to your organization. It is not limited to job submission; it works across the whole API, including the BenchPod endpoints.
# Create a key on the API keys page, then send it on every request: Authorization: ApiKey eci_<kid>_<secret>
Keys carry scopes, enforced per endpoint: jobs:submit for job submission, benchpod:control for the BenchPod endpoints (pods, command, capture, waveforms, DAC, and the /auth/token exchange), aivision:control for AI Vision boards (registering and driving them; it never reaches a pod, and benchpod:control never reaches a board), and emi:analyze for the EMI Analyzer (board upload and analysis runs; see EMI analysis in CI). Pick scopes when you create a key, or edit them later, on the API keys page. A key missing the required scope gets a 403. (Scopes constrain API keys only; interactive web sessions are unrestricted.)
BenchPod firmware updates (/ota, including release installs and aborts), persisted settings (Wi-Fi, cloud, Ethernet, DAC safety limits, calibration, the pod's LAN and signature policies) and every other T2/T3 command need an organization owner or admin. With an API key that means the benchpod:admin scope on top of benchpod:control, and the key's owner must still be an owner or admin; even an owner's key without the scope gets a 403. Only organization owners and admins can give a key benchpod:admin, and it is never a default. A cloud token from /auth/token keeps this only when the key or session that minted it has it; GitHub Actions OIDC tokens never do (reads and instrument control only).
Direct pod access (REST)
These accept your API key directly, with no exchange step. This is the same surface the BenchPod page uses. You address a pod by its id (from GET /benchpod/devices). Every path needs the benchpod:control scope; the ones marked benchpod:admin also need an organization owner or admin. Commands that touch the pod answer 409 when it is offline or another user or CI run holds its lease, and pass the pod's own refusal on as the error message (see the firmware command reference).
Pods
| Method | Path | Purpose |
|---|---|---|
GET | /benchpod/devices | List your pods with online state, capabilities, firmware and lease. parameters also carries the setup milestones the server records: setup.first_flash_at and setup.first_run_at (RFC 3339 times of the first flash and the first cloud session, API command or capture), which a register cannot set |
POST | /benchpod/devices | Register a pod (benchpod register does this): name, public key; links it to your organization |
PATCH | /benchpod/devices/{id} | Rename a pod |
GET | /benchpod/devices/movable | The pods you can move into your active organization: yours in your other organizations, plus every pod of the organizations you are an owner or admin of. Each has id, name, organization_id, organization_name, owner_user_id and online |
POST | /benchpod/devices/{id}/move | Move a pod into another organization you are an owner or admin of: organization_id, and an optional name to rename it on the way (409 when the name is taken there). You must own the pod or be an owner or admin of its organization. The pod stays connected and keeps its history, wiring and owner; everyone in the new organization can use it. A signed-in session only, not an API key |
POST | /benchpod/devices/{id}/deregister | Detach a pod from your account but keep its history, so another account can register it; registering it again brings the history back |
DELETE | /benchpod/devices/{id} | Remove a pod and its data |
GET | /benchpod/devices/{id}/firmware | The pod's own running firmware version |
GET | /benchpod/compat | Minimum supported versions (firmware, SDK, CLI) and the latest firmware release; no authentication |
GET | /benchpod/firmware/latest | The newest firmware release, or null when the release check is off |
GET | /benchpod/firmware/releases | The releases POST /ota?release= can install, newest first |
GET | /benchpod/shipments | Pods on their way to you, with carrier and tracking |
POST | /benchpod/firmware-events | Report a firmware change made outside the server (the benchpod CLI does this after flash-self, an OTA over USB or the LAN, or install-blobs, unless BENCHPOD_NO_FIRMWARE_REPORT=1 or --no-firmware-report): public_key, kind (firmware or blobs), old_version, new_version, old_gateware, new_gateware, blobs (with either kind: flash-self also installs blobs), method (dfu, usb_network, usb_console, lan or cloud), cli_version, at. Linked to the pod only when you can access it. Returns 201 {"id"} |
Commands, lease and terminal
| Method | Path | Purpose |
|---|---|---|
POST | /benchpod/devices/{id}/command | One firmware JSON command, e.g. {"command":{"cmd":"status"}}: ping, status, target_power, la, gpio, generate, dac_stop, can_*, … (T2/T3 commands need benchpod:admin) |
POST | /benchpod/devices/{id}/lease | Take the pod lease: {"lease_id":"<uuid>","ttl_seconds":N}; present it as X-Benchpod-Lease (or ?lease_id=) on your other calls |
POST | /benchpod/devices/{id}/lease/renew | Renew your lease |
POST | /benchpod/devices/{id}/lease/release | Release your lease |
POST | /benchpod/devices/{id}/terminal/sessions | Open a viewer session on the pod; then connect to /terminal/ws?session_id=… (409 when the pod is offline) |
WS | /terminal/ws | The terminal stream for a session from …/terminal/sessions |
WS | /benchpod/devices/{id}/uart/ws | Live UART bridge to the DUT on the wiring profile's UART pins |
GET | /benchpod/devices/{id}/power | Last known target-power state (stored; never contacts the pod) |
POST | /benchpod/devices/{id}/psram/recover | Reboot the pod to recover an inoperable PSRAM datapath (status.psram_ok false); a timeout means it worked |
POST | /benchpod/devices/{id}/speedtest | Cloud throughput test: ?dir=up|down|both&bytes=N[&window_kb=W] |
Wiring
| Method | Path | Purpose |
|---|---|---|
GET | /benchpod/devices/{id}/wiring | The saved wiring map (which LA channel each role is on, baud, I²C address, power rail) |
PUT | /benchpod/devices/{id}/wiring | Validate and save the wiring map; a rejected map answers 400 {"error":"invalid wiring","fields":[…]} |
GET | /benchpod/devices/{id}/wiring/profile | The effective profile: defaults overlaid with the saved map, plus warnings |
GET | /benchpod/wiring/defaults | The schema defaults and the pod's fixed LA pull resistors (no pod needed) |
Analog
Owner- and lease-gated like /command. Need the analog front end; the digital-only board refuses them.
| Method | Path | Purpose |
|---|---|---|
POST | /benchpod/devices/{id}/analog/path | Apply a named analog path: {"path":"off|3v3|5v|12v|ext|cal1|cal2|current_in|current_out"} |
POST | /benchpod/devices/{id}/analog/dac | Route a DAC output and set a calibrated voltage: {"path":"5v","volts":3.3} → {path,mv,code} |
POST | /benchpod/devices/{id}/analog/adc | One calibrated ADC reading: {"source":"ext"} → {source,mv,count} |
POST | /benchpod/devices/{id}/analog/current | The 4-20 mA output (J9): {"ua":12000}, or {} for the range |
POST | /benchpod/devices/{id}/analog/calibrate | The pod's own calibration: {} reads it, {"source":"current_in"} measures the ADC offset, {"dac":"5v","code1","uv1","code2","uv2"} stores a DAC line from two DMM readings, {"adc":"cal1"} measures an ADC gain (cal1, cal2, ext), {"clear":true} removes it all and {"clear":"offset"|"dac"|"adc"} one part (the DMM forms need firmware that announces cal_dmm; benchpod:admin to change). Any form may add "method" (dmm, reference or restore, with "reference_uv" for a reference) to record how the change was made in the pod's calibration history |
GET | /benchpod/devices/{id}/analog/calibration/restore-point | The calibration the pod had before its latest reference calibration: {restore_point:{recorded_at,detail:{dac,adc_gain_ppm},...},reference_at}, both null when there is none |
Captures and decoding
Start a capture, then read it from /ws/capture (live) or GET /capture/{id}. Bodies name the pod with device_id.
| Method | Path | Purpose |
|---|---|---|
POST | /capture/start | Simultaneous ADC + LA capture off one trigger: adc_samples, adc_rate_mhz, la_samples, la_rate_mhz, stop_dac_after_us, adc_source (ext|current_in) |
POST | /scope/captures/start | ADC (scope) capture: samples, sample_rate_mhz |
POST | /scope/measure/start | Stimulus + capture (firmware measure): waveform, freq, amplitude, offset, samples |
POST | /la/captures/start | Raw 14-channel LA capture: samples, sample_rate_mhz, stop_dac_after_us |
POST | /analyzer/captures/start | I²C logic-analyzer capture with decoding: protocol, duration_ms, sample_rate |
POST | /analyzer/decode | Decode an LA capture you already have (I²C, UART or SPI) without a pod: {"words":[...]} one per sample, or {"edges":[[index,word],...],"count":N} as run-length edges for a deep capture (up to 32 MB a request) |
GET | /capture/{id} | A capture's frames for replay (ADC + LA + I²C) |
POST | /capture/{id}/cancel | Stop a running capture (idempotent) |
WS | /ws/capture | Live capture stream (ADC, LA and I²C frames tagged by stream); auth via ?token= |
DAC replay and the waveform library
| Method | Path | Purpose |
|---|---|---|
POST | /dac/replay/start | Replay a saved (waveform_id) or inline (samples_b64) waveform out the DAC: sample_rate_mhz, dac_path, mapping, window, fault injection, on_capture |
GET | /benchpod/waveforms | List the waveform library |
POST | /benchpod/waveforms | Save a sampled waveform: name, samples_b64, sample_rate_hz, bits, full_scale_v |
POST | /benchpod/waveforms/segments | Save a waveform built from segments (ramp, hold, …) for an output path |
POST | /benchpod/waveforms/recording | Store a raw ADC recording (metadata in the query, 16-bit samples as the body) |
GET | /benchpod/waveforms/{id} | Fetch a saved waveform |
PATCH | /benchpod/waveforms/{id} | Rename a waveform: {"name":"…"} |
DELETE | /benchpod/waveforms/{id} | Delete a saved waveform |
GET | /benchpod/waveforms/{id}/recording | Download a recording's raw samples |
GET | /benchpod/waveforms/{id}/preview | Downsampled preview of what replay will drive: ?mapping=faithful|fit&dac_path=…&points=N |
GET | /benchpod/loop-presets | List saved control-loop settings |
POST | /benchpod/loop-presets | Save control-loop settings under a name (replaces one with the same name) |
DELETE | /benchpod/loop-presets/{id} | Delete a control-loop preset |
Flashing
| Method | Path | Purpose |
|---|---|---|
GET | /benchpod/devices/{id}/flashable-artifacts | List build artifacts that can be flashed |
POST | /benchpod/devices/{id}/flash | Flash an uploaded build onto the DUT over the pod's CMSIS-DAP probe; OpenOCD runs on embeddedci.com. Name the file (job_id, pack_id, artifact_name) or the build (build, repo, ref, artifact) |
GET | /benchpod/builds/artifact | Resolve ?build=, ?repo=, ?ref= and ?artifact= to the one uploaded file a flash would use (newest matching build; .elf before .hex before .bin) |
GET | /benchpod/builds/{job_id}/packs/{pack_id}/artifacts/{name...} | Download that file, to flash it with a local OpenOCD |
POST | /benchpod/devices/{id}/spi/flash/id | Read the DUT's SPI flash ID and size |
POST | /benchpod/devices/{id}/spi/flash | Erase, write and verify an image in the DUT's SPI flash (returns 202; runs in the background) |
GET | /benchpod/devices/{id}/spi/flash | Poll the SPI flash job: phase, percent, bytes, ETA |
POST | /benchpod/devices/{id}/spi/flash/cancel | Stop the SPI flash job |
POST | /benchpod/devices/{id}/spi/flash/read | Read up to 128 KB of the SPI flash back as a file |
Pod firmware and policies
benchpod:admin, and the caller must be an organization owner or admin.
| Method | Path | Purpose |
|---|---|---|
POST | /benchpod/devices/{id}/ota | Update the pod: the raw .bin as the body, or ?release=<tag> (firmware plus its blobs); ?target=gw0|gw1|esp|bridge|ca for one blob (bridge: the Wi-Fi bridge box's firmware); ?commit=false stages and verifies only. Returns 202 |
GET | /benchpod/devices/{id}/ota | Poll the running update: phase, step, steps, percent, time left, bytes |
POST | /benchpod/devices/{id}/ota/abort | Drop a staged image and release the pod |
GET | /benchpod/devices/{id}/ota/history | The pod's finished updates, newest first |
GET | /benchpod/devices/{id}/lan-policy | The pod's LAN access policy: open, locked or off. With firmware that has the USB network setting, also usb_network {"setting":"on|off","net":"on|off","off_reason":"setting|wifi"}, read-only (only the device changes it) |
PUT | /benchpod/devices/{id}/lan-policy | Set LAN access: {"policy":"open|locked|off"} |
GET | /benchpod/devices/{id}/lan-tls | The pod's API over TLS (firmware 3.9.3+): supported, live, port (8443), https_only, sessions, and its certificate: origin, key, subject, san, not_after, sha256 (what clients pin) |
PUT | /benchpod/devices/{id}/lan-tls | {"https_only":true|false} turns plaintext 8080 off or on for Ethernet and Wi-Fi; {"regenerate":true} makes a new key and self-signed certificate |
GET | /benchpod/devices/{id}/sig-policy | The pod's firmware signature policy: audit, permissive or required |
PUT | /benchpod/devices/{id}/sig-policy | Make the signature policy stricter (only the pod's USB console loosens it) |
Used by the pod itself
The firmware calls these; you do not need them in a client.
| Method | Path | Purpose |
|---|---|---|
POST | /benchpod/devices/{id}/challenge | A single-use nonce the pod signs to authenticate (no authentication; useless without the pod's private key) |
WS | /benchpod/ws | The pod's control connection, authenticated by the signed challenge |
# List your pods
curl -H "Authorization: ApiKey $ECI_KEY" \
https://www.embeddedci.com/api/benchpod/devices
# Power the target on (efuse 1 = internal 5V) via the generic command channel
curl -H "Authorization: ApiKey $ECI_KEY" -H 'Content-Type: application/json' \
-d '{"command":{"cmd":"target_power","efuse":1,"state":1}}' \
https://www.embeddedci.com/api/benchpod/devices/<device_id>/command
# Start an ADC (scope) capture, then poll the result
curl -H "Authorization: ApiKey $ECI_KEY" -H 'Content-Type: application/json' \
-d '{"device_id":"<id>","samples":1024,"sample_rate_mhz":1}' \
https://www.embeddedci.com/api/scope/captures/start
curl -H "Authorization: ApiKey $ECI_KEY" \
https://www.embeddedci.com/api/capture/<capture_id>SPI flash
The pod can program a 25-series SPI NOR flash on the DUT (W25Q, MX25, GD25, IS25 and similar, first 16 MB) over four LA pins. It needs a pod that announces spi_master. The pins come from the wiring profile (spi_sclk, spi_mosi, spi_miso, spi_cs); sck, mosi, miso, cs, hz (default 1 MHz), mode (0 or 3) and hold_reset (default true) override them per request. The start address must be a multiple of 4 KB. The DUT is held in reset during the job and released at the end, also on failure. Over the cloud expect about 40 KB/s (25 to 30 seconds per MB); blank (0xFF) areas are skipped. The same panel is on the Flash DUT tab.
# Read the chip ID (pins from the wiring profile's spi_* keys)
curl -X POST -H "Authorization: ApiKey $ECI_KEY" \
https://www.embeddedci.com/api/benchpod/devices/<device_id>/spi/flash/id
# -> {"id":"ef4017","present":true,"size":8388608,"manufacturer":"Winbond",...}
# Write an image at 0x10000 (the body is the raw .bin), then poll until "live" is false
curl -H "Authorization: ApiKey $ECI_KEY" -H 'Content-Type: application/octet-stream' \
--data-binary @app.bin \
"https://www.embeddedci.com/api/benchpod/devices/<device_id>/spi/flash?addr=0x10000&name=app.bin"
curl -H "Authorization: ApiKey $ECI_KEY" \
https://www.embeddedci.com/api/benchpod/devices/<device_id>/spi/flashFor the live WebSocket streams, pass the key in the ?token= query parameter (an eci_ token is recognized as an API key):
# Live capture/terminal streams take the key as a ?token= query param # (eci_ tokens are recognised as API keys): wss://www.embeddedci.com/api/ws/capture?device_id=<id>&token=$ECI_KEY
Capabilities gate some features per board: e.g. generate/replay require an AC waveform DAC and are rejected on DC-only boards.
Drive pods by name (cloud): token exchange
The cloud endpoints address a pod by name and add a one-consumer-at-a-time lease and a raw byte-tunnel (for flashing/captures from CI). They take a short-lived session token, not your static key, so a long-lived secret never ends up in a WebSocket URL or a CI log. Mint one from your API key with POST /auth/token (the API-key analogue of the GitHub Actions OIDC exchange).
# 1) Exchange your API key for a short-lived cloud-session token.
# Omit the body for any pod in your org, or scope it down:
curl -H "Authorization: ApiKey $ECI_KEY" -H 'Content-Type: application/json' \
-d '{"devices":["pod-a"],"ttl_seconds":3600}' \
https://www.embeddedci.com/api/auth/token
# -> { "token": "<jwt>", "token_type": "bearer", "expires_at": "...", "expires_in": 3600 }
# 2) Drive a pod BY NAME with the minted token (Bearer):
curl -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-H 'X-Benchpod-Device: pod-a' \
-d '{"cmd":"status"}' \
https://www.embeddedci.com/api/cloud/devices/command
# WebSocket byte-tunnel (flash / capture) — token in the query string is a
# short-lived JWT, never your static key:
wss://www.embeddedci.com/api/cloud/devices/ws?device=pod-a&token=$TOKEN| Method | Path | Purpose |
|---|---|---|
POST | /auth/token | API key → short-lived cloud_session JWT (optional devices[] + ttl_seconds) |
POST | /github/oidc/token | GitHub Actions OIDC token → short-lived session token for a trusted repo (no API key; the OIDC signature is the credential) |
POST | /github/oidc/validate | Check a GitHub Actions OIDC token against your trusted repos without minting anything |
GET | /github/trusted-repos | List the repos trusted to mint tokens for your organization |
POST | /github/trusted-repos | Trust a repo (signed-in session only, never an API key) |
DELETE | /github/trusted-repos/{id} | Stop trusting a repo (signed-in session only) |
POST | /cloud/devices/command | One command on a pod by name (X-Benchpod-Device) |
WS | /cloud/devices/ws | Raw byte-tunnel for flashing / captures |
POST | /cloud/devices/lease | Acquire the pod lease (one consumer at a time) |
POST | /cloud/devices/lease/renew | Renew the lease while you work |
POST | /cloud/devices/lease/release | Release the lease |
POST | /cloud/builds | Record an external (CI) build for the token's organization; returns its id |
POST | /cloud/builds/{id}/artifacts | Upload a firmware artifact to that build |
POST | /cloud/builds/{id}/logs | Upload a log to that build |
POST | /cloud/builds/{id}/status | Report the build's test outcome |
Not covered here
Some routes are internal to the web app, the CLI or our operations, and may change without notice: the CLI sign-in flow (/auth/device/*), guest sessions (/auth/guest*, /auth/benchpod/revoke-refresh), the trusted-repo form's repo lookup, and everything under /admin, /agent and /hw-agent.
Higher-level clients
Most users don't call these endpoints by hand. The pytest SDK, CLI, and MCP server wrap them (auth, leasing, tunnels, chunk reassembly). Reach for those first; drop to the raw API for custom integrations. The Python SDK performs the /auth/token exchange for you: set BENCHPOD_API_KEY (or pass api_key=) and connect to embeddedci:<name>.