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.slugis 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 alsoPOST /devices/to pre-provision one and get its per-device token back, which is what a factory flashing rig wants.PATCHupdates theactiveflag and metadata;DELETEremoves the device and its history, so prefer settingactive: falseunless 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.DELETEon the same path revokes it.Groups and channels are collection-only. There are no individual
GET /groups/{id}/orGET /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.
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:
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, 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:
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:
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:
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.