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
Users /users?email= GET
Project audit /projects/{id}/audit/ GET
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
Device checks /projects/{id}/devices/{did}/check-events/ GET
Groups /projects/{id}/groups/ GET, POST
/projects/{id}/groups/{gid}/members/ GET, 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.

Staff personal tokens may read another owner's project resources, including the audit list, but may write only to their own projects. Staff project lists require an exact email or an owner_id query parameter. GET /users?email= is a staff-only exact email lookup. These read-only support requests are excluded from the customer's audit log. Staff mutations remain owner-scoped, and normal customer mutations continue to emit their documented audit events.

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

Optional query parameters:

Parameter Values Behavior
active Boolean Filters by active status.
group_id Group UUID Returns devices in the group. An unknown or cross-project UUID returns an empty list.
channel_id Channel UUID Returns devices on the channel. An unknown or cross-project UUID returns an empty list.

When group_id and channel_id are both set, a device must match both.

{
  "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",
  "last_check_at":        "2026-07-24T20:52:00Z",
  "last_check_status":    "up_to_date",
  "check_state":          "no_pending_check",
  "group_ids":            ["1a2652ac-83d5-4b52-9026-70f59121f331"]
}

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.

last_check_at and last_check_status describe the most recently recorded OTA decision. check_state describes whether a new check is expected:

  • never_seen: the device has no heartbeat or recorded check.
  • waiting_for_next_check: an active deployment targets the device and no check or offer has been recorded since that deployment started.
  • no_pending_check: the API has no evidence that the device owes a check. This says nothing about device activity, compatibility, token requirements, or whether an update is available. Use last_check_status for the latest decision outcome.

group_ids lists the device's groups in ascending group-name order, with the group UUID as a stable tie-breaker. Devices without groups return an empty list. The same fields are returned by the device detail, create, update, and group-members endpoints.

GET /projects/{id}/devices/{did}/check-events/

Returns the device's recorded OTA decisions in deterministic newest-first order. The write path retains the newest 20 records per device without a time expiry. Recording is best effort so an observability failure does not change an otherwise valid OTA response.

Optional query parameters:

Parameter Values Behavior
limit Integer Defaults to 20 and is capped at 20.
since ISO 8601 timestamp Includes records created at or after the timestamp.
decision offered, no_update Filters the top-level decision.
status offered, up_to_date, no_compatible_build, device_inactive, over_limit, not_in_rollout, no_active_deployment, token_required Filters the detailed outcome.

Each record includes id, project_id, device_id, created_at, decision, status, optional deployment_id, optional artifact_build_number, optional reported_build_number, reported_version, and message.

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 (artifacts → deployments → transition) 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. target_group_ids and target_channel_ids are optional. Within each list, any id matches. When both lists are non-empty, the device must match a group and a channel. An empty list is not a filter on that axis. Leaving both lists empty includes every device in the project. 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 a deployment that names neither groups nor channels includes every device in the project. When that project has two or more devices, start and resume require "confirm_all": true. A project with no devices cannot start that deployment. 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", "confirm_all": true }

Valid action values: start, pause, resume, cancel, complete, ramp (requires rollout_percentage). confirm_all is required on start and resume when the deployment names neither groups nor channels and the project has two or more devices. 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.