Skip to content

Developer API

CRUD endpoints for humans and CI pipelines. All endpoints accept either a personal API token (PAT) or a project-scoped token (constrained to that project).

Base path: /api/v1/ (project resources under /api/v1/projects/, personal tokens under /api/v1/me/)

Conventions

  • All requests/responses are JSON unless noted (artifact upload uses multipart/form-data).
  • Timestamps are ISO 8601 UTC.
  • Every path identifier is a UUID. project.slug is returned in responses as a human label, but no endpoint accepts it in place of the project id.
  • List endpoints return plain JSON arrays (no pagination wrapper).

Resource map

Resource Endpoint Methods
Projects /projects/ GET, POST
/projects/{id}/ GET, DELETE
Release (one-shot) /projects/{id}/release/ POST
Personal tokens /me/tokens/ GET, POST
/me/tokens/{tid}/ DELETE
Project tokens /projects/{id}/tokens/ GET, POST
/projects/{id}/tokens/{tid}/ DELETE
Signing keys /projects/{id}/signing-keys/ GET, POST
/projects/{id}/signing-keys/{key_id}/ DELETE
Devices /projects/{id}/devices/ GET, POST
/projects/{id}/devices/{did}/ GET, PATCH, DELETE
/projects/{id}/devices/{did}/token/ POST, DELETE
Groups /projects/{id}/groups/ GET, POST
/projects/{id}/groups/{gid}/members/ POST
/projects/{id}/groups/{gid}/members/{did}/ DELETE
Channels /projects/{id}/channels/ GET, POST
Artifacts /projects/{id}/artifacts/ GET, POST
/projects/{id}/artifacts/{aid}/ PATCH
Deployments /projects/{id}/deployments/ GET, POST
/projects/{id}/deployments/{did}/ GET
/projects/{id}/deployments/{did}/transition/ POST
/projects/{id}/deployments/{did}/states/ GET

Devices usually create themselves. The common path is auto-registration: a device appears the first time it calls /api/v1/ota/check/. You can also POST /devices/ to pre-provision one and get its per-device token back, which is what a factory flashing rig wants. PATCH updates the active flag and metadata; DELETE removes the device and its history, so prefer setting active: false unless you really want it gone.

Per-device tokens are issued and rotated with POST /devices/{did}/token/. The raw value is returned once and never again. DELETE on the same path revokes it.

Groups and channels are collection-only. There are no individual GET /groups/{id}/ or GET /channels/{id}/ endpoints; fetch the list and filter client-side. This is also the only way to discover a channel's UUID, which CI needs to target a deployment.

Deleting a project requires a personal access token; a project-scoped token cannot delete its own project. It is refused while any deployment is active or paused.

Selected endpoints

GET /projects/{id}/devices/

Returns every device in the project, including the full set of compatibility dimensions the decision engine matches on. If a device is not being offered a build, compare these against the artifact's manifest to see which dimension excludes it (see compatibility).

{
  "device_id":            "esp32-demo-0001",
  "framework":            "esp_idf",
  "chip_family":          "esp32",
  "board_id":             "esp32-devkitc",
  "hardware_revision":    "",
  "partition_profile":    "",
  "nvs_schema_version":   1,
  "security_mode":        "signed",
  "current_build_number": 2,
  "current_version_label":"v2.0",
  "last_ota_status":      "confirmed",
  "active":               true,
  "last_seen":            "2026-07-24T20:52:00Z"
}

security_mode is what a device reports about itself, so it is the field to watch during a basic to signed migration: devices still reporting basic have not yet taken the key-aware build.

POST /projects/{id}/artifacts/

Uploads a firmware artifact. multipart/form-data only.

curl -X POST https://simpleota.com/api/v1/projects/${PID}/artifacts/ \
  -H "Authorization: Bearer ${TOKEN}" \
  -F "manifest=$(jq -c . manifest.json)" \
  -F "[email protected]"

Form fields:

Field Type Notes
manifest string (JSON) Validated against the manifest schema.
binary file Streamed to object storage; SHA-256 computed.

Response 201:

{
  "id":               "8e1f…",
  "build_number":     24,
  "version_label":    "1.4.2",
  "framework":        "arduino",
  "chip_family":      "esp32",
  "board_id":         "esp32dev",
  "checksum_sha256":  "8f3c…",
  "file_size":        1287456,
  "security_mode":    "basic",
  "archived":         false,
  "created_at":       "2026-04-25T17:21:00Z"
}

