# FuelAtlas Public API — full developer reference for LLMs This single file concatenates the FuelAtlas developer guides, the error-code registry, the design limits, and a condensed endpoint reference generated from the OpenAPI spec. It is deterministic and safe to embed. Human docs: https://www.fuelatlas.com/developers. Interactive API reference: https://www.fuelatlas.com/developers/api. Base URL: https://api.fuelatlas.com — all endpoints are under /v1. Auth: Bearer API key (fa_live_ / fa_test_); partner keys pass FA-Fleet: flt_...; scopes gate each endpoint. --- ## Scopes - fleets:manage: Create and manage fleets (partner keys only). - vehicles:manage: Create, read, update, deactivate vehicles. - locations:write: Ingest vehicle location breadcrumbs. - fuelings:write: Record and read fuelings. - routes:calculate: Compute routes and fuel plans. - guidance:read: Read continuous-autoguide sessions and recommended stops. - guidance:manage: Set destination, acknowledge/skip stops, cancel guidance. - prices:read: Query stations and prices (nearby / along a route). - trips:manage: Create and manage dispatch trips and their stops. - webhooks:manage: Register and manage webhook endpoints and deliveries. - usage:read: Read usage, savings reports, and metering. --- # Quickstart: TMS Provision a fleet, add vehicles, stream locations, and read fuel guidance — end to end. This is the full path for a TMS that manages many fleets: from a partner key to live fuel guidance for a truck, in about fifteen calls. Run it against the [sandbox](/developers/guides/sandbox) first (`fa_test_` key) — every step is identical in production. You'll use a **partner key**, which manages many fleets and selects the acting fleet per request with the `FA-Fleet` header. See [Authentication](/developers/guides/authentication) for the key model. ## 0. What you need A partner key from the [Developer Console](/dev-console) with these scopes: `fleets:manage`, `vehicles:manage`, `locations:write`, `guidance:read`, `guidance:manage`, `usage:read`. Confirm it: ```bash curl https://api.fuelatlas.com/v1/me \ -H "Authorization: Bearer $FA_KEY" ``` The response shows `partner`, your `scopes`, and your plan. If `partner` is `null`, you have a company key, not a partner key — you can skip fleet provisioning and go straight to step 2 (your fleet is implicit). ## 1. Provision a fleet Create a fleet for your customer. This is a partner-level call — **no** `FA-Fleet` header. The response's `id` (`flt_…`) is the fleet you'll act on for everything else. ```bash curl -X POST https://api.fuelatlas.com/v1/fleets \ -H "Authorization: Bearer $FA_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Bolt Carriers", "external_ref": "CUST-4471", "contact_email": "ops@bolt.example", "timezone": "America/Chicago" }' ``` ```python import requests fleet = requests.post( "https://api.fuelatlas.com/v1/fleets", headers={"Authorization": f"Bearer {FA_KEY}"}, json={"name": "Bolt Carriers", "external_ref": "CUST-4471", "contact_email": "ops@bolt.example", "timezone": "America/Chicago"}, ).json() fleet_id = fleet["id"] # flt_… ``` ```typescript const res = await fetch("https://api.fuelatlas.com/v1/fleets", { method: "POST", headers: { Authorization: `Bearer ${FA_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ name: "Bolt Carriers", external_ref: "CUST-4471", contact_email: "ops@bolt.example", timezone: "America/Chicago", }), }); const fleet = await res.json(); const fleetId = fleet.id; // flt_… ``` ```csharp using var http = new HttpClient(); http.DefaultRequestHeaders.Authorization = new("Bearer", faKey); var resp = await http.PostAsJsonAsync("https://api.fuelatlas.com/v1/fleets", new { name = "Bolt Carriers", external_ref = "CUST-4471", contact_email = "ops@bolt.example", timezone = "America/Chicago", }); var fleet = await resp.Content.ReadFromJsonAsync(); var fleetId = fleet.GetProperty("id").GetString(); // flt_… ``` Store `external_ref` as your own stable reference for the fleet — you can look it up by that later. ## 2. Add vehicles Now act **on** the fleet: every fleet-scoped call carries `FA-Fleet: `. Identify each vehicle by your own `external_ref` (required) so you never have to store our ids. Set `tank_size_gallons` and `estimated_mpg` — guidance needs them to project fuel. ```bash curl -X POST https://api.fuelatlas.com/v1/vehicles \ -H "Authorization: Bearer $FA_KEY" \ -H "FA-Fleet: $FLEET_ID" \ -H "Content-Type: application/json" \ -d '{ "external_ref": "TRUCK-042", "name": "Unit 042", "vin": "1FUJGLDR9CLBP8834", "tank_size_gallons": 200, "estimated_mpg": 6.5, "license_plate": "TN-8842J" }' ``` ```python vehicle = requests.post( "https://api.fuelatlas.com/v1/vehicles", headers={"Authorization": f"Bearer {FA_KEY}", "FA-Fleet": fleet_id}, json={"external_ref": "TRUCK-042", "name": "Unit 042", "vin": "1FUJGLDR9CLBP8834", "tank_size_gallons": 200, "estimated_mpg": 6.5, "license_plate": "TN-8842J"}, ).json() vehicle_id = vehicle["id"] # veh_… ``` ```typescript const vehicle = await fetch("https://api.fuelatlas.com/v1/vehicles", { method: "POST", headers: { Authorization: `Bearer ${FA_KEY}`, "FA-Fleet": fleetId, "Content-Type": "application/json" }, body: JSON.stringify({ external_ref: "TRUCK-042", name: "Unit 042", vin: "1FUJGLDR9CLBP8834", tank_size_gallons: 200, estimated_mpg: 6.5, license_plate: "TN-8842J", }), }).then((r) => r.json()); ``` ```csharp http.DefaultRequestHeaders.Remove("FA-Fleet"); http.DefaultRequestHeaders.Add("FA-Fleet", fleetId); var vResp = await http.PostAsJsonAsync("https://api.fuelatlas.com/v1/vehicles", new { external_ref = "TRUCK-042", name = "Unit 042", vin = "1FUJGLDR9CLBP8834", tank_size_gallons = 200, estimated_mpg = 6.5, license_plate = "TN-8842J", }); ``` If a vehicle with the same VIN already exists in the fleet, the call returns `200` with `merged_with_existing: true` (it linked your `external_ref` to the existing vehicle) instead of `201`. Repeat for each truck. ## 3. Stream locations Push GPS breadcrumbs in batches — this is the fuel that powers everything downstream. Send every 1–5 minutes per vehicle, and include `fuel_level_percent`. Identify vehicles by the `external_ref` you set. ```bash curl -X POST https://api.fuelatlas.com/v1/locations \ -H "Authorization: Bearer $FA_KEY" \ -H "FA-Fleet: $FLEET_ID" \ -H "Content-Type: application/json" \ -d '{ "locations": [ { "external_ref": "TRUCK-042", "latitude": 35.1495, "longitude": -90.0490, "recorded_at": "2026-07-06T15:04:05Z", "fuel_level_percent": 34.0, "speed_mph": 62 } ] }' ``` Check the per-item `results` — a `rejected` row with `code: "unknown_vehicle"` means that `external_ref` isn't created yet. See [Push locations](/developers/guides/push-locations). ## 4. Set a destination Bind the truck to where it's headed. Guidance takes over from here. ```bash curl -X POST https://api.fuelatlas.com/v1/vehicles/$VEHICLE_ID/guidance \ -H "Authorization: Bearer $FA_KEY" \ -H "FA-Fleet: $FLEET_ID" \ -H "Content-Type: application/json" \ -d '{ "destination": { "latitude": 41.8781, "longitude": -87.6298, "name": "Chicago DC" }, "min_arrival_fuel_percent": 15 }' ``` ## 5. Read the recommendation Poll the guidance object (or subscribe via [webhooks](/developers/guides/webhooks)). When `phase` is `stop_recommended`, `stops[0]` is the cheapest on-route stop — fully costed, with a driver-ready `reason`. ```bash curl https://api.fuelatlas.com/v1/vehicles/$VEHICLE_ID/guidance \ -H "Authorization: Bearer $FA_KEY" \ -H "FA-Fleet: $FLEET_ID" ``` Surface `stops[0].station.name`, `gallons_to_buy`, and net price (`price_usd_per_gallon − discount_usd_per_gallon`) to your dispatcher or driver. When the driver commits, `acknowledge` the stop; if they can't take it, `skip` it. Full field reference in [Autoguide](/developers/guides/autoguide). ## 6. Report savings After trucks fuel at recommended stops, roll up the value you delivered: ```bash curl "https://api.fuelatlas.com/v1/reports/savings?from=2026-06-01&to=2026-06-30" \ -H "Authorization: Bearer $FA_KEY" \ -H "FA-Fleet: $FLEET_ID" ``` You get fleet totals, an adherence rate, and per-vehicle savings — the numbers to put in front of your customer. ## You're integrated That's the whole loop: **provision → vehicles → locations → guidance → savings.** Everything else (trips, fuelings, webhooks) layers on top of it. Next: - [Webhooks](/developers/guides/webhooks) — stop polling; get pushed guidance and arrival events. - [Autoguide](/developers/guides/autoguide) — the full guidance object reference. - [Errors](/developers/guides/errors) — the problem+json envelope for robust handling. --- # Quickstart: ELD The location-push path — stream breadcrumbs and subscribe to fuel and arrival events. An ELD already knows where every truck is and how much fuel is in the tank. That's exactly FuelAtlas's one required input — so an ELD integration is mostly one endpoint (push locations) plus webhooks to get value back out. This is the short path; the [TMS quickstart](/developers/quickstart/tms) covers full provisioning if you also manage fleets. ## 0. Confirm your key ```bash curl https://api.fuelatlas.com/v1/me -H "Authorization: Bearer $FA_KEY" ``` You need `locations:write` (to push) and `webhooks:manage` (to subscribe); add `guidance:read` if you'll also read recommendations. Partner keys pass `FA-Fleet: ` on every fleet-scoped call; company keys don't. ## 1. Make sure vehicles exist Locations attach to vehicles by your `external_ref`. Create each truck once (idempotent by VIN), setting tank and mpg so fuel projections are accurate: ```bash curl -X POST https://api.fuelatlas.com/v1/vehicles \ -H "Authorization: Bearer $FA_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_ref": "ELD-8842", "vin": "1FUJGLDR9CLBP8834", "tank_size_gallons": 200, "estimated_mpg": 6.5 }' ``` ## 2. Stream breadcrumbs This is the workhorse call. Batch your fleet's latest positions and send every 1–5 minutes. **Always include `fuel_level_percent`** — as an ELD you have it, and it's what unlocks accurate guidance. ```bash curl -X POST https://api.fuelatlas.com/v1/locations \ -H "Authorization: Bearer $FA_KEY" \ -H "Content-Type: application/json" \ -d '{ "locations": [ { "external_ref": "ELD-8842", "latitude": 35.1495, "longitude": -90.0490, "recorded_at": "2026-07-06T15:04:05Z", "fuel_level_percent": 34.0, "speed_mph": 62, "heading_degrees": 91 }, { "external_ref": "ELD-8843", "latitude": 36.1627, "longitude": -86.7816, "recorded_at": "2026-07-06T15:04:05Z", "fuel_level_percent": 71.0, "speed_mph": 58, "heading_degrees": 12 } ] }' ``` Up to 500 items or 1 MB per batch. Each item is accepted or rejected independently — check `results` for rejects (a `rejected` row usually means that `external_ref` isn't created yet). Full detail in [Push locations](/developers/guides/push-locations). ## 3. Subscribe to events Register a webhook so FuelAtlas pushes the events an ELD cares about instead of you polling: ```bash curl -X POST https://api.fuelatlas.com/v1/webhooks \ -H "Authorization: Bearer $FA_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.youreld.com/fuelatlas", "event_types": ["vehicle.fuel_low", "guidance.updated", "vehicle.stop_arrived", "fueling.recorded"], "description": "ELD event feed" }' ``` Store the `secret` from the response — you'll need it to verify deliveries. Then verify every delivery's `X-FuelAtlas-Signature` before trusting it (HMAC-SHA256; code samples in [Webhooks](/developers/guides/webhooks)). Useful events for an ELD: - **`vehicle.fuel_low`** — surface a refuel prompt in the driver's HOS/ELD UI. - **`guidance.updated`** — a fresh recommended stop is available; fetch and show it. - **`vehicle.stop_arrived` / `fueling.recorded`** — reconcile stops and fuel purchases automatically. ## 4. (Optional) Show the recommendation If you want to display the next stop in-cab, read the guidance object for a vehicle whenever `guidance.updated` fires: ```bash curl https://api.fuelatlas.com/v1/vehicles/$VEHICLE_ID/guidance \ -H "Authorization: Bearer $FA_KEY" ``` `stops[0]` carries the station, `gallons_to_buy`, net price, and a driver-ready `reason`. See [Autoguide](/developers/guides/autoguide). ## That's the integration Push locations, verify webhooks, optionally show guidance. Two endpoints do the heavy lifting. - [Push locations](/developers/guides/push-locations) — cadence and per-item results. - [Webhooks](/developers/guides/webhooks) — signature verification and retries. --- # Quickstart: AI agents Give an LLM agent fuel context via llms.txt, the MCP server, and a one-shot route optimize call. FuelAtlas is built to be driven by software, including LLM agents. There are three ways to plug an agent in, from zero-integration to fully tool-using — pick the one that matches how your agent is built. ## 1. Feed the agent context: llms.txt The fastest path needs no API calls at all. The site publishes machine-readable docs at its root, following the [llms.txt](https://llmstxt.org) convention: - **`/llms.txt`** — a compact index: what the API is, the auth model, and a linked list of every guide with a one-line summary. - **`/llms-full.txt`** — the full corpus in one file: every guide, the complete error-code registry, the limits/caps tables, and a condensed endpoint reference generated from the OpenAPI spec. ```bash curl https://www.fuelatlas.com/llms.txt curl https://www.fuelatlas.com/llms-full.txt ``` Drop `llms-full.txt` into your agent's context (or a retrieval index) and it can answer "how do I push a location?" or "what does `station_budget_exceeded` mean?" accurately, with real endpoints and field names. It's deterministic and safe to cache. ## 2. Give the agent tools: MCP For an agent that should *act*, connect the FuelAtlas **MCP server** — it exposes the API as Model Context Protocol tools (set a destination, read guidance, query stations, record a fueling) so a compatible agent can call them directly with your key. See [MCP server](/developers/mcp) for the endpoint, the tool catalog, and connection details. Use an `fa_test_` key while developing so the agent operates on the [sandbox](/developers/guides/sandbox) fleet. ## 3. Discover capabilities: `/v1/me` Whether tool-using or not, an agent should start by introspecting its own key — it's the reliable way to know what it's allowed to do before it tries: ```bash curl https://api.fuelatlas.com/v1/me \ -H "Authorization: Bearer $FA_KEY" ``` The `scopes` array tells the agent which actions are available; `plan` tells it the rate/quota budget it's working within. `/v1/me` needs no scope and is quota-exempt, so it's always safe to call first. ## 4. A one-shot optimize You don't need continuous guidance to get value — `POST /v1/routes` computes a route and a fuel plan in a single call (requires `routes:calculate`). Give it a vehicle and a destination and it resolves origin, tank, and burn from the vehicle's latest state: ```bash curl -X POST https://api.fuelatlas.com/v1/routes \ -H "Authorization: Bearer $FA_KEY" \ -H "Content-Type: application/json" \ -d '{ "vehicle_id": "veh_5tR…", "destination": { "latitude": 41.8781, "longitude": -87.6298 }, "options": { "max_stops": 3, "min_arrival_fuel_percent": 15 } }' ``` Or, with no vehicle on file, supply everything manually: ```json { "origin": { "latitude": 35.15, "longitude": -90.05 }, "destination": { "latitude": 41.88, "longitude": -87.63 }, "fuel": { "current_gallons": 60, "tank_capacity_gallons": 200, "estimated_mpg": 6.5 }, "options": { "max_stops": 3 } } ``` The response is a fuel plan the agent can act on or summarize — recommended stops with net price and a one-sentence `reason`, plus the totals: ```json { "id": "rte_3k…", "distance_miles": 532.1, "fuel_plan": { "stops": [ { "sequence": 1, "station": { "name": "TA Bucksville", "chain": "TA Petro" }, "detour_miles": 0.4, "gallons_to_buy": 140, "price_usd_per_gallon": 3.499, "discount_usd_per_gallon": 0.42, "savings_vs_baseline_usd": 58.80, "reason": "Cheapest net stop within range; 0.4 mi detour." } ], "baseline_cost_usd": 612.40, "optimized_cost_usd": 553.60, "total_savings_usd": 58.80 } } ``` Every `reason` and `hint` in the API is written to be surfaced to a human verbatim — so an agent can explain its recommendation without inventing rationale. ## When to use which - **Answer questions about the API** → feed `llms-full.txt`. - **Take actions on a fleet** → connect [MCP](/developers/mcp) (or call the REST endpoints directly). - **Continuous "where next" for a moving truck** → [Autoguide](/developers/guides/autoguide). - **A single route's plan, right now** → `POST /v1/routes` (above). ## Next - [MCP server](/developers/mcp) — the tool catalog and connection. - [Autoguide](/developers/guides/autoguide) — continuous recommendations. - [Errors](/developers/guides/errors) — so the agent can react to failures precisely. --- # Authentication API keys, Bearer auth, partner vs company keys, the FA-Fleet header, and scopes. Every request to the FuelAtlas API is authenticated with an API key sent as a Bearer token. There are no cookies, no sessions, and no OAuth redirect dance — a single header authenticates every call. ```bash curl https://api.fuelatlas.com/v1/me \ -H "Authorization: Bearer fa_live_your_key_here" ``` ## Base URL All endpoints live under `/v1` on a single host: ```text https://api.fuelatlas.com/v1 ``` The machine-readable spec is served anonymously at `https://api.fuelatlas.com/v1/openapi.json` — no key required. ## Getting a key Create keys in the [Developer Console](/dev-console). Two environments are available, distinguished by prefix: - **`fa_live_…`** — production keys. Operate on your real fleet, vehicles, and prices. - **`fa_test_…`** — sandbox keys. Operate on a simulated fleet with synthetic prices and guidance. Nothing you do with a test key touches production. See the [Sandbox guide](/developers/guides/sandbox). A key is shown **once**, at creation. Store it in a secret manager; if you lose it, revoke it and generate a new one. Keys are hashed at rest and can never be recovered. ## Using the key Send the key in the `Authorization` header on every request: ```http GET /v1/me HTTP/1.1 Host: api.fuelatlas.com Authorization: Bearer fa_live_3fA9… ``` A missing key returns `401 missing_api_key`; a bad or revoked key returns `401 invalid_api_key` / `401 key_revoked`. See [Errors](/developers/guides/errors) for the full envelope. ## Company keys vs partner keys There are two kinds of key, and the difference decides how you address a fleet. - **Company keys** belong to a single fleet (one company). The acting fleet is implicit — every request operates on that company. This is what most direct integrations use. - **Partner keys** belong to an integration partner (a TMS or ELD vendor) that manages *many* fleets. A partner key can create fleets (`POST /v1/fleets`) and must name the fleet it is acting on for every fleet-scoped call. Partner keys select the acting fleet with the **`FA-Fleet`** header: ```bash curl https://api.fuelatlas.com/v1/vehicles \ -H "Authorization: Bearer fa_live_partner_key" \ -H "FA-Fleet: flt_8Qk2…" ``` Omit it on a fleet-scoped call with a partner key and you get `400 fleet_header_required`. Pass a fleet the key isn't granted and you get `403 fleet_scope_mismatch`. Fleet-management calls (`POST /v1/fleets`) are partner-level and take **no** `FA-Fleet` header. ## Scopes Keys carry OAuth-style scopes. A call into an endpoint whose scope the key lacks returns `403 insufficient_scope`. Request only the scopes an integration needs. | Scope | Grants | | --- | --- | | `fleets:manage` | Create and manage fleets (partner keys only) | | `vehicles:manage` | Create, read, update, deactivate vehicles | | `locations:write` | Ingest vehicle location breadcrumbs | | `fuelings:write` | Record and read fuelings | | `routes:calculate` | Compute routes and fuel plans | | `guidance:read` | Read autoguide sessions and recommended stops | | `guidance:manage` | Set destination, acknowledge/skip stops, cancel guidance | | `prices:read` | Query stations and prices | | `trips:manage` | Create and manage dispatch trips | | `webhooks:manage` | Register and manage webhook endpoints | | `usage:read` | Read usage and savings reports | ## Introspect the key with `/v1/me` `GET /v1/me` is your first-stop debugging endpoint. It requires only a valid key (no scope) and is exempt from quotas, so a maxed-out key can always inspect why. It returns the key's environment and scopes, the acting fleet (if any), the owning partner (for partner keys), and live month-to-date usage against your plan. ```json { "partner": { "id": "ptn_2b…", "name": "Acme TMS" }, "fleet": { "id": "flt_8Qk2…" }, "key": { "id": "key_9f…", "environment": "production", "scopes": ["locations:write", "guidance:read", "vehicles:manage"] }, "plan": { "name": "Growth", "rate_limit_per_min": 600, "monthly_quota": { "locations": 5000000, "stations": 200000 }, "used_this_month": { "locations": 812043, "stations": 4120 } } } ``` For partner and fleet keys, `partner` and `fleet` are always present as explicit `null` when not applicable — so you can branch on a stable shape. ## Next - [Push locations](/developers/guides/push-locations) — start streaming vehicle positions. - [Autoguide](/developers/guides/autoguide) — the flagship: continuous fuel-stop recommendations. - [TMS quickstart](/developers/quickstart/tms) — provision a fleet end to end. --- # Push locations Stream vehicle GPS breadcrumbs in batches — the single input that powers guidance, savings, and events. Location breadcrumbs are the one input FuelAtlas needs to do everything else. Every recommended stop, savings figure, arrival event, and low-fuel alert is derived from the position (and, ideally, tank level) you stream in. Get this pipe flowing and the rest of the platform lights up. ## The endpoint ```http POST /v1/locations Authorization: Bearer fa_live_… FA-Fleet: flt_8Qk2… # partner keys only Content-Type: application/json ``` Requires the `locations:write` scope. It ingests a **batch** of breadcrumbs — up to **500 items or 1 MB** per request, whichever comes first. Exceed either and the whole request returns `413 batch_too_large`; split into smaller batches. ## Request body ```json { "locations": [ { "external_ref": "TRUCK-042", "latitude": 35.1495, "longitude": -90.0490, "recorded_at": "2026-07-06T15:04:05Z", "fuel_level_percent": 48.5, "speed_mph": 62.0, "heading_degrees": 91.0, "driver_ref": "D-7781" } ] } ``` | Field | Type | Notes | | --- | --- | --- | | `vehicle_id` **or** `external_ref` | string | Identify the vehicle by its `veh_…` id **or** your own `external_ref`. Provide one. | | `latitude`, `longitude` | number | WGS84 degrees. Required. | | `recorded_at` | string | RFC 3339 UTC timestamp the reading was taken. Required. | | `fuel_level_percent` | number | Tank level 0–100. Optional but **strongly recommended** — it's what makes fuel projections accurate. | | `speed_mph` | number | Ground speed, mph (converted to km/h on write). Optional. | | `heading_degrees` | number | 0–360. Optional. | | `driver_ref` | string | Your driver reference, stamped on the breadcrumb. Optional. | | `address` | string | Reverse-geocoded address, if you have one. Optional. | Units are US customary (mph). Identify vehicles by `external_ref` if you'd rather not store our `veh_…` ids — the reference you set when creating the vehicle is a first-class lookup key. ## Per-item results The response reports each item independently, keyed by its zero-based index in the request. **One bad row never fails the batch** — it's rejected on its own while the good rows are accepted. ```json { "accepted": 2, "rejected": 1, "results": [ { "index": 0, "status": "accepted" }, { "index": 1, "status": "accepted" }, { "index": 2, "status": "rejected", "code": "unknown_vehicle", "hint": "No matching vehicle found. Create it first with POST /v1/vehicles, or check the id/external_ref." } ] } ``` `code` and `hint` are present only on rejected items. Log the rejects and reconcile — a common cause is a breadcrumb for a vehicle you haven't created yet. ## Cadence Send breadcrumbs on a steady cadence rather than one request per ping: - **Every 1–5 minutes per vehicle** is the sweet spot. Guidance recomputes on this rhythm, so more frequent than ~1 minute buys little. - **Batch across your fleet.** One `POST /v1/locations` with 400 vehicles' latest positions is far better than 400 single-item calls — it's easier on your rate limit and quota. - **Always include `fuel_level_percent` when you have it.** Without a tank level, fuel projections fall back to estimates and guidance can't tell you how many gallons to buy. ## Reading a position back The latest known position for a vehicle is available at: ```bash curl https://api.fuelatlas.com/v1/vehicles/veh_5tR…/location \ -H "Authorization: Bearer fa_live_…" ``` Returns `404` until the first breadcrumb lands. Requires `vehicles:manage`. ## Next - [Autoguide](/developers/guides/autoguide) — turn this position stream into continuous fuel-stop recommendations. - [Webhooks](/developers/guides/webhooks) — get pushed `vehicle.fuel_low`, arrival, and guidance events instead of polling. --- # Autoguide The flagship — bind a vehicle to a destination and continuously pull the cheapest on-route fuel stop. Autoguide is the heart of the platform. You bind a vehicle to a destination once; from then on FuelAtlas continuously answers a single question as the truck drives: **where should this vehicle fuel next, and how much should it buy?** The answer accounts for the remaining route, current tank level and burn rate, live prices, your negotiated discounts, and how far each stop is off the corridor. You don't recompute anything. You set a destination and then read a `guidance` object — a self-describing snapshot that tells you the current recommendation, how fresh it is, and when to check back. ## The lifecycle Autoguide moves a vehicle through four **phases**. Every guidance object reports its current `phase`, so a poller or agent always knows whether the recommendation is actionable. 1. **`awaiting_data`** — guidance is set, but no usable recent breadcrumb has arrived yet. `stops` is empty. Start [pushing locations](/developers/guides/push-locations). 2. **`tracking`** — the vehicle is en route with enough fuel; no stop is needed yet. `stops` is empty, but the object carries a live fuel projection to the destination. 3. **`stop_recommended`** — the vehicle needs fuel before (or to optimize) the trip. `stops` contains one or more recommended stops, cheapest-net-cost first. 4. **`completed`** — the vehicle reached the destination (or guidance was cancelled). Set a new destination for the next leg. ## 1. Set a destination ```http POST /v1/vehicles/veh_5tR…/guidance Authorization: Bearer fa_live_… Content-Type: application/json ``` Requires `guidance:manage`. ```json { "destination": { "latitude": 41.8781, "longitude": -87.6298, "name": "Chicago DC" }, "min_arrival_fuel_percent": 15 } ``` `min_arrival_fuel_percent` is the reserve you want in the tank on arrival — the emergency floor guidance plans around. Setting a destination opens (or supersedes) the session and returns the guidance object with `201`. ## 2. Read the guidance object ```bash curl https://api.fuelatlas.com/v1/vehicles/veh_5tR…/guidance \ -H "Authorization: Bearer fa_live_…" ``` Requires `guidance:read`. A vehicle with no active session returns `404 no_active_guidance`. ```json { "id": "gd_7hK…", "vehicle_id": "veh_5tR…", "status": "active", "phase": "stop_recommended", "destination": { "latitude": 41.8781, "longitude": -87.6298, "name": "Chicago DC" }, "based_on_location_at": "2026-07-06T15:04:05Z", "data_freshness": "fresh", "fuel": { "current_percent": 34.2, "current_gallons": 68.4, "range_miles": 410.0, "projected_percent_at_destination": -6.0 }, "stops": [ { "stop_id": "stop_1a…", "status": "recommended", "station": { "id": "stn_9Qm…", "name": "TA Bucksville", "chain": "TA Petro", "latitude": 40.61, "longitude": -86.10, "address": "I-65 Exit 201, Bucksville", "highway_exit": "I-65 Exit 201" }, "distance_ahead_miles": 128.4, "eta": "2026-07-06T17:22:00Z", "fuel_percent_on_arrival": 12.5, "price_usd_per_gallon": 3.499, "discount_usd_per_gallon": 0.42, "gallons_to_buy": 140.0, "target_fuel_percent_after": 88.0, "savings_vs_baseline_usd": 58.80, "reason": "Cheapest net stop within range; keeps you above your 15% reserve to Chicago." } ], "next_review_expected_at": "2026-07-06T15:09:05Z", "created_at": "2026-07-06T14:40:00Z" } ``` ### Reading the fields that matter - **`phase`** tells you whether to act. Only `stop_recommended` carries stops. - **`data_freshness`** grades the breadcrumb the object was computed from: `fresh` (< 10 min), `stale` (10–30 min), `expired` (> 30 min). Treat a recommendation built on `expired` data with caution — the truck may have moved on. Keep locations flowing to stay `fresh`. - **`fuel.projected_percent_at_destination`** can go **negative** — that's guidance telling you the vehicle will run dry before arrival unless it stops. That's exactly when a stop is recommended. - **Each stop** is fully costed: `gallons_to_buy`, `price_usd_per_gallon`, your `discount_usd_per_gallon`, the resulting `target_fuel_percent_after`, and `savings_vs_baseline_usd` (versus fueling naively). `reason` is a one-sentence, agent-friendly rationale you can surface to a driver verbatim. - **`next_review_expected_at`** is when a fresh object is expected — roughly the guidance cycle. Poll no faster than this. ## 3. Poll, or subscribe You can pull the object on `next_review_expected_at`, but the lighter path is [webhooks](/developers/guides/webhooks). Subscribe to: - **`guidance.updated`** — the recommended set for a vehicle was created, refreshed, or cleared. Fetch the object when you receive it. - **`guidance.stop_approaching`** — the vehicle came within the proximity radius of a recommended stop. A good trigger to notify the driver. Webhooks + an occasional reconciliation poll is the pattern most integrations settle on. ## 4. Acknowledge or skip a stop When a driver accepts a recommendation, acknowledge it — this pins the stop so guidance stops re-shuffling it: ```bash curl -X POST \ https://api.fuelatlas.com/v1/vehicles/veh_5tR…/guidance/stops/stop_1a…/acknowledge \ -H "Authorization: Bearer fa_live_…" ``` If the driver won't take it (closed, no parking, personal preference), skip it — guidance produces a fresh recommendation on the next cycle: ```bash curl -X POST \ https://api.fuelatlas.com/v1/vehicles/veh_5tR…/guidance/stops/stop_1a…/skip \ -H "Authorization: Bearer fa_live_…" \ -H "Content-Type: application/json" \ -d '{ "reason": "no overnight parking" }' ``` Both require `guidance:manage`. A stop's `status` moves through `recommended → acknowledged → fueled` (or `skipped`). When FuelAtlas later detects the truck fueled at the stop, it's marked `fueled` and rolls into your [savings report](/developers/guides/prices-and-stations). ## 5. Finish or re-target Guidance auto-completes when the vehicle reaches the destination (`phase: completed`, `status: completed`). To retarget mid-trip, just `POST` a new destination — it supersedes the current session. To stop guidance entirely: ```bash curl -X DELETE https://api.fuelatlas.com/v1/vehicles/veh_5tR…/guidance \ -H "Authorization: Bearer fa_live_…" ``` ## A note on station ids Stations in a guidance object are disclosed as opaque `stn_…` ids — never the internal truck-stop identifier — and each disclosure is metered against your plan's station-query budget. This is by design; see [Prices & stations](/developers/guides/prices-and-stations). ## Next - [Webhooks](/developers/guides/webhooks) — subscribe to guidance events instead of polling. - [AI-agents quickstart](/developers/quickstart/ai-agents) — drive autoguide from an LLM agent. --- # Webhooks Register endpoints, subscribe to events, verify HMAC signatures, and handle retries and replays. Webhooks push events to your server the moment they happen — a vehicle arrives at a stop, guidance refreshes, a fueling is detected — so you don't poll. Every delivery is signed so you can verify it came from FuelAtlas and wasn't tampered with. ## Register an endpoint ```http POST /v1/webhooks Authorization: Bearer fa_live_… Content-Type: application/json ``` Requires `webhooks:manage`. ```json { "url": "https://hooks.yourapp.com/fuelatlas", "event_types": ["guidance.updated", "vehicle.fuel_low", "fueling.matched"], "description": "Prod dispatch integration" } ``` The URL must be **HTTPS and publicly reachable** — plain `http` and internal/loopback addresses are rejected at creation. `event_types` must be a subset of the [catalog](#event-catalog), or the single wildcard `["*"]` to receive everything. The response returns the endpoint plus a **signing secret, shown exactly once**: ```json { "id": "whk_4mZ…", "url": "https://hooks.yourapp.com/fuelatlas", "event_types": ["guidance.updated", "vehicle.fuel_low", "fueling.matched"], "status": "active", "secret": "whsec_9c…store_this_now" } ``` Store `secret` immediately — you need it to verify deliveries, and it is never shown again. If you lose it, [rotate it](#rotating-the-secret). ## The delivery envelope Each delivery is a `POST` to your URL with a JSON body and these headers: ```http POST /fuelatlas HTTP/1.1 Content-Type: application/json User-Agent: FuelAtlas-Webhooks/1.0 X-FuelAtlas-Event-Id: evt_2s… X-FuelAtlas-Event-Type: guidance.updated X-FuelAtlas-Signature: t=1751813045,v1=6f2b…hex ``` ```json { "id": "evt_2s…", "type": "guidance.updated", "api_version": "2026-07-01", "sandbox": false, "sequence": 48213, "occurred_at": "2026-07-06T15:04:05.1234567Z", "fleet_id": "flt_9Qm…", "data": { "vehicle_id": "veh_5tR…", "guidance_id": "gd_7hK…" } } ``` `sequence` is a monotonically increasing integer per `fleet_id` — retries and backoff mean deliveries can arrive out of order, so use `sequence` (together with `id` for de-duplication) to detect gaps and reconstruct the correct order, rather than relying on receipt order. Respond with any `2xx` within a few seconds to acknowledge. Anything else (or a timeout) is treated as a failure and retried. ## Verifying the signature The `X-FuelAtlas-Signature` header is `t=,v1=`. To verify: recompute `HMAC-SHA256(secret, "{t}.{rawBody}")` over the **raw** request body and constant-time-compare it to the `v1` value. Bind the timestamp into a tolerance window (e.g. 5 minutes) to reject replays. During a [secret rotation](#rotating-the-secret) the header may carry **multiple** `v1=` values — one per currently-valid secret. Accept the delivery if **any** of them matches. ```typescript import crypto from "node:crypto"; // rawBody: the exact bytes received (do NOT re-serialize the parsed JSON) export function verify(rawBody: string, header: string, secret: string, toleranceSec = 300): boolean { const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const t = Number(parts.t); if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); // header may list several v1= values during rotation — match any return header .split(",") .filter((p) => p.startsWith("v1=")) .some((p) => { const sig = p.slice(3); return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); }); } ``` ```python import hashlib, hmac, time def verify(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) t = int(parts["t"]) if abs(time.time() - t) > tolerance_sec: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() sigs = [p[3:] for p in header.split(",") if p.startswith("v1=")] return any(hmac.compare_digest(sig, expected) for sig in sigs) ``` ```csharp using System.Security.Cryptography; using System.Text; static bool Verify(string rawBody, string header, string secret, int toleranceSec = 300) { var parts = header.Split(',').Select(p => p.Split('=', 2)).ToList(); var t = long.Parse(parts.First(p => p[0] == "t")[1]); if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > toleranceSec) return false; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}"))).ToLowerInvariant(); return parts.Where(p => p[0] == "v1") .Any(p => CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(p[1]), Encoding.UTF8.GetBytes(expected))); } ``` > Verify against the **raw request body**, before any JSON parsing re-serializes it. Re-encoding the parsed object will change the bytes and break the signature. ## Event catalog `GET /v1/webhooks/event-types` returns this list at runtime (any valid key, no scope). Subscribe to `["*"]` to receive all of them. | Event | Fires when | | --- | --- | | `guidance.updated` | The recommended fuel-stop set for a vehicle was created, refreshed, or cleared. | | `guidance.stop_approaching` | A vehicle came within the proximity radius of a recommended fuel stop. | | `vehicle.stop_arrived` | GPS detected a vehicle arriving at a stop. | | `vehicle.stop_departed` | GPS detected a vehicle departing a stop. | | `trip.started` | A dispatch trip transitioned to active. | | `trip.completed` | A dispatch trip completed. | | `trip.cancelled` | A dispatch trip was cancelled. | | `fueling.recorded` | A fueling was detected from a tank-level rise. | | `fueling.matched` | A detected fueling was matched to a recommended or proposed stop. | | `vehicle.fuel_low` | A vehicle's fuel percentage crossed the low-fuel threshold (with hysteresis). | | `vehicle.offline` | A previously-reporting vehicle stopped sending location updates. | | `vehicle.online` | A vehicle that had gone offline resumed reporting. | | `fleet.provisioned` | A fleet was created via the provisioning API. | Sandbox endpoints (registered with a `fa_test_` key) only receive events flagged `sandbox: true`; production endpoints only receive live events. The two never cross. ## Retries and replays A delivery that doesn't get a `2xx` is retried on a backoff schedule over roughly a day. Deliveries are logged; inspect them with: ```bash curl "https://api.fuelatlas.com/v1/webhooks/whk_4mZ…/deliveries?status=failed" \ -H "Authorization: Bearer fa_live_…" ``` To re-send a specific past event (for example after fixing a bug on your side), replay it: ```bash curl -X POST \ https://api.fuelatlas.com/v1/webhooks/whk_4mZ…/deliveries/whd_8p…/replay \ -H "Authorization: Bearer fa_live_…" ``` A replay re-delivers the **same** event (marked `is_replay: true`); it returns `410 event_expired` if the event is past its replay window. Design your handler to be **idempotent** on `X-FuelAtlas-Event-Id` — replays and rare duplicate deliveries both repeat that id. ## Test a delivery Send a canned sample payload for any event type to your endpoint, synchronously, without waiting for the real event. A test send is signed like any delivery and never disables the endpoint: ```bash curl -X POST https://api.fuelatlas.com/v1/webhooks/whk_4mZ…/test \ -H "Authorization: Bearer fa_live_…" \ -H "Content-Type: application/json" \ -d '{ "event_type": "guidance.updated" }' ``` ## Rotating the secret ```bash curl -X POST https://api.fuelatlas.com/v1/webhooks/whk_4mZ…/rotate-secret \ -H "Authorization: Bearer fa_live_…" ``` Returns a **new** secret once. The previous secret stays valid for **24 hours** (deliveries in that window are signed with both, so a verifier matching either accepts them). Roll the new secret into your config before the window closes. ## Next - [Autoguide](/developers/guides/autoguide) — the source of `guidance.*` events. - [Errors](/developers/guides/errors) — the problem+json envelope every 4xx/5xx uses. --- # Prices & stations Query diesel stations and your effective prices — and why the radius, corridor, and budget caps exist. FuelAtlas exposes truck-stop diesel prices as **your** effective prices: the retail price *and* your negotiated discount at each site, already applied. Two endpoints answer the two questions that matter — "what's near this point?" and "what's along this route?" Both require the `prices:read` scope. ## Stations near a point ```bash curl "https://api.fuelatlas.com/v1/stations?latitude=35.15&longitude=-90.05&radius_miles=25" \ -H "Authorization: Bearer fa_live_…" ``` Returns nearby stations, nearest-first, each with your price: ```json { "count": 12, "stations": [ { "id": "stn_9Qm…", "name": "TA Memphis", "chain": "TA Petro", "latitude": 35.06, "longitude": -89.99, "address": "I-40 Exit 12, Memphis, TN", "price_usd_per_gallon": 3.499, "discount_usd_per_gallon": 0.42, "price_updated_at": "2026-07-06T09:00:00Z", "amenities": ["showers", "truck_parking", "scales"] } ] } ``` `price_usd_per_gallon` is the site's current retail; `discount_usd_per_gallon` is your per-gallon discount off it (0 when you have none there). Net cost is `price − discount`. ## Stations along a route Most fuel decisions are corridor decisions, not radius decisions. `POST /v1/stations/along-route` returns stations within a driving corridor — supply either an encoded polyline **or** an origin/destination pair: ```json { "origin": { "latitude": 35.15, "longitude": -90.05 }, "destination": { "latitude": 41.88, "longitude": -87.63 }, "corridor_width_miles": 3, "max_results": 40, "fuel_level_percent": 40, "estimated_mpg": 6.5, "tank_capacity_gallons": 200 } ``` Add the fuel context (`fuel_level_percent` + `estimated_mpg` + `tank_capacity_gallons`) and results are narrowed to stations the vehicle can actually **reach** on its current tank. Omit it to see every station on the corridor. ## The caps are the design The station endpoints have firm caps. They aren't rate-limit accidents — they're what keeps the pricing dataset a *routing* tool rather than a scrapeable price book. Design to them and you'll never be surprised by a `4xx`. | Cap | Value | Endpoint | | --- | --- | --- | | Corridor half-width | default **3 mi**, hard cap **5 mi** | `along-route` | | Results per query | default **40**, hard cap **50** | `along-route` | | Radius | bounded per plan | `stations` | | Station-query budget | per-plan monthly budget | both (every disclosed station counts) | - **Every request is shape-checked before it runs.** Radius and corridor width/results-per-query beyond the caps above are rejected immediately with `422 validation_failed` — fix the field named in `errors` and retry. - **Every disclosed station is charged** to your plan's station-query budget. A response with 40 stations spends 40. When the budget is exhausted you get `429 station_budget_exceeded` — reduce query volume or upgrade the plan. - **Sustained broad/grid-style scanning is detected, not blocked per-request.** There's no per-call "you're scanning" error — instead, a pattern like querying dozens of distinct grid cells in an hour, or a day's unique-station count creeping past ~90% of your budget, logs a warning against your key. Warnings that repeat or escalate get your key **auto-suspended** (an admin alert fires at the same time); every call after that returns `403 key_suspended` until support reinstates it. Querying tight corridors along real routes doesn't come close to these thresholds; sweeping a grid of the whole country does. - **Station ids are opaque** (`stn_…`) and stable per fleet. They are never the internal truck-stop identifier, so you can't use them to enumerate the master dataset. The practical rule: query the **corridor of a real trip**, not a wide area "to be safe." Autoguide already does this optimally for you — if you just want the right stop for a vehicle heading somewhere, use [Autoguide](/developers/guides/autoguide) instead of raw station queries. ## Savings report Once trucks are fueling at recommended stops, `GET /v1/reports/savings` rolls up how much you saved over a period (requires `usage:read`): ```bash curl "https://api.fuelatlas.com/v1/reports/savings?from=2026-06-01&to=2026-06-30" \ -H "Authorization: Bearer fa_live_…" ``` It returns fleet totals (gallons, actual vs baseline cost, savings, per-gallon savings), an **adherence** rate (how often you fueled at a recommended stop), and per-vehicle rows. The figures derive from a rolling projection window; a period entirely before that window returns zeros with an explanatory `note` — that's an expected data-availability limit, not an error. ## Next - [Rate limits](/developers/guides/rate-limits) — how budgets, quotas, and per-minute limits interact. - [Autoguide](/developers/guides/autoguide) — let the platform pick the stop for you. --- # Rate limits & quotas Per-key request rates, monthly quotas, the station budget, and how to handle 429s. FuelAtlas enforces three independent limits. Understanding which one you hit tells you exactly what to do about it — the error `code` is always specific. | Limit | Window | Error when exceeded | | --- | --- | --- | | **Request rate** | per minute, per key | `429 rate_limited` | | **Monthly quota** | calendar month, per endpoint family | `429 quota_exceeded` | | **Station-query budget** | per plan | `429 station_budget_exceeded` | Your exact numbers depend on your plan. `GET /v1/me` returns your `plan.rate_limit_per_min`, monthly `plan.monthly_quota` by family, and `plan.used_this_month` — check it rather than hard-coding limits. ## Request rate Each key gets a requests-per-minute allowance. Responses carry standard rate-limit headers so you can pace yourself before hitting the wall: ```http RateLimit-Limit: 600 RateLimit-Remaining: 574 RateLimit-Reset: 27 ``` When you exceed it: ```http HTTP/1.1 429 Too Many Requests Retry-After: 12 Content-Type: application/problem+json ``` ```json { "type": "https://www.fuelatlas.com/developers/guides/errors#rate_limited", "title": "Rate limit exceeded", "status": 429, "code": "rate_limited", "retry_after": 12 } ``` Honor `retry_after` (seconds) — or the `RateLimit-Reset` header — and retry with exponential backoff plus jitter. Don't retry tighter than the reset window; it just burns more budget. ## Monthly quotas Quotas are metered per **endpoint family** (locations, stations, routes, and so on), so heavy location ingestion doesn't starve your station queries. `GET /v1/me` shows the split. `429 quota_exceeded` means a family's monthly allowance is spent — wait for the month to reset, or upgrade in the [Developer Console](/dev-console). `GET /v1/me` itself is quota-exempt, so a maxed-out key can always introspect why. ## Station-query budget Station disclosures have their own budget on top of quotas, because each disclosed station is a piece of the pricing dataset. Every station returned by `GET /v1/stations`, `POST /v1/stations/along-route`, or an autoguide recommendation is charged. See [Prices & stations](/developers/guides/prices-and-stations) for how to stay well under it (query real corridors, not wide areas). Exhaustion is `429 station_budget_exceeded`. ## Handling 429 well - **Read the `code`,** not just the status — `rate_limited`, `quota_exceeded`, and `station_budget_exceeded` need different responses. - **Back off on `rate_limited`;** retrying immediately never helps. - **Don't retry `quota_exceeded` / `station_budget_exceeded`** in a loop — they won't clear until reset or upgrade. Alert instead. - **Batch writes.** One `POST /v1/locations` with 500 breadcrumbs costs one request; 500 single-item calls cost 500. ## Next - [Errors](/developers/guides/errors) — the full problem+json envelope and code registry. - [Prices & stations](/developers/guides/prices-and-stations) — the station budget in detail. --- # Sandbox Build and test against a simulated fleet with synthetic prices — no real data, no risk. Every `fa_test_…` key operates against a **fully simulated** environment: a small fleet of demo trucks that drive real corridors, synthetic diesel prices, and guidance computed exactly like production. Nothing you do with a sandbox key touches real fleets or real market data — so you can build, run your CI, and demo integrations without a single live truck. > Sandbox fleet, prices, and guidance are entirely simulated — never real market data. Don't treat sandbox prices as quotes. ## Getting a sandbox key Create a key with the **sandbox** environment in the [Developer Console](/dev-console); it's prefixed `fa_test_`. Point your integration at the same base URL (`https://api.fuelatlas.com/v1`) — the key's prefix selects the environment. A sandbox key against a production-only surface returns `403 sandbox_key_on_production`. ## The simulated fleet The sandbox comes pre-seeded with demo vehicles that tick along assigned routes. Inspect the live simulation at any time: ```bash curl https://api.fuelatlas.com/v1/sandbox/simulation \ -H "Authorization: Bearer fa_test_…" ``` ```json { "status": "running", "last_tick_at": "2026-07-06T15:04:00Z", "note": "Sandbox fleet, prices and guidance are entirely SIMULATED — never real market data.", "vehicles": [ { "id": "veh_sb1…", "name": "Sim Truck 1", "scenario": "drive", "latitude": 36.10, "longitude": -86.67, "fuel_percent": 52.0, "speed_mph": 61.0, "route": "Nashville → Louisville", "route_polyline": "…encoded…" } ] } ``` The `route_polyline` lets you draw the corridor a truck is following. No internal route id is ever exposed — the polyline is all you need to render it. ## Driving a scenario Force a specific vehicle into a scenario to exercise a code path on demand — for example, drop its tank to trigger a low-fuel recommendation: ```bash curl -X POST https://api.fuelatlas.com/v1/sandbox/vehicles/veh_sb1…/scenario \ -H "Authorization: Bearer fa_test_…" \ -H "Content-Type: application/json" \ -d '{ "scenario": "low_fuel" }' ``` | Scenario | Effect | | --- | --- | | `drive` | Vehicle cruises its assigned corridor at highway speed. | | `low_fuel` | Tank drops toward the reserve — great for testing `stop_recommended` and `vehicle.fuel_low`. | | `arrive_at_stop` | Vehicle pulls into a stop — exercises `vehicle.stop_arrived` and fueling detection. | | `park` | Vehicle stops reporting movement (no assigned route). | Set a destination with [Autoguide](/developers/guides/autoguide) on a sandbox vehicle, drive `low_fuel`, and you'll see a real recommended-stop object come back — the full flow, end to end, with zero production risk. ## Resetting Put the sandbox back to its seeded state whenever you want a clean slate (before a test run, after a demo): ```bash curl -X POST https://api.fuelatlas.com/v1/sandbox/reset \ -H "Authorization: Bearer fa_test_…" ``` ```json { "status": "ok", "fleet": "Sandbox Fleet", "vehicle_count": 4, "message": "Sandbox reset to seed state." } ``` ## Sandbox webhooks Webhook endpoints registered with a sandbox key only receive events flagged `sandbox: true`, and production endpoints never receive them — so you can point sandbox deliveries at a test receiver without polluting production. See [Webhooks](/developers/guides/webhooks). ## Next - [TMS quickstart](/developers/quickstart/tms) — run the whole provisioning flow against the sandbox first. - [Autoguide](/developers/guides/autoguide) — pair a sandbox vehicle with a destination and watch guidance work. --- # Errors The RFC 9457 problem+json envelope and the complete, stable error-code registry. Every `4xx` and `5xx` response uses the same machine-readable shape: [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem+json. You branch on one stable field — `code` — and never have to parse prose. ## The envelope ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json ``` ```json { "type": "https://www.fuelatlas.com/developers/guides/errors#insufficient_scope", "title": "Key lacks required scope", "status": 403, "code": "insufficient_scope", "detail": "This key does not hold the 'guidance:manage' scope.", "hint": "Request a key with the required scope from the Developer Console, or use GET /v1/me to see current scopes.", "request_id": "req_9f2c…" } ``` | Field | Use it for | | --- | --- | | `code` | **Branch on this.** A stable, documented string — the contract. | | `status` | The HTTP status (also on the response line). | | `title` | Short, stable human summary of the code. | | `detail` | Context specific to *this* occurrence (may name the field/scope/id involved). | | `hint` | A concrete next step to resolve it. | | `type` | A URL to this page, anchored at the code (`#`). | | `request_id` | Include it when contacting support — it pins the exact request in our logs. | Validation failures (`422 validation_failed`) additionally carry an `errors` array naming each offending field: ```json { "code": "validation_failed", "status": 422, "errors": [ { "field": "destination.latitude", "message": "is required" }, { "field": "min_arrival_fuel_percent", "message": "must be between 0 and 100" } ] } ``` ## Handling errors well - **Switch on `code`, not `status`.** Several codes share a status (three different `429`s, for instance) and need different handling. - **Retry only what's retryable.** `503`/`504` and `429 rate_limited` are transient — back off and retry. `4xx` validation, scope, and not-found errors won't fix themselves on retry. - **Surface `hint` to your operators.** It's written to be actionable. - **Log `request_id`.** It's the fastest way for support to find your request. ## Error-code registry Every code the API can return is listed below, grouped by category. Each entry is anchored (`#`), and the `type` URL in any error response points straight here. This registry is generated from the API's source of truth, so it's always complete and current. ## Next - [Rate limits](/developers/guides/rate-limits) — the three `429` codes in detail. - [Authentication](/developers/guides/authentication) — resolving `401`/`403` auth codes. --- ## Error codes RFC 9457 problem+json. `type` = https://www.fuelatlas.com/developers/guides/errors#. ### Authentication & authorization - missing_api_key (HTTP 401): API key missing. Send your API key in the Authorization header: 'Authorization: Bearer fa_live_...'. - invalid_api_key (HTTP 401): API key invalid. Double-check the key value; generate a new one in the Developer Console if needed. - key_revoked (HTTP 401): API key revoked. This key was revoked. Generate a new key in the Developer Console. - key_suspended (HTTP 403): API key suspended. This key is suspended, typically for a billing or abuse issue. Contact support. - sandbox_key_on_production (HTTP 403): Sandbox key used against production. Use a production key against the production API, or call the sandbox base URL with this key. - insufficient_scope (HTTP 403): Key lacks required scope. Request a key with the required scope from the Developer Console, or use GET /v1/me to see current scopes. - fleet_header_required (HTTP 400): FA-Fleet header required. Partner keys must select the acting fleet via the 'FA-Fleet: flt_...' header. - fleet_scope_mismatch (HTTP 403): Fleet does not match key scope. The 'FA-Fleet' header does not match a fleet this key is authorized for. - auth_unavailable (HTTP 503): Auth service temporarily unavailable. Retry with backoff; this is transient and not caused by your request. ### Validation - validation_failed (HTTP 422): Request body failed validation. Fix the fields listed in 'errors' and retry. - invalid_cursor (HTTP 400): Pagination cursor invalid or expired. Restart pagination from the first page (omit the cursor param). - invalid_public_id (HTTP 400): Public id has the wrong type prefix or is malformed. Check the id's prefix matches the expected resource type (e.g. 'veh_' for vehicles). - batch_too_large (HTTP 413): Batch request exceeds the item or size limit. Split the request into smaller batches within the documented limit. - resource_conflict (HTTP 409): Resource conflicts with an existing one. A concurrent request created a conflicting resource (e.g. a duplicate external_ref); re-read the current state and retry if appropriate. ### Idempotency - idempotency_key_required (HTTP 400): Idempotency-Key header required. Send a unique 'Idempotency-Key' header (max 64 chars) with this request. - idempotency_key_reuse (HTTP 422): Idempotency key reused with a different request body. Use a new Idempotency-Key for a different request, or resend the exact same body to replay the original response. - idempotency_conflict (HTTP 409): A request with this idempotency key is already in flight. Wait for the in-flight request to complete, then retry with the same key to get its result. ### Rate limits & abuse - rate_limited (HTTP 429): Rate limit exceeded. Slow down and retry after the duration in 'retry_after' / the RateLimit-Reset header. - quota_exceeded (HTTP 429): Monthly quota exceeded. Wait for the quota to reset, or upgrade your plan in the Developer Console. - station_budget_exceeded (HTTP 429): Station-query budget exceeded for this plan. Reduce station query volume, or upgrade your plan for a higher station-query budget. - enumeration_suspected (HTTP 403): Request pattern flagged as station-data enumeration. Reduce the breadth/frequency of station queries; contact support if this was legitimate use. ### Not found - resource_not_found (HTTP 404): Resource not found. Verify the id and that it belongs to the authenticated fleet. - no_active_guidance (HTTP 404): No active guidance session for this vehicle. Start one with POST /v1/vehicles/{id}/guidance. - unknown_vehicle (HTTP 404): No matching vehicle found. Create the vehicle first with POST /v1/vehicles, or check the id/external_ref. ### Resource state - invalid_trip_state (HTTP 409): Trip is not in a state that allows this transition. Check the trip's current status and only call transitions valid from that state. - trip_not_editable (HTTP 409): Trip can no longer be edited. Only 'planned' trips can be edited; use the lifecycle endpoints once a trip is active. - fuel_stop_immutable (HTTP 422): Fuel stops are system-managed and cannot be edited directly. Modify non-fuel stops instead, or call POST /v1/trips/{id}/optimize to regenerate fuel stops. - vehicle_inactive (HTTP 409): Vehicle is deactivated. Reactivate the vehicle before performing this operation. - event_expired (HTTP 410): Webhook event is past its replay window. This event can no longer be fetched or redelivered; rely on your original delivery record. ### Routing & computation - route_computation_timeout (HTTP 504): Route computation exceeded the time limit. Retry the request; if it repeatedly times out, simplify constraints (fewer stops, shorter route). - route_capacity (HTTP 503): Routing engine is at capacity. Retry with backoff; this is transient load-shedding, not a request error. - insufficient_fuel_data (HTTP 422): Not enough fuel data to compute a projection. Set the vehicle's tank_size_gallons and estimated_mpg via PATCH /v1/vehicles/{id}. - routing_unreachable (HTTP 502): No drivable route found between the given points. Verify both points are reachable by road; the routing engine could not find a path. --- ## Limits and caps (by design) - POST /v1/locations: max 500 items OR 1 MB per batch (413 batch_too_large). Recommended cadence: one batch every 1-5 minutes per vehicle. - POST /v1/stations/along-route: corridor half-width default 3 mi, hard cap 5 mi; max_results default 40, hard cap 50. - GET /v1/stations: radius/result caps apply; every disclosed station is charged to the plan station-query budget (429 station_budget_exceeded when exhausted). - Idempotency-Key header: required on unsafe writes that request it; max 64 chars. Same key + same body replays the original response. - Rate limiting: per-key requests/minute (RateLimit-* response headers; 429 rate_limited with retry_after). Monthly quotas per endpoint family (429 quota_exceeded). - Anti-scrape: requests are shape-checked per call (422 validation_failed if radius/corridor/results caps are exceeded); sustained broad/grid-style scanning is detected and, after repeated warnings, auto-suspends the key (403 key_suspended on subsequent calls) with an admin alert. - Station ids are opaque (stn_...) and never expose the internal truck-stop identifier. --- ## Condensed endpoint reference Generated from the OpenAPI snapshot. Field lists are top-level only; see the interactive reference for full schemas. ``` GET /v1/fleets summary: Cursor list of THIS partner's granted fleets only. scope: fleets:manage response(200): { data:array, next_cursor:string } POST /v1/fleets summary: Creates a new fleet + partner grant (one transaction). Partner-level; no FA-Fleet header. scope: fleets:manage request: { name:string, external_ref:string, contact_email:string, timezone:string } response(200): { id:string, name:string, external_ref:string, status:string, created_at:string } GET /v1/fleets/{id} summary: Gets a single granted fleet; 404 if not granted to this partner (IDOR guard). scope: fleets:manage response(200): { id:string, name:string, external_ref:string, status:string, created_at:string } PATCH /v1/fleets/{id} summary: Updates a granted fleet's name/contact_email/external_ref. scope: fleets:manage request: { name:string, contact_email:string, external_ref:string } response(200): { id:string, name:string, external_ref:string, status:string, created_at:string } DELETE /v1/fleets/{id} summary: Soft-deactivates a fleet (revokes the grant + marks the company inactive). scope: fleets:manage response: 200 GET /v1/fuelings summary: Cursor list of the fleet's fuelings, filterable by vehicle/from/to. scope: fuelings:write response(200): { data:array, next_cursor:string } POST /v1/fuelings summary: Records a batch of fuelings (Idempotency-Key required). 200 with a per-item result envelope. scope: fuelings:write request: { fuelings:array } response(200): { results:array, accepted:integer, duplicate:integer, rejected:integer } GET /v1/integration/health summary: The scorecard. Company key → own fleet. Partner key → the `?fleet_id=` fleet, or (when omitted and no FA-Fleet header) a per-fleet summary list across all granted fleets. scope: none (any valid key) response: 200 GET /v1/integration/health/history summary: The health trend for a fleet from persisted snapshots (default 30 days, max 90). scope: none (any valid key) response(200): { fleet_id:string, days:integer, snapshots:array } POST /v1/integration/test summary: "Test my integration": runs the synchronous checks + vehicle counts, fires a REAL test webhook to each of the fleet's active endpoints, and returns the scorecard + next steps ordered by severity. scope: none (any valid key) response(200): { fleet_id:string, auth:string, fleet_provisioned:string, vehicles:(object), webhooks:array, health:(object), next_steps:array } POST /v1/locations summary: Ingests a batch of location breadcrumbs (max 500 items / 1 MB). Returns a per-item envelope. scope: locations:write request: { locations:array } response(200): { accepted:integer, rejected:integer, results:array } GET /v1/me scope: none (any valid key) response(200): { partner:(object), fleet:(object), key:(object), plan:(object) } GET /v1/reports/savings summary: Fleet + per-vehicle fuel-savings roll-up over a period (defaults to the last 30 days). scope: usage:read response(200): { period:(object), fleet:(object), vehicles:array, note:string } POST /v1/routes summary: Computes a fuel-optimized route (201). Vehicle-shortcut or manual form; Idempotency-Key required. scope: routes:calculate request: { vehicle_id:string, origin:(object), destination:(object), fuel:(object), options:(object) } response(200): { id:string, status:string, distance_miles:number, duration_seconds:number, polyline:string, fuel_plan:(object), created_at:string } GET /v1/routes/{id} summary: Replays a previously computed route by its `rte_…` id; 404 outside the fleet (IDOR). scope: routes:calculate response: 200 POST /v1/sandbox/reset summary: Re-provisions the partner's sandbox fleet to initial profiles, clearing guidance + history. scope: none (any valid key) response(200): { status:string, fleet:string, vehicle_count:integer, message:string } GET /v1/sandbox/simulation summary: The live simulation snapshot: per-vehicle position/fuel/scenario + the sim status. scope: none (any valid key) response(200): { status:string, last_tick_at:string, note:string, vehicles:array } POST /v1/sandbox/vehicles/{id}/scenario summary: Sets a simulated vehicle's scenario override (applied on the next simulator tick). scope: none (any valid key) request: { scenario:string } response(200): { status:string, fleet:string, vehicle_count:integer, message:string } GET /v1/stations summary: Lists priced stations within `radius_miles` of a point, nearest-first, optionally filtered by `chain`. Caps: `radius_miles` ≤ 50, `limit` ≤ 25 (and ≤ the plan's price-query cap). scope: prices:read response(200): { stations:array, count:integer } POST /v1/stations/along-route summary: Lists priced stations along a driving corridor (from an encoded `polyline` OR an `origin`/`destination` pair). Caps: corridor width ≤ 5 mi, max_results ≤ 50, route ≤ 3,500 mi. scope: prices:read request: { polyline:string, origin:(object), destination:(object), corridor_width_miles:number, max_results:integer, fuel_level_percent:number, estimated_mpg:number, tank_capacity_gallons:number } response(200): { stations:array, count:integer } GET /v1/trips summary: Cursor list of the fleet's trips, filterable by status/vehicle/from/to. scope: trips:manage response(200): { data:array, next_cursor:string } POST /v1/trips summary: Creates a trip (Idempotency-Key required). With `auto_optimize` the fuel stops are generated. 201. scope: trips:manage request: { vehicle_id:string, external_ref:string, scheduled_start:string, stops:array, auto_optimize:boolean } response(200): { id:string, status:string, vehicle_id:string, external_ref:string, stops:array, polyline:string, fuel_plan_summary:(object), created_at:string } GET /v1/trips/{id} summary: Gets a trip by `trip_…` id; 404 outside the fleet (IDOR). scope: trips:manage response(200): { id:string, status:string, vehicle_id:string, external_ref:string, stops:array, polyline:string, fuel_plan_summary:(object), created_at:string } PATCH /v1/trips/{id} summary: Edits a trip — only while it is `planned` (else 409 trip_not_editable). scope: trips:manage request: { external_ref:string, scheduled_start:string, stops:array } response(200): { id:string, status:string, vehicle_id:string, external_ref:string, stops:array, polyline:string, fuel_plan_summary:(object), created_at:string } DELETE /v1/trips/{id} summary: Deletes a `planned` trip (hard delete); an active trip must be cancelled (409). scope: trips:manage response: 200 POST /v1/trips/{id}/cancel summary: Cancels a trip (planned/active → cancelled). scope: trips:manage request: { reason:string } response(200): { id:string, status:string, vehicle_id:string, external_ref:string, stops:array, polyline:string, fuel_plan_summary:(object), created_at:string } POST /v1/trips/{id}/complete summary: Completes a trip (planned/active → completed). scope: trips:manage response(200): { id:string, status:string, vehicle_id:string, external_ref:string, stops:array, polyline:string, fuel_plan_summary:(object), created_at:string } POST /v1/trips/{id}/optimize summary: (Re)generates the trip's system-managed fuel stops. scope: trips:manage response(200): { id:string, status:string, vehicle_id:string, external_ref:string, stops:array, polyline:string, fuel_plan_summary:(object), created_at:string } GET /v1/trips/{id}/progress summary: Ordered progress-event history for the trip. scope: trips:manage response(200): { events:array } POST /v1/trips/{id}/start summary: Starts a trip (planned → active); 409 invalid_trip_state from any other state. scope: trips:manage response(200): { id:string, status:string, vehicle_id:string, external_ref:string, stops:array, polyline:string, fuel_plan_summary:(object), created_at:string } POST /v1/trips/{id}/stops summary: Adds a non-fuel stop; a `fuel`-typed stop is rejected 422 fuel_stop_immutable. scope: trips:manage request: { type:string, latitude:number, longitude:number, name:string, planned_arrival:string } response(200): { id:string, status:string, vehicle_id:string, external_ref:string, stops:array, polyline:string, fuel_plan_summary:(object), created_at:string } PATCH /v1/trips/{id}/stops/{stopid} summary: Patches a non-fuel stop; a system-managed fuel stop is rejected 422 fuel_stop_immutable. scope: trips:manage request: { type:string, latitude:number, longitude:number, name:string, planned_arrival:string } response(200): { id:string, status:string, vehicle_id:string, external_ref:string, stops:array, polyline:string, fuel_plan_summary:(object), created_at:string } DELETE /v1/trips/{id}/stops/{stopid} summary: Deletes a non-fuel stop; a system-managed fuel stop is rejected 422 fuel_stop_immutable. scope: trips:manage response: 200 POST /v1/trips/{id}/stops/{stopid}/arrive summary: Manually marks a stop arrived (GPS auto-detection still runs). scope: trips:manage response: 200 POST /v1/trips/{id}/stops/{stopid}/depart summary: Manually marks a stop departed (GPS auto-detection still runs). scope: trips:manage response: 200 GET /v1/vehicles summary: Cursor list of the fleet's vehicles, filterable by `updated_after` and `external_ref`. scope: vehicles:manage response(200): { data:array, next_cursor:string } POST /v1/vehicles summary: Creates a vehicle, or links the external_ref to an existing vehicle when its VIN already exists in the fleet (200 with `merged_with_existing:true` instead of 201). scope: vehicles:manage request: { external_ref:string, name:string, vin:string, tank_size_gallons:number, estimated_mpg:number, license_plate:string } response(200): { id:string, external_ref:string, vin:string, status:string, tank_size_gallons:number, estimated_mpg:number, name:string, license_plate:string, created_at:string, merged_with_existing:boolean } GET /v1/vehicles/{id} summary: Gets a vehicle by `veh_…` id or `ext:{external_ref}`; 404 outside the fleet (IDOR). scope: vehicles:manage response(200): { id:string, external_ref:string, vin:string, status:string, tank_size_gallons:number, estimated_mpg:number, name:string, license_plate:string, created_at:string, merged_with_existing:boolean } PATCH /v1/vehicles/{id} summary: Updates a vehicle's name/tank_size_gallons/estimated_mpg/license_plate. scope: vehicles:manage request: { name:string, tank_size_gallons:number, estimated_mpg:number, license_plate:string } response(200): { id:string, external_ref:string, vin:string, status:string, tank_size_gallons:number, estimated_mpg:number, name:string, license_plate:string, created_at:string, merged_with_existing:boolean } DELETE /v1/vehicles/{id} summary: Deactivates a vehicle. scope: vehicles:manage response: 200 GET /v1/vehicles/{id}/location summary: Current location snapshot from `vehicle_locations_current`; 404 if no location yet. scope: vehicles:manage response(200): { latitude:number, longitude:number, recorded_at:string, speed_mph:number, heading_degrees:number, fuel_level_percent:integer, address:string } GET /v1/vehicles/{vehicleid}/guidance summary: The current guidance object; 404 `no_active_guidance` when the vehicle has none. scope: guidance:read response(200): { id:string, vehicle_id:string, status:string, phase:string, destination:(object), based_on_location_at:string, data_freshness:string, fuel:(object), stops:array, next_review_expected_at:string, created_at:string } POST /v1/vehicles/{vehicleid}/guidance summary: Opens (or supersedes) a guidance session for the vehicle and sets its destination. 201. scope: guidance:manage request: { destination:(object), min_arrival_fuel_percent:number } response(200): { id:string, vehicle_id:string, status:string, phase:string, destination:(object), based_on_location_at:string, data_freshness:string, fuel:(object), stops:array, next_review_expected_at:string, created_at:string } DELETE /v1/vehicles/{vehicleid}/guidance summary: Cancels the active guidance session (reason `caller_cancelled`). scope: guidance:manage response: 200 POST /v1/vehicles/{vehicleid}/guidance/stops/{stopid}/acknowledge summary: Acknowledges a recommended stop (the driver/agent has accepted it). scope: guidance:manage response: 200 POST /v1/vehicles/{vehicleid}/guidance/stops/{stopid}/skip summary: Skips a recommended stop, forcing a fresh recommendation on the next cycle. scope: guidance:manage request: { reason:string } response: 200 GET /v1/webhooks summary: Cursor list of the caller's endpoints (never returns the secret). scope: webhooks:manage response(200): { data:array, next_cursor:string } POST /v1/webhooks summary: Registers a webhook endpoint. Rejects http/internal URLs (validation). Returns the secret ONCE. 201. scope: webhooks:manage request: { url:string, event_types:array, fleet_id:string, description:string } response(200): { id:string, url:string, event_types:array, status:string, fleet_id:string, sandbox:boolean, secret:string, created_at:string } GET /v1/webhooks/event-types summary: The static catalog of subscribable event types + one-line descriptions (any valid key). scope: none (any valid key) response(200): { data:array } GET /v1/webhooks/{id} summary: Gets an endpoint by `whk_…` id; 404 outside the caller's ownership (IDOR). scope: webhooks:manage response(200): { id:string, url:string, event_types:array, status:string, description:string, fleet_id:string, sandbox:boolean, consecutive_failures:integer, last_success_at:string, last_failure_at:string, disabled_at:string, disabled_reason:string, created_at:string } PATCH /v1/webhooks/{id} summary: Updates url / event_types / description / status ("active" to re-enable a disabled endpoint). scope: webhooks:manage request: { url:string, event_types:array, status:string, description:string } response(200): { id:string, url:string, event_types:array, status:string, description:string, fleet_id:string, sandbox:boolean, consecutive_failures:integer, last_success_at:string, last_failure_at:string, disabled_at:string, disabled_reason:string, created_at:string } DELETE /v1/webhooks/{id} summary: Deletes an endpoint (its pending deliveries are marked skipped_disabled). scope: webhooks:manage response: 200 GET /v1/webhooks/{id}/deliveries summary: Cursor delivery log for the endpoint, filterable by status + event_type. Own endpoint only. scope: webhooks:manage response(200): { data:array, next_cursor:string } POST /v1/webhooks/{id}/deliveries/{deliveryid}/replay summary: Re-enqueues a past delivery of the SAME event (is_replay=true). 202; 410 if the event expired. scope: webhooks:manage response(200): { delivery_id:string, status:string } POST /v1/webhooks/{id}/rotate-secret summary: Rotates the signing secret; the old one stays valid 24h (dual-valid). Returns the NEW secret once. scope: webhooks:manage response(200): { id:string, secret:string, previous_secret_expires_at:string } POST /v1/webhooks/{id}/test summary: Delivers a canned sample payload for an event type SYNCHRONOUSLY (signed). Never disables the endpoint. 200. scope: webhooks:manage request: { event_type:string } response(200): { delivered:boolean, http_status:integer, latency_ms:integer, error:string } ```