API Routes
Every route a developer or an app may call is below, generated from the manifest the cloud is tested against; the routes that serve the platform’s own pages and machines are deliberately absent. Method, path, auth and schemas are checked against the cloud; the status and the error list of each route are written by hand from its handler. The API Reference is the narrative; the API Schemas page lists every request and response field; openapi.json is the same information as an OpenAPI 3.1 document.
Auth mechanisms: a developer session is a Fleetless user’s console login; an app user’s token comes from the client auth API, by password or through one of the app’s identity providers; a server key is an app credential for server-side callers. Error codes are the ones a route is known to answer; the envelope is always { code, message, details? }.
Developer auth
POST /api/auth/refresh
Rotates a developer refresh token and mints a fresh access token.
| Audience | developer (console) |
| Auth | none |
| Rate limited | yes |
| Status | 200 |
| Request body | refresh-request |
| Response | session-tokens |
Errors: rate_limited, validation_error, token_expired, token_revoked.
The whole family is re-checked here, not just the token: an account that has been removed from the org, or whose token_version was bumped by an owner’s two-factor reset, cannot mint a fresh console token and answers token_revoked. Refusing that only on the other routes would leave a session that is dead everywhere but here.
POST /api/auth/logout
Revokes the whole refresh family behind a developer refresh token.
| Audience | developer (console) |
| Auth | none |
| Rate limited | yes |
| Status | 204 |
| Request body | refresh-request |
Errors: rate_limited, validation_error.
Unauthenticated by design — the refresh token in the body is the credential. A token the server does not recognise is still a 204: the end state a caller asked for is the end state they get, and distinguishing the two would say whether a token ever existed. Open /realtime sockets for the session are closed too.
GET /api/auth/me
Answers the calling developer and the org they belong to.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | auth-me-response |
Errors: unauthorized, token_expired, token_revoked.
PATCH /api/auth/me
Changes the calling developer’s own display name and nothing else.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | patch-auth-me-request |
| Response | auth-me-response |
Errors: unauthorized, token_expired, token_revoked, validation_error.
No Owner tier: this can only ever touch the caller’s own row, so there is nothing for a tier check to gate. Saving the name already held writes nothing and records no audit event — the org activity stream reaches every developer with the console open, and an event for a no-op would misreport that something changed.
GET /api/auth/two-factor
Answers the calling developer’s passkeys, authenticator, recovery codes left and the org’s policy.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | developer-two-factor |
Errors: unauthorized, token_expired, token_revoked.
What Settings › Profile › Security draws. No key material, secret or code travels here — the passkeys are names and dates, the authenticator is a date, the recovery codes are a count.
POST /api/auth/passkeys/options
Answers the WebAuthn creation options for registering a passkey.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | webauthn-options-response |
Errors: unauthorized, token_expired, token_revoked.
Hand options to the browser’s WebAuthn API as it is. The relying party is fleetless.dev, so the passkey works on the auth portal and in the console alike; user verification is required and the credential is discoverable, so it can sign the person in without an address. The passkeys the caller already has are excluded. The challenge is single-use and expires with the ceremony.
POST /api/auth/passkeys
Registers a passkey from the browser’s answer to the creation options.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 201 |
| Request body | create-passkey-request |
| Response | create-passkey-response |
Errors: unauthorized, token_expired, token_revoked, validation_error.
A ceremony that does not verify — a wrong challenge, origin or relying party, no user verification — is 400 validation_error naming credential. When this is the account’s first second factor, ten recovery codes are issued and answered once; otherwise recovery_codes is null and the existing ones stay valid. Audited as developer.two_factor_added with details.kind passkey.
PATCH /api/auth/passkeys/:id
Renames one of the caller’s passkeys.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | rename-passkey-request |
| Response | developer-passkey |
| Path parameter | |
|---|---|
:id |
The passkey’s uuid, as listed by GET /api/auth/two-factor; another person’s passkey answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found.
DELETE /api/auth/passkeys/:id
Removes one of the caller’s passkeys.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 204 |
| Path parameter | |
|---|---|
:id |
The passkey’s uuid, as listed by GET /api/auth/two-factor; another person’s passkey answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, target_state_conflict.
409 target_state_conflict names two_factor with rule required_by_org when this is the caller’s last second factor and the organisation requires one. Removing the last one otherwise also voids the recovery codes. Audited as developer.two_factor_removed with details.kind passkey.
POST /api/auth/totp
Starts an authenticator setup and answers its secret and otpauth URL.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | two-factor-setup-response |
Errors: unauthorized, token_expired, token_revoked.
The secret is pending until POST /api/auth/totp/confirm accepts a code from it; a second call replaces a pending secret. A developer who already has an authenticator keeps it until the new one is confirmed, which is how Replace… works.
POST /api/auth/totp/confirm
Confirms the pending authenticator with a code it shows now.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | yes |
| Status | 200 |
| Request body | totp-confirm-request |
| Response | totp-confirm-response |
Errors: unauthorized, token_expired, token_revoked, rate_limited, validation_error, invalid_code, token_spent.
A code that does not match the pending secret is 400 invalid_code; no pending setup is 410 token_spent. On success the new authenticator replaces any earlier one. Ten recovery codes are answered when it is the account’s first second factor, otherwise null. Audited as developer.two_factor_added with details.kind authenticator.
DELETE /api/auth/totp
Removes the caller’s authenticator app.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 204 |
Errors: unauthorized, token_expired, token_revoked, not_found, target_state_conflict.
404 not_found when there is no authenticator. 409 target_state_conflict names two_factor with rule required_by_org when it is the caller’s last second factor and the organisation requires one. Audited as developer.two_factor_removed with details.kind authenticator.
POST /api/auth/recovery-codes
Issues ten new recovery codes and voids the old ones.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | recovery-codes-response |
Errors: unauthorized, token_expired, token_revoked, target_state_conflict.
The codes are shown this once. 409 target_state_conflict names two_factor with rule off when the caller has no second factor: recovery codes only stand in for one. Audited as developer.recovery_codes_generated.
POST /api/waitlist
Adds an address to the closed-beta waiting list.
| Audience | developer (console) |
| Auth | none |
| Rate limited | yes |
| Status | 202 |
| Request body | waitlist-request |
Errors: rate_limited, validation_error.
Answers 202 whether or not the address was already listed: the landing page’s form must not be an oracle for who signed up. The operator notification is detached from the response — awaiting it made latency answer the question the status code refuses to — and is capped by its own global ceiling, above which the row is still written and the mail is skipped.
App-user (client) auth
POST /api/client/login
Signs an app user in with an app identifier, an email address and a password.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 200 |
| Request body | client-login-request |
| Response | client-sign-in-result |
Errors: rate_limited, validation_error, invalid_credentials, method_not_allowed.
One refusal for every miss — unknown app, unknown address, wrong password, a blocked account and one still pending_verification — because the caller supplies the app_identifier unauthenticated, so “this app knows this user” is not a fact the answer may carry. The argon2 verify is paid unconditionally, including for an unknown app identifier, so response time is not an oracle either.
The answer is a clientSignInResult: session tokens, or a twoFactorChallenge when the person has a confirmed authenticator or the app requires one — then no session exists until POST /api/client/two-factor/verify or the setup is done. 403 method_not_allowed when the app has the password method off; it names the app’s policy, not a person.
POST /api/client/login/code
Mails a six-digit sign-in code, and answers the same whether or not the address exists.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 202 |
| Request body | client-login-code-request |
Errors: rate_limited, validation_error, not_found, method_not_allowed.
202 and an empty body for every request the policy allows, in status, body and timing, whether or not the address names an active account of this app — a decoy like POST /api/client/resend-verification, so this is no enumeration oracle. A mail goes out for an active account and for one still pending_verification — spending the code proves the address, as the verification link would — and never for a blocked one or an unknown address. The code is six digits, valid ten minutes, takes five wrong attempts, and a new request expires the previous one for the same address; a request within sixty seconds of the last sends no second mail. The address is trimmed and compared case-insensitively. 404 not_found is the app identifier, never the address; 403 method_not_allowed when the app has the email-code method off. Limited per app, address and IP, so it cannot be used to mail somebody repeatedly.
POST /api/client/login/code/verify
Spends a mailed sign-in code and answers a session or a two-factor challenge.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 200 |
| Request body | client-login-code-verify-request |
| Response | client-sign-in-result |
Errors: rate_limited, validation_error, invalid_code, token_spent, method_not_allowed.
A wrong code is 400 invalid_code with details.attempts_left (invalidCodeDetails). A code that is spent, past its ten minutes, out of attempts, or was never mailed is 410 token_spent — one answer, because telling them apart would say whether a code was ever sent to that address; the recovery is the same, ask for a new code. The address is trimmed and compared case-insensitively, so the address typed at the request and here need not match in case.
The answer is a clientSignInResult, like the password login: tokens, or a twoFactorChallenge when the person has an authenticator or the app requires one. A pending-verification account that spends a code is activated — reading a mail at that address is the proof verification asks for.
POST /api/client/register
Creates an app user in the pending_verification state and mails them a verification link.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 202 |
| Request body | client-register-request |
Errors: rate_limited, validation_error, not_found, registration_closed, domain_not_allowed, target_state_conflict, quota_exceeded.
202 and an empty body for every request policy allows — a new address, one this app already knows and one it does not answer identically, in status, body and timing. An answer that depended on existence would be the account-enumeration oracle the whole client family is built to avoid. The account cannot log in until the mailed link is spent; POST /api/client/verify-email is what does that.
An address on an account still pending_verification is re-registered, not ignored. The password and display name from this call replace what is stored, every outstanding verification link for the address stops working, and a fresh one is mailed. Otherwise whoever typed an address first would own the password of the account its real owner later verifies. An address on an active account changes nothing and sends nothing — that account has already been proven, and its way back in is POST /api/client/password/reset. Neither case is visible in the answer.
The refusals it does make are about policy or about what the caller typed, never about a person. 403 registration_closed when the app has self-registration off and 403 domain_not_allowed when the address is outside allowed_domains: both are the developer’s own configuration, and a stranger learns the app’s policy rather than who is in it. A password under twelve characters is part of that 400 validation_error and not a code of its own — the minimum is the password field’s schema rule, and the error names the field, which is what a form needs to mark it. The same 400 names password when one is missing while the app’s password method is on, or sent while it is off: an email-code-only app registers people without one. 404 not_found names an app identifier no app carries, and never an address: an app identifier is already public (it is in the MCP metadata path and in the developer’s own URLs), while collapsing it into registration_closed sent a developer who mistyped their own identifier hunting a configuration bug that was not there. 409 target_state_conflict when the app has no default role — there would be no role to give the person. An app with no verify_url is not refused: the mailed link points at the hosted confirmation page instead.
409 quota_exceeded when the org is at its max_end_users limit, counted across every app of the org. It is the one refusal here that is answered before the address is looked at — and that ordering is the point rather than an implementation detail: a quota checked after the existence branch would answer 202 for an address the app already knows and 409 for one it does not, which is precisely the enumeration oracle every other line of this route exists to close. At the quota, every registration is refused identically, including one that would only have re-mailed a pending account’s link.
POST /api/client/verify-email
Spends a verification token, activates the account and answers a session.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 200 |
| Request body | client-verify-email-request |
| Response | client-sign-in-result |
Errors: rate_limited, validation_error, token_spent.
The answer is a session, not a 204 — or, as on every sign-in step, a twoFactorChallenge when the app requires two-factor (clientSignInResult). Somebody who has just proved they can read the mail should not be asked to type their password again on the next screen, and the app has an access token to carry them into it. The token is spent first and the account is activated second, as two writes: the spend is the atomic one, so a link opened twice cannot mint two sessions, but a process that died between them would leave a spent token on an account still pending_verification, whose recovery is POST /api/client/resend-verification. Spending the token also proves the address, so a later PATCH may return the account to active after a block.
One refusal for every token that does not work: 410 token_spent — unknown, past its twenty-four hours, or already used. There is one code because distinguishing them would tell a stranger whether a token ever existed, and because the recovery is the same in all three cases: ask for a fresh link with POST /api/client/resend-verification. An app rendering this refusal should offer that and nothing conditional on which of the three it was.
POST /api/client/resend-verification
Mails the verification link again, and answers the same whether or not the address exists.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 202 |
| Request body | client-resend-verification-request |
Errors: rate_limited, validation_error, not_found.
202 in status, body and timing for an address that names a pending_verification account, one that names an already-active account, and one that names nothing at all. A mail is sent only in the first case. This is the same discipline POST /api/client/register keeps, by the other door: an answer that varied here would undo it. 404 not_found is the app identifier and nothing else, exactly as on register — the address is never the subject of a refusal. Limited per app, address and IP, so this cannot be used to mail somebody repeatedly.
POST /api/client/password/reset
Mails an app user a reset link, and answers the same either way.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 202 |
| Request body | client-password-reset-request |
Errors: rate_limited, validation_error, not_found, method_not_allowed.
The pair of app identifier and address is the identifier: an app user’s address is unique only within their app. 403 method_not_allowed when the app has the password method off — a reset link whose confirmation would be refused is not mailed; the code names the app’s policy, not a person. Status, body and timing are identical for a known and an unknown address. An account with no password — one created through an identity provider — is mailed nothing and still answers 202. 404 not_found is the app identifier, never the address. The link points at the app’s reset_url, or at the hosted reset page when the app has configured none.
POST /api/client/password/reset/confirm
Spends a reset token, sets the new password and answers a fresh session.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 200 |
| Request body | client-password-reset-confirm-request |
| Response | client-sign-in-result |
Errors: rate_limited, validation_error, token_spent, method_not_allowed.
A new password does not bypass the second factor: a person with an authenticator, or in an app that requires one, gets a twoFactorChallenge instead of tokens (clientSignInResult), and the authenticator stays on. 403 method_not_allowed when the app has the password method off.
Every refresh family of that account is revoked, then a fresh pair is minted for the caller — a forgotten password is one of the two states where somebody else may be holding a live session, and the person completing the reset is the one who should keep theirs. The account is activated if it was still pending_verification: reading a mail at that address is the same proof verification asks for.
One refusal for every token that does not work: 410 token_spent — unknown, past its hour, or already used. There is one code because distinguishing them would tell a stranger whether a token ever existed, and the recovery is identical either way: ask for a new link. A replacement password under twelve characters is a 400 validation_error naming the new_password field — the twelve-character minimum is that field’s schema rule, and it is refused the way any other malformed field is.
POST /api/client/invitations/accept
Spends an invitation token, creates or activates the app user and answers a session.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 200 |
| Request body | client-accept-invitation-request |
| Response | client-sign-in-result |
Errors: rate_limited, validation_error, token_spent, email_taken, target_state_conflict, quota_exceeded.
An app invitation, not a team one. POST /api/org/invitations/accept is the other space and answers 204; this one answers a session, because the person is landing in the developer’s app and there is no second door for them to sign in through. The role is the one the invitation fixed at creation, so a later change to the app’s default role does not re-aim a link already in somebody’s inbox, and the invitation bypasses allowed_domains — a developer inviting somebody by hand has already made the decision the whitelist automates. The answer is a clientSignInResult: a twoFactorChallenge instead of tokens when the app requires two-factor. password is required while the app’s password method is on and refused while it is off, both as 400 validation_error naming the field.
One refusal for every token that does not work: 410 token_spent — unknown, expired past the seven days, revoked by the developer, or already accepted. There is one code because telling them apart would say whether a token ever existed, and because the one thing the holder of a dead link can do is ask the developer for a new one, whichever of the four it was. A chosen password under twelve characters is part of the 400 validation_error, naming the password field. 409 email_taken is an address this app has acquired since the invitation was written as an account that is already in use — the invitation stays outstanding rather than being spent, so the developer can revoke it or point the person at the login. An address that registered itself and is still pending_verification is not that state: accepting sets the password the invitee just chose, activates the account and gives it the invitation’s role, because reading the invitation mail proves the address the verification link was waiting on.
409 target_state_conflict names role_id with rule not_set when the role the invitation was fixed to has since been deleted and the app has no default role to fall back on: there is no access to hand the acceptor, and creating an account with none would be worse than saying so.
409 quota_exceeded when accepting would CREATE an account and the org is at its max_end_users limit, counted across every app of the org. An invitation that names a row the developer already created, and one whose address is held by an unfinished self-registration, both finish an account that already counts — those are not refused, because the org is not one account larger afterwards. The token is not spent by the refusal: the developer can raise the limit, or delete somebody, and the same link still works.
POST /api/client/refresh
Rotates an app-user refresh token and mints a fresh access token.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 200 |
| Request body | client-refresh-request |
| Response | session-tokens |
Errors: rate_limited, validation_error, token_expired, token_revoked.
The account is re-proved here, not just the token: refresh is where every session eventually re-proves itself, so a user who was blocked or deleted loses the family here even if the proactive revoke had not landed. A family minted from a resource-carrying token exchange keeps its audience across every rotation.
POST /api/client/logout
Revokes an app-user refresh family.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 204 |
| Request body | client-logout-request |
Errors: rate_limited, validation_error.
204, and a token the server does not recognise gets it too — the end state a caller asked for is the end state they get, and distinguishing the two would say whether a token ever existed. It answered a body until 2026-09-05, reporting what was left of the session at the identity provider; that belonged to the hosted login flow, where Fleetless owned the browser. The developer’s app owns it now and redirects to its own provider itself, knowing which one it is. Open /realtime sockets for the session are closed.
POST /api/client/password/change
Changes an app user’s own password and answers a fresh session.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Request body | password-change-request |
| Response | session-tokens |
Errors: unauthorized, token_expired, token_revoked, forbidden, validation_error, invalid_credentials, target_state_conflict, method_not_allowed.
403 method_not_allowed when the app has the password method off: a stored password stays stored but is not in use, so it is not changed either. The guard admits all three caller kinds, but a password belongs to an app user specifically — a developer bearer or a server key reaching this is 401 unauthorized. Every other session of the account ends; the answer is the replacement pair, so the tab that made the change stays signed in. An app user belongs to one app, so “every session” is this app’s. An account that has no password — an OIDC-only app user, which the schema admits — answers 409 target_state_conflict naming the password field with rule not_set, not 401: the session is live and the token is fine, it is the account that has nothing to change, and telling such a caller to sign in again sends them round a loop that ends here.
GET /api/client/me
Answers who the calling token is and what it is allowed to reach.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | client-identity |
Errors: unauthorized, token_expired, token_revoked, forbidden.
The one route that answers for all three caller kinds — a developer bearer, an app-user bearer and a server key — which is why the shape names each of developer_id, app_user_id and server_key_id and fills exactly one.
POST /api/client/two-factor/verify
Answers a two-factor challenge with an authenticator or recovery code, and answers the session.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 200 |
| Request body | client-two-factor-verify-request |
| Response | session-tokens |
Errors: rate_limited, validation_error, invalid_code, token_spent.
The challenge is the one a sign-in step answered with two_factor_required; it lives five minutes and takes five wrong codes, after which it is 410 token_spent and the sign-in starts over. A wrong code is 400 invalid_code with details.attempts_left. A code is accepted at most once: the same authenticator code sent twice, even at the same moment, signs in exactly once. A recovery code is spent by its use and audited as app_user.recovery_code_used. Exactly one of code and recovery_code, or 400 validation_error.
POST /api/client/two-factor/setup
Starts an authenticator setup and answers its secret and otpauth URL.
| Audience | client (app user or server key) |
| Auth | verified by the handler (see the notes) |
| Rate limited | yes |
| Status | 200 |
| Request body (optional) | client-two-factor-setup-request |
| Response | two-factor-setup-response |
Errors: rate_limited, validation_error, token_spent, unauthorized, target_state_conflict.
Two ways in, decided in the handler. During sign-in the body carries the two_factor_setup_required challenge, and that is the credential; from the app’s own account settings the app user’s bearer is, with no challenge and an empty or missing body. Neither is 401 unauthorized, and a dead challenge is 410 token_spent. 409 target_state_conflict names two_factor with rule off when the app’s policy is off. The secret is not in use until POST /api/client/two-factor/setup/confirm accepts a code from it; a second call replaces a pending secret, and an account that already has an authenticator keeps it until the new one is confirmed.
POST /api/client/two-factor/setup/confirm
Confirms the new authenticator with a code and answers the recovery codes and a session.
| Audience | client (app user or server key) |
| Auth | verified by the handler (see the notes) |
| Rate limited | yes |
| Status | 200 |
| Request body | client-two-factor-setup-confirm-request |
| Response | client-two-factor-setup-confirm-response |
Errors: rate_limited, validation_error, invalid_code, token_spent, unauthorized.
The same two ways in as setup. A code that does not match the pending secret is 400 invalid_code; no pending setup, or a dead challenge, is 410 token_spent. On success the authenticator is on, ten recovery codes are issued — shown this once, any earlier set void — and the answer carries a session: the one the sign-in was waiting for, or, from account settings, a fresh one while every other session of the account ends. Audited as app_user.two_factor_enabled.
DELETE /api/client/two-factor
Turns the signed-in app user’s authenticator off.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | yes |
| Status | 204 |
| Request body | client-two-factor-disable-request |
Errors: unauthorized, token_expired, token_revoked, forbidden, rate_limited, validation_error, invalid_code, target_state_conflict.
The app user’s own door; a developer bearer or a server key is 401 unauthorized, because the factor is the person’s. A current code proves they still hold the authenticator: a stolen session alone cannot remove it. The authenticator and every recovery code go. 409 target_state_conflict names two_factor with rule required while the app requires two-factor, and with rule off when there is none to remove. Audited as app_user.two_factor_disabled. The developer’s support door is DELETE /api/apps/:id/users/:userId/two-factor.
GET /api/client/providers
Lists the app’s enabled sign-in providers, so the app can draw its buttons.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | no |
| Status | 200 |
| Query | client-provider-list-query |
| Response | client-provider-list-response |
Errors: validation_error, not_found.
Answers { "providers": [{ slug, name }, …] } and nothing else: the issuer, the client id, the scopes and the linking policy are management-side facts, and this route is public. An app with no provider answers an empty array, which is the state of an app that offers password login alone; a disabled provider is not a button that refuses, it is a button that is not there.
404 not_found is the app identifier and can be nothing else — the answer does not vary by person, so there is no address here to be silent about. Not rate limited, unlike the rest of the public client family: it reads back two strings of the developer’s own public configuration, an app’s login page calls it on every render, and there is nothing behind it to enumerate. The limiter on start is where the cost of this flow actually is.
GET /api/client/oidc/:slug/start
Begins a federated sign-in and redirects the browser to the app’s identity provider.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 302 |
| Query | client-oidc-start-query |
| Path parameter | |
|---|---|
:slug |
The provider to sign in with, as listed by GET /api/client/providers; an unknown slug answers 404. |
Errors: rate_limited, validation_error, not_found, provider_disabled, invalid_redirect_uri, provider_misconfigured, idp_unavailable.
Every refusal here is JSON, answered before any redirect — the apiError envelope, not the ?error= redirect the callback uses. The difference is the open-redirect discipline: the callback knows a redirect_uri this route has already confirmed, and this route does not, so sending a browser anywhere on the strength of an unvalidated parameter is the attack rather than the error report. 400 invalid_redirect_uri is a malformed target or an origin outside the app’s allowed_origins, and it is checked first.
404 not_found is an unknown app_identifier or a slug this app does not carry; 403 provider_disabled is a slug it carries with enabled off, which is a distinction a developer’s own page can render as “temporarily off” rather than “gone”. 422 provider_misconfigured and 502 idp_unavailable are the provider’s discovery failing the two ways the create route already describes.
The app runs its own PKCE against Fleetless here, which is a second exchange independent of the one Fleetless runs against the identity provider: code_challenge binds the one-time code the callback returns to a verifier only the app’s page holds. state comes back unchanged on the success redirect and on the error redirect alike. Rate limited per ip, because this is the unauthenticated door that makes Fleetless fetch a remote system.
GET /api/client/oidc/callback
Takes the identity provider’s redirect and sends the browser back to the app.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 302 |
| Query | client-oidc-callback-query |
Errors: rate_limited.
One callback URL for every app and every provider, and the value of appAuthConfig.oidc_callback_url — the string a developer registers at their IdP. CLIENT_OIDC_CALLBACK_PATH in client-auth.ts is the single spelling of this path; the URL is that path on the cloud’s canonical public base, never a friendly alias, because the provider compares the redirect target against the one string it was given.
Rate limited per ip, generously. The first draft left this route unlimited on the argument that the caller is an identity provider redirecting somebody’s browser, so a limiter would drop real sign-ins on the strength of traffic none of those people sent. The cost of that argument is a state obtained from one start being replayable for the interaction’s full ten minutes, unbounded and unauthenticated, with every replay driving a server-side POST to the developer’s token endpoint and one audit row into their org — an amplifier against a third party. The interaction is now spent on every terminal outcome, refusals included, which closes the replay itself; the limiter is the ceiling on how fast the attempts may arrive at all. It is per ip and sized for a browser, so a person completing a sign-in never meets it. 429 rate_limited is the one apiError this route can answer, and it is not a sign-in outcome — it is a refusal to begin the work, which is why it does not ride back to the app as an ?error=.
The query is the provider’s rather than a Fleetless shape, and clientOidcCallbackQuery describes it without being strict: state always, code on success, error and error_description on the provider’s own refusal, and whatever else that provider adds — RFC 9207’s iss, a session_state, a vendor field. Refusing those would refuse conforming providers, the trap POST /mcp/oauth/register documents avoiding. state is the required field because it is the only one Fleetless minted.
It lists rate_limited and no other code, because every sign-in outcome it has is a redirect. Success and failure alike are a 302 to the app’s own redirect_uri: ?code=…&state=… when a session was resolved, ?error=<clientOidcErrorCode>&state=… when it was not, so the app renders its own message and can bind either answer to the request it started. This route renders no page for an outcome.
The one exception is a state that resolves to no interaction — unknown, hand-edited, or past its ten minutes. Then there is no confirmed redirect target to carry the answer to, and bouncing a browser to an unvalidated one is the hole the whole flow is arranged to avoid, so the cloud renders an HTML problem page at 400. It is HTML rather than an apiError, which is why no code is listed: a code here would document an envelope no caller receives, and this manifest’s other HTML pages (GET /mcp/oauth/interaction/:id, GET /console/oauth/interaction/:id) say their status in prose for the same reason.
POST /api/client/oidc/exchange
Trades the one-time code from the callback for an app-user session.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 200 |
| Request body | client-oidc-exchange-request |
| Response | session-tokens |
Errors: rate_limited, validation_error, token_spent.
The second half of the app’s own PKCE: the code from the callback redirect plus the code_verifier for the challenge start carried. Sixty seconds, single-use, and worth nothing to whoever intercepted the redirect without the verifier.
One refusal for every code that does not work: 410 token_spent — unknown, past its sixty seconds, already exchanged, or presented with a verifier that does not match. There is one code because this code is a credential: telling the four apart would say whether a given value ever existed, and the recovery is the same in all four — start the sign-in again. The MCP interaction routes collapse their four states the same way and for the same reason, and answer interaction_expired rather than this code — the difference is what the value is, not how vague the answer is: a mailed one-time code is a credential, an interaction id names a pending request, and the two deserve different advice on the app’s own page.
GET /api/client/mcp/interactions/:id
Reads a pending MCP authorization so the app can draw its own consent screen.
| Audience | client (app user or server key) |
| Auth | verified by the handler (see the notes) |
| Rate limited | no |
| Status | 200 |
| Response | client-mcp-interaction |
| Path parameter | |
|---|---|
:id |
The interaction id, as GET /mcp/:appIdentifier/oauth/authorize put it into the app’s mcp_login_url. |
Errors: interaction_expired.
The bearer is optional, which is why the credential is decided in the handler rather than by a guard. An app renders this page before it knows who is at the keyboard — the client’s claimed name, marked unverified, and the scopes it asked for — and reads the document again once the person has signed in. The only field that moves is already_granted: a grant belongs to a user, so without a token there is no user for it to be about and it is false. An app-user token for a different app is treated as absent rather than refused, for the same reason: nothing in this document is that user’s, so there is nothing to refuse them, and a 401 would break the page for somebody whose browser happens to hold another app’s session.
One code for every interaction that is not live: 410 interaction_expired. Unknown, past its ten minutes, already decided, an interaction of the central flow, or one whose authorize step never handed a browser to the app — one status and one body, so an id nobody holds cannot be told from one that ran out. The last of those is what makes the redirect stamp a real gate rather than a note: an id invented or replayed outside the flow names no interaction this route will describe, and a page reloading its own consent screen is a second read rather than a second redirect, so it keeps working. A 404 beside it would let a caller who did not start the flow ask whether somebody else’s sign-in is in progress, which is the only question this document could be used to answer. The word is still interaction_expired rather than token_spent, because an interaction id names a pending request rather than a credential and the app’s page owes the person the better advice: “that took too long, start again”.
Not rate limited, unlike most of the public client family and unlike the two decisions beside it: the id is unguessable and names a request the server already holds, the answer says nothing about any person, and the app’s consent page fetches it on every render. There is nothing behind it to enumerate — to somebody who did not start the flow, an id that resolves and one that does not are equally uninformative.
POST /api/client/mcp/interactions/:id/approve
Approves a pending MCP authorization on behalf of the signed-in app user.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | yes |
| Status | 200 |
| Response | client-mcp-interaction-decision-response |
| Path parameter | |
|---|---|
:id |
The interaction id the app read with GET /api/client/mcp/interactions/:id. |
Errors: unauthorized, token_expired, token_revoked, forbidden, rate_limited, interaction_expired, mcp_disabled.
The person is already signed in at the app, by whatever means that app uses, and this is the app telling Fleetless what they decided. Fleetless never sees that sign-in.
The guard admits all three caller kinds and the handler takes one: a developer bearer or a server key reaching this is 401 unauthorized, because a consent is a person’s and a server key is not a person — the same shape POST /api/client/password/change has. 403 mcp_disabled is the app’s switch, re-read here as it is on every request — and it is the one refusal on this surface that names the switch, because reaching it needs an app-user session of that very app.
An interaction of ANOTHER app answers 410 interaction_expired, not 403. An interaction of one app cannot be decided with a session from another — that is what stops a developer running two apps from letting one speak for the other — but saying so with a distinct code would tell any bearer holder that the id names a real, live interaction somewhere else, which is the existence answer the shared 410 exists to withhold. Unknown, expired, already decided, an interaction of the central flow, and one belonging to a different app are one status and one body. Approve and deny spend an interaction alike, so the second call gets it whichever route made the first.
Rate limited per app user, unlike the read. The read is a public document about a request the server already holds; this one spends something, and a decision is the one thing a leaked interaction id would be worth hammering for. The limit is on the signed-in account rather than on the ip, because that is what the caller has had to prove.
The answer is a redirect target, not a redirect. redirect_to is the MCP client’s own callback carrying the authorization code, and the app’s page sends the browser there. The app is holding that browser and Fleetless is answering its JSON call, so a 302 here would be a redirect on the wrong request. Approving records the grant for this user and this client, which is what a later already_granted reads back.
POST /api/client/mcp/interactions/:id/deny
Denies a pending MCP authorization on behalf of the signed-in app user.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | yes |
| Status | 200 |
| Response | client-mcp-interaction-decision-response |
| Path parameter | |
|---|---|
:id |
The interaction id the app read with GET /api/client/mcp/interactions/:id. |
Errors: unauthorized, token_expired, token_revoked, forbidden, rate_limited, interaction_expired, mcp_disabled.
The same route with the opposite decision, and it answers a redirect_to as well — the client’s own callback carrying error=access_denied. A client that is refused must learn so from the place it is waiting rather than from a page nobody sent it, the discipline POST /mcp/oauth/consent already keeps.
Two routes rather than one with a decision field, which is what the hosted consent screen has to be: there the decision arrives from a browser form, so anything that is not the Allow value must deny, and a missing field failing closed is a rule somebody has to keep getting right. Here the caller is the app’s own server-side code and the path is the decision — there is no value to misread. The refusals are the approve route’s, for the reasons stated there, including the limiter: rate limited per app user, on the signed-in account rather than the ip, because a denial spends the interaction exactly as an approval does and a caller holding a leaked id must not be able to burn other people’s sign-ins in a loop.
GET /api/client/mcp/grants
Lists the MCP clients the signed-in app user has consented to.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | mcp-consent-grant-list-response |
Errors: unauthorized, token_expired, token_revoked, forbidden.
So the developer’s app can offer a “connected apps” screen of its own, which is the only place an end user could ever be shown this: Fleetless renders no page for an app’s users, and the console is the developer’s tool rather than their customers’.
The answer is about the bearer’s own account and takes no user id — there is no id to pass and therefore nothing to pass the wrong one. The guard admits all three caller kinds because it is shared, and the handler takes one: a developer bearer or a server key is 401 unauthorized, the shape POST /api/client/password/change has, because a consent is a person’s and a server key is not a person.
Every client_name is unverified, on every row: dynamic registration takes no credential, so the name is text the client chose about itself and client_name_verified is the literal false. A screen that renders it as an identity is showing somebody a string an attacker picked, and this list is read long after the moment of approval, when nobody remembers what they clicked. Withdrawn grants are absent, not listed as withdrawn.
Not rate limited and not gated on the app’s MCP switch. It reads one small table for one account, and a person must be able to see and end what they agreed to even after a developer switches MCP off — a withdrawal door that closes with the feature is a door that is shut exactly when somebody wants it.
DELETE /api/client/mcp/grants/:clientId
Withdraws the signed-in app user’s consent to one MCP client.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 204 |
| Path parameter | |
|---|---|
:clientId |
The MCP client, as GET /api/client/mcp/grants reports its client_id. Not a uuid — the identifier dynamic registration issued. |
Errors: unauthorized, token_expired, token_revoked, forbidden.
The person’s own door, beside the developer’s DELETE /api/apps/:id/users/:userId/mcp-grants/:clientId. It acts on the bearer’s own account and on no other — the path carries a client and never a subject — so there is no user for a caller to name and none to confuse. The shared guard admits all three caller kinds and the handler takes one: a developer bearer or a server key is 401 unauthorized, because withdrawing a consent is the same person’s act as giving it. Audited as app_user.mcp_grant_revoked, with the app user themselves as the actor.
204 whether or not there was anything to withdraw. A client id this account never approved, and one it withdrew a minute ago, both answer the end state that was asked for: a 404 would tell the caller which clients some account has connected, and would make the ordinary double-click a failure. Only a withdrawal that ended a standing agreement is audited.
It ends a session already running, at that client’s very next call. The app’s MCP endpoint reads this table on every request and keys the check on the client_id the access token carries, so the withdrawn client is answered 401 with a challenge and has to ask this person again. The token it holds is still unexpired — up to fifteen minutes are left on it — and is refused anyway. Nothing else stops: this ends one client, not the account, which is what blocking would end.
Org
GET /api/audit
Reads the org’s audit log, newest first, cursor-paged over the durable sequence number.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Query | audit-query |
| Response | audit-list-response |
Errors: unauthorized, token_expired, token_revoked, validation_error.
action and action_prefix are mutually exclusive, a cross-field rule no JSON Schema can express — this route is where it is enforced. Nothing redacts an event’s details: it is returned exactly as the call site wrote it.
GET /api/audit/export
Downloads every audit event matching the same filters as a CSV attachment.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Query | audit-query |
| Response | bytes, text/csv |
Errors: unauthorized, token_expired, token_revoked, validation_error.
Answers text/csv; charset=utf-8 with a Content-Disposition attachment, not JSON — so it has no response schema. AUDIT_CSV_COLUMNS names the columns and their order. Takes the same filters as GET /api/audit but refuses before_seq and limit with 400 validation_error: an export is not a page, it is everything the filter matches up to a fixed row ceiling.
PATCH /api/org
Renames the org, requires two-factor for its members, or both.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 200 |
| Request body | patch-org-request |
| Response | patch-org-response |
Errors: unauthorized, token_expired, token_revoked, tier_required, validation_error.
Answers { "org": org }. Owner tier, and the gate runs before the body is looked at, so a malformed patch and a forbidden one answer the same way — 403 tier_required for a developer, whichever field they sent. An empty body is 400 validation_error. Writing the values already held writes nothing and records no audit event.
require_two_factor on signs nobody out: each member without a passkey or authenticator sets one up at their next sign-in, before any session exists, on the console and the central MCP endpoint alike. Server keys and robot bridges are not people and are not affected. Audited as org.two_factor_required_changed.
GET /api/org/quotas
Reports every quota’s limit next to what the org is currently using.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | org-quota-usage |
Errors: unauthorized, token_expired, token_revoked.
Every dial is read at the moment of the call and nothing is cached, so an exhausted quota is self-evident from this one answer rather than something a developer needs audit access to discover. max_end_users counts app users only — an org admin is not an app user, and counting the whole pool would report the Owner an org has by construction as consumption. It is summed across the org’s apps, because the same address in two apps is two accounts, and that sum is the number the four routes that create an app user refuse 409 quota_exceeded against: POST /api/apps/:id/users, POST /api/client/register, POST /api/client/invitations/accept, and a federated sign-in that would create an account, which carries quota_exceeded back to the app as its error redirect. A gauge nothing enforces is a number that reads as a limit and is not one.
GET /api/org/health
Reports the health of every camera and streaming resource across the org.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Query | org-health-query |
| Response | resource-health-list-response |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
?robot_id= narrows it to one robot; omitted, the answer is the whole org. Org-wide rather than per-robot because the console shows health on the robot list too, and a per-robot path would make that N requests to render one screen. This is the snapshot half of the channel; the live half is the /realtime socket.
GET /api/org/jobs
Reads durable job-run history across the org, newest first, cursor-paged.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Query | job-run-query |
| Response | job-run-list-response |
Errors: unauthorized, token_expired, token_revoked, validation_error.
Developer-only, and that is a property of the scope: a run row names the actor who invoked it, so a client-facing version would tell one end user which others have been driving the machine. Page until the cursor is null, not until a page looks short. A malformed robot_id is refused by the query schema as a validation_error; an unknown but well-formed one is an empty list, never a 404 — it is a filter.
GET /api/org/jobs/summary
Counts the running, started and failed job runs since a moment the caller names.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Query | job-run-summary-query |
| Response | job-run-summary |
Errors: unauthorized, token_expired, token_revoked, validation_error.
since_ms is required and has no default: which day “today” is, only the browser knows, and a cloud that chose its own boundary would show a developer in another timezone a number they cannot reproduce. The window is echoed back so a rendered tile can say what it is describing.
GET /api/org/latency
Reads one-minute bridge latency buckets per robot over a window the caller names.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Query | org-latency-query |
| Response | org-latency-response |
Errors: unauthorized, token_expired, token_revoked, validation_error.
Both bounds are required: the table holds a bucket per robot per minute, so “everything” is thousands of rows per robot and a default window would be a query size chosen by whoever forgot to pass one. from_ms < to_ms is a cross-field rule the published JSON Schema cannot express, so this route is the only place it is enforced. truncated costs whole robots off the end of the id order, not the tail of every series — narrow the window or name a robot_id.
GET /api/org/usage
Reads what the org consumed per day, per app and per metric.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Query | org-usage-query |
| Response | org-usage-response |
Errors: unauthorized, token_expired, token_revoked, validation_error.
A window longer than USAGE_WINDOW_MAX_DAYS is refused naming the field, not silently capped: a caller who asked for more than the platform will answer is owed a refusal, not a shorter answer they will mistake for the whole picture. from_day <= to_day is a cross-field rule no JSON Schema can express and is enforced here. The window is echoed back.
POST /api/feedback
Sends a message from a developer to the people who build Fleetless.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | yes |
| Status | 202 |
| Request body | feedback-request |
| Response | feedback-response |
Errors: unauthorized, token_expired, token_revoked, validation_error, rate_limited.
The message is stored before any mail is tried, so 202 means it is kept whatever mail says: sent, failed, or not_configured when this cloud has no feedback address. At most 10 messages per developer per hour; the 11th answers 429 rate_limited with retry_after_ms. Replies come by mail, to the sender’s address.
GET /api/org/plan
Reads the org’s plan: its limits, its usage against them, its add-ons and any change already queued.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | org-plan |
Errors: unauthorized, token_expired, token_revoked.
The one read the console’s Plan & billing page, its usage and limit gauges, and every upgrade prompt and feature gate draw from — nothing else computes limits or usage on its own. limits is already the effective ceiling, the catalogue row raised by addons or replaced by an operator’s override, so a consumer never recomputes it from the catalogue. usage is counted fresh on every call, never cached. switch is present only for an organization still on the beta that has not yet landed on a priced plan.
PUT /api/org/plan/change
Moves the org’s plan down — a lower plan or a cancellation to Basic — queuing the change rather than applying it at once.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 200 |
| Request body | plan-change-request |
| Response | org-plan |
Errors: unauthorized, token_expired, token_revoked, tier_required, validation_error, plan_limit, target_state_conflict.
Owner tier, and downward only: this route moves the org to a lower plan or cancels it outright to Basic. It never moves the org up — an upgrade or an add-on goes through the billing routes instead (POST /api/billing/checkout, POST /api/billing/change). 409 target_state_conflict names target_plan with rule not_lower when the chosen plan is not below the org’s current one; with rule locked_basic_only when the org is locked (orgLock) and the chosen plan is anything but Basic; and with rule migration_basic_only when the org is still on the beta, awaiting the switch to priced plans, and the chosen plan is anything but Basic — that choice is exactly what the org lands on at the switch. An owner is never named in keep and always stays, but still counts against the target plan’s seats; 409 plan_limit names seats when the owners alone already exceed it, and names whichever other limit keep still exceeds otherwise. keep is null when the org’s current usage already fits the target plan outright and nothing is deleted; named, it lists exactly the robots, apps, app users and developers that stay. The choice is stored as pending_change and takes effect at period_ends_at — everything of the chosen kind not named in keep is deleted at that instant, never before — except a choice made while the org is locked, which takes effect at once, and a beta org’s choice, which takes effect at the switch date instead. Anything created while the choice is pending is checked against the target plan too and, when it passes, is folded into keep, so exactly what the confirmation counted is what is actually deleted. A later PUT replaces a still-pending choice outright.
DELETE /api/org/plan/change
Withdraws a plan change that was queued but has not taken effect yet.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 204 |
Errors: unauthorized, token_expired, token_revoked, tier_required, not_found.
Owner tier. 404 not_found when the org has no pending_change to withdraw. The org stays on its current plan, unchanged, as if the choice had never been made; a developer who wants a different one sends a new PUT, which would have replaced this one outright anyway.
Billing
GET /api/billing
Reads the org’s billing account, payment method and invoices.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 200 |
| Response | billing-view |
Errors: unauthorized, token_expired, token_revoked, tier_required.
available: false when MOLLIE_API_KEY is not configured — this cloud takes no payments, and every billing route that charges or opens a Mollie checkout answers 503 billing_unavailable instead of acting; cancel, resume, the details and the VAT-ID check need no Mollie and answer normally. account is null before the org has ever checked out; the plan and its limits still come from GET /api/org/plan (fleetless/fleetless issue 103) and are not repeated here.
POST /api/billing/checkout
Starts a Mollie checkout for a plan, or an upgrade paid at once.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | yes |
| Owner tier | yes |
| Status | 201 |
| Request body | checkout-request |
| Response | checkout-response |
Errors: unauthorized, token_expired, token_revoked, tier_required, validation_error, plan_limit, target_state_conflict, rate_limited, billing_unavailable, payment_provider_unavailable.
Rate limited on the billing.checkout bucket, same as POST /api/billing/payment-method and POST /api/billing/invoices/:id/pay — the three routes that mint a Mollie checkout. 400 validation_error names who may not pay with these rules: { field: 'billing.address.country', rule: 'eu_person' } for a person in another EU country, { field: 'billing.vat_id', rule: 'vat_id_required' } for a company there with no VAT ID, { field: 'billing.vat_id', rule: 'vat_id_invalid' } once VIES has said so, and { field: 'accept_withdrawal', rule: 'required' } for a person who did not confirm it. 409 target_state_conflict names plan with rule already_billed when the org already has an active or past_due billing account — checkout is for the first payment only, every later change is POST /api/billing/change. checkout_url is Mollie’s hosted page, where the payer chooses card, PayPal or Apple Pay; the return lands on <console>/settings/billing?checkout=<checkout_id>, which polls GET /api/billing/checkout/:id until the webhook — or the poll itself — has reconciled the payment. 409 plan_limit is the org’s own usage against the plan being bought.
GET /api/billing/checkout/:id
Reads a checkout’s status, for the return page’s poll.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 200 |
| Response | checkout-status |
| Path parameter | |
|---|---|
:id |
The checkout id from checkoutResponse.checkout_id, carried on the return URL. |
Errors: unauthorized, token_expired, token_revoked, tier_required, not_found.
Calls the same reconcilePayment(deps, molliePaymentId) the webhook calls, so a return page that lands before the webhook does still sees the payment applied — this route, not the webhook, is what the dev stack and the test-mode suite rely on, since Mollie refuses an unreachable webhook URL. plan is the org’s plan after applying, unchanged unless purpose is upgrade and status is paid.
POST /api/billing/vat-id/check
Checks a VAT ID against VIES, for the checkout form and the details page.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | yes |
| Owner tier | yes |
| Status | 200 |
| Request body | vat-id-check-request |
| Response | vat-id-check-response |
Errors: unauthorized, token_expired, token_revoked, tier_required, validation_error, rate_limited.
Rate limited on its own billing.vat_check bucket, sized for a form checked on blur rather than for a checkout. Never refuses for an unverified VIES answer — the checkout itself accepts unverified and the hourly sweep re-checks it — this route only reports what VIES currently says, in a different place for the same ID, entered either at checkout or on PATCH /api/billing/details.
PATCH /api/billing/details
Edits the billing account’s invoice email or VAT ID.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 200 |
| Request body | billing-details-update |
| Response | billing-view |
Errors: unauthorized, token_expired, token_revoked, tier_required, validation_error, not_found.
404 not_found when the org has no billing account yet — there is nothing here to edit before the first checkout. A new vat_id is re-checked through VIES the same way POST /api/billing/vat-id/check does; a valid ID entered here lifts the block a definitive invalid answer placed on the next renewal.
POST /api/billing/change
Moves the org’s plan, cycle or add-ons, charging increases at once.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 200 |
| Request body | billing-change-request |
| Response | billing-change-response |
Errors: unauthorized, token_expired, token_revoked, tier_required, validation_error, plan_limit, target_state_conflict, billing_unavailable, payment_provider_unavailable.
The body names the absolute target — plan, cycle and add-ons — never a delta: the route compares it with the org’s current state and splits the difference. The increasing part is charged now, through the same proration changeNetCents computes, and takes effect the moment Mollie accepts the recurring payment with anything but failed, canceled or expired — card mandates, Apple Pay’s included, usually answer paid within seconds, a PayPal one may stay pending for a while; if it fails later, its open invoice enters dunning like a failed renewal. If Mollie refuses or does not answer, nothing is applied and this answers 502 payment_provider_unavailable instead. The decreasing part — a lower plan, yearly → monthly, fewer add-ons — is stored as a pending change and applied at the period’s end, same as PUT /api/org/plan/change; a lower plan over the target’s limits answers 409 plan_limit and the console opens its choose-what-stays page. Any increase first withdraws a pending downgrade or cancel, exactly as the admin route does. 409 target_state_conflict names billing with rule no_account (no checkout yet — use POST /api/billing/checkout), past_due (an open invoice has to be paid first) or no_valid_mandate (the payment method needs renewing first, POST /api/billing/payment-method). charged in the response is the invoice from the part charged now, or null when the whole request was a decrease.
POST /api/billing/payment-method
Starts a Mollie checkout for a new payment method.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | yes |
| Owner tier | yes |
| Status | 201 |
| Request body | payment-method-change-request |
| Response | checkout-response |
Errors: unauthorized, token_expired, token_revoked, tier_required, validation_error, not_found, rate_limited, billing_unavailable, payment_provider_unavailable.
Rate limited on the billing.checkout bucket, same as POST /api/billing/checkout. Creates a hosted first payment on the existing Mollie customer, restricted to the chosen method (card → Mollie’s creditcard, paypal, applepay). For card and paypal it is a payment of 0.00 in the org’s currency that pays no open invoice: the invoice stays open, the next dunning retry charges the new mandate — except for an invoice that was charged back, which is never charged again automatically; pay it with POST /api/billing/invoices/:id/pay — and that route still pays any open invoice at once. applepay behaves the same where Mollie accepts a zero-amount Apple Pay payment; where it does not, a change to Apple Pay is only offered together with paying an open invoice — the payment is then that invoice’s amount and pays it — and without one this answers 400 validation_error with { field: 'method', rule: 'applepay_needs_open_invoice' }. A client offers what payment_method_options in GET /api/billing lists. 404 not_found when the org has no billing account yet. When the new mandate turns valid it becomes the account’s — an Apple Pay one is a card mandate, shown as applepay — and every other mandate of the customer is revoked.
POST /api/billing/cancel
Schedules the org’s plan to cancel to Basic at the period’s end.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 200 |
| Request body (optional) | billing-cancel-request |
| Response | billing-view |
Errors: unauthorized, token_expired, token_revoked, tier_required, validation_error, not_found, plan_limit.
A body is optional — reason alone, and nobody but Fleetless reads it. 404 not_found when the org has no billing account to cancel. Takes effect at the period’s end, nothing credited or refunded; over Basic’s limits this is refused 409 plan_limit and the console sends the owner to its choose-what-stays page instead, the same as a plan downgrade. “Cancel at period end” is not a flag here — it reads as pending_change.target_plan === 'basic' on GET /api/org/plan.
POST /api/billing/resume
Withdraws a scheduled cancel, keeping the current plan.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 200 |
| Response | billing-view |
Errors: unauthorized, token_expired, token_revoked, tier_required, not_found.
404 not_found when nothing is pending — there is no scheduled cancel to withdraw. The org stays on its current plan, unchanged.
GET /api/billing/invoices/:id/pdf
Downloads one invoice’s rendered PDF.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 200 |
| Response | bytes, application/pdf |
| Path parameter | |
|---|---|
:id |
The invoice id, from billingInvoice.id in GET /api/billing’s invoices. |
Errors: unauthorized, token_expired, token_revoked, tier_required, not_found.
Rendered once, with pdfkit, when the invoice is issued, and stored as bytes — an issued invoice never changes, so this always answers the same PDF for the same id. 404 not_found for an unknown id or one from another org.
POST /api/billing/invoices/:id/pay
Pays one open invoice, through a new Mollie payment.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | yes |
| Owner tier | yes |
| Status | 201 |
| Response | checkout-response |
| Path parameter | |
|---|---|
:id |
The invoice id, from billingInvoice.id, of the open invoice to pay — or a due charge’s id, from dunning.invoice_id. |
Errors: unauthorized, token_expired, token_revoked, tier_required, not_found, target_state_conflict, rate_limited, billing_unavailable, payment_provider_unavailable.
Rate limited on the billing.checkout bucket, same as POST /api/billing/checkout. 409 target_state_conflict names invoice with rule not_open when the invoice is already paid or uncollectible — there is nothing left to pay. Paying the open invoice of a locked org unlocks it and keeps the plan running to the period’s end, the same as a renewal that succeeds on a retry. :id may also be a due charge’s id from dunning.invoice_id in GET /api/billing: while the payer’s VAT ID is unsettled a held renewal is a due charge that has no number yet, and it is numbered and issued as an invoice once this payment is paid. The payment charges the amount owed (dunning.gross_cents). While that due charge is held by the VAT ID this answers 409 target_state_conflict with { field: 'billing', rule: 'vat_id_invalid' }. 404 not_found for an unknown id or one from another org.
Team
GET /api/org/users
Lists the organisation’s Fleetless users — the team who reach the console.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | fleetless-user-list-response |
Errors: unauthorized, token_expired, token_revoked.
Fleetless users, not an app’s users. The two identity spaces are separate and nothing joins them, so an app’s users are listed per app and never appear here. There is nothing to narrow by: the group filter this route used to take described a model with no successor, and every Fleetless user of the org is in this answer.
GET /api/org/users/:id
Reads one Fleetless user of the org.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | fleetless-user |
| Path parameter | |
|---|---|
:id |
The Fleetless user’s uuid, as listed by GET /api/org/users. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
POST /api/org/invitations
Invites an address onto the team and returns the accept link.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 201 |
| Request body | create-team-invite-request |
| Response | team-invite |
Errors: unauthorized, token_expired, token_revoked, tier_required, validation_error, email_taken.
A Fleetless user, not an app user. Inviting somebody into an app is POST /api/apps/:id/invitations and is a different link into a different space. tier is required, because “I did not think about it” and “I meant developer” must not be the same request on the field that decides who can remove whom. Inviting an Owner is Owner-only — an invitation carrying tier: "owner" is a promotion with an extra step, since the response hands back the accept_url. ownerTier is false here because the gate is on that value, not on the route: any team member may invite a developer. This collection sits beside /api/org/users, not under it: an invitation is not a user yet, and the old spelling put a literal invitations where GET /api/org/users/:id expects a uuid — reachable only because a router ranks a static segment above a parametric one.
GET /api/org/invitations
Lists the pending team invitations of the org, without their tokens.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | pending-team-invite-list-response |
Errors: unauthorized, token_expired, token_revoked.
No accept_url is in this listing, and that omission is the point: it exists so an admin can spot a backdoor invitation planted for an address they merely control, not so anyone can re-read a link.
DELETE /api/org/invitations/:id
Revokes a pending invitation so its link stops resolving.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 204 |
| Path parameter | |
|---|---|
:id |
The invitation’s uuid, as listed by GET /api/org/invitations. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
An invitation that was already accepted is not pending and answers 404, the same answer one that never existed gets.
POST /api/org/invitations/:id/reissue
Mints a fresh token onto the same invitation and returns the new accept link.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | team-invite |
| Path parameter | |
|---|---|
:id |
The invitation’s uuid, as listed by GET /api/org/invitations. |
Errors: unauthorized, token_expired, token_revoked, tier_required, invalid_uuid, not_found, rate_limited.
The old link stops resolving the instant this returns — the row is looked up by token hash and the previous hash is gone. Two live links to one invitation would reopen the door the listing’s missing accept_url closes. Limited server-side to once a minute per invitation, answering 429 rate_limited with retry_after_ms; a disabled button is a hint, this is the limit. Re-issuing an owner-tier invitation needs Owner tier, exactly as creating one does.
POST /api/org/invitations/accept
Spends an invitation token and creates the account it was addressed to.
| Audience | developer (console) |
| Auth | none |
| Rate limited | yes |
| Status | 204 |
| Request body | accept-team-invite-request |
Errors: rate_limited, validation_error, token_spent, email_taken.
204, not a session. The console signs in through its own OAuth portal, so a session minted here would be a second credential door for one account — and every security property would then have to be right in two places. No password: the mailed link proves the address, so accepting needs no code either, and the new member signs in by emailed code from then on. When the organisation requires two-factor, the member sets one up at their first sign-in. Unknown, expired and already-accepted tokens collapse into 410 token_spent. A browser form post gets the rendered “you’re in” page instead.
PATCH /api/org/users/:id
Changes a team member’s display name.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | patch-fleetless-user-request |
| Response | fleetless-user |
| Path parameter | |
|---|---|
:id |
The Fleetless user’s uuid, as listed by GET /api/org/users. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, validation_error.
Nothing here has a consequence a PATCH body cannot carry: the tier is its own route, because it is owner-only and has a last-owner guard, and the address is immutable. The audit event records which fields were addressed, never their values.
DELETE /api/org/users/:id
Removes a team member and ends every session they hold.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 204 |
| Path parameter | |
|---|---|
:id |
The Fleetless user’s uuid, as listed by GET /api/org/users. |
Errors: unauthorized, token_expired, token_revoked, tier_required, invalid_uuid, not_found, last_owner.
Sessions are revoked before the row is deleted: a still-existing user with a dead session is recoverable by retrying, a deleted user whose old token still works is not. Any team invitation still outstanding for that address is expired too — a link mailed before the removal is a standing re-admission ticket. App accounts sharing the address are untouched, in this org and in every other: they are separate identities in a separate space, and deleting a colleague must not delete a customer. Removing an Owner needs Owner tier, and removing the last one is 409 last_owner.
PUT /api/org/users/:id/tier
Promotes or demotes a team member between Owner and developer tier.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 200 |
| Request body | tier-change-request |
| Response | fleetless-user |
| Path parameter | |
|---|---|
:id |
The Fleetless user’s uuid, as listed by GET /api/org/users. |
Errors: unauthorized, token_expired, token_revoked, tier_required, invalid_uuid, not_found, validation_error, last_owner.
Owner tier, unconditionally — this is the route the whole owner-exclusive list is about. A uuid that is not a Fleetless user of this org answers 404 not_found, the same as one that does not exist anywhere: the 409 target_state_conflict documented here until the two-space cut had exactly one producer, the Org Admins membership check, and went with it. Demoting the last Owner is 409 last_owner, decided by a row lock inside the writing transaction rather than by a read beforehand. Setting the tier already held changes nothing and writes no audit event. No session is revoked: a tier is re-read from the row on every request, so no issued token carries a stale copy of it.
DELETE /api/org/users/:id/two-factor
Removes a team member’s passkeys, authenticator and recovery codes and ends their sessions.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 204 |
| Path parameter | |
|---|---|
:id |
The Fleetless user’s uuid, as listed by GET /api/org/users. |
Errors: unauthorized, token_expired, token_revoked, tier_required, invalid_uuid, not_found, target_state_conflict.
Owner tier: the door for a member who lost every second factor and every recovery code. Every session of the member ends, and when the organisation requires two-factor they set one up again at their next sign-in. An owner cannot reset their own — 409 target_state_conflict naming user_id with rule self; Settings › Profile is where they change it. A member with no second factor answers 204 too. Audited as developer.two_factor_reset, naming the owner who did it.
Apps
POST /api/apps
Creates an app, optionally attaching robots to it at the same time.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 201 |
| Request body | create-app-request |
| Response | app |
Errors: unauthorized, token_expired, token_revoked, validation_error, identifier_taken, quota_exceeded.
Every robot id is checked before anything is created, so a bad one never leaves a robotless app to clean up. The identifier mcp is reserved by the central MCP server and refused as a validation_error. An app belongs to the org and to nothing inside it: the group an app used to be created in, and the 409 target_state_conflict that refused the Org Admins one, are both gone with the group model.
GET /api/apps
Lists every app in the caller’s org.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-list-response |
Errors: unauthorized, token_expired, token_revoked.
Answers { "apps": [app, …] } — the whole org, unpaged; an org’s app count is bounded by quota.
GET /api/apps/:id
Reads one app of the org, with its robots and default role.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
An app belonging to another org reads exactly like one that does not exist — 404, never a 403.
PATCH /api/apps/:id
Changes an app’s name, its attached robots or its default role.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | update-app-request |
| Response | app |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found.
A default_role_id naming a role of another app is refused: it is the one cross-app authorization check this shape can carry. Changing the robot set closes every live subscription the app’s users hold, since a grant may no longer name a reachable robot.
GET /api/apps/:id/deletion-preview
Reports what deleting the app would destroy, without destroying it.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-deletion-summary |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
The same shape the delete’s own audit event carries, computed by the same function on purpose: the confirmation dialog and the eventual receipt agree by construction, and any difference between them is real drift rather than two estimates that quietly disagree.
No force parameter, unlike the robot pair this is modelled on. A robot’s open live session is a single nameable state whose interruption is its own hazard, which is why that route makes the caller pass force explicitly. An app has no equivalent state to force past, and inventing one would be a guess wearing a guard’s clothes — this preview is the guard.
DELETE /api/apps/:id
Deletes an app and everything it produced.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 204 |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, tier_required, invalid_uuid, not_found.
Owner tier, and the gate runs after the org-scoped lookup: a developer-tier admin therefore sees the same 404 a stranger would for an app outside their org, rather than a tier refusal that confirms the id exists. A full cascade — its users, roles, server keys, invitations, OIDC provider configuration and mail templates all go, recorded once as app.deleted carrying an appDeletionSummary. Its robots are untouched: they belong to the org, not to the app.
No force parameter — see GET /api/apps/:id/deletion-preview.
POST /api/apps/:id/roles
Creates a custom role on the app.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 201 |
| Response | role |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, validation_error.
The body is { "name": string } — non-empty, trimmed, at most 60 characters as on role.name — and is deliberately not a contract shape: contracts define the role this answers with, not this one trivial request. The answer is a bare role, not an envelope, unlike the listing beside it.
GET /api/apps/:id/roles
Lists the app’s roles, builtin and custom.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | role-list-response |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Answers { "roles": [role, …] }, builtin roles included — a role a developer never created is still one a user can hold.
PUT /api/apps/:id/roles/:roleId/permissions
Replaces a role’s grants and capabilities in one write.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | role-permissions |
| Response | role-permissions |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:roleId |
The role’s uuid, from GET /api/apps/:id/roles; a role of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, validation_error.
role_id in the body must name the role in the path, compared case-insensitively — a uuid is a value, not a string, and a client that uppercases them consistently must not be refused for repeating what the path says. A grant naming a robot the app does not have is refused rather than stored: a permission for something the role cannot reach reads as authoritative to whoever writes the next consumer. Every user holding this role has their live subscriptions re-authorized.
GET /api/apps/:id/roles/:roleId/permissions
Reads a role’s grants and capabilities.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | role-permissions |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:roleId |
The role’s uuid, from GET /api/apps/:id/roles; a role of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
GET /api/apps/:id/roles/:roleId/mcp-tools
Previews the robot datasheets an MCP caller holding this role would be offered.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | mcp-role-preview-response |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:roleId |
The role’s uuid, from GET /api/apps/:id/roles; a role of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Built by the same builder the MCP server’s own robot_describe uses, so the two cannot drift. It answers what the role would be offered and consults nothing about any user’s actual MCP entitlement. A robot the role grants nothing on still appears, with an empty exposures — dropping it would read as “not attached”, which is a different fact.
PATCH /api/apps/:id/roles/:roleId
Renames a role; its users keep it.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | role-rename-request |
| Response | role |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:roleId |
The role’s uuid, from GET /api/apps/:id/roles; a role of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, validation_error, role_name_taken.
Names are unique per app, compared exactly as stored after trimming. Built-in roles can be renamed.
DELETE /api/apps/:id/roles/:roleId
Deletes a role, moving its users, pending invitations and default-role status to another role.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 204 |
| Query | role-delete-query |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:roleId |
The role’s uuid, from GET /api/apps/:id/roles; a role of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, validation_error, role_in_use, last_role.
Without move_to, a role that app users or pending invitations hold, or that is the app’s default, answers 409 role_in_use with { users, invitations, is_default }. With move_to — another role of the same app, else 400 validation_error — one transaction moves app_users.role_id, pending invitations and default_role_id, then deletes the role and its permissions. The app’s only role answers 409 last_role. Built-in roles can be deleted like any other.
POST /api/apps/:id/server-keys
Mints a server key for the app and returns the raw secret once.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 201 |
| Response | create-server-key-response |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, tier_required, invalid_uuid, not_found, validation_error.
Owner tier only: a server key carries full app rights and outlives its creator’s removal. The body is { "name": string }, the same trivial shape role creation takes. key is the only moment the raw secret exists outside the caller’s hands — it is never in a listing, never in an audit event, and cannot be read back.
GET /api/apps/:id/server-keys
Lists the app’s server keys as metadata, never the secrets.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | server-key-list-response |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Answers { "server_keys": [serverKey, …] }. serverKey names the five fields it carries rather than spreading the stored row — that is what keeps this listing from becoming a second place a credential leaves the cloud.
POST /api/apps/:id/server-keys/:keyId/rotate
Replaces a server key’s secret in place and returns the new one once.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 200 |
| Response | create-server-key-response |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:keyId |
The server key’s uuid, from GET /api/apps/:id/server-keys; a key of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, tier_required, invalid_uuid, not_found.
Owner tier, for the reason creation is. The old secret is refused from this call on, and any /realtime socket that authenticated with it is closed — rotation is what a developer reaches for when a key has leaked, and the holder of that socket is exactly who they are rotating against.
DELETE /api/apps/:id/server-keys/:keyId
Revokes a server key and closes every socket holding it.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 204 |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:keyId |
The server key’s uuid, from GET /api/apps/:id/server-keys; a key of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, tier_required, invalid_uuid, not_found.
Owner tier, like minting and rotating: all three decide who may speak for the whole app.
GET /api/apps/:id/users
Lists the app’s users — the developer’s own customers, not the Fleetless team.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-user-list-response |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Answers { "users": [appUser, …] }. A different identity space from GET /api/org/users, and nothing joins the two: an app user belongs to exactly one app, their address is unique per app rather than globally, and the same address may exist as unrelated accounts in several apps of one org. No password hash, no token and no provider secret appears here — appUser names the fields it carries rather than spreading the stored row.
POST /api/apps/:id/users
Creates an app user directly, without an invitation or a self-registration.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 201 |
| Request body | create-app-user-request |
| Response | app-user |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found, email_taken, target_state_conflict, quota_exceeded.
The developer-authenticated door into the app’s user table, and the one place 409 email_taken is an honest answer about an app user: the caller is authenticated into this app already, so telling them the address is taken discloses nothing they could not read from the listing beside it. POST /api/client/register answers 202 to the same fact, because there the caller is a stranger. 404 not_found is the app, or a role_id that is not a role of it — a role of another app is refused rather than stored, since a user holding one would carry rights nothing in this app can resolve. The password policy answers 400 validation_error, not a code of its own: the twelve-character minimum is the password field’s schema rule, and every route that takes a password refuses a short one exactly the way it refuses any other malformed field. An account created here is active immediately: a developer entering somebody by hand has made the decision the verification mail automates, and its address counts as proven. 409 target_state_conflict names default_role_id when role_id is absent and the app has no default role, or its default names a role that no longer resolves — a user with no role holds rights nothing in this app can read, so nothing is created.
409 quota_exceeded when the org holds as many app users as max_end_users allows, counted across every app of the org — the same number GET /api/org/quotas reports as usage.max_end_users, since the same address in two apps is two accounts. details carries { quota, limit }, as every count quota’s refusal does. The check is at creation only: an existing user signs in, is patched and is deleted at the quota exactly as under it, because a protection limit that also froze the accounts already made would be an outage rather than a limit.
GET /api/apps/:id/users/:userId
Reads one user of the app.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-user |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:userId |
The app user’s uuid, from GET /api/apps/:id/users; a user of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
A user of another app, or of another org, reads exactly like one that does not exist — 404, never a 403.
PATCH /api/apps/:id/users/:userId
Changes an app user’s display name, role or status.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | patch-app-user-request |
| Response | app-user |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:userId |
The app user’s uuid, from GET /api/apps/:id/users; a user of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found, target_state_conflict.
The address is immutable: it is half of what identifies the account within the app, and a rewrite would silently move every token and invitation addressed to the old one. Setting status to blocked ends every session the user holds and closes their live /realtime subscriptions — blocking somebody who keeps a working socket is not blocking them. Moving them back to active mints nothing; they log in again.
active is a way out of blocked and out of nothing else. An account still pending_verification answers 409 target_state_conflict naming status with rule unverified: activating it would let somebody who typed an address they do not own log in without ever spending the mailed token. Unblocking restores the status the account had — active for one whose address was proven, pending_verification for one blocked before it ever verified. A write that names the status the account already holds changes nothing and mints no event, so it does not end the sessions a re-sent form would otherwise have killed. A role_id naming a role of another app is 404 not_found, the same refusal creation makes.
DELETE /api/apps/:id/users/:userId
Deletes an app user and ends every session they hold.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 204 |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:userId |
The app user’s uuid, from GET /api/apps/:id/users; a user of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Sessions are revoked before the row goes, for the reason DELETE /api/org/users/:id states: a live user with a dead session is recoverable by retrying, a deleted user whose token still works is not. Outstanding invitations and unspent tokens for that address are expired with it — a link mailed before the deletion is a standing re-admission ticket. Nothing outside this app is touched: a Fleetless user sharing the address keeps their console account, and an account with the same address in a sibling app is a different person as far as this platform is concerned.
POST /api/apps/:id/users/:userId/reset-password
Mails an app user a password-reset link on the developer’s behalf.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 202 |
| Response | mail-outcome |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:userId |
The app user’s uuid, from GET /api/apps/:id/users; a user of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, target_state_conflict, method_not_allowed.
The support door beside POST /api/client/password/reset, refused like it with 403 method_not_allowed while the app has the password method off: the same one-hour token and the same link, triggered by a developer for a user who asked them rather than the form. No enumeration discipline applies — the caller is authenticated into the app and can read the user list — so this one answers what actually happened: { "mail": mailStatus }, where not_configured is a deployment without a mailer and failed is the state worth somebody’s attention. The link points at the app’s reset_url, or at the hosted reset page when the app has configured none. 409 target_state_conflict names password with rule not_set for an account that has none — an OIDC-only app user, whom a reset link would hand a second, quieter door — and status with rule blocked for a blocked one, since POST /api/client/password/reset mails a blocked account nothing and the two doors may not disagree. Setting the password directly is deliberately not offered; a developer who could would hold their customers’ credentials.
DELETE /api/apps/:id/users/:userId/two-factor
Removes an app user’s authenticator and recovery codes and ends every session they hold.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 204 |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:userId |
The app user’s uuid, from GET /api/apps/:id/users; a user of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
The support door for a person who lost their authenticator and their recovery codes. The authenticator and every recovery code go, and so does every session of the account — whoever held one may be the reason for the reset. A user with no second factor answers 204 too: that is the end state being asked for. When the app requires two-factor, the person sets it up again at their next sign-in, before any session exists. Audited as app_user.two_factor_reset.
GET /api/apps/:id/users/:userId/mcp-grants
Lists the MCP clients one app user has consented to.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | mcp-consent-grant-list-response |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:userId |
The app user’s uuid, from GET /api/apps/:id/users; a user of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
A consent is remembered so that a later authorization can skip the app’s own screen, and a client’s registration lapsing does not end it — so a person who approved something once had no way back and neither did the developer supporting them. This is the reading half of that door.
Every name here is a claim the client made about itself. Dynamic registration takes no credential, so client_name is attacker-chosen text, unverified on every row, and client_name_verified is the literal false; a console that renders it as an identity is rendering a string somebody picked. Withdrawn grants are absent rather than listed as withdrawn: the question is what is connected now.
The user is scoped to the app and the app to the org, so a user of a sibling app and one that does not exist read identically — 404, never a 403. A developer sees which clients their customer connected and nothing those clients did: this route reads the consent table alone, and no scope, token or session of the person appears in it, because the authorization server issues no scopes at all.
DELETE /api/apps/:id/users/:userId/mcp-grants/:clientId
Withdraws one app user’s consent to an MCP client, on the developer’s behalf.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 204 |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:userId |
The app user’s uuid, from GET /api/apps/:id/users; a user of another app answers 404. |
:clientId |
The MCP client, as GET /api/apps/:id/users/:userId/mcp-grants reports its client_id. Not a uuid — the identifier dynamic registration issued. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
The support door beside DELETE /api/client/mcp/grants/:clientId, which is the same act by the person themselves. Audited as app_user.mcp_grant_revoked, whose details carry the client id and the app’s uuid — and nothing else, in particular no token and no name the client chose for itself.
204 whether or not there was anything to withdraw, so a double-clicked button and a client id no grant names both land on the end state the caller asked for. The alternative — 404 for a client this user never approved — would make the route an oracle for which clients somebody has connected, answered before the listing beside it was read; and it would turn the ordinary retry into a refusal. Only a withdrawal that actually ended a standing agreement writes an audit event, so the log counts consents ended rather than buttons pressed. 404 is still the app and the user, which are the two things the caller must own.
It ends a session already running, at that client’s very next call. The app’s MCP endpoint reads this table on every request, beside the account checks it already makes, so a withdrawn client is answered 401 with the WWW-Authenticate challenge that sends it back to the consent screen. The refusal is keyed on the client_id the access token carries, so it bites at the next call rather than at the next token: that token is still unexpired — up to fifteen minutes are left on it — and is refused anyway. Only this client stops. The person’s other clients and their own use of the app are untouched, which is the difference from blocking the account (PATCH /api/apps/:id/users/:userId).
GET /api/apps/:id/invitations
Lists the app’s outstanding invitations, without their tokens.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-invitation-list-response |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Answers { "invitations": [pendingAppInvitation, …] } — pending only, since an accepted invitation is history rather than something to revoke. No accept_url, the rule the team listing already keeps: this list exists so a developer can see what is outstanding and withdraw it, and neither needs the token, while a list that carried it would turn every screenshot and browser-history entry of that page into a live credential for somebody else’s account. mail is omitted too — it described what happened at creation time, and re-serving it invites a reader to take it as current.
POST /api/apps/:id/invitations
Invites an address into the app with a role, and optionally mails the link.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 201 |
| Request body | create-app-invitation-request |
| Response | app-invitation |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found, email_taken, target_state_conflict, rate_limited.
An app user, not a team member. POST /api/org/invitations is the other space and leads to the console; this link leads into the developer’s own app. The role is resolved and stored now, so a later change to default_role_id does not re-aim a link already sent. An invitation always bypasses allowed_domains.
The answer carries accept_url: the app’s invite_url with the token in it, or the Fleetless-hosted invitation page when the app has configured none — so mailing it is never refused for a missing URL. 409 target_state_conflict names default_role_id when role_id is absent and the app has no default role, or its default no longer resolves: an invitation that names no role has nothing to hand its acceptor, so it is refused here rather than at the acceptance a week later. 409 email_taken is an address the app already has as a user; 404 not_found is the app or a role_id that is not one of its roles.
Creating shares the reissue route’s ceiling of five invitation mails a minute per app, answering 429 rate_limited with retry_after_ms: re-creating an invitation for one address replaces it and mails again, so a limit that bound only reissue would be a limit on the wrong door.
POST /api/apps/:id/invitations/:invId/reissue
Mints a fresh token onto the same invitation and returns the new link.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-invitation |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:invId |
The invitation’s uuid, from GET /api/apps/:id/invitations; an invitation of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, rate_limited.
The old link stops resolving the instant this returns: the row is found by token hash and the previous hash is gone. Two live links to one invitation would reopen the door the listing’s missing accept_url closes.
The answer carries a new id. The old row is revoked and a fresh one takes its place, so a caller holding the previous id gets 404 from its next revoke or reissue: re-read the listing after this call rather than keeping the id you sent. The seven days start again.
Limited server-side to five reissues a minute per app — shared with POST /api/apps/:id/invitations, since both mint a link and mail it — answering 429 rate_limited with retry_after_ms. A disabled button is a hint, this is the limit. An invitation that has already been accepted is not pending and answers 404.
DELETE /api/apps/:id/invitations/:invId
Revokes a pending invitation so its link stops resolving.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 204 |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:invId |
The invitation’s uuid, from GET /api/apps/:id/invitations; an invitation of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
An invitation that was already accepted is not pending and answers 404, the same answer one that never existed gets — the account it created is a user now, and deleting that is DELETE /api/apps/:id/users/:userId. A revoked token answers 410 token_spent at POST /api/client/invitations/accept, the same answer one that expired or never existed gets — the developer withdrew it deliberately, and an answer saying so would tell whoever still holds the link that it was once real.
GET /api/apps/:id/oidc-providers
Lists every OIDC provider configured on the app, enabled or not.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-oidc-provider-list-response |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Answers { "providers": [appOidcProvider, …] } — the management view, so a disabled provider is here and is absent from the public GET /api/client/providers. An app may have any number: the at-most-one rule this replaces was a property of the deleted group, not of identity, and a developer serving two customers needs two. No client secret appears in the answer, by construction of appOidcProvider — a secret a response can carry is a secret in every log that captured a response, which is the rule server keys and the deleted group provider already kept.
POST /api/apps/:id/oidc-providers
Configures an OIDC provider on the app after checking that its issuer answers.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 201 |
| Request body | create-app-oidc-provider-request |
| Response | app-oidc-provider |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found, duplicate_slug, provider_misconfigured, idp_unavailable.
Discovery runs before the row is written, so a provider that cannot work is refused while the developer is looking at the form rather than a week later in an app user’s failed sign-in. 502 idp_unavailable is an issuer that could not be reached and may work on a retry; 422 provider_misconfigured is one that answered with something unusable — not a discovery document, an issuer disagreeing with the configured one, or an authorization_endpoint, token_endpoint or jwks_uri that is not an http(s) URL — and will answer the same until somebody changes the configuration. That is the whole reason the two codes are separate: one says wait, the other says fix it.
The issuer is shape-checked by idpIssuer (http(s), no credentials, query or fragment) and that is not the SSRF defence: it cannot tell a loopback dev provider from a loopback database, and the real check refuses loopback, link-local and private ranges at the fetch itself. 409 duplicate_slug is a slug this app already uses — slugs are unique per app and immutable, since linked identities are keyed by them. 404 not_found is the app. The client secret goes in here and comes back out of nothing: not this answer, not the read, not an audit detail.
GET /api/apps/:id/oidc-providers/:providerId
Reads one OIDC provider of the app, without its client secret.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-oidc-provider |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:providerId |
The provider’s uuid, from GET /api/apps/:id/oidc-providers; a provider of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
A provider of another app, or of another org, reads exactly like one that does not exist — 404, never a 403. The stored client secret is not in appOidcProvider and there is no route that reads one back; a developer who has lost theirs sends a replacement through the PATCH.
PATCH /api/apps/:id/oidc-providers/:providerId
Changes a provider’s name, issuer, client, scopes, linking policy or enabled flag.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | patch-app-oidc-provider-request |
| Response | app-oidc-provider |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:providerId |
The provider’s uuid, from GET /api/apps/:id/oidc-providers; a provider of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found, provider_misconfigured, idp_unavailable.
slug is not in the body and offering it is a 400 validation_error naming the field, because the request is strict: the slug is in the path and is what app_user_identities rows are keyed by, so a rename would orphan every linked account. An answer that ignored it silently is the failure updateAppRequest was made strict to avoid.
client_secret absent means keep the stored one, so a routine scope edit need not put the secret back on the wire. Changing the issuer re-runs discovery, which is why this route carries the same 422 provider_misconfigured and 502 idp_unavailable the create does — and identities linked under the old issuer keep their (provider, subject) key rather than being re-resolved. 409 duplicate_slug is absent because the one field that could collide cannot be written here. Turning enabled off keeps the row and its linked identities: the provider disappears from GET /api/client/providers and a start answers provider_disabled.
DELETE /api/apps/:id/oidc-providers/:providerId
Deletes an OIDC provider and every identity linked through it.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 204 |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:providerId |
The provider’s uuid, from GET /api/apps/:id/oidc-providers; a provider of another app answers 404. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
The linked identities go with it, and the app users do not. An account that only ever signed in through this provider survives with no way back in until the developer mails them a reset link or re-configures the provider — deleting the accounts instead would make a mistyped click destroy the developer’s customers. Setting enabled to false on the PATCH is the reversible door and is what a developer switching a provider off should use; this one is not reversible, because a re-created provider with the same slug resolves no old (provider, subject) link. Answers 204 and 404 only: a provider in use is still deleted, since the alternative is a row nothing can remove.
GET /api/apps/:id/auth-config
Reads the app’s auth settings: sign-in methods, two-factor, registration, pages, the hosted look and the MCP switch.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-auth-config |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
One row per app, created with the app and never absent — an app that has configured nothing reads back the defaults rather than a 404. oidc_callback_url is in the answer and not in the request: it is minted by the cloud from its own public base URL, is the same for every app and every provider, and is the value a developer registers at their identity provider. It stays read-only on every slice write below for a second reason: a writable callback URL would let a caller point the return leg of an OIDC sign-in, which carries an authorization code, at a host they own. updated_at is read-only for a duller one: the server stamps it on every write, and a client-supplied value would be a lie about when the row last changed. hosted_pages and hosted_logo_url are read-only as well: the cloud mints both from the auth portal’s base URL and the app’s identifier.
PUT /api/apps/:id/auth-config/registration
Replaces who may self-register, and from where.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | put-app-auth-registration-request |
| Response | app-auth-config |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found.
A replace, not a merge, and .strict(): self_registration, allowed_domains and allowed_origins all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. oidc_callback_url and updated_at are the server’s, refused in this body as in every slice’s — see GET’s notes for why.
400 validation_error is where the two field rules land: an entry in allowed_domains must be lowercase, since a capitalised one can never match a lowercased address, and an entry in allowed_origins must be a bare scheme-host-port with no path, since a browser sends nothing longer in its Origin header. Each refuses at configuration time rather than failing silently later.
The merge is server-side against the stored row, so this write never disturbs another slice.
PUT /api/apps/:id/auth-config/sign-in
Replaces how the app’s users sign in and whether they give a second factor.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | put-app-auth-sign-in-request |
| Response | app-auth-config |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found.
A replace, not a merge, and .strict(): sign_in_methods and two_factor both arrive or the write is refused. Both methods off is 400 validation_error naming sign_in_methods.password — an app needs at least one door besides its identity providers.
Turning a method off refuses its routes with method_not_allowed from the next request on; a stored password stays stored. Setting two_factor to required signs nobody out: each person without an authenticator sets one up at their next sign-in, before any session exists. Audited with both old and new values. The merge is server-side against the stored row, so this write never disturbs another slice.
PUT /api/apps/:id/auth-config/urls
Replaces the app’s home page and the four pages Fleetless’s mails and MCP sign-in point at.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | put-app-auth-urls-request |
| Response | app-auth-config |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found.
A replace, not a merge, and .strict(): app_url, invite_url, verify_url, reset_url and mcp_login_url all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. oidc_callback_url and updated_at are the server’s, refused in this body as in every slice’s — see GET’s notes for why.
Each may be null, and then the Fleetless-hosted page in hosted_pages stands in for it: nothing is refused for a missing URL. 400 validation_error is where the field rules land: a URL template must be https (or http on localhost) and carry its placeholder exactly once — a second occurrence leaves one literal in a mailed link, refused here rather than failing silently once the mail is sent — and app_url takes the same host rule with no placeholder. mcp_login_url moved here from the mcp slice, because one screen owns all four pages.
The merge is server-side against the stored row, so this write never disturbs another slice.
PUT /api/apps/:id/auth-config/mcp
Turns the app’s MCP endpoint on or off.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | put-app-auth-mcp-request |
| Response | app-auth-config |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found.
A replace, not a merge, and .strict(): mcp_enabled arrives or the write is refused. It used to take mcp_login_url as well, because on without a URL refused every sign-in; the hosted MCP sign-in now stands in for an unset URL, and the URL moved to the urls slice. A body still carrying it is 400 validation_error. oidc_callback_url and updated_at are the server’s, refused in this body as in every slice’s. The merge is server-side against the stored row, so this write never disturbs another slice.
PUT /api/apps/:id/auth-config/look
Replaces the hosted pages’ accent colour.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | put-app-auth-look-request |
| Response | app-auth-config |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found.
A replace, and .strict(): hosted_accent arrives, #rrggbb in lowercase, or null for the neutral shell’s own accent. The logo is its own write, PUT /api/apps/:id/auth-config/logo, because it is an image rather than a field. The merge is server-side against the stored row, so this write never disturbs another slice.
PUT /api/apps/:id/auth-config/logo
Stores the logo the hosted pages show above the app’s name.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-auth-config |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found, unsupported_media_type.
The body is the raw image, not JSON, so it has no request schema: Content-Type is one of HOSTED_LOGO_TYPES (image/png, image/svg+xml) and anything else is 415 unsupported_media_type. At most HOSTED_LOGO_MAX_BYTES (100 KB); a larger body, or one that is not the image its type names, is 400 validation_error. A new logo replaces the stored one. The hosted pages load it from hosted_logo_url as an image only, and the cloud serves an SVG sandboxed, so a script inside one never runs.
DELETE /api/apps/:id/auth-config/logo
Removes the logo from the hosted pages.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-auth-config |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Answers the whole configuration, with hosted_logo_url now null; the hosted pages show the app’s name alone. An app with no logo answers the same: that is the end state being asked for.
GET /api/apps/:id/mail-templates
Lists the custom mail templates the app has, which may be none.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-mail-template-list-response |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Answers { "templates": [appMailTemplate, …] } with only the kinds that have a custom template — at most four. A kind that does not appear is one using the Fleetless default text, which is an ordinary state and not a missing row. Mails to Fleetless users, a team invitation or a console sign-in code, are not in this list and are deliberately not customisable: they are about this platform, not about the developer’s product.
GET /api/apps/:id/mail-templates/:kind
Reads one custom mail template of the app.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | app-mail-template |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:kind |
Which of the four mails this template replaces — a mailTemplateKind: invite, verify, reset or login_code. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found.
The kind segment is a mailTemplateKind, so a fourth word is 400 validation_error — the path names a set that is closed, and answering 404 about it would read as “this app has no such template” when the truth is that no app can. 404 not_found is the app, or a kind this app has left on the Fleetless default: there is no stored row to read back, and inventing one would present the default text as something the developer wrote.
PUT /api/apps/:id/mail-templates/:kind
Stores or replaces the app’s template for one kind of mail, refusing one that does not render.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | put-app-mail-template-request |
| Response | app-mail-template |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:kind |
Which of the four mails this template replaces — a mailTemplateKind: invite, verify, reset or login_code. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found, template_invalid, rate_limited.
The body carries subject, text and an optional html, each a Liquid template; kind is in the path and updated_at is the server’s, so neither may arrive. text is required even when html is given — a mail with no text part is unreadable to a client that refuses HTML.
Liquid runs in strict mode and every part is rendered here before anything is stored, so 422 template_invalid is an unknown variable or a syntax error rather than an empty line in a mail somebody already received. Its details is a mailTemplateProblemDetails naming which of the three parts failed and the renderer’s own message, because an error that did not say which leaves the developer re-reading all three. The permitted variables are MAIL_TEMPLATE_VARIABLES and the set is closed. Rendering here promises nothing about send time: a template that fails for one recipient falls back to the Fleetless default and writes an audit event, and no answer on this route can say otherwise.
A rendered part is capped while it is being written, so a template that would produce megabytes answers 422 template_invalid rather than building the string first. Limited server-side to ten calls a minute per app, shared with the preview, answering 429 rate_limited with retry_after_ms.
DELETE /api/apps/:id/mail-templates/:kind
Drops the app’s custom template for one kind, returning that mail to the Fleetless default.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 204 |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:kind |
Which of the four mails this template replaces — a mailTemplateKind: invite, verify, reset or login_code. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found.
The mail keeps being sent — this removes the developer’s wording, not the message. A kind that already has no custom template answers 404 not_found rather than 204: there is nothing here to reach the end state of, and the two facts are worth telling apart to somebody who thinks they still have a template stored.
POST /api/apps/:id/mail-templates/:kind/preview
Renders a template with sample data and answers the three parts, storing nothing.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | mail-template-preview-request |
| Response | mail-template-preview-response |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:kind |
Which of the four mails this template replaces — a mailTemplateKind: invite, verify, reset or login_code. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found, template_invalid, rate_limited.
Takes the same document the PUT does and writes nothing, so a developer can see the rendered subject, text and HTML before anybody receives them. The sample data fills every variable in MAIL_TEMPLATE_VARIABLES, including link, which is a plausible URL and not a live token. 422 template_invalid carries the same mailTemplateProblemDetails the PUT does, which is the point of previewing: the error arrives on the screen where the template is being written. 404 not_found is the app — a kind with no stored template previews perfectly well, since the body being rendered is the one in the request.
The sample data does not vary with the kind. Every kind renders against one fixed set: an invite-shaped link and expires_in_hours: 24, where a real reset mail says 1 and a real invitation says 168. A preview shows how the template renders, not what the recipient of that kind will read.
Limited server-side to ten calls a minute per app, shared with the PUT, answering 429 rate_limited with retry_after_ms: rendering is synchronous CPU work on the shared cloud and an unbounded loop of it is a denial of service against every other org.
POST /api/apps/:id/mail-templates/:kind/test
Sends the rendered template as a real mail to the calling developer.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 202 |
| Request body | mail-template-preview-request |
| Response | mail-outcome |
| Path parameter | |
|---|---|
:id |
The app’s uuid, as returned by POST /api/apps or listed by GET /api/apps. |
:kind |
Which of the four mails this template replaces — a mailTemplateKind: invite, verify, reset or login_code. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, validation_error, not_found, template_invalid, rate_limited, target_state_conflict.
The recipient is the calling developer’s own address and cannot be chosen. A test send that named an arbitrary address would be a mail relay with an authentication step in front of it. The body and the sample data are the preview’s, so what arrives is what the preview showed, in a real client with real HTML.
The answer is { "mail": mailStatus } rather than an empty 202, because the one thing a developer needs next is whether a mail actually left: not_configured on a deployment with no mailer looks exactly like a successful send otherwise, and they wait for a message nobody posted. 409 target_state_conflict is that state made explicit where the deployment can already tell — there is no mailer configured at all, so nothing will be attempted. 422 template_invalid refuses before sending, and 429 rate_limited bounds how often this can be used to mail anybody, the developer included.
Robots
POST /api/robots
Creates a robot and returns its bridge token once.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 201 |
| Request body | create-robot-request |
| Response | create-robot-response |
Errors: unauthorized, token_expired, token_revoked, validation_error, quota_exceeded.
token is the only moment the raw bridge token exists outside the caller’s hands — the cloud stores a hash, so nothing can read it back and a caller who loses it rotates rather than recovers. Audited: this mints a credential that can speak for the org from anywhere, and the event carries no details, because the one interesting value here is the token. max_robots is checked before anything is created, which is only safe because robot deletion exists.
POST /api/robots/:id/token/rotate
Mints a new bridge token for the robot and invalidates the old one.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 201 |
| Response | robot-token-rotate-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, tier_required, invalid_uuid, not_found.
Owner tier, behind the org-scoped lookup, so a developer-tier admin sees the 404 a stranger would for a robot outside their org rather than a tier refusal that confirms the id exists. token is the only moment the new secret exists outside the caller’s hands — the cloud stores a hash — so a caller who loses it rotates again. Audited as robot.token_rotated, with no details: the one interesting value here is the token.
It stops the bridge that is connected right now. The old secret is gone the instant the hash is replaced, so the cloud closes that socket with CLOSE_TOKEN_ROTATED rather than leaving a bridge speaking on a credential nothing would accept again. A bridge that does not know the code reconnects and is refused at hello as invalid_token, which is the honest answer and ends the same way. The robot is offline until somebody puts the new token on it — this is a deliberate interruption, not a background rekey, and a fleet cannot be rotated without a visit to each robot.
PUT /api/robots/:id/urdf/joint-state
Chooses the datapoint whose joint positions move the robot’s URDF, or clears it.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | joint-state-put-request |
| Response | joint-state-put-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, validation_error.
What qualifies: a datapoint of the published configuration whose ROS type is sensor_msgs/msg/JointState and which carries no field — the whole message, because positions and names arrive together and a single extracted field is half of a pose. Anything else is a validation_error naming that rule rather than a stored mapping that renders a battery reading as a robot. { "slug": null } clears it, which is why the field is required and nullable rather than optional.
The mapping cannot outlive what it points at. Every successful publish re-checks it against the new document and clears it when it no longer qualifies, recording robot.joint_state_cleared with the version that did it; a slug rename rewrites it like every other reference the editor already rewrites; deleting the robot takes it along. Every write through this route — a slug or null — is on the record too, as robot.joint_state_set with the actor and the slug, so a clear a person made is never mistaken for one a publish made. The stored value reads back on GET /api/robots/:id/assets as joint_state_slug, so a renderer fetches the URDF, the meshes and the mapping from one place.
GET /api/robots
Lists the org’s robots with their connection state and exposure counts.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | robot-list-response |
Errors: unauthorized, token_expired, token_revoked.
GET /api/robots/:id
Reads one robot with its published configuration state and live bridge state.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | robot-detail-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
A robot belonging to another org reads exactly like one that does not exist — 404, never a 403.
PATCH /api/robots/:id
Renames the robot.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | patch-robot-request |
| Response | patch-robot-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, validation_error.
Answers { "robot": robot }. The lookup runs before the body is parsed, so a robot outside the caller’s org answers 404 whether or not the body was also malformed. Saving the name already held writes nothing and records no audit event.
GET /api/robots/:id/deletion-preview
Reports what deleting the robot would destroy, without destroying it.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | robot-deletion-summary |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
The same shape the delete’s own audit event carries, computed by the same function on purpose: the confirmation dialog and the eventual receipt agree by construction, and any difference between them is real drift — a robot that kept recording in between — rather than two estimates that quietly disagree.
DELETE /api/robots/:id
Deletes a robot and everything it produced.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 204 |
| Query | robot-delete-query |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, tier_required, invalid_uuid, not_found, robot_in_use, robot_deletion_partial.
Owner tier, and the gate runs after the org-scoped lookup: a developer-tier admin therefore sees the same 404 a stranger would for a robot outside their org, rather than a tier refusal that confirms the id exists. A full cascade — everything the robot produced goes, except the audit trail, which is a record of what happened and must survive the thing it happened to. An open live session is 409 robot_in_use unless ?force=true is passed, matched as the bare string so the caller has to actually say it. A cascade that fails partway is 500 robot_deletion_partial with the progress, never a bare internal_error that would read as “nothing happened”.
PUT /api/robots/:id/details
Replaces the developer-maintained details document shown alongside the robot.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | put-robot-details-request |
| Response | put-robot-details-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, validation_error.
Answers { "details": robotDetailsDoc } — the stored document, which is the one that was sent. The update is fanned out to every /realtime subscriber of the robot_details built-in, so a client watching the robot sees the new document without polling.
GET /api/robots/:id/datapoints
Lists the datapoints of a robot, filtered to what the caller’s role grants.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | datapoint-list-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found.
A developer bearer sees the robot’s whole list unfiltered; an end user or a server key sees only the slugs their role grants, and a robot their app does not attach answers 404 exactly as one that does not exist.
GET /api/robots/:id/exposures
Lists every grantable slug of a robot with its kind — the material the roles matrix is built from.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | exposure-list-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Developer-only: this is what a role could be granted, which is a configuration fact rather than something an end user is entitled to enumerate.
GET /api/robots/:id/datapoints/:slug
Reads the latest value of one datapoint.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | datapoint-value |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
:slug |
The datapoint’s slug from the published configuration, as listed by GET /api/robots/:id/datapoints. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found, unknown_datapoint, no_data.
For a client caller the grant check runs before any existence lookup, with no extra query on either path to time: a denied slug and a nonexistent one must be one answer. That is why an ungranted slug is 403 forbidden while a granted-but-unconfigured one is 404 unknown_datapoint and a configured one with no sample yet is 404 no_data — three facts a caller who is entitled to them needs told apart. The plane built-ins (bridge_state, robot_details) answer here too, without appearing in any document.
GET /api/client/robots
Lists the robots the caller reaches, with bridge state and the published configuration version.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | client-robot-list-response |
Errors: unauthorized, token_expired, token_revoked, forbidden.
The REST twin of the MCP tool robots_list, and the one robot question no robot-scoped route can answer: which robots may I name at all. An app user sees the robots their app attaches on which their role grants at least one slug or capability; a server key sees every robot its app attaches; a developer bearer sees the organisation’s robots. Name order, id as the tiebreak. A robot on which the role grants nothing is absent rather than listed empty — the same answer robots_list gives, for the same reason: reach is a grant, not an attachment. Under /api/client/ because it names no robot; every robot-scoped read stays under /api/robots/:id/….
GET /api/robots/:id/datasheet
Describes everything the caller’s role lets them do on one robot, with parameter schemas.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | mcp-robot-datasheet |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as GET /api/client/robots lists it. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found.
The REST twin of the MCP tool robot_describe: one answer per robot — every datapoint, action, service, publisher and camera the role grants, each with its input_schema where it takes parameters, plus the two capabilities that gate whole features, action_history and assets. A robot with nothing published answers an empty exposures list, never a refusal. A robot the caller does not reach — not attached to their app, or attached with a role that grants nothing on it — answers 404 exactly as one that does not exist. The app-user datapoint and camera listings under this prefix stay; this is the one read that also names actions, services, publishers and capabilities, which is what an app needs before it can draw a screen.
GET /api/robots/:id/introspection
Reads the cached ROS graph of a robot and whether it is stale.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | introspection-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
A robot that has never been introspected answers 200 with a null body, not a 404: an enrichment that has not happened yet is not a missing resource. The response schema describes the non-null case. stale is true whenever the bridge is offline — the snapshot survives a disconnect, since a robot that has never connected is still configurable.
POST /api/robots/:id/introspection/refresh
Asks the robot for a fresh ROS graph, stores it and answers it.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | introspection-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, robot_offline, bridge_timeout.
stale is false by construction here: the graph came from the robot just now. 409 robot_offline means nothing is connected; 504 bridge_timeout means something was and did not answer. Anything else is rethrown rather than turned into a tidy status.
GET /api/robots/:id/types
Lists every ROS message type definition stored for the robot.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | types-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
A plain read with no bridge involved — the robot need not be online.
POST /api/robots/:id/types/fetch
Fetches named message type definitions from the robot and stores them.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | fetch-types-request |
| Response | fetch-types-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, validation_error, robot_offline, bridge_timeout.
unresolved names the types the robot could not produce; it is an answer, not a failure, because a graph often references a type whose package is not installed. The robot lookup runs before the body is parsed, so a robot outside the caller’s org answers 404 whether or not the body was also malformed.
GET /api/robots/:id/datapoints/:slug/history
Reads recorded samples of one datapoint, or aggregated buckets over a window.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Query | history-query |
| Response | history-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
:slug |
The datapoint’s slug from the published configuration. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found, validation_error, invalid_range, not_recorded, not_aggregatable.
Two answers, carried by one union (historyResponse): without window it is a historySamplesResponse, with one it is a historyBucketsResponse, told apart by kind. window and agg must be given together or not at all — one without the other is refused rather than defaulted, since a silently chosen aggregation is a chart that lies quietly. A range and window that would produce more buckets than limit is 400 invalid_range computed before the query runs: the bucket response carries no truncated field, so a refusal is the only honest answer. 409 not_recorded says retention is off for this slug right now and deliberately does not claim the table is empty — rows written before the switch was flipped still exist, unreadable through any route and still counting against the quota.
Configuration (draft/publish)
GET /api/robots/:id/config/draft
Reads the robot’s configuration draft, its author text and its current issues.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | config-draft-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Issues are recomputed on every read and every write, so an editor never has to guess whether it may publish. doc is null for a draft that is valid YAML but not a Fleetless configuration — a state the format admits and the publish route refuses.
PUT /api/robots/:id/config/draft
Replaces the draft with the author’s text and answers the parsed document with its issues.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | put-config-draft-request |
| Response | config-draft-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, validation_error, invalid_yaml, unstorable_yaml.
The request carries the text, not a document: the author’s comments and layout are what a restore has to give back, so the source is what is stored and the document is derived from it. Text that is not YAML at all is 422 invalid_yaml, and text that parses but cannot be stored — an anchor cycle, say — is 422 unstorable_yaml. A document with schema errors is still stored, because the draft is where a developer works; publishing is where the errors block.
POST /api/robots/:id/config/publish
Publishes the draft as an immutable version and sends it to the robot.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | publish-config-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, validation_error, draft_not_a_document.
A draft that is valid YAML but not a Fleetless configuration is 422 draft_not_a_document, carrying every issue rather than the blocking subset — nothing about that text is publishable, so there is no subset to pick, and the warning naming the checks that could not run is part of reading the list correctly. A document with severity: "error" issues is 422 validation_error with just those. The draft’s own text travels into the version, so a restore later returns what the author wrote rather than a re-rendering of it.
GET /api/robots/:id/config/versions
Lists the published configuration versions of a robot with their publish times.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | config-versions-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
GET /api/robots/:id/config/versions/:v
Reads one published version: its document and the author text it was published from.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | config-version-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
:v |
The version number, as listed by GET /api/robots/:id/config/versions. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
A :v that is not a version number and one that names no version of this robot are the same 404; the refusal quotes what the caller actually sent.
POST /api/robots/:id/config/versions/:v/restore
Copies a published version back into the draft, text and document both.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | config-draft-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
:v |
The version number, as listed by GET /api/robots/:id/config/versions. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Both halves, not just the document — a restore that put back the document alone would hand the author a configuration stripped of every comment they wrote, which is the loss this format exists to prevent. The answer is read off the row that was written, not off the version that was meant to be written. Nothing is published: the restored draft still has to be published to reach the robot.
POST /api/robots/:id/config/rename-slug
Renames a slug in the draft and moves every role grant and the recorded history with the name.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Request body | rename-slug-request |
| Response | rename-slug-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, validation_error, draft_not_a_document, unknown_slug, reserved_slug, duplicate_slug, history_migrating, internal_error.
One transaction: the draft document and every app-role grant carrying the slug are rewritten, and the recorded history moves with the name without rewriting any stored sample, so a rename takes the same time however much history there is. Samples the robot still sends under the old slug until the next publish join the renamed history. The published configuration is immutable, so requires_publish says the rename is not live on the robot yet. A draft that is not a document is 409 draft_not_a_document — the same word the usage preview uses for the same state. While the recorded history is being migrated the rename is refused with 409 history_migrating; nothing is written, and the same request succeeds once the migration has finished.
GET /api/robots/:id/config/slug-usage/:slug
Reports what a rename of one slug would touch, before a developer confirms it.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | slug-usage-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
:slug |
The slug in the draft whose blast radius is being previewed. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found, draft_not_a_document.
A draft that is valid YAML but not a Fleetless document is refused rather than answered with alert_count: 0: the two states are this slug has no alerts and there is no document to ask, and a zero cannot tell them apart — it would show a smaller blast radius than the rename actually has. The other three counts are real whatever the draft holds, and a partial answer to a preview whose whole purpose is to be complete is not worth the ambiguity.
Alerts
GET /api/robots/:id/alerts
Lists a robot’s alerts as defined in its published configuration, joined with their runtime state.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Response | alert-list-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, invalid_uuid, not_found.
Read-only, and that is the design. An alert used to be created, edited and deleted through this file; it is now a key in the published document, which is what makes every change to one versioned, comparable and revertible. The published version is read, never the draft: an alert typed but not published is evaluated by nothing, and reporting its state would claim a reading no machine has taken.
GET /api/org/alerts
Lists every firing alert across the org, with the robot each belongs to.
| Audience | developer (console) |
| Auth | developer session |
| Rate limited | no |
| Status | 200 |
| Query | org-alerts-query |
| Response | org-firing-alerts-response |
Errors: unauthorized, token_expired, token_revoked, validation_error.
?state=firing is required and is the only value accepted — refused rather than silently ignored, because a door with one answer must not advertise a dial. A firing row whose definition has left the document, or has been disabled, is skipped: it can never be evaluated again, so it can never resolve, and it would otherwise sit in the overview’s open-issues tile forever.
Commands (jobs, publishers)
GET /api/robots/:id/jobs
Reads the current job on every slug of the robot the caller is granted.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | robot-jobs-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found.
At most one entry per slug, and not a history endpoint. The first version answered every job the registry still held — six rows and four full result payloads after a few minutes of traffic on one robot, unbounded for a robot that has run all day. This reads the one-current-job-per-slug map instead. It exists because the per-slug route alone cannot cover it: a reconciled-but-unminted job, or one left on a slug a republish removed, has no slug-shaped door to be found through.
GET /api/robots/:id/jobs/history
Reads what has run on the robot, newest first, cursor-paged.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Query | job-run-query |
| Response | job-run-list-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found, capability_required, validation_error.
Needs the action_history capability, and this route is what makes that switch mean something — it was unkeepable while nothing durable recorded what had run. Two residuals worth stating rather than implying away. history is a syntactically valid slug, and a static segment matches before a parameter, so a robot with a service literally slugged history can no longer be read through GET /api/robots/:id/jobs/:slug; invoking, cancelling and the listing are unaffected. And a run row names its actor by email address, so an end user holding this capability learns which other people have been driving the machine. robot_id in the query is shared with the org-wide read; a different one here is refused rather than quietly answered about the robot in the path.
POST /api/robots/:id/jobs/:slug
Invokes an action or calls a service on the robot.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 202 |
| Request body | invoke-request |
| Response | invoke-or-service-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
:slug |
The action or service slug from the published configuration — the cloud already knows which kind. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found, validation_error, parameter_invalid, robot_offline, busy, bridge_timeout, internal_error.
One route for both kinds, because a path segment naming the kind would demand a fact a role grant does not carry. An action answers 202 with an invokeResponse the moment the job exists; a service answers 200 with a serviceCallResponse once the result is in — two shapes, carried by one union (invokeOrServiceResponse) and told apart by whether kind or a bare result arrives. Parameters are checked before anything about the world (offline, busy): the same request must get the same verdict whether or not the robot happens to be reachable, or a developer testing against an offline robot never learns their parameters were wrong. A slug is 409 busy while it holds a running job, an unknown one the robot has not accounted for yet, or an external goal someone else started; the refusal’s details.running names that job, state and origin included. A service the robot reports as failed answers 502 carrying the job’s own error code, which is an open set and not one of the codes above.
GET /api/robots/:id/jobs/:slug
Reads the most recent job on one slug.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | job-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
:slug |
The action or service slug from the published configuration. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found.
job is null when nothing has ever run on that slug — an answer, not a 404.
POST /api/robots/:id/jobs/:slug/cancel
Cancels the job running on one slug.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Request body (optional) | cancel-request |
| Response | job-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
:slug |
The action slug from the published configuration; a service slug is refused. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found, validation_error, not_cancellable, robot_offline, cancel_rejected, bridge_timeout, unknown_slug, action_server_lost, internal_error.
The body is optional: a bodyless POST was every caller’s shape before job_id existed, and absent or job_id: null both mean “cancel whatever is running”. A named job_id that is not what is running cancels nothing and answers 404 — the caller named an id and thereby ruled the other one out. An external job is cancelled the same way, through its goal id. Cancelling an unknown job also cancels every external goal on its action, since one of them may be that job. A service is 422 not_cancellable: a service call has no goal to cancel. Nothing running is a 200 with job: null. The answer waits for the bridge’s cancel_result: a 200 means the action server accepted the cancel request, not that the goal has ended — the job’s end arrives as its own update. Any goal answered ERROR_REJECTED makes it 409 cancel_rejected, with every goal and its return_code in details.goals; no answer within JOB_HEARTBEAT_TIMEOUT_MS is 504 bridge_timeout; a cancel the bridge could not send at all is 502 carrying the bridge’s own code (unknown_slug, action_server_lost, internal_error).
POST /api/robots/:id/publishers/:slug
Publishes one message onto a configured publisher.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 204 |
| Request body | publish-request |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
:slug |
The publisher slug from the published configuration. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found, validation_error, parameter_invalid, robot_offline, publisher_busy.
Fire and forget — not a job, so there is nothing to poll and nothing to cancel. A publisher is held exclusively by one caller until it has been quiet long enough, and another caller meanwhile is 409 publisher_busy with the timeout and a retry hint. Parameters are checked before offline and before exclusivity, the same order the invoke path uses and for the same reason. The acquisition is audited, not every message: auditing only takeovers left the single-operator case with no record of who was driving at all.
Cameras
GET /api/robots/:id/cameras
Lists the cameras of a robot, filtered to what the caller’s role grants.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | camera-list-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found.
GET /api/robots/:id/cameras/:slug/snapshot
Returns the most recent snapshot frame as image bytes.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | bytes, image/* |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
:slug |
The camera slug from the published configuration, as listed by GET /api/robots/:id/cameras. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found, no_snapshot_yet.
Image bytes, not JSON, so it has no response schema. contentType is the family rather than a type: the frame is served in the mime the producer sent it as, so which image format arrives is the camera configuration’s answer, not this route’s. The age, capture time and dimensions ride in the x-fleetless-* headers SNAPSHOT_HEADERS names — which a browser can only read because CORS exposes them. Never checks whether the bridge is online: a snapshot read is a pure cache read, which is what makes “the last frame, with its real age” true for free across a disconnect. There is nothing here to refuse, and age_ms carries the whole honesty story. cache-control: no-store, because a picture of someone’s premises does not belong on disk longer than the request that fetched it.
GET /api/robots/:id/cameras/:slug/snapshot/meta
Reports the age and dimensions of the latest snapshot without downloading it.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | snapshot-meta-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
:slug |
The camera slug from the published configuration, as listed by GET /api/robots/:id/cameras. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found.
Exists so a client polling at the camera’s own interval does not re-fetch a whole frame merely to learn whether a newer one arrived. Nothing captured yet is nulls, not a 404: “nothing yet” is an answer.
POST /api/robots/:id/cameras/:slug/live
Takes a hold on a live camera stream and returns a room token.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 201 |
| Response | live-session-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
:slug |
The camera slug from the published configuration, as listed by GET /api/robots/:id/cameras. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found, robot_offline, camera_offline, live_unavailable.
Refcounted: the first viewer starts the robot publishing and the last release stops it. No token is ever minted for an ungranted or offline camera — both refusals return before the hold is taken. 409 camera_offline means the robot itself reported the failure; 502 live_unavailable means this cloud could not start the stream. The difference matters, and it is why a failure the robot named is never dressed up as one this side invented.
DELETE /api/robots/:id/cameras/:slug/live
Releases a live hold, one session or all of this caller’s.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 204 |
| Query | release-live-query |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
:slug |
The camera slug from the published configuration, as listed by GET /api/robots/:id/cameras. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found.
?session_id= releases that one hold; omitting it releases every hold this caller’s identity has on this camera, which a client that lost its id — or a tab that is already closing — still needs. A malformed session_id is 400 invalid_uuid, never a silent fallback to the blunt form, which would strand this identity’s other tabs over a typo. A named-and-unknown id is 404; a stale one, real and already ended, is idempotently 204.
Assets (URDF, meshes)
GET /api/robots/:id/assets
Lists the robot’s synced assets and how complete its URDF is.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | asset-list-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found, capability_required.
Needs the assets capability, refused as 403 capability_required rather than a bare forbidden: the code says a capability is missing and the message says which, so a developer who switched the wrong toggle on is told what to switch. The capability is checked before existence, so a denied robot and an absent one read alike to a caller with no right to tell them apart. urdf reports whether a URDF is present and which of its mesh references have no stored asset.
GET /api/robots/:id/assets/:assetId
Returns one stored asset as bytes.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | bytes, application/octet-stream |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
:assetId |
The asset’s uuid, as listed by GET /api/robots/:id/assets. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found, capability_required, internal_error.
Bytes, so it has no response schema. contentType here is the floor, not the answer: the header carries the asset’s own stored media type when that type is on the cloud’s allow-list, and application/octet-stream only when it is not — an allow-list rather than a pass-through, because a stored type is developer-supplied and a browser will act on it. X-Content-Type-Options: nosniff rides along for the same reason. A row whose blob has vanished from object storage is a logged 500 internal_error, not a 404: the asset exists and this cloud could not read it, which is a different fact from “there is no such asset”.
GET /api/robots/:id/urdf
Returns the robot’s URDF with every mesh reference rewritten to a Fleetless URL.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | bytes, application/xml |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found, capability_required, internal_error.
XML, so no response schema. Every filename is rewritten, not only a resolvable package:// one — an absolute URL that arrived in a URDF from ROS graph input must never be served through untouched, because a mesh loader attaches the caller’s bearer token to whatever absolute URL it is handed. Anything with no stored asset points at GET /api/robots/:id/assets/missing instead. A robot with no synced URDF is 404.
GET /api/robots/:id/assets/missing
The placeholder a rewritten URDF points at for a mesh Fleetless does not hold.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 404 |
| Query | missing-asset-query |
| Path parameter | |
|---|---|
:id |
The robot’s uuid; an end user reaches it through an app that attaches it. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found, capability_required, asset_missing.
This route has no success answer — 404 asset_missing naming the unresolved reference is what it exists to give, and status says so rather than declaring a 200 no caller can ever receive. ?name= is echoed into the message and changes the sentence, never the outcome; it discloses nothing, since it is what the caller sent. It carries the same assets capability gate as the real bytes would: a missing-asset placeholder is not an exemption from the authorization the thing it stands in for needs.
GET /api/asset-links/missing
The bearer-free placeholder a linked URDF points at for an unresolvable mesh.
| Audience | client (app user or server key) |
| Auth | verified by the handler (see the notes) |
| Rate limited | no |
| Status | 404 |
Errors: asset_missing.
No success answer either, for the reason its authenticated twin has none. Unauthenticated by design and unauthenticated in fact: it reads nothing and reveals nothing the caller did not put in the query string itself, so there is no credential for the handler to verify and none is required. It sits under the signed-link prefix because that is where a linked URDF’s references have to point.
GET /api/asset-links/:token
Serves one asset, or a rendered URDF, to whoever holds a signed link.
| Audience | client (app user or server key) |
| Auth | verified by the handler (see the notes) |
| Rate limited | no |
| Status | 200 |
| Response | bytes, application/octet-stream |
| Path parameter | |
|---|---|
:token |
The signed, time-limited link an MCP tool minted; it is the whole credential. |
Errors: not_found, internal_error.
The token is the authorization — there is no route guard on purpose, and verifying it is the whole gate. An MCP session token is refused on REST by design, so the asset tools mint a fifteen-minute signed link instead and this spends it. The capability was checked at mint against the minting caller’s own access; the residual — whoever holds the URL reads that asset until it expires — is named rather than closed by a second gate, which would be a different policy for one decision. Every refusal collapses into one 404 with one message, including a malformed id inside a validly signed token, because a link holder has no business learning which of them it was. A URDF served this way has its own references minted as links, back-dated so they expire with the parent — otherwise spending a link in its last second would hand out another fifteen minutes, and each of those another. contentType is the floor, not the answer: an asset is served in its own stored media type where that type is allow-listed and application/octet-stream otherwise, and a linked URDF is application/xml. cache-control: no-store, since the URL itself is the credential.
POST /api/robots/:id/assets/sync
Asks the robot to upload its URDF and meshes, and returns the sync id.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 202 |
| Request body | asset-sync-request |
| Response | asset-sync-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, forbidden, tier_required, invalid_uuid, validation_error, not_found, robot_offline, busy.
Owner tier, unconditionally. The guard admits an end user or a server key, but only a developer session gets past the handler — and the body is parsed before that 401, because this route has always answered a malformed body first and the order has to survive. The request is strict: a caller naming a source that does not exist learns so, instead of silently getting a bridge sync they did not ask for. A robot that has reported nothing available to sync is 404. A second sync is 409 busy naming the sync_id that is actually running, so the caller who pressed the button twice can pick it straight up.
GET /api/robots/:id/assets/sync/:syncId
Reports how far an asset sync has got.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Status | 200 |
| Response | asset-sync-status |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
:syncId |
The sync id from POST /api/robots/:id/assets/sync, or from its 409 busy refusal. |
Errors: unauthorized, token_expired, token_revoked, forbidden, invalid_uuid, not_found.
Developer sessions only, like starting a sync: the guard admits three caller kinds and the handler answers 401 unauthorized to the other two. A sync belonging to another robot reads exactly like one that never existed, which is why the robot is resolved first.
DELETE /api/robots/:id/assets
Empties a robot’s asset store: every URDF, mesh and texture, gone at once.
| Audience | client (app user or server key) |
| Auth | an app user’s token or a server key, or a developer session |
| Rate limited | no |
| Owner tier | yes |
| Status | 200 |
| Response | assets-clear-response |
| Path parameter | |
|---|---|
:id |
The robot’s uuid, as returned by POST /api/robots or listed by GET /api/robots. |
Errors: unauthorized, token_expired, token_revoked, forbidden, tier_required, invalid_uuid, not_found, busy.
The store’s escape hatch: a full store is never a dead end, and this is the blunt third of the three answers to it — the URDF upload is exempt from the gate, reconcile after a sync already frees what the new URDF stopped referencing, and this route lets an Owner clear the robot outright. Owner tier, unconditionally, like starting a sync. Removes every asset of the robot and resets its store to 0; the next sync fills it again. It does not touch the bridge’s availability report — urdf_available still answers from the connected robot, unrelated to what this cloud happens to have stored. A clear while a sync is running is 409 busy naming that sync’s details, the same refusal starting a second sync gets, because deleting under a running upload would leave the store counter wrong.
MCP
GET /.well-known/oauth-protected-resource/mcp
Publishes what the MCP endpoint says about who may authorize for it.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | no |
| Status | 200 |
| Response | protected-resource-metadata |
Errors: none beyond the transport-level ones.
RFC 9728, for the one central MCP endpoint. resource and authorization_servers are the same URL: the MCP server is its own authorization server here. One document for the whole deployment, because there is one endpoint and it is scoped to nothing narrower: every Fleetless user of every org authorizes for the same resource, and the token names the person.
GET /.well-known/oauth-authorization-server/mcp
Publishes the authorization-server metadata an MCP client reads to sign a person in.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | no |
| Status | 200 |
| Response | authorization-server-metadata |
Errors: none beyond the transport-level ones.
registration_endpoint being present is the whole point of the dynamic-registration work: a client that finds it registers itself and never asks a person for a client_id. authorization_endpoint is the only field that moves to the auth-portal origin when one is configured — issuer, token_endpoint and the resource identifier stay canonical, because a client checks a token’s iss and aud against those strings and moving them would invalidate every token ever minted.
POST /mcp/oauth/register
Registers an MCP client dynamically, with no app identifier and no human in the loop.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 201 |
| Request body | dynamic-client-registration-request |
| Response | dynamic-client-registration-response |
Errors: rate_limited.
RFC 7591. The request schema is what this endpoint accepts, not what it parses: the handler reads the body field by field, because §3.2.2 distinguishes invalid_redirect_uri from invalid_client_metadata and one safeParse failure cannot say which of the two a caller earned. The shape is deliberately not strict, which is the schema agreeing with §3.1 rather than a gap in it — a conforming client sends client_uri, logo_uri and software_id, and both the schema and the server ignore them. client_name and redirect_uris are the two fields read; grant_types, response_types and scope are accepted and ignored. What comes back is what was actually granted, which §3.2.1 allows a server to substitute — this authorization server grants authorization_code and refresh_token to every registration. The registration carries a TTL. Refusals are oauthError; the rate limiter answers apiError.
GET /mcp/oauth/authorize
Starts an MCP sign-in and redirects the browser to the identify card.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | no |
| Status | 302 |
| Query | oauth-authorize-query |
Errors: none beyond the transport-level ones.
The query schema is what this endpoint accepts, not what it parses: the handler reads it parameter by parameter because the answers differ, and one parse would collapse them. Client and redirect_uri are validated first and a failure there never redirects, the same open-redirect discipline the app flow applies; those refusals are oauthError. Exact redirect_uri matching for both client kinds — the loopback-port wildcard of RFC 8252 §7.3 belongs to the one central client alone, whose URIs are configured ahead of time and cannot name an ephemeral port. A client that registered itself seconds ago can name the port it bound, and widening the wildcard there would only widen where a stolen client_id may send a browser. Nothing about the person is decided here — the next card asks for an email address, or a passkey, and the steps after it resolve the account; this route knows only the client.
POST /mcp/oauth/token
Exchanges an MCP authorization code for an access token.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | no |
| Status | 200 |
| Request body | oauth-token-request |
| Response | oauth-token-response |
Errors: none beyond the transport-level ones.
authorization_code mints an mcp_session access token bound to the central resource and a refresh token; refresh_token rotates that pair, and the presented refresh token is consumed — a second presentation revokes the session, as on /api/auth/refresh. The refresh token lives ninety days from its last use and is bound to the client_id it was issued to. A refresh re-reads the Fleetless user, so a removed account cannot refresh. Refusals are RFC 6749 §5.2’s oauthError, so this route emits none of the codes in this reference. The code is single-use, PKCE-verified, and its resource must match the audience it was authorized for; a resource on a refresh must match the session’s audience, and is checked before the token is consumed.
POST /mcp
The central MCP endpoint: a stateless Streamable HTTP transport carrying the robot and console tool catalogs.
| Audience | client (app user or server key) |
| Auth | verified by the handler (see the notes) |
| Rate limited | no |
| Status | 200 |
Errors: unauthorized, forbidden.
JSON-RPC over MCP’s Streamable HTTP, so neither the request nor the response is a shape contracts describes; the tool arguments and results are the schemas in each tool definition. Fleetless users only — an app’s users reach their own app endpoint instead. The bearer is verified inside the handler, not by a route guard: the identity comes from the token and the path names none, and the refusal has to carry a WWW-Authenticate challenge that a guard shared with the REST surface does not send. Origin is checked against the cloud’s own, and a foreign one is the 403 forbidden above. Both catalogs, unconditionally: every caller admitted here is a Fleetless user, so the tool list has nothing left to vary with and the admin-ness check on tools/call is gone — a console tool that is still narrower than the catalog refuses for itself (console_robot_delete answers tier_required to a non-Owner). 403 forbidden is also what an mcp_session token whose subject is an app user gets: this endpoint serves the team only, and such a token belongs to its own app’s endpoint. The code is forbidden rather than mcp_disabled because nothing is switched off — the caller is at the wrong server — and per-app sign-in mints exactly such tokens, so the two states must not share a word. mcp_access_denied is gone with the per-user override and the group flag it read: every Fleetless user has MCP access here. Stateless: a fresh transport per request, no session id, nothing survives the call.
POST /mcp/:appIdentifier
One app’s MCP endpoint: the same stateless Streamable HTTP transport, carrying that app’s robots.
| Audience | client (app user or server key) |
| Auth | verified by the handler (see the notes) |
| Rate limited | no |
| Status | 200 |
| Path parameter | |
|---|---|
:appIdentifier |
The app’s public identifier — app.identifier, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool. |
Errors: not_found, unauthorized, forbidden.
JSON-RPC over MCP’s Streamable HTTP, so neither the request nor the response is a shape contracts describes — exactly as POST /mcp is, and stateless for the same reason: a fresh transport per request, no session id, nothing surviving the call. App users only. The tools are this app’s robots filtered by the caller’s role, built by the same builder GET /api/apps/:id/roles/:roleId/mcp-tools previews, so the console’s preview and the live catalog cannot drift. The console tool family belongs to the central endpoint and is offered here to nobody.
The bearer is verified inside the handler, not by a route guard, for the two reasons the central endpoint gives — the identity comes from the token and the path names none of it, and the refusal has to carry a WWW-Authenticate challenge a guard shared with the REST surface does not send. The challenge names this app’s protected-resource document (RFC 9728’s resource_metadata), which is how an MCP client discovers the right authorization server from a bare 401; pointing it at the central document would send every app’s client to the wrong sign-in.
404 not_found covers an identifier no app carries AND an app whose appAuthConfig.mcp_enabled is off — one answer for both, the same one the two metadata documents and register give. A separate 403 mcp_disabled here would hand an anonymous caller a three-way oracle (404 = no such app, 403 = the app exists with MCP off, 401 = the app exists and is live), which is exactly the distinction discovery collapses; there is no point collapsing it in one place and publishing it in another. The switch is re-read on every request rather than cached off the token, so a developer turning it off ends the sessions already running, and it is decided before the bearer is looked at — the reverse of the usual order, and deliberate: it is a fact about the path, an app identifier is public, and an absent server that answered 401 would send a client hunting a credential no credential can satisfy. 401 unauthorized is a missing, unverifiable or expired bearer, an aud that is not this endpoint, or a consent this person has since withdrawn from the client the token was minted for: the access token names its client, and the standing consent is re-read here on every request exactly as the account is, so DELETE /api/client/mcp/grants/:clientId and its developer twin bite at the next call rather than when the token expires. 403 forbidden is a token that verifies and is not this app’s user: another app’s session, a Fleetless user’s central mcp_session, an account that is blocked or still pending_verification, or a foreign Origin.
GET /mcp/:appIdentifier
Answers the standalone SSE stream’s GET, which a stateless transport does not serve.
| Audience | client (app user or server key) |
| Auth | verified by the handler (see the notes) |
| Rate limited | no |
| Status | 405 |
| Path parameter | |
|---|---|
:appIdentifier |
The app’s public identifier — app.identifier, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool. |
Errors: not_found, unauthorized, forbidden.
MCP’s Streamable HTTP gives this path three verbs: POST carries JSON-RPC, GET opens the server-initiated SSE stream, and DELETE ends a session. This server has no sessions — the argument is in MCP_PROTOCOL_VERSION’s own note, and a per-process session map is what breaks at the second cloud instance — so GET and DELETE answer 405, which is what a client is built to fall back from.
The 405 is this cloud’s own answer, not the SDK’s, and the two differ: MCP SDK 1.30.0 opens an SSE stream on GET (handleGetRequest) and answers 200 on DELETE (handleDeleteRequest), neither of which a stateless server has any business doing, so the cloud writes the 405 itself in the transport’s own JSON-RPC error shape with Allow: POST.
The row exists so that the 405 is not a 404. An unregistered verb answers 404, and at a path whose last segment is an app identifier a 404 already means no such app — so an unregistered verb and an unknown app would answer identically. Registering the verb lets the endpoint say “this app’s server is here; this verb is not part of it”. The central /mcp registers neither verb and does not need to: its path takes no parameter, so nothing can misread its 404.
The 405 body is the transport’s JSON-RPC error object, not the apiError envelope. The three codes above are the refusals that come first — the app, its switch, then the bearer, in the order POST describes — and they are apiError because they are answered before the transport is reached at all. If this server ever becomes stateful, this row and the DELETE beside it are where that lands, and the cloud’s route-manifest test is what would make both repositories notice.
DELETE /mcp/:appIdentifier
Answers the session-termination DELETE, which a stateless transport has no session to end.
| Audience | client (app user or server key) |
| Auth | verified by the handler (see the notes) |
| Rate limited | no |
| Status | 405 |
| Path parameter | |
|---|---|
:appIdentifier |
The app’s public identifier — app.identifier, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool. |
Errors: not_found, unauthorized, forbidden.
The other half of what the GET row above explains, and registered for the same reason: without a row here, a client tidying up after itself would read 404 and could not tell a stateless server from an app that does not exist. 405, from the same transport, with the same three refusals ahead of it. A caller that wants a session to end simply stops sending requests — there is no server-side state for this verb to remove, which is the point rather than a limitation.
GET /.well-known/oauth-protected-resource/mcp/:appIdentifier
Publishes what one app’s MCP endpoint says about who may authorize for it.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | no |
| Status | 200 |
| Response | protected-resource-metadata |
| Path parameter | |
|---|---|
:appIdentifier |
The app’s public identifier — app.identifier, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool. |
Errors: not_found.
RFC 9728, for the resource <PUBLIC_API_BASE_URL>/mcp/<identifier>. resource and authorization_servers are the same URL: each app’s MCP server is its own authorization server, as the central one is, and that identity is what keeps one app’s tokens out of another’s — the audience a token carries is this app’s endpoint URL and nothing broader.
The identifier goes last, after the document name. §3.1 inserts /.well-known/oauth-protected-resource before the resource’s path, so the document for /mcp/<id> is at /.well-known/oauth-protected-resource/mcp/<id>; a hand-written /.well-known/oauth-protected-resource/<id> is a path no conforming client ever fetches. MCP_APP_PATHS builds both, which is why this row does not spell either.
An app with MCP switched off answers 404, the same as an identifier no app carries, and that is a decision rather than a gap. A metadata document is present or it is absent; 403 is not a state a client’s discovery code models, and one that met it would either error out or retry forever. Nothing is being hidden — the identifier is public and is in this very path — the two answers are simply the same answer: there is no MCP server here to authorize for. Every other unauthenticated route on this surface says the same — the authorization-server document, register, authorize and the transport itself all answer 404 for both states, so nothing an anonymous caller can reach distinguishes them. A person whose app has the switch off learns that from the console, not from a status code a stranger can also read.
GET /.well-known/oauth-authorization-server/mcp/:appIdentifier
Publishes the authorization-server metadata an MCP client reads to sign in to one app.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | no |
| Status | 200 |
| Response | authorization-server-metadata |
| Path parameter | |
|---|---|
:appIdentifier |
The app’s public identifier — app.identifier, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool. |
Errors: not_found.
RFC 8414, for the issuer <PUBLIC_API_BASE_URL>/mcp/<identifier> — the same path rule as the document above, and the same 404 for a switched-off app. registration_endpoint is present for the reason the central document states: a client that finds it registers itself and never asks a person for a client_id.
issuer, token_endpoint and the resource identifier are minted from the canonical public base, never from the friendly mcp.fleetless.dev alias or the request’s Host, because a client checks a minted token’s iss and aud against these exact strings.
Unlike the central document, authorization_endpoint does not move to an auth-portal origin: the authorization step renders no page itself. It redirects to the app’s own mcp_login_url, or to the hosted MCP sign-in on the auth portal when the app has configured none.
POST /mcp/:appIdentifier/oauth/register
Registers an MCP client dynamically for one app, with no human in the loop.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | yes |
| Status | 201 |
| Request body | dynamic-client-registration-request |
| Response | dynamic-client-registration-response |
| Path parameter | |
|---|---|
:appIdentifier |
The app’s public identifier — app.identifier, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool. |
Errors: rate_limited, not_found.
RFC 7591, the same wire and the same handler as POST /mcp/oauth/register — one implementation, because a second answer to “is this redirect URI acceptable” would agree with the first only by luck. The request schema is what the endpoint accepts rather than what it parses, for the reason that row gives: §3.2.2 needs two distinguishable refusals and one safeParse failure offers one. client_name and redirect_uris are read; grant_types, response_types and scope are accepted and ignored, and what comes back is what was actually granted, which §3.2.1 allows — this authorization server grants authorization_code and refresh_token to every registration. The registration carries a TTL.
The registration is scoped to this app. A client_id minted here authorizes at this app’s endpoint and nowhere else, so a client registered against one app cannot walk into another’s authorize with it, and a developer who switches MCP off is not left with strangers’ registrations valid somewhere adjacent.
Refusals are oauthError; the rate limiter and 404 not_found answer apiError. That 404 covers an unknown identifier and an app with the switch off, mirroring the two metadata documents this endpoint is discovered from — a client that could not read those has no business registering here, and giving it a third distinct answer would only tell it something the documents deliberately do not.
GET /mcp/:appIdentifier/oauth/authorize
Starts an MCP sign-in and redirects the browser to the app’s own login page, or to the hosted one.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | no |
| Status | 302 |
| Query | oauth-authorize-query |
| Path parameter | |
|---|---|
:appIdentifier |
The app’s public identifier — app.identifier, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool. |
Errors: not_found.
The same query as GET /mcp/oauth/authorize, read the same way — parameter by parameter, because the answers differ and one parse would collapse them.
This route renders no page. It writes an interaction — ten minutes, as the OIDC ones live — and redirects to appAuthConfig.mcp_login_url with {interaction} filled in. The app then authenticates the person with its own UI, reads GET /api/client/mcp/interactions/:id to show the client’s claimed name and the scopes it asked for, and calls approve or deny. An app with no mcp_login_url is redirected to the hosted MCP sign-in (GET /app/:appIdentifier/mcp/:interaction), which runs the same steps on the auth portal; nothing is refused for a missing URL.
Client and redirect_uri are validated first and a failure there never redirects — the open-redirect discipline GET /mcp/oauth/authorize and GET /api/client/oidc/:slug/start both keep — and those refusals are RFC 6749’s flat oauthError, which is why none of them appear above. redirect_uri is matched exactly against the registration, with no loopback-port wildcard: every client here registered itself minutes ago and can name the port it bound, so a wildcard would only widen where a stolen client_id may send a browser.
The code above is the apiError envelope because it is a refusal about the app, decided before an OAuth parameter is looked at. 404 not_found covers an identifier no app carries AND an app with MCP switched off — the same single answer the two metadata documents, register and the transport give. An earlier draft answered 403 mcp_disabled here, on the argument that a client which registered while the switch was on is owed the difference between “turned off” and “mistyped”; that argument does not survive the caller being anonymous. This route takes no credential, so the extra code was readable by anyone who could type an identifier, and it handed back precisely the existence distinction every neighbouring route collapses. mcp_disabled survives only where the caller has already proved they belong to the app — the two decision routes under /api/client/mcp/interactions/:id.
POST /mcp/:appIdentifier/oauth/token
Exchanges one app’s MCP authorization code for an access token.
| Audience | client (app user or server key) |
| Auth | none |
| Rate limited | no |
| Status | 200 |
| Request body | oauth-token-request |
| Response | oauth-token-response |
| Path parameter | |
|---|---|
:appIdentifier |
The app’s public identifier — app.identifier, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool. |
Errors: none beyond the transport-level ones.
authorization_code, PKCE-verified and single-use, and refresh_token, which rotates the pair the exchange minted; the refresh token lives ninety days from its last use, is bound to its client and to this app, and a refresh re-reads the app user’s status and their standing consent to the client, so a block or a withdrawn consent ends the session at its next refresh at the latest. The aud is this app’s endpoint URL on the canonical public base, and the code’s resource must match it — that is the whole of what stops a token minted for one app being spent at another’s endpoint.
Every refusal is RFC 6749 §5.2’s oauthError, so this route emits none of the codes in this reference — including the ones about the app. An unknown identifier and a switched-off app are invalid_client here, not the 404 and 403 the authorize route beside it answers. The difference is who reads the answer: authorize is walked by a browser and its refusal is read by a person, while this endpoint is called by a client’s own code in the middle of a flow, and handing that code an envelope its OAuth library cannot parse turns a clean refusal into an unexplained crash.
Realtime and bridge transports
GET /realtime
The client WebSocket: subscriptions on datapoints, jobs, bridge state and presence, plus full command parity with REST.
Authentication happens in the first frame, not on the upgrade. The frame types are the realtime schemas.