Failure modes, all 400 with the message in detail:

  • "manifest must be JSON: …" or "manifest invalid: …" when the manifest does not parse or does not validate. Note the manifest rejects unknown keys, so a misspelled field fails here rather than being ignored.
  • "monthly artifact upload limit reached for current plan; upgrade your plan to upload more."

Plan limits apply to device count and uploads per month. Binary size is not capped.

PATCH /projects/{id}/artifacts/{aid}/

Archives an artifact, or returns it to circulation. The only field is archived.

{ "archived": true }

An archived build is never offered to a device and cannot be chosen for a new deployment, while the record of which devices ran it is kept. Artifacts are never deleted.

Returns 400 while a draft, active or paused deployment still references the build:

{ "detail": "1 deployment still reference this artifact; cancel or complete them first." }

That keeps a deployment's own state the single answer to what it can reach.

POST /projects/{id}/release/

Upload, create a deployment and start it, in one call. This is what the SimpleOTA GitHub Actions use for a ship-on-merge pipeline. multipart/form-data only.

curl -X POST https://simpleota.com/api/v1/projects/${PID}/release/ \
  -H "Authorization: Bearer ${TOKEN}" \
  -F "manifest=$(jq -c . manifest.json)" \
  -F "[email protected]" \
  -F "channel_name=stable" \
  -F "rollout_percentage=100"

Form fields:

Field Type Default Notes
manifest string (JSON) required Same schema as the artifact upload.
binary file required The application image.
channel_name string stable By name, not id. Must already exist.
rollout_percentage int 100 0..100.

Response 201:

{
  "artifact_id":   "8e1f…",
  "build_number":  24,
  "deployment_id": "b93c…",
  "state":         "active",
  "channel":       "stable"
}

Unlike POST /deployments/, the resulting deployment is already started, and the channel is named rather than addressed by UUID. Use the three-call flow (artifactsdeploymentstransition) when you need canary percentages, device-group targeting, or a human approval step in between.

POST /projects/{id}/deployments/

{
  "artifact_id":          "8e1f…",
  "target_group_ids":     ["<group-uuid>"],
  "target_channel_ids":   ["<channel-uuid>"],
  "rollout_percentage":   5,
  "rollout_strategy":     "immediate",
  "notes":                ""
}

artifact_id is required, and so is an audience: you must supply at least one of target_channel_ids or target_group_ids. A deployment that targets neither matches no devices, so it could never ship; the API rejects it with 400 rather than creating one that silently does nothing. There is no endpoint to attach targets after creation.

Most fleets target the default channel

The dashboard's quick upload applies the project's default channel for you. From the API this is explicit: fetch GET /projects/{id}/channels/, take the entry with "is_default": true, and pass its id in target_channel_ids. Devices belong to a channel (stable by default), so this is the usual way to reach a whole fleet.

The remaining fields are optional. Deployments are created in draft state; call /transition/ with {"action": "start"} to activate. Starting also requires a non-empty audience, so a deployment created before this rule existed fails with 400 at start rather than shipping to nobody. Returns the new deployment object.

GET /projects/{id}/deployments/{did}/states/

Returns per-device rollout state records for the deployment; one entry per device that has been offered the update or reported a result:

[
  {
    "device_id":    "d7a2…",
    "status":       "validated",
    "updated_at":   "2026-04-25T18:05:11Z"
  }
]

status values: pending, offered, download_started, downloaded, flashed, validated, reboot, confirmed, failed, rolled_back. Use this to monitor rollout health without polling individual devices. confirmed is the terminal success state; failed and rolled_back are the terminal failure states.

POST /projects/{id}/deployments/{did}/transition/

Transitions a deployment's state. Payload:

{ "action": "start" }

Valid action values: start, pause, resume, cancel, complete, ramp (requires rollout_percentage). Invalid transitions return 400.

~~POST /projects/{id}/deployments/{did}/pause/~~

Removed. Use POST /transition/ with {"action": "pause"} instead.

Errors

Errors carry a single detail key:

{ "detail": "monthly artifact upload limit reached for current plan; …" }

Match on the HTTP status; detail is a human-readable message whose wording may change. Request-body schema failures return 422 with detail as an array rather than a string. See Errors.