Errors and reason codes
A robot hears "no" in two ways. An HTTP error means the request couldn't be answered as sent: a signing problem, a bad body, too many requests. A refusal is a normal answer, HTTP 200, that says the building won't let your robot through now, and why. Handle them separately: fix an HTTP error in your software, and act on a refusal's code.
The error body
Every HTTP error has a JSON body:
{"code": "signature_mismatch", "error": "signature mismatch", "time": 1792562594.0, "request_id": "ece7c2db7072b481"}
code: a stable code for your software. Where nothing more particular applies, it's the status's own code, from the table below.error: plain words, for people and logs. The words may change, so act oncode.request_id: this request's ID. Every answer, error or not, also carries it in theX-Request-Idheader. Quote it when you write to Plynth about a call.time: on a 401 only, the gateway's clock in unix seconds, to check yours against.
New codes may be added. Handle one you don't know by its status.
Status codes
| Status | code | When | What to do |
|---|---|---|---|
| 400 | bad_request | Bad JSON, a body that isn't a JSON object, a missing or malformed field, or a passage request over 4,096 bytes of JSON. error starts "bad request:" and says what's wrong | Fix the request: sent again unchanged, it gets the same answer |
| 401 | unsigned, signature_mismatch, timestamp_skew | The request isn't signed, isn't signed right, or its timestamp is off (below) | Fix the signing, or the clock |
| 403 | not_for_this_key | A sandbox visitor key tried something other than its trips and its heartbeat | Use your company's own keys |
| 403 | read_only_key | A read-only key made a call other than GET | Use the robot's secret for that call |
| 403 | forbidden | The stream, for a suspended robot or a stopped company; or a ROS 2 file the owner doesn't share with your company, with reason_code trip_only | Ask the building; don't reconnect in a loop |
| 404 | not_found | An unknown path; a passage, booking, charger slot, help request, handoff, emergency or trip file that doesn't exist or isn't your robot's (the same answer for both, on purpose); or a sandbox-only call in a building | Check the path and the ID |
| 405 | method_not_allowed | PUT, PATCH or DELETE, or HEAD on anything but the public pages. The Allow header says what's accepted | Use GET or POST |
| 409 | conflict | A passage ID used before with other contents or by another robot; a report out of order; a cancel once aboard; ready before your turn; a trip file not released yet, or expired; an emergency that's over; a handoff already collected; a booking past the 20 a robot may hold open | Read error: the same call won't succeed as it is |
| 411 | length_required | A chunked body. The connection closes | Send the body with Content-Length |
| 413 | too_large | A body over 64 KiB (the connection closes), or over 2 KB from a visitor key | Send less |
| 429 | rate_limited | Past a limit (below) | Wait Retry-After seconds |
| 503 | unavailable | Something unexpected on the gateway | Treat it as no; ask again later with the same passage_id |
| 502, 503, 504 | none | Not the gateway's answer: something in between, such as a proxy or the site's network, answering for a gateway it couldn't reach. The body isn't the gateway's JSON and has no request_id | Treat it as no answer, as a timeout (below) |
Asking for a door or elevator that doesn't exist isn't a 404: it's a refusal, unknown_resource.
When you get 401
code | error | What it means | What to do |
|---|---|---|---|
unsigned | send X-Robot-Id, X-Timestamp and X-Signature | One of the three headers is missing or empty | Send all three on every call but health and the API spec |
signature_mismatch | signature mismatch | The secret is wrong or was replaced, the robot ID isn't known here, or what you signed isn't what arrived. The answer is the same for each, on purpose, so it never says whether a robot ID exists | Check the robot ID and the secret, then work through Signing requests |
timestamp_skew | timestamp outside ±30 s of gateway time | The signature is right, since the gateway checks it first, but X-Timestamp is more than 30 seconds from the gateway's clock | Compare your robot's clock with time in the answer, and fix it with NTP |
In the raw answer, the ± arrives escaped, as \u00b1: compare code, or compare error after parsing the JSON. After Rotate on your company's page, the old secret works for 24 hours more; after Revoke Old, or when the building replaces a secret, it gets signature_mismatch at once. The gateway logs every refused call, so fix the cause before you send again.
429 and Retry-After
Requests are limited per robot and per caller address. The sandbox's limits are on the sandbox page; a building may set its own. A 429 carries Retry-After, in whole seconds, at least 1, and the same number as retry_after in its body: wait that long, then send again (the kit's client gives it as GatewayError.retry_after). Handle 429 by its status, and back off further if they keep coming.
Sending again safely
- No answer is a timeout, a dropped connection, or a 502, 503 or 504 that isn't the gateway's own JSON. Treat it as no, and use your own fallback; the kit's client raises it as
URLError, anOSError, neverGatewayError. Then send the same passage request again, unchanged, with the samepassage_id: you get its current status, and no second command is sent. Reconnect a dropped stream withLast-Event-ID. - After a 503 from the gateway itself, do the same.
- The reports
passed,boardedandexited, a cancel, and a check-in are each safe to repeat. - A call that got 400, 401, 403, 404 or 409 gets the same answer if you send it again unchanged.
Methods
The API takes GET and POST. HEAD answers the public pages (/v1/health, /v1/openapi.yaml, /docs and its pages, /status) with their headers and no body; anywhere else, it's 405. OPTIONS answers 204 with Allow: GET, POST, OPTIONS, and HEAD too on the public pages. PUT, PATCH and DELETE answer 405, with the same Allow.
GET /v1/health needs no signature and answers {"ok": true}, with the gateway's release in the X-Gateway-Release header and the API's version in X-Api-Version. Watch it from your own monitoring.
Refusals
A refusal is a normal answer, HTTP 200, with state DENIED, or FAILED if something stops the passage later:
{"passage_id": "3f2b9c1e-6a4d-4e8b-9f1a-2c7d5e0b8a14", "resource_id": "lift", "state": "DENIED",
"reason": "closed: Car being serviced", "reason_code": "closed", "retry": true, "retry_after": 1200,
"car": null, "done": false, "approval_code": null, "line": null, "alone": false, "arrived": null}
- Act on
reason_code, never on the words inreason. retryis true if waiting can help, and false if it needs the owner or your company. It's null where the gateway can't say.retry_afteris the seconds until waiting should help, where the gateway knows. Otherwise it's null, and you back off as you see fit.- Treat a code you don't know as a refusal, and a state you don't know as not final. New codes may be added; existing ones never change meaning.
The same codes appear elsewhere: in GET /v1/places, for why a place isn't open to your robot now; in notices (booking conflict, directions changed, stop); and in directions that are refused. The guide has more. On the sandbox, the scenario places give closed, outside_hours, approval_declined, device_refused and fire_recall whenever you ask them.
Reason codes
A few codes come from the refusal's own words, which say more: closed (the reason gives the closure's), timeout (it says in which state), gateway_restarted, emergency (which one, and where), fire_recall, and device_refused (what the door or elevator answered). Each has retry true. no_route comes from directions only: every way there is closed to your robot now. other is anything else: read the reason. The rest are in this table, from the gateway's own list.
| reason_code | Waiting can help | The refusal says, for example |
|---|---|---|
access_ended | no | company's access has ended |
access_not_started | yes | company's access hasn't started |
approval_declined | no | approval declined |
approval_timeout | yes | no approval in time |
badge_inactive | no | site credential not active |
busy_can_wait | yes | elevator busy: this group can wait |
busy_group_share | yes | elevator busy: group's hourly share used |
busy_robot_limit | yes | elevator busy: robot's hourly limit reached |
cancelled | no | cancelled by robot |
cant_cross | no | can't cross this threshold or slope |
city_paused | no | paused by the city: a parade, return to your hub |
city_rule | it depends | city rule: Pier Plaza, 4 robots there now |
closed | yes | closed: the floor is being waxed |
company_stopped | no | company stopped |
curb_rule | it depends | curb rule: no stopping, 7 to 10 am |
device_refused | yes | open refused: the controller refused to open it |
do_not_disturb | no | do not disturb: Room 412, take it to the front desk |
earthquake | yes | earthquake |
egress_only | no | emergency exit: egress only |
elevator_budget | yes | elevator robot budget reached |
emergency | yes | emergency: fire alarm on 2 |
evacuation | yes | evacuation |
fire_alarm | yes | fire alarm |
fire_recall | yes | fire service recall |
floors_required | no | ride must name its floors |
gateway_restarted | yes | gateway restarted while CAR_CALLED |
insurance_expired | no | insurance expired |
insurance_missing | no | no insurance on file |
layout_missing | no | door's floor isn't in the site layout |
line_expired | yes | waited too long in line |
line_waiting | yes | robots are waiting in line |
lost | no | lost: stop and ask for help |
mantrap_people_only | no | mantrap is for people: robots use the dock |
missed_turn | yes | missed its turn |
no_robot_window | yes | no-robot window |
no_route | it depends | no way there: Lab Door in the way |
no_visitor_pass | no | no visitor pass |
not_on_route | no | not on its route |
not_permitted | no | not permitted |
other | it depends | anything else: read reason |
outside_floors | no | outside this company's floors |
outside_hours | yes | outside route hours |
outside_pass | no | outside its visitor pass |
over_rated_load | no | over the elevator's rated load |
paused | yes | all robots paused |
payload_refused | no | not for clean loads |
ride_alone_unavailable | no | riding alone isn't available here |
robot_suspended | no | robot suspended |
room_vacant | no | vacant: Room 412 has checked out, take it to the front desk |
rule_off | no | no route switched on |
tenant_approval | no | no tenant approval |
tenant_pending | yes | tenant approval pending |
tenant_revoked | no | tenant revoked its approval |
timeout | yes | timeout while CAR_CALLED |
too_big | no | too big for this door or car |
trip_only | no | the owner shares this robot's trips, not whole floors |
unknown_floor | no | floor not served here |
unknown_outcome | yes | unknown outcome after restart |
unknown_resource | no | unknown resource |
work_order_required | no | work order required |