fleetlessfleetlessdocs
Reference/API/Overview

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 and bridge_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