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

MethodPathPurpose
GET/benchpod/devicesList 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/devicesRegister a pod (benchpod register does this): name, public key; links it to your organization
PATCH/benchpod/devices/{id}Rename a pod
GET/benchpod/devices/movableThe 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}/moveMove 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}/deregisterDetach 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}/firmwareThe pod's own running firmware version
GET/benchpod/compatMinimum supported versions (firmware, SDK, CLI) and the latest firmware release; no authentication
GET/benchpod/firmware/latestThe newest firmware release, or null when the release check is off
GET/benchpod/firmware/releasesThe releases POST /ota?release= can install, newest first
GET/benchpod/shipmentsPods on their way to you, with carrier and tracking
POST/benchpod/firmware-eventsReport 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

MethodPathPurpose
POST/benchpod/devices/{id}/commandOne 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}/leaseTake 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/renewRenew your lease
POST/benchpod/devices/{id}/lease/releaseRelease your lease
POST/benchpod/devices/{id}/terminal/sessionsOpen a viewer session on the pod; then connect to /terminal/ws?session_id=… (409 when the pod is offline)
WS/terminal/wsThe terminal stream for a session from …/terminal/sessions
WS/benchpod/devices/{id}/uart/wsLive UART bridge to the DUT on the wiring profile's UART pins
GET/benchpod/devices/{id}/powerLast known target-power state (stored; never contacts the pod)
POST/benchpod/devices/{id}/psram/recoverReboot the pod to recover an inoperable PSRAM datapath (status.psram_ok false); a timeout means it worked
POST/benchpod/devices/{id}/speedtestCloud throughput test: ?dir=up|down|both&bytes=N[&window_kb=W]

Wiring

MethodPathPurpose
GET/benchpod/devices/{id}/wiringThe saved wiring map (which LA channel each role is on, baud, I²C address, power rail)
PUT/benchpod/devices/{id}/wiringValidate and save the wiring map; a rejected map answers 400 {"error":"invalid wiring","fields":[…]}
GET/benchpod/devices/{id}/wiring/profileThe effective profile: defaults overlaid with the saved map, plus warnings
GET/benchpod/wiring/defaultsThe 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.

MethodPathPurpose
POST/benchpod/devices/{id}/analog/pathApply a named analog path: {"path":"off|3v3|5v|12v|ext|cal1|cal2|current_in|current_out"}
POST/benchpod/devices/{id}/analog/dacRoute a DAC output and set a calibrated voltage: {"path":"5v","volts":3.3} → {path,mv,code}
POST/benchpod/devices/{id}/analog/adcOne calibrated ADC reading: {"source":"ext"} → {source,mv,count}
POST/benchpod/devices/{id}/analog/currentThe 4-20 mA output (J9): {"ua":12000}, or {} for the range
POST/benchpod/devices/{id}/analog/calibrateThe 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-pointThe 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.

MethodPathPurpose
POST/capture/startSimultaneous 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/startADC (scope) capture: samples, sample_rate_mhz
POST/scope/measure/startStimulus + capture (firmware measure): waveform, freq, amplitude, offset, samples
POST/la/captures/startRaw 14-channel LA capture: samples, sample_rate_mhz, stop_dac_after_us
POST/analyzer/captures/startI²C logic-analyzer capture with decoding: protocol, duration_ms, sample_rate
POST/analyzer/decodeDecode 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}/cancelStop a running capture (idempotent)
WS/ws/captureLive capture stream (ADC, LA and I²C frames tagged by stream); auth via ?token=

DAC replay and the waveform library

MethodPathPurpose
POST/dac/replay/startReplay 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/waveformsList the waveform library
POST/benchpod/waveformsSave a sampled waveform: name, samples_b64, sample_rate_hz, bits, full_scale_v
POST/benchpod/waveforms/segmentsSave a waveform built from segments (ramp, hold, …) for an output path
POST/benchpod/waveforms/recordingStore 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}/recordingDownload a recording's raw samples
GET/benchpod/waveforms/{id}/previewDownsampled preview of what replay will drive: ?mapping=faithful|fit&dac_path=…&points=N
GET/benchpod/loop-presetsList saved control-loop settings
POST/benchpod/loop-presetsSave control-loop settings under a name (replaces one with the same name)
DELETE/benchpod/loop-presets/{id}Delete a control-loop preset

Flashing

MethodPathPurpose
GET/benchpod/devices/{id}/flashable-artifactsList build artifacts that can be flashed
POST/benchpod/devices/{id}/flashFlash 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/artifactResolve ?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/idRead the DUT's SPI flash ID and size
POST/benchpod/devices/{id}/spi/flashErase, write and verify an image in the DUT's SPI flash (returns 202; runs in the background)
GET/benchpod/devices/{id}/spi/flashPoll the SPI flash job: phase, percent, bytes, ETA
POST/benchpod/devices/{id}/spi/flash/cancelStop the SPI flash job
POST/benchpod/devices/{id}/spi/flash/readRead 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.

MethodPathPurpose
POST/benchpod/devices/{id}/otaUpdate 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}/otaPoll the running update: phase, step, steps, percent, time left, bytes
POST/benchpod/devices/{id}/ota/abortDrop a staged image and release the pod
GET/benchpod/devices/{id}/ota/historyThe pod's finished updates, newest first
GET/benchpod/devices/{id}/lan-policyThe 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-policySet LAN access: {"policy":"open|locked|off"}
GET/benchpod/devices/{id}/lan-tlsThe 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-policyThe pod's firmware signature policy: audit, permissive or required
PUT/benchpod/devices/{id}/sig-policyMake 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.

MethodPathPurpose
POST/benchpod/devices/{id}/challengeA single-use nonce the pod signs to authenticate (no authentication; useless without the pod's private key)
WS/benchpod/wsThe 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/flash

For 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
MethodPathPurpose
POST/auth/tokenAPI key → short-lived cloud_session JWT (optional devices[] + ttl_seconds)
POST/github/oidc/tokenGitHub Actions OIDC token → short-lived session token for a trusted repo (no API key; the OIDC signature is the credential)
POST/github/oidc/validateCheck a GitHub Actions OIDC token against your trusted repos without minting anything
GET/github/trusted-reposList the repos trusted to mint tokens for your organization
POST/github/trusted-reposTrust 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/commandOne command on a pod by name (X-Benchpod-Device)
WS/cloud/devices/wsRaw byte-tunnel for flashing / captures
POST/cloud/devices/leaseAcquire the pod lease (one consumer at a time)
POST/cloud/devices/lease/renewRenew the lease while you work
POST/cloud/devices/lease/releaseRelease the lease
POST/cloud/buildsRecord an external (CI) build for the token's organization; returns its id
POST/cloud/builds/{id}/artifactsUpload a firmware artifact to that build
POST/cloud/builds/{id}/logsUpload a log to that build
POST/cloud/builds/{id}/statusReport 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>.