API Reference
This page maps the routes: method, path, who may call it. API Routes, API Schemas and openapi.json are generated from the contracts and carry every field. Here is what they cannot say.
Two APIs, one command surface
- REST (
api.fleetless.dev) — polling, history, action and service calls, publishes, snapshots, URDF and assets, and users. - Realtime (WebSocket, same host,
/realtime) — subscriptions on datapoints, action state andbridge_state, plus full command parity: everything REST can do works over the same connection.
Every subscriber of a datapoint gets its configured send rate, not one stream each.
Errors
Every rejection carries a stable error code. Validation failures name the field and the rule.
Plan errors
A plan limit or a feature the plan does not have is refused with its own code,
and the details say what lifts it. See Plans and Limits.
| Code | Status | Meaning |
|---|---|---|
plan_limit |
409 |
The request would pass a limit of the org’s plan. details: limit, used, max, plan (the plan that refused; during a pending downgrade, the target plan) and lifted_by (plan and addon, each null when it does not apply). Refused before anything is written. An asset upload that does not fit the storage also carries store_bytes, used_bytes and size_bytes. |
plan_required |
403 |
The feature is not on the org’s plan. details: feature, plan (the org’s own) and required_plan (the cheapest plan that has it). |
org_locked |
403 |
The org is locked and may only move to Basic. details.reason is payment or migration. |
Billing errors
The /api/billing routes are owner-only: a developer gets 403 tier_required.
Billing refusals name the field and the rule, as validation errors do.
A refused or unanswered payment changes nothing. See Paying.
| Code | Status | Meaning |
|---|---|---|
billing_unavailable |
503 |
This cloud takes no payments, because no payment provider is configured. Retrying does not help. |
payment_provider_unavailable |
502 |
Mollie did not answer, or answered with an error, so nothing changed. If a plan change or an add-on purchase was charged anyway, the charge is refunded automatically within about an hour, with an email. details: provider (mollie) and status (Mollie’s HTTP status, null for a timeout). Retrying may work. |
The validation_error rules a billing route can name:
| Field | Rule | Meaning |
|---|---|---|
billing.address.country |
eu_person |
A person in another EU country cannot pay. Companies with a valid VAT ID can. |
billing.vat_id |
vat_id_required |
A company in another EU country needs a VAT ID. |
billing.vat_id |
vat_id_invalid |
The VAT ID is not valid in VIES. |
accept_withdrawal |
required |
A person confirms the right of withdrawal at checkout. |
The target_state_conflict rules name what the org’s billing state refuses:
plan already_billed (the checkout for a plan the org already pays for),
billing no_account, past_due and no_valid_mandate (a change that needs
a payment method on file), billing vat_id_invalid (a charge, or paying a
renewal on hold, while VIES does not confirm the VAT ID), billing
charge_in_progress (an earlier change is still being settled with Mollie; try
again later), and invoice not_open (paying an invoice that is already paid
or cancelled).
POST /api/billing/mollie/webhook is Mollie’s callback and not for developers:
it takes the payment id from Mollie and fetches the payment itself.
Asset errors
One file has its own limit, apart from the storage.
| Code | Status | Meaning |
|---|---|---|
file_too_large |
413 |
One asset file is larger than one gigabyte (ASSET_FILE_MAX_BYTES), on every plan and for every kind, the URDF included. details: max_bytes and size_bytes, the file’s size (the announced one, or the bytes that arrived), which is null when no size was announced and the upload was stopped at the server’s body limit. Retrying does not help; a smaller file does. |
/api/admin/* is the operator’s surface. Public hosts answer 404 for every
path under it, and it is not described here.
Job errors
Job.error.code on a failed, unknown, lost or cancelled job — the codes
below, open-ended like every other error code, but worth naming once rather
than left to grep:
| Code | State | Meaning |
|---|---|---|
goal_timeout |
failed |
The action server did not accept the goal within patienceMs. |
goal_rejected |
failed |
The action server rejected the goal outright. |
goal_send_failed |
failed |
Sending the goal to the action server itself raised. |
result_failed |
failed |
Asking the action server for its result raised. |
action_failed |
failed |
The action ended with a ROS status other than succeeded. |
bridge_timeout |
unknown |
The cloud heard nothing from a running job — heartbeat or real progress — for longer than it tolerates while the bridge stayed connected. Not final: the cloud asks the bridge, and its answer or its next report resolves the job. |
bridge_disconnected |
unknown |
The robot has been offline for more than five minutes while the job was running, or the cloud restarted while it ran. Not final: a reconnecting bridge’s own report of the job resolves it, one way or another. |
goal_uncontrollable |
lost |
A goal accepted after its own timeout could not then be cancelled, so the robot may be pursuing it with nobody tracking it. |
action_server_lost |
lost |
The robot’s action server vanished mid-goal — the bridge’s liveness check found it gone for three seconds straight. The goal’s real outcome is unknown, which is why this is lost, not failed. |
job_unknown_to_bridge |
lost |
The bridge does not know this job, and — for an action — no goal it cannot otherwise attribute is active on the job’s action either, so none of them can be it. This is how an unknown job ends final once nobody ever claims it. |
config_changed |
cancelled |
A republish invalidated the job’s action or service while it was running. |
config_changed |
lost |
A bridge restarted with a job it had been running on an action its configuration no longer has, so it cannot follow the goal to its end. |
A restart on either side is not a loss. The bridge keeps which goal belongs
to which job in FLEETLESS_STATE_DIR
and names the jobs it recognises when it reconnects; one it does not know
ends lost, job_unknown_to_bridge, under the rule above. A cloud restart
leaves every Fleetless job that was in flight unknown (bridge_disconnected)
until its robot’s bridge reconnects and answers — GET /api/org/jobs?state=unknown lists them meanwhile.
busy and parameter_invalid are refusals of the invoke/call itself,
not a job outcome — see the SDK reference. The refusals
of a cancel, cancel_rejected among them, are under Cancelling a
job.
Job origin and external goals
job.origin says who started a job: fleetless for everything minted by an
invoke, external for a goal the bridge found active on a published action
without having sent it — anyone else’s goal on the robot’s ROS graph. An
external job has the same shape, the same states and the same live stream as
any other, but no parameters, no starter, and it lives only in memory: never
written to job history, never counted towards quotas.
A busy refusal’s details.running names the job that holds the slug,
state and origin included, so a caller can tell “wait for it” from
“someone else is using this slug”. Cancelling an external job works exactly
like cancelling any other — by its id, or by slug for whatever is running —
and is audited goal by goal, with the action server’s answer; see Cancelling
a job. Cancelling an unknown job the bridge does not
know cancels every external goal still active on its action instead, since
one of them may be that very job — never another Fleetless job.
Only an explicit cancel does that: when a republish changes or removes an action, the cancel the cloud sends for the job it resets stops that Fleetless job alone and never an external goal — from bridge 6.1.0.
A slug held by a job — running, unknown or external — stays held while the
robot is offline: a start there is refused busy, naming that job, before
robot_offline is even considered.
Health
| Method | Path | Auth |
|---|---|---|
| GET | /healthz |
none |
Developer auth
| Method | Path | Auth |
|---|---|---|
| POST | /api/waitlist |
none (rate-limited) |
| POST | /api/auth/refresh |
none (rate-limited) |
| POST | /api/auth/logout |
none |
| GET | /api/auth/me |
developer |
| PATCH | /api/auth/me |
developer — own display name |
| GET | /api/auth/two-factor |
developer — passkeys, authenticator, recovery codes left, the org’s policy |
| POST | /api/auth/passkeys/options |
developer — WebAuthn creation options |
| POST | /api/auth/passkeys |
developer — registers one from the browser’s answer |
| PATCH | /api/auth/passkeys/:id |
developer — rename |
| DELETE | /api/auth/passkeys/:id |
developer |
| POST | /api/auth/totp |
developer — starts a setup, answers the secret and otpauth:// URL |
| POST | /api/auth/totp/confirm |
developer — confirms the pending setup with a code |
| DELETE | /api/auth/totp |
developer |
| POST | /api/auth/recovery-codes |
developer — ten new ones; voids whichever of the old are unused |
There is no password and no POST /api/auth/login. A developer signs in
at the auth portal below, of which the console is an OAuth client, by an
emailed code or a passkey. In closed beta, sign-up answers 403 signup_closed.
| Method | Path | Auth |
|---|---|---|
| GET | /console/oauth/authorize |
none — starts a console sign-in |
| GET | /console/oauth/interaction/:id |
none — the identify card |
| POST | /console/oauth/identify |
none (rate-limited) — mails the sign-in code |
| POST | /console/oauth/code |
none (rate-limited) — spends it, or hands back the second step |
| GET | /console/oauth/two-factor/:id |
none — the authenticator-code-or-passkey step |
| GET | /console/oauth/two-factor/:id/recovery |
none — the recovery-code step |
| POST | /console/oauth/two-factor |
none (rate-limited) — an authenticator or recovery code |
| POST | /console/oauth/passkey/options |
none — WebAuthn request options |
| POST | /console/oauth/passkey |
none — a passkey finishes the sign-in on its own |
| GET | /console/oauth/two-factor/setup/:id |
none — the “your organisation requires two-factor” choice |
| POST | /console/oauth/two-factor/setup/totp |
none — starts an authenticator setup mid sign-in |
| POST | /console/oauth/two-factor/setup/totp/confirm |
none — confirms it, answers the ten recovery codes |
| POST | /console/oauth/two-factor/setup/passkey/options |
none |
| POST | /console/oauth/two-factor/setup/passkey |
none — registers it, answers the ten recovery codes |
| POST | /console/oauth/two-factor/setup/done |
none — finishes once the codes are saved |
| GET | /console/oauth/signup/:id |
none — sign-up step 1, the email card |
| POST | /console/oauth/signup |
none (rate-limited) — mails the sign-up code |
| POST | /console/oauth/signup/code |
none (rate-limited) — the account |
| POST | /console/oauth/signup/organization |
none (rate-limited) — the org |
| POST | /console/oauth/token |
none — the code exchange, PKCE S256 |
These answer a <form> post with a page and a JSON post with JSON. A console
sign-in is never a password: single sign-on is per-app.
App-user auth (the client auth API)
Fleetless renders no page for an app user — your app owns every screen,
and these routes are what it calls. CORS is answered for the app’s
allowed_origins on every status, refusals included, so a 400 or 429
arrives readable rather than as an opaque error. The model is in Manage Users
and Roles, the working code in the app
starter.
| Method | Path | Auth |
|---|---|---|
| POST | /api/client/register |
none (rate-limited) — 202 for an allowed request |
| POST | /api/client/verify-email |
none (rate-limited) — spends the token, answers a sign-in result |
| POST | /api/client/resend-verification |
none (rate-limited) — always 202 |
| POST | /api/client/login |
none (rate-limited) — answers a sign-in result |
| POST | /api/client/login/code |
none (rate-limited) — mails six digits, valid ten minutes; 202 whether or not the address has an account |
| POST | /api/client/login/code/verify |
none (rate-limited) — spends the code, answers a sign-in result |
| POST | /api/client/password/reset |
none (rate-limited) — always 202 |
| POST | /api/client/password/reset/confirm |
none (rate-limited) — revokes every session, answers a sign-in result |
| POST | /api/client/invitations/accept |
none (rate-limited) — answers a sign-in result |
| POST | /api/client/refresh |
none (rate-limited) |
| POST | /api/client/logout |
none |
| GET | /api/client/me |
developer, app user or server key |
| POST | /api/client/password/change |
app user — needs the current password |
| POST | /api/client/two-factor/verify |
none — an authenticator or recovery code against a challenge, answers the session |
| POST | /api/client/two-factor/setup |
challenge or app-user session — answers the secret and otpauth:// URL |
| POST | /api/client/two-factor/setup/confirm |
challenge or app-user session — answers ten recovery codes and the session |
| DELETE | /api/client/two-factor |
app user — needs a current code; refused while the app requires it |
| GET | /api/client/providers |
none — the app’s enabled providers, slug and name only |
| GET | /api/client/oidc/:slug/start |
none (rate-limited) — 302 to the provider |
| GET | /api/client/oidc/callback |
none (rate-limited) — 302 back to the app |
| POST | /api/client/oidc/exchange |
none (rate-limited) — the one-time code for a session |
| GET | /api/client/mcp/interactions/:id |
app user, optional — see below |
| POST | /api/client/mcp/interactions/:id/approve |
app user of that app |
| POST | /api/client/mcp/interactions/:id/deny |
app user of that app |
| GET | /api/client/mcp/grants |
app user — their own standing consents |
| DELETE | /api/client/mcp/grants/:clientId |
app user — withdraws one |
The enumeration discipline is the design’s, not a preference. register,
resend-verification, password/reset and login/code answer 202 whether
or not the address exists, and login and login/code/verify answer one
invalid_credentials / invalid_code for a wrong password or code, a
blocked account and an unverified one alike. Past rate_limited and
validation_error, which every route here carries, the refusals are about
the app and never a person: 403 registration_closed, 403 domain_not_allowed, 404 not_found, 409 quota_exceeded, and 409 target_state_conflict for an app with no default role.
An app with no verify_url is not refused: the mail links the hosted
confirmation page. password/* and login answer 403 method_not_allowed while the app’s
sign_in_methods has passwords off, login/code and login/code/verify
while it has the email code off.
Every step that can mint a session answers a sign-in result: tokens, or a
challenge first. login, login/code/verify, verify-email,
password/reset/confirm and invitations/accept all answer either the
session tokens ({ access_token, refresh_token, expires_in }) or { status: 'two_factor_required' | 'two_factor_setup_required', challenge }. Only the
challenge carries status, so that is how to tell the two apart.
A user with a confirmed authenticator, or whose app requires one, never gets
a session without it. oidc/exchange is the one exception: the provider already owns
that sign-in, so it always answers tokens. A challenge is spent at
two-factor/verify (or, for the setup case, two-factor/setup then
two-factor/setup/confirm), both answering the session.
GET /api/client/mcp/interactions/:id takes an optional bearer, the one
field already_granted is derived from. Read it anonymously and that field
is permanently false, with a 200 on it.
Org
| Method | Path | Auth |
|---|---|---|
| PATCH | /api/org |
developer, Owner tier — rename the org, or set require_two_factor |
| GET | /api/org/quotas |
developer |
| GET | /api/org/health |
developer |
| GET | /api/org/jobs |
developer — org-wide run history, newest first, cursor-paged |
| GET | /api/org/jobs/summary |
developer — running, started, failed since ?since_ms= |
| GET | /api/org/latency |
developer — one-minute bridge latency buckets per robot |
| GET | /api/org/usage |
developer — what the org consumed per day |
| GET | /api/org/plan |
developer — the plan, its limits against usage, add-ons and any pending change |
| PUT | /api/org/plan/change |
developer, Owner tier — move to a lower plan or to Basic; queued until the period ends |
| DELETE | /api/org/plan/change |
developer, Owner tier — withdraw a queued change |
| GET | /api/audit |
developer |
| GET | /api/audit/export |
developer — the same filters, everything they match, as CSV |
next_cursor === null is the only end-of-data signal on /api/org/jobs;
an over-large limit is refused, not capped, and the window parameters have
no defaults. None of these has a client-facing twin: a run row names the actor
who invoked it.
/api/org/usage answers one row per metric, per day, and per app where the
metric has an app dimension.
| Metric | Unit | App dimension |
|---|---|---|
api_calls |
requests | yes |
live_session_ms |
milliseconds | yes |
robot_online_ms |
milliseconds | no |
retention_bytes |
bytes | no |
asset_bytes |
bytes | no |
app_id: null means the org itself, and what that implies depends on the
metric. On the two with an app dimension it is console traffic, not
billable; on the other three every row carries it and every one is. Both
byte metrics are each day’s peak, not a daily total.
The team (Fleetless users)
Two identity spaces, and nothing joins them: Fleetless users are the org’s
team and hold console access, app users belong to one app. A Fleetless
user’s address is globally unique across the platform, so an invitation to a
claimed one is refused email_taken.
| Method | Path | Auth |
|---|---|---|
| GET | /api/org/users |
developer — the whole team |
| GET | /api/org/users/:id |
developer |
| PATCH | /api/org/users/:id |
developer — display name only; no email and no tier |
| DELETE | /api/org/users/:id |
developer — 409 last_owner too; every session ends |
| DELETE | /api/org/users/:id/two-factor |
developer, Owner tier — removes passkeys, authenticator and recovery codes; every session ends |
| PUT | /api/org/users/:id/tier |
developer, Owner tier — 409 last_owner guards the last one |
| POST | /api/org/invitations |
developer |
| GET | /api/org/invitations |
developer — pending only, no tokens |
| DELETE | /api/org/invitations/:id |
developer |
| POST | /api/org/invitations/:id/reissue |
developer, Owner tier for an owner-tier one |
| POST | /api/org/invitations/accept |
none (rate-limited) — answers sessionTokens |
Re-issuing mints a fresh link onto the same row, so the old one stops
resolving at once. Limited to once a minute per invitation, refused with 429
and details.retry_after_ms.
Apps
| Method | Path | Auth |
|---|---|---|
| POST | /api/apps |
developer |
| GET | /api/apps |
developer |
| GET | /api/apps/:id |
developer |
| PATCH | /api/apps/:id |
developer — name, robots, default role |
| GET | /api/apps/:id/deletion-preview |
developer |
| DELETE | /api/apps/:id |
developer, Owner tier |
| POST | /api/apps/:id/roles |
developer |
| GET | /api/apps/:id/roles |
developer |
| PUT | /api/apps/:id/roles/:roleId/permissions |
developer |
| GET | /api/apps/:id/roles/:roleId/permissions |
developer |
| GET | /api/apps/:id/roles/:roleId/mcp-tools |
developer — the per-role MCP datasheet |
| POST | /api/apps/:id/server-keys |
developer |
| GET | /api/apps/:id/server-keys |
developer |
| POST | /api/apps/:id/server-keys/:keyId/rotate |
developer |
| DELETE | /api/apps/:id/server-keys/:keyId |
developer, Owner tier — revokes the key |
DELETE .../server-keys/:keyId removes the row, not just the secret.
rotate invalidates the old secret and keeps the key; DELETE is for a key
that should stop existing, and it closes any /realtime socket on it.
Deleting an app cascades its users, roles, server keys, invitations, OIDC
provider configuration and mail templates, recorded once as app.deleted
carrying an appDeletionSummary — the same shape deletion-preview answers,
computed by the same function, so the confirmation and the eventual receipt
agree by construction. Its robots are untouched: they belong to the
organisation, not the app. No force parameter, unlike the robot pair
this route is modelled on — a robot’s open live session is a hazard worth
naming explicitly, and an app has no equivalent state to force past, so the
preview is the guard instead.
An app identifier may not be mcp, oauth, welcome, healthz, realtime
or bridge — each is a fixed segment of the MCP host.
An app’s users
| Method | Path | Auth |
|---|---|---|
| GET | /api/apps/:id/users |
developer |
| POST | /api/apps/:id/users |
developer — creates the account active |
| GET | /api/apps/:id/users/:userId |
developer |
| PATCH | /api/apps/:id/users/:userId |
developer — display name, role, active/blocked |
| DELETE | /api/apps/:id/users/:userId |
developer — and every session ends |
| POST | /api/apps/:id/users/:userId/reset-password |
developer — mails a reset link; 403 method_not_allowed while the app’s password method is off |
| DELETE | /api/apps/:id/users/:userId/two-factor |
developer — removes the authenticator and recovery codes, ends every session |
| GET | /api/apps/:id/users/:userId/mcp-grants |
developer — that user’s standing consents |
| DELETE | /api/apps/:id/users/:userId/mcp-grants/:clientId |
developer — withdraws one |
| GET | /api/apps/:id/invitations |
developer — pending only, no tokens |
| POST | /api/apps/:id/invitations |
developer — carries the invitee’s role |
| POST | /api/apps/:id/invitations/:invId/reissue |
developer — answers a new invitation id |
| DELETE | /api/apps/:id/invitations/:invId |
developer |
status accepts only active and blocked on the patch.
pending_verification is entered once, by self-registration, and left once,
by spending the mailed token; setting it back would void a verified address.
A re-issued invitation answers a new id: the row is replaced, so the previous id is a dead handle.
An app’s auth configuration
How people get into one app: the policy, the origins, the app-hosted URLs, the providers.
| Method | Path | Auth |
|---|---|---|
| GET | /api/apps/:id/auth-config |
developer |
| PUT | /api/apps/:id/auth-config/registration |
developer — replaces self_registration, allowed_domains, allowed_origins |
| PUT | /api/apps/:id/auth-config/sign-in |
developer — replaces sign_in_methods, two_factor |
| PUT | /api/apps/:id/auth-config/urls |
developer — replaces app_url, invite_url, verify_url, reset_url, mcp_login_url |
| PUT | /api/apps/:id/auth-config/mcp |
developer — replaces mcp_enabled alone |
| PUT | /api/apps/:id/auth-config/look |
developer — replaces hosted_accent |
| PUT | /api/apps/:id/auth-config/logo |
developer — raw body, Content-Type image/png or image/svg+xml, at most 100 KB |
| DELETE | /api/apps/:id/auth-config/logo |
developer |
| GET | /api/apps/:id/oidc-providers |
developer — disabled ones included |
| POST | /api/apps/:id/oidc-providers |
developer — discovery runs at write time; client_secret required |
| GET | /api/apps/:id/oidc-providers/:providerId |
developer |
| PATCH | /api/apps/:id/oidc-providers/:providerId |
developer — an absent secret keeps the stored one |
| DELETE | /api/apps/:id/oidc-providers/:providerId |
developer |
| GET | /api/apps/:id/mail-templates |
developer |
| GET | /api/apps/:id/mail-templates/:kind |
developer — invite, verify, reset or login_code |
| PUT | /api/apps/:id/mail-templates/:kind |
developer (rate-limited) |
| DELETE | /api/apps/:id/mail-templates/:kind |
developer — back to the default |
| POST | /api/apps/:id/mail-templates/:kind/preview |
developer (rate-limited) — sample data |
| POST | /api/apps/:id/mail-templates/:kind/test |
developer (rate-limited) — one to the caller |
Five slice writes, one document. Each PUT is .strict(): every field
of its own slice arrives or the write is refused, so a client built against
an older shape cannot silently clear a setting it does not know about. The
merge is server-side, against the stored row, never the caller’s copy — five
screens carving up one all-required request is how a field nobody’s screen
shows becomes a field somebody’s save clears.
oidc_callback_url, hosted_logo_url, hosted_pages and updated_at are
the server’s on every slice and refused in the body. The logo is the one
exception to “one document”: it travels as its own raw-body write, not a
JSON field, because it is binary.
allowed_origins is both the CORS allow-list and the redirect-URI check,
and its entries are bare origins: scheme, host, port, nothing longer. An entry
carrying a path compares unequal forever, so it is refused on the write.
None of the four link URLs is refused for being unset any more.
invite_url, verify_url, reset_url and mcp_login_url each carry their
placeholder exactly once when you do set them — {token} for the first
three, {interaction} for the last — and nothing checks that a set URL
resolves. Leave one unset and hosted_pages names the Fleetless page that
takes its place; see Create an App.
Robots
| Method | Path | Auth |
|---|---|---|
| POST | /api/robots |
developer |
| GET | /api/robots |
developer |
| GET | /api/robots/:id |
developer |
| PATCH | /api/robots/:id |
developer — rename the robot |
| GET | /api/robots/:id/deletion-preview |
developer |
| DELETE | /api/robots/:id |
developer |
| PUT | /api/robots/:id/details |
developer |
| GET | /api/robots/:id/datapoints |
developer or client |
| GET | /api/robots/:id/datapoints/:slug |
developer or client |
| GET | /api/robots/:id/datapoints/:slug/history |
developer or client — samples, optionally bucketed |
| GET | /api/robots/:id/exposures |
developer |
| GET | /api/robots/:id/introspection |
developer |
| POST | /api/robots/:id/introspection/refresh |
developer |
| GET | /api/robots/:id/types |
developer |
| POST | /api/robots/:id/types/fetch |
developer |
| POST | /api/robots/:id/token/rotate |
developer, Owner tier — mints a new bridge token, returned once, and closes the current connection; the robot stays offline until the new token is on it |
| PUT | /api/robots/:id/urdf/joint-state |
developer — the datapoint that drives the URDF viewer |
exposes on GET /api/robots and /api/robots/:id/exposures disagree on
purpose, and both are correct. exposes is five counts from the latest
published configuration, never the draft. /exposures lists every
grantable slug and prepends the built-ins — bridge_state and
robot_details — a set that can grow, so the difference is not a number to
hard-code.
Configuration (draft/publish)
| Method | Path | Auth |
|---|---|---|
| GET | /api/robots/:id/config/draft |
developer |
| PUT | /api/robots/:id/config/draft |
developer |
| POST | /api/robots/:id/config/publish |
developer |
| GET | /api/robots/:id/config/versions |
developer |
| GET | /api/robots/:id/config/versions/:v |
developer |
| POST | /api/robots/:id/config/versions/:v/restore |
developer |
| GET | /api/robots/:id/config/slug-usage/:slug |
developer — what a rename would touch |
| POST | /api/robots/:id/config/rename-slug |
developer — atomic slug rename, see below |
A configuration is written as text. PUT .../config/draft takes
{ "source": "<the fleetless.yaml>" } and nothing else, so the server is the
one account of what it says. Reads give you the parsed doc and source, the
author’s text, never null. Saving asks only whether that text is YAML; publish
asks whether it is a fleetless.yaml.
| Route | Status | Code | When |
|---|---|---|---|
| draft | 422 |
invalid_yaml |
not YAML at all; details.line and details.column can both be null |
| draft | 422 |
unstorable_yaml |
it parses, but a YAML anchor makes it refer to itself |
| publish | 422 |
draft_not_a_document |
valid YAML, not a configuration; details.issues carries every finding |
| publish | 422 |
validation_error |
a document with findings of severity error; warnings never block |
On a draft, doc can be null: the text does not parse to a
configuration, which is not the same as nothing being configured. The last
published version is untouched and the robot is still running it, so a view
built out of doc must tell the two apart. Such a draft is never clean — its
issues carry draft_not_a_document.
POST .../config/rename-slug is one atomic transaction across the draft
entry, the app-role grants, the alert and chart-display rows, and the recorded
history. The published config is immutable, so requires_publish: true is not
a suggestion: until the publish lands the bridge still reports the old slug,
and those samples are never moved.
Saving a draft is last-write-wins. A caller that read it before a rename and saves it after reverts the slug while the grants and history stay moved.
Alerts
| Method | Path | Auth |
|---|---|---|
| GET | /api/robots/:id/alerts |
developer — the runtime state of this robot’s alerts |
| GET | /api/org/alerts |
developer — what is firing across the org |
The org route takes ?state=firing, required and the only value it accepts.
Alerts are defined in the configuration document, not through this API — an alert is a key under the datapoint it watches, so there is no create, patch or delete route. See alerts.
These routes return runtime state: ok or firing, since when, and the
value at the last transition. Across a publish an unchanged condition keeps
its state, a changed one resets to ok, a deleted key takes its state with
it.
Commands (jobs, publishers)
See When the link drops for the semantics behind cancel, timeouts and failsafes.
| Method | Path | Auth |
|---|---|---|
| GET | /api/robots/:id/jobs |
developer or client |
| GET | /api/robots/:id/jobs/history |
developer or client — what has run |
| POST | /api/robots/:id/jobs/:slug |
developer or client — invoke an action or service |
| GET | /api/robots/:id/jobs/:slug |
developer or client — job state by slug |
| POST | /api/robots/:id/jobs/:slug/cancel |
developer or client |
| POST | /api/robots/:id/publishers/:slug |
developer or client |
Cancelling a job
POST .../jobs/:slug/cancel takes { "job_id": "…" } to cancel that job
alone, or no body to cancel whatever runs on the slug. The cloud answers
once the robot’s action server has answered the cancel request — ROS 2’s
CancelGoal, one return code per goal the cancel reached:
return_code |
ROS 2 name | Means |
|---|---|---|
0 |
ERROR_NONE |
The server accepted the cancel request. |
1 |
ERROR_REJECTED |
The server refused it. |
2 |
ERROR_UNKNOWN_GOAL_ID |
The server does not know the goal. |
3 |
ERROR_GOAL_TERMINATED |
The goal had already ended. |
null |
— | The server did not answer within the bridge’s bound (3 s). |
An accepted cancel is not an ended goal. Whether the goal ends, and how,
is what the action reports afterwards: the job’s cancelled (or any other
end) arrives as its next update, over subscribe or GET .../jobs/:slug.
| Status | Code | When |
|---|---|---|
200 |
— | Accepted. { job } names the job the cancel was sent for, still running; { job: null } when no job was named and nothing ran. |
409 |
cancel_rejected |
At least one goal answered ERROR_REJECTED. details.goals lists every goal the cancel reached — the accepted ones too — as { job_id, goal_id, return_code }. The goal runs on unless its job later says otherwise. |
409 |
robot_offline |
The robot is offline, or went offline before it answered. |
404 |
not_found |
The named job_id does not hold the slug — it ended, or the cloud never knew it. Nothing is sent to the robot. |
422 |
not_cancellable |
The slug is a service: a service call has no goal to cancel. |
502 |
the bridge’s own | The bridge could not ask the action server at all, e.g. unknown_slug or action_server_lost. |
504 |
bridge_timeout |
The bridge did not answer within five seconds; whether the cancel reached the server is unknown. It may still take effect: watch the job, whose own update says how it ended, and its audit event then carries no return_code. |
A named job_id of an unknown job the bridge does not hold cancels every
external goal active on the action, each listed in the answer, and never one
of Fleetless’s own jobs. The realtime cancel answers the same way in its
command_result (ok: false, code, details.goals), and so does the MCP
tool job_cancel.
A republish that changes or removes the action cancels its job on its own
(cancelled, config_changed). That cancel stops only the Fleetless job it
names, never an external goal, even when the job is unknown — from bridge
6.1.0; bridge 6.0.0 still treats it like a cancel of an unknown job.
Every cancel that reached the robot is audited per goal: one
job.cancelled event for each goal in the bridge’s answer, carrying
robot_id, the slug as target, job_id, goal_id, origin and the
server’s return_code — for every return code, a refused cancel included —
with the caller as actor. A job the answer does not list, because none came
in time or the robot went offline, still gets its event, without a
return_code. A refusal before anything was sent (not_found,
not_cancellable, robot_offline up front) and a slug cancel that found
nothing running write no event.
GET .../jobs/history is the per-robot half of /api/org/jobs and shares its
paging rules; an app user’s role must carry action_history or the read
answers 403 capability_required.
An app user sees only runs their role grants, and that filter runs after the page is read — so a narrow role gets a page of two where it asked for fifty, with a good cursor behind it.
Cameras
See Cameras for snapshot versus live semantics.
| Method | Path | Auth |
|---|---|---|
| GET | /api/robots/:id/cameras |
developer or client |
| GET | /api/robots/:id/cameras/:slug/snapshot |
developer or client |
| GET | /api/robots/:id/cameras/:slug/snapshot/meta |
developer or client |
| POST | /api/robots/:id/cameras/:slug/live |
developer or client — start or join a session |
| DELETE | /api/robots/:id/cameras/:slug/live |
developer or client — release a session (?session_id=) |
Assets (URDF, meshes)
See URDF & meshes for the authorization model.
| Method | Path | Auth |
|---|---|---|
| GET | /api/robots/:id/assets |
developer or client |
| GET | /api/robots/:id/assets/:assetId |
developer or client — one asset by UUID |
| GET | /api/robots/:id/urdf |
developer or client — mesh URIs rewritten |
| GET | /api/robots/:id/assets/missing |
developer or client (?name=) |
| POST | /api/robots/:id/assets/sync |
developer, Owner tier — spends the robot’s store |
| GET | /api/robots/:id/assets/sync/:syncId |
developer or client — sync status |
| DELETE | /api/robots/:id/assets |
developer, Owner tier — clears the robot’s store |
| GET | /api/asset-links/:token |
none — minted by asset_get/urdf_get, valid 15 minutes |
| GET | /api/asset-links/missing |
none — the placeholder for a mesh that never arrived |
The robots share the organisation’s asset storage, the robots times the
plan’s allowance per robot, and a single file can be at most one gigabyte,
the URDF included: a larger one answers 413 file_too_large with max_bytes
and size_bytes. A file that would not fit the storage answers
409 plan_limit with the plan-limit details plus store_bytes (the
storage), used_bytes and size_bytes, and the sync moves on to the next
one; the URDF itself is never refused for the storage, and never counted
against used_bytes. Reconcile after every sync frees whatever the
new URDF stopped referencing, and
DELETE .../assets clears the store outright, unless a sync is running, which
is 409 busy.
A sync ends succeeded only if the cloud’s own store holds something.
stored and announced say how many of the announced files — the URDF and
every mesh its description references — actually arrived; stored is null
until the robot reports the sync done. A robot that reports a clean finish
over a store that holds none of them ends failed, with an upload_failed
entry naming each missing file.
The two /api/asset-links routes take no bearer: the token is the
authorization, minted for an agent whose MCP session token REST refuses. A
link is a bearer capability for its lifetime.
OAuth 2.1 / MCP
OAuth for apps is gone. Apps use the JSON client auth API above. What still speaks OAuth is two MCP authorization servers, one central and one per app, plus the console’s own sign-in documented at the top of this page.
The central MCP server: Fleetless users
One server for the org’s team, at POST /mcp. Every Fleetless user has
access; there is no override.
| Method | Path | Auth |
|---|---|---|
| POST | /mcp |
MCP access token — the central endpoint; Origin, if sent, must be the cloud’s own |
| GET | /.well-known/oauth-protected-resource/mcp |
none — RFC 9728 metadata |
| GET | /.well-known/oauth-authorization-server/mcp |
none — RFC 8414 metadata |
| GET | /mcp/oauth/authorize |
none — starts the login |
| GET | /mcp/oauth/interaction/:id |
none — the identify card |
| POST | /mcp/oauth/identify |
none (rate-limited) — mails the sign-in code |
| POST | /mcp/oauth/code |
none (rate-limited) — spends it, or hands back the second step |
| GET | /mcp/oauth/two-factor/:id |
none — the authenticator-code-or-passkey step |
| GET | /mcp/oauth/two-factor/:id/recovery |
none — the recovery-code step |
| POST | /mcp/oauth/two-factor |
none (rate-limited) — an authenticator or recovery code |
| POST | /mcp/oauth/passkey/options |
none — WebAuthn request options |
| POST | /mcp/oauth/passkey |
none — a passkey finishes the sign-in on its own |
| GET | /mcp/oauth/two-factor/setup/:id |
none — the “your organisation requires two-factor” choice |
| POST | /mcp/oauth/two-factor/setup/totp |
none — starts an authenticator setup mid sign-in |
| POST | /mcp/oauth/two-factor/setup/totp/confirm |
none — confirms it, answers the ten recovery codes |
| POST | /mcp/oauth/two-factor/setup/passkey/options |
none |
| POST | /mcp/oauth/two-factor/setup/passkey |
none — registers it, answers the ten recovery codes |
| POST | /mcp/oauth/two-factor/setup/done |
none — finishes once the codes are saved |
| GET | /mcp/oauth/consent/:id |
none — consent for a self-registered client |
| POST | /mcp/oauth/consent |
none (rate-limited) — the consent decision |
| POST | /mcp/oauth/token |
none — PKCE code exchange, or grant_type=refresh_token |
| POST | /mcp/oauth/register |
none (rate-limited) — RFC 7591 dynamic client registration |
| GET | /mcp/welcome |
none — a static page, for a URL pasted into a browser |
Dynamic client registration is supported, public clients only. The
well-known id fleetless-central-mcp is the fallback for a tool without it,
and the one client with no consent step; central consent is per connection and
nothing is stored.
Hand out https://mcp.fleetless.dev — pathless; that vhost rewrites POST / and the root discovery documents onto their suffixed forms. But
https://api.fleetless.dev/mcp stays the canonical resource identifier
every audience is minted from.
An app’s MCP server: that app’s users
One server per app, switched on by mcp_enabled, with the login delegated to
the app.
| Method | Path | Auth |
|---|---|---|
| POST | /mcp/:appIdentifier |
MCP access token minted for this app |
| GET | /mcp/:appIdentifier |
MCP access token — the Streamable HTTP channel |
| DELETE | /mcp/:appIdentifier |
MCP access token — session teardown |
| GET | /.well-known/oauth-protected-resource/mcp/:appIdentifier |
none |
| GET | /.well-known/oauth-authorization-server/mcp/:appIdentifier |
none |
| POST | /mcp/:appIdentifier/oauth/register |
none (rate-limited) — scoped to this app |
| GET | /mcp/:appIdentifier/oauth/authorize |
none — renders nothing; 302 to mcp_login_url, or to the hosted sign-in when it is unset |
| POST | /mcp/:appIdentifier/oauth/token |
none — only for an approved interaction, or a refresh |
The app decides the interaction through the client auth API above, with the
app user’s own access token, and it lives ten minutes. An unauthenticated
caller cannot tell “this app exists but MCP is off” from “no such app”: both
answer one 404.
Gating is checked at login and again on every single request, against the
rows as they are now rather than a value cached off the token: mcp_enabled,
the user’s status, the standing consent behind the client. Withdrawing that
consent answers that client 401 at its next call.
Hosted pages for app users
Plain server-rendered forms on the auth portal, under /app/<identifier>/…;
the page an unset invite_url, verify_url, reset_url or mcp_login_url
falls back to (see Create an App).
Every one calls the same /api/client/* route a page of your own would —
there is no second policy.
| Method | Path | Page |
|---|---|---|
| GET | /app/:appIdentifier/logo |
the app’s logo, served sandboxed |
| GET | /app/:appIdentifier/mcp/:interaction |
MCP sign-in: password and/or email code; the form posts go to the steps below |
| POST | /app/:appIdentifier/mcp/:interaction/password |
the password step |
| POST | /app/:appIdentifier/mcp/:interaction/code |
mails the sign-in code |
| POST | /app/:appIdentifier/mcp/:interaction/code/verify |
spends it |
| POST | /app/:appIdentifier/mcp/:interaction/consent |
the MCP consent decision |
| POST | /app/:appIdentifier/two-factor |
the authenticator-or-recovery-code step |
| POST | /app/:appIdentifier/two-factor/setup |
starts an authenticator setup, when required |
| POST | /app/:appIdentifier/two-factor/setup/confirm |
confirms it, shows the ten recovery codes |
| GET, POST | /app/:appIdentifier/invite/:token |
accept an invitation |
| GET, POST | /app/:appIdentifier/reset/:token |
set a new password |
| GET, POST | /app/:appIdentifier/forgot |
request a reset link |
| GET, POST | /app/:appIdentifier/sign-up |
create an account, when self-registration is on |
| GET, POST | /app/:appIdentifier/verify/:token |
confirm the address |
Every GET here renders a page and spends nothing; the token or code is
spent only by the form POST the page submits, so a mail scanner or link
preview opening the GET leaves it untouched. A spent, expired or exhausted
one answers 410 token_spent, with the page saying what to do next.
The two tool catalogs
Every session is offered the same tools, and a caller’s role decides what a
call answers rather than which tools appear: fourteen robot tools on both
endpoints, sixty-three console_* tools at the central one only. Each tool
dispatches onto the REST route the MCP page names beside it — the same
function, so validation, refusals and audit are identical, never a second
implementation.
MCP lists the fourteen robot tools with their inputs, and
the console families with their counts.
Realtime and bridge transports
| Path | Direction | Notes |
|---|---|---|
wss://api.fleetless.dev/realtime |
client apps ↔ cloud | subscriptions plus full command parity |
wss://api.fleetless.dev/bridge |
robot bridge ↔ cloud | the versioned bridge protocol, not client-facing |