This is the full developer documentation for Fleet API
# Build on Fleet
> Get fleet costs into your ERP, and connect fuel cards, telematics and workshop systems, with a modern REST API, signed webhooks and scoped keys.
Predictable REST
Resource URLs, JSON, standard verbs and status codes. One OpenAPI 3.1 document describes all of it, generated from the code that serves it.
Safe to retry
Every write accepts an `Idempotency-Key`. Retry a timed-out request and it runs at most once.
Scoped keys
Give each integration its own key with only the access it needs, an optional IP allowlist and expiry. Roll keys without downtime.
Sync-friendly
Cursor pagination and `updated_since` on every list make incremental sync a single loop.
## What you can build
[Section titled “What you can build”](#what-you-can-build)
[Fleet costs in your ERP](/guides/costs-and-erp/)Fuel, maintenance, tyres, renewals and expenses into Odoo or Business Central: once, coded, reconciled.
[Approve in Fleet, buy in Odoo](/recipes/odoo-costs/)Fleet requests go to Odoo for PR, PO and bill; the outcome comes back.
[Import fuel-card transactions](/recipes/fuel-card-transactions/)Batch-create fuel logs from your card provider, without duplicates.
[Stream GPS positions](/recipes/telematics/)Send positions and odometer readings from any telematics platform.
[Sync vehicles with your ERP](/recipes/erp-vehicle-sync/)Keep your asset register and Fleet in step, both ways.
[Run workshop jobs](/recipes/workshop-work-orders/)Open work orders, add parts and labour, close them out.
## Built for AI assistants too
[Section titled “Built for AI assistants too”](#built-for-ai-assistants-too)
Every page has **Copy page** and **Open in Claude / ChatGPT** buttons, and the whole site is available as [`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt). Point your coding assistant at [`/openapi.json`](/openapi.json) and it knows every endpoint.
# Changelog
> Every change to the Fleet API.
## 2026-11-01: Currency and fuel data fixes
[Section titled “2026-11-01: Currency and fuel data fixes”](#2026-11-01-currency-and-fuel-data-fixes)
* **Records saved without a currency** (from the app, imports or the API) now take their vehicle’s branch currency, else their own branch’s, else the organization’s. They used to default to USD.
* **Fuel logs and EV charging sessions stored as USD by that default** were corrected to the right currency. Their cost entries moved to a new `revision`, so any you had already exported show as `pending_update` in `GET /v1/cost_exports`. No webhooks were sent for the correction.
* **Fuel log dates and duplicates:** fuel logs whose dates a CSV import had misread (day and month swapped, or YY/MM/DD read as DD/MM/YY) were moved to their real dates, and rows from a file imported twice were removed. Their cost entries moved to a new `revision` or became `void`. Imports now read dates day/month unless the file is month/day, refuse dates before 2010 or in the future, and skip rows already imported.
## 2026-11-01: Wider coverage
[Section titled “2026-11-01: Wider coverage”](#2026-11-01-wider-coverage)
* **Files:** attach receipts, invoices and photos to fuel logs, expenses, work orders, vehicle renewals and vehicles with one multipart request (`POST …/{id}/attachments`); list and delete them.
* **Your ids in paths:** `GET /v1/vehicles/odoo:77` works wherever a Fleet id does, on every resource with `external_ids`.
* **`GET /v1/me`:** check a key’s organization, environment, scopes and version.
* **Parts and inventory:** `/v1/parts`, `/v1/part_locations`, `/v1/inventory_levels`, `/v1/inventory_transactions`.
* **Purchase orders:** `/v1/purchase_orders` with line items; create drafts and record the accounting status.
* **Work order costs:** `costs` gives parts, labor, service and other subtotals.
* **Record events:** `created`, `updated`, `deleted` and `status_changed` for vehicles, fuel logs, EV charging, expenses, work orders (plus `work_order.completed`), renewals, issues, purchase orders, parts and vendors; `inspection.submitted`. See [Event types](/events/).
* **Docs:** [Coming from Fleetio](/guides/coming-from-fleetio/), Odoo Enterprise and JSON-2 in the [Odoo recipe](/recipes/odoo-costs/), and code in three languages for your own ids and files.
## 2026-11-01: Costs, ERPs and webhooks
[Section titled “2026-11-01: Costs, ERPs and webhooks”](#2026-11-01-costs-erps-and-webhooks)
Everything an ERP integration needs to get fleet costs into the books once, coded and reconciled. See [Costs and your ERP](/guides/costs-and-erp/).
* **Cost ledger:** `/v1/cost_entries` (every cost as ERP-ready lines with tax, references, dimensions and settlement), `/v1/cost_exports` (an exactly-once export queue per ERP, with stale-safe acknowledgements), `/v1/cost_summaries` (totals that never guess an exchange rate) and receipts as signed links.
* **Fleet requests:** `/v1/fleet_requests` with lines, lifecycle actions, ERP financial write-back (`financial_events:write`) and a readiness check.
* **More costs:** `/v1/expenses`, `/v1/expense_types`, `/v1/vehicle_renewals`, `/v1/ev_charging_sessions`, `/v1/tire_replacements`; VAT / eTIMS, payment method and dimensions on fuel logs, work orders and vehicles.
* **Mapping:** accounting dimensions with bulk value sync, external references (`external_ids` on objects, `external_id=system:id` filters, dedupe on create), branch default dimensions, accounting settings and soft period close.
* **Webhooks** signed per Standard Webhooks, with retries, delivery logs, secret rotation and an event feed.
* **Change:** new cost records without a `currency` take their branch’s currency (or the vehicle’s branch’s), then the organization’s.
## 2026-11-01: Fleet API v1
[Section titled “2026-11-01: Fleet API v1”](#2026-11-01-fleet-api-v1)
The first public version of the Fleet REST API.
* **Resources:** branches, vehicles, users, contacts, vendors, vehicle assignments, meter entries, fuel logs, work orders and line items, issues, inspection templates, inspections, service reminders, vehicle positions.
* **Keys:** scoped API keys with IP allowlists, expiry and zero-downtime rolling, managed in Settings → Developers.
* **Conventions:** cursor pagination, `updated_since` on every list, `expand`, `Idempotency-Key` on every write, `ETag`/`If-Match`, RFC 9457 errors, IETF `RateLimit` headers.
* **Request log** in Settings → Developers, kept for 30 days.
* **Docs:** this site, the interactive reference, `llms.txt` and the OpenAPI 3.1 document.
## Coming next
[Section titled “Coming next”](#coming-next)
* **Self-serve sandboxes** with sample data, reset and event simulators.
* **SDKs** for TypeScript and Python, and a Postman collection.
* **More resources:** claims, transport bookings, comments, contact renewals.
* **OAuth apps**, for products that connect many Fleet organizations.
# Authentication and scopes
> API keys, how to send them, scopes, IP allowlists, expiry and rotation.
Every request needs an API key. Create keys in **Settings → Developers** (you need the *Developers → Manage* permission). Create **one key per integration**, so you can revoke one without breaking the others.
## Sending the key
[Section titled “Sending the key”](#sending-the-key)
Send it as a bearer token:
```http
GET /v1/vehicles HTTP/1.1
Host: api.get-fleet.com
Authorization: Bearer flt_live_3fQ...
```
`curl -u flt_live_3fQ...:` (HTTP Basic, key as the username) and an `X-Api-Key` header also work.
Keep keys server-side
A key acts for your whole organization within its scopes. Never put one in a mobile app, browser JavaScript or a repository. If a key leaks, revoke it in Settings → Developers: it stops working immediately.
## Scopes
[Section titled “Scopes”](#scopes)
A key can only do what its scopes allow. A `:write` scope includes the matching `:read`. A missing scope returns `403 insufficient_scope` with the scope you need in `required_scope`.
You can only grant scopes whose permission your own role has, so a key never has more access than the person who created it. All keys in an organization also share a ceiling: the **API integrations** role in Settings → Users & Roles. An administrator can narrow that role to restrict every key at once (`403 permission_denied`).
| Scope | Allows |
| ------------------------ | ------------------------------------------------------------------------------------ |
| `branches:read` | Read branches. |
| `vehicles:read` | Read vehicles. |
| `vehicles:write` | Create and update vehicles. |
| `users:read` | Read people in the organization (name, email, phone, branch). |
| `contacts:read` | Read contacts. |
| `contacts:write` | Create, update and delete contacts. |
| `vendors:read` | Read vendors. |
| `vendors:write` | Create, update and delete vendors. |
| `assignments:read` | Read vehicle assignments. |
| `assignments:write` | Create, update and delete vehicle assignments. |
| `meter_entries:read` | Read odometer and hour-meter readings. |
| `meter_entries:write` | Record and void meter readings. |
| `fuel_logs:read` | Read fuel logs. |
| `fuel_logs:write` | Create, update and delete fuel logs. |
| `work_orders:read` | Read work orders and their line items. |
| `work_orders:write` | Create and update work orders and add line items. |
| `issues:read` | Read issues. |
| `issues:write` | Create, update and delete issues. |
| `inspections:read` | Read inspection templates and submitted inspections. |
| `service_reminders:read` | Read service reminders. |
| `parts:read` | Read parts, part locations, stock levels and inventory movements. |
| `parts:write` | Create, update and delete parts. |
| `purchase_orders:read` | Read purchase orders and their line items. |
| `purchase_orders:write` | Create draft purchase orders and record their accounting status. |
| `telematics:read` | Read vehicle positions. |
| `telematics:write` | Send vehicle positions (GPS) from your telematics system. |
| `costs:read` | Read the cost ledger and cost summaries. |
| `costs:write` | Acknowledge cost entries exported to your ERP. |
| `expenses:read` | Read vehicle expenses and expense types. |
| `expenses:write` | Create, update and delete vehicle expenses and expense types. |
| `renewals:read` | Read vehicle renewals (insurance, licences, inspections). |
| `renewals:write` | Create, update and delete vehicle renewals. |
| `fleet_requests:read` | Read fleet requests, their lines and financial status. |
| `fleet_requests:write` | Raise, edit, submit, approve and progress fleet requests. |
| `financial_events:write` | Write ERP outcomes back to fleet requests: commitment, PO, bill, payment, rejection. |
| `accounting:read` | Read accounting dimensions, ERP references and accounting settings. |
| `accounting:write` | Manage dimensions and their values, ERP references, and period close. |
| `webhooks:read` | Read webhook endpoints, deliveries and events. |
| `webhooks:write` | Create, update and test webhook endpoints. |
## IP allowlists and expiry
[Section titled “IP allowlists and expiry”](#ip-allowlists-and-expiry)
When you create a key you can restrict it to IP addresses or CIDR ranges (for example `203.0.113.10` or `10.0.0.0/8`); calls from elsewhere fail with `403 ip_not_allowed`. You can also give it an expiry date, after which calls fail with `401 api_key_expired`.
## Rolling a key
[Section titled “Rolling a key”](#rolling-a-key)
Rolling issues a new key with the same name and access, and keeps the old one working for a grace period you choose (immediately, 1 hour, 24 hours or 7 days). Deploy the new key everywhere within the grace period. Roll keys periodically and whenever someone with access to them leaves.
## Who the API acts as
[Section titled “Who the API acts as”](#who-the-api-acts-as)
Changes made through the API are attributed to the key, not to a person. The request log shows which key made each call, and where Fleet records a creator (work orders, meter entries) the API’s records are marked as created by the API. Fleet’s normal rules apply: the API cannot do anything that the organization’s permissions don’t allow.
# Coming from Fleetio
> Fleetio's API concepts and resources, and their Fleet equivalents, for integrations moving over or supporting both.
If you have built on Fleetio’s API, most of what you know carries over. This page maps the concepts and resources, and points out where Fleet behaves differently.
## Conventions
[Section titled “Conventions”](#conventions)
| | Fleetio | Fleet |
| ----------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Base URL | `https://secure.fleetio.com/api` | `https://api.get-fleet.com/v1` |
| Credentials | `Authorization: Token ` and `Account-Token: ` | `Authorization: Bearer flt_live_…`. The key belongs to one organization, so there’s no second header. Test keys start with `flt_test_`. |
| Versions | `X-Api-Version: 2025-05-05` | `Fleet-Version: 2026-11-01`; without it, the version pinned to your key |
| Pages | `per_page`, `start_cursor` → `{ records, next_cursor }` | `limit`, `cursor` → `{ data, has_more, next_cursor }` |
| Filters | `filter[field][op]=…`, `sort[field]=asc` | Named parameters (`vehicle_id=`, `date_from=`, `status=`), `updated_since=`, `q=`, `sort=-date` |
| Your ids | `external_ids` on vehicles; `/vehicles/accounting_id:987` | `external_ids` on vehicles, branches, vendors, users, contacts, parts, purchase orders and every cost record; `/vehicles/odoo:77`; `external_id=odoo:77` filters; dedupe on create |
| Retries | — | `Idempotency-Key` on every write |
| Errors | JSON message | [RFC 9457 problem details](/guides/errors/) with a stable `code` |
| Webhooks | HMAC-signed, events like `fuel_entry_created` | [Standard Webhooks](/guides/webhooks/) signing, events like `fuel_log.created` ([all types](/events/)) |
| Files | Upload policy, then attach a `file_url` | One multipart request to `…/{id}/attachments` ([Files](/guides/requests/#files)) |
| First call | `GET /users/me` | `GET /v1/me` |
## Resources
[Section titled “Resources”](#resources)
| Fleetio | Fleet | Notes |
| ----------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| Vehicles | `/v1/vehicles` | Groups are branches (`/v1/branches`). |
| Contacts | `/v1/users` (people with a login) and `/v1/contacts` | |
| Vendors | `/v1/vendors` | |
| Vehicle assignments | `/v1/vehicle_assignments` | |
| Meter entries | `/v1/meter_entries` | Voids instead of deletes. |
| Fuel entries | `/v1/fuel_logs` | `quantity` × `unit_price`; also VAT / eTIMS, payment method and accounting dimensions. |
| — | `/v1/ev_charging_sessions` | |
| Expense entries, expense entry types | `/v1/expenses`, `/v1/expense_types` | `date` instead of `occurred_at`; tax, payment and dimensions. |
| Service entries, work orders | `/v1/work_orders` with `/line_items` | One resource for both. `costs` gives parts, labor, service and other subtotals; a completed work order is a cost. |
| Issues | `/v1/issues` | |
| Submitted inspection forms | `/v1/inspections` (`/v1/inspection_templates` for the forms) | |
| Service reminders | `/v1/service_reminders` | |
| Vehicle renewal reminders | `/v1/vehicle_renewals` | |
| Parts, part locations, part location details | `/v1/parts`, `/v1/part_locations`, `/v1/inventory_levels` | |
| Inventory journal entries | `/v1/inventory_transactions` | Read only. |
| Purchase orders (+ line items) | `/v1/purchase_orders` (line items embedded) | Create drafts; approval and receiving happen in Fleet, as in Fleetio. |
| Location entries | `/v1/vehicle_positions`, `/v1/vehicles/{id}/location` | |
| Comments, labels, places, equipment, contact renewals | — | Not in the API yet. |
## What Fleet adds for cost integrations
[Section titled “What Fleet adds for cost integrations”](#what-fleet-adds-for-cost-integrations)
Fleetio gives you each kind of cost separately; you merge them and keep your own sync state. Fleet also gives you:
* **One cost ledger** ([`/v1/cost_entries`](/reference#tag/costs)): every fuel log, charging session, completed work order line, expense, renewal, tyre and ERP bill, once, totalling what Fleet’s cost reports show.
* **An export queue per ERP** (`/v1/cost_exports`) with acknowledgements, so each cost is posted exactly once and changes or deletions after posting come back as updates or reversals.
* **Accounting dimensions** pushed from your ERP, and **period close**.
* **Fleet requests**: spend approved in Fleet, purchased and billed in the ERP, with the outcome written back.
Start with [Costs and your ERP](/guides/costs-and-erp/).
Tip
Running both while you move? Store Fleetio’s ids in Fleet as external ids (`external_system: "fleetio"`) when you import, and every record stays findable by its old id: `GET /v1/fuel_logs?external_id=fleetio:123456`.
# Costs and your ERP
> How fleet costs get from Fleet into Odoo, Business Central or any ERP, once, with the right codes, and reconciled.
Most integrations with Fleet exist for one reason: **fleet costs have to end up in the ERP**. Fuel, maintenance, tyres, insurance and licences, tolls and fines, all coded to the right accounts, departments and projects, without double entry and without double counting. This page explains the model; the [Odoo](/recipes/odoo-costs/) and [Business Central](/recipes/business-central-costs/) recipes put it to work.
## Who owns what
[Section titled “Who owns what”](#who-owns-what)
**Fleet holds the codes. The ERP holds the accounting rules.**
* Fleet records *what happened*: which vehicle, which supplier, how many litres, which parts, which department or project it was for, the VAT on the receipt and how it was paid.
* Your ERP decides *how to book it*: which G/L account, which tax code, which journal, what the approval limits are.
So Fleet never stores G/L accounts as accounting truth. It stores **dimension codes** your ERP gives it, and **external references** that tie Fleet objects to ERP records. Your integration translates.
## Two ways costs flow
[Section titled “Two ways costs flow”](#two-ways-costs-flow)
| | Request-first (Fleet requests) | Record-first (cost ledger) |
| ----------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| When | Spend needs approval or a PO before it happens: services, repairs, tyres, parts | The cost already happened: fuel-card transactions, receipts, claims, renewals |
| Flow | Fleet request → approved in Fleet → your ERP raises the PR / PO / bill → ERP writes the outcome back | Fleet records the cost → your ERP pulls it from the export queue and posts it |
| Actual cost | The **ERP bill** (Fleet records it from your write-back) | The Fleet record |
| Endpoints | `/v1/fleet_requests`, `…/financial_events`, webhooks `fleet_request.*` | `/v1/cost_entries`, `/v1/cost_exports`, webhooks `cost_entry.*` |
Most organizations use both. Neither double counts: when your ERP bill for a fleet request arrives, the ledger entries for the linked work order are marked `superseded` and the bill becomes the cost.
## The cost ledger
[Section titled “The cost ledger”](#the-cost-ledger)
Every cost in Fleet appears once in `/v1/cost_entries`, as ERP-ready lines:
```json
{
"id": "6f1c2b0e-8a43-4c1e-9d55-0c2b7f3e9a10",
"object": "cost_entry",
"status": "posted",
"revision": 1,
"category": "fuel",
"subcategory": "diesel",
"incurred_on": "2026-10-01",
"posting_date": "2026-10-01",
"amount": "7600.00",
"currency": "KES",
"tax": { "amount": "1048.28", "rate": 16 },
"quantity": "40", "unit": "litre", "unit_price": "190.00",
"vehicle_id": "…", "branch_id": "…", "vendor_id": "…",
"settlement": "fuel_card",
"payee": { "type": "vendor", "id": "…" },
"references": { "receipt_number": "R-55102", "tax_invoice_number": "KRAMW004202610010123" },
"dimensions": { "department": "OPS", "project": "P-104" },
"source": { "type": "fuel_log", "id": "…", "origin": "api", "url": "/v1/fuel_logs/…" }
}
```
* **Categories** are `fuel`, `ev_charging`, `maintenance` (one line each for parts, labour and service on a completed work order), `tires`, `renewals` and `expenses`. Totals match Fleet’s Operating Cost Summary report, so what you post reconciles with what fleet managers see.
* **Settlement** says who is owed: `vendor_bill` (a payable), `employee_reimbursement` (an approved claim), `prepaid_card` (an allocation against a prepaid card account, not a new payable), `fuel_card`, `cash`, `stock_issue` (parts from inventory), `internal_labor`, `financing` (lease or loan payments).
* **Dimensions** combine branch defaults, vehicle defaults and the record’s own codes, most specific last.
* **Entries are never deleted.** A change raises `revision`. A deleted or no-longer-qualifying record becomes `void`; an ERP bill replacing a work order makes it `superseded`. Reverse what you posted in both cases.
## Exporting exactly once
[Section titled “Exporting exactly once”](#exporting-exactly-once)
`GET /v1/cost_exports?system=business_central` is a queue of what your ERP has not seen in its current form:
* `pending_new`: never exported. Post it.
* `pending_update`: changed since you exported it. `last_export` holds what you posted; post the difference or reverse and re-post.
* `pending_void`: you exported it and it has since been voided or superseded. Reverse it.
After posting, acknowledge with the revision you posted and your document id:
```sh
curl https://api.get-fleet.com/v1/cost_exports \
-H "Authorization: Bearer $FLEET_API_KEY" -H "Content-Type: application/json" \
-d '{"system": "business_central", "entries": [{"id": "6f1c…", "revision": 1, "external_id": "PI-10442"}]}'
```
If the entry changed while you were posting it, the acknowledgement is still recorded for the revision you posted and comes back `stale`: the entry stays in the queue as `pending_update` (or `pending_void`) with `last_export` showing what you posted. Nothing is lost between reading and acknowledging, and nothing is posted twice. Each ERP has its own queue; records your ERP itself created through the API (they carry its external reference) are never offered back to it.
Tip
Poll the queue every few minutes, or subscribe to `cost_entry.*` [webhooks](/guides/webhooks/) and drain the queue when one arrives. The queue is the source of truth either way; webhooks only tell you when to look.
## Mapping
[Section titled “Mapping”](#mapping)
Set these up once, from your ERP, with `accounting:write`:
1. **Dimensions and their values.** Create a dimension per ERP dimension (department, project, cost centre, donor, activity…), then push the value list with `PUT /v1/accounting_dimensions/{key}/values`. Send `deactivate_missing: true` to make it a full sync. Values can have a parent (sub-activity under activity) and validity dates. Fleet only accepts active codes on records.
2. **Default codes** on branches (`PUT /v1/branches/{id}/dimensions`) and vehicles (`dimensions` on the vehicle), so people rarely have to pick them.
3. **External references** for the objects your ERP needs ids for: vehicles (asset or analytic account), vendors (partner / vendor no.), users (employee), expense types, and **cost categories** (`maintenance.parts`, `fuel`, `renewals.insurance`…), whose `metadata` can carry the account and tax code your integration should use:
```json
PUT /v1/external_references
{ "data": [
{ "object_type": "cost_category", "object_key": "fuel", "system": "odoo", "external_id": "611000",
"metadata": { "account": "611000", "tax": "VAT 16% (purchases)" } },
{ "object_type": "vehicle", "object_key": "6f1c…", "system": "odoo", "external_id": "77" }
] }
```
Objects then carry `external_ids` (`{"odoo": "77"}`), and every list takes `external_id=odoo:77`.
`GET /v1/fleet_requests/{id}/readiness?system=odoo` runs these checks for one request before anyone approves it: missing amounts or categories, required dimensions, and unmapped vehicles, suppliers or categories.
## Tax and payment
[Section titled “Tax and payment”](#tax-and-payment)
Fuel logs, expenses and EV charging sessions carry `tax_amount`, `tax_rate`, `tax_invoice_number` (for example the KRA eTIMS invoice number), `supplier_invoice_number`, `payment_method` and `payment_reference`. They flow into the ledger as `tax` and `references`, so input VAT can be claimed from the ERP side.
## Periods and currencies
[Section titled “Periods and currencies”](#periods-and-currencies)
* **Soft period close.** When your ERP closes a period, set `closed_through` with `PATCH /v1/accounting_settings`. A cost dated in a closed period that changes later keeps its `incurred_on` but gets a `posting_date` in the first open day, so you never have to reopen a closed period. Post by `posting_date`.
* **Currencies are explicit.** Each entry is in its transaction currency (a fuel log at a Ugandan branch is in UGX). `GET /v1/cost_summaries` converts with Fleet’s monthly rates and, where a rate is missing, says so (`missing_fx: true`) instead of guessing.
## Creating costs from the ERP side
[Section titled “Creating costs from the ERP side”](#creating-costs-from-the-erp-side)
If the ERP is where a cost is first captured (a supplier invoice for tyres, an insurance renewal), create it in Fleet so vehicle cost reports are complete: `POST /v1/expenses`, `/v1/vehicle_renewals`, `/v1/fuel_logs` or `/v1/work_orders`. Send `external_system` and `external_id`: re-sending the same pair returns the existing record instead of a duplicate, and the ledger knows not to export it back to you.
# Errors
> Error format, status codes, and which errors to retry.
Fleet uses standard HTTP status codes: `2xx` succeeded, `4xx` means the request needs to change, `5xx` means something went wrong on our side.
Error bodies are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details, sent as `application/problem+json`:
```json
{
"type": "https://developers.get-fleet.com/errors/validation_failed",
"title": "Validation failed",
"status": 400,
"code": "validation_failed",
"detail": "2 parameters are invalid.",
"instance": "/v1/fuel_logs",
"request_id": "req_7Hx2mQ9vLk3Pz8Rt1Ws4Yb6N",
"param": "vehicle_id",
"errors": [
{ "param": "vehicle_id", "code": "invalid_format", "message": "Invalid UUID" },
{ "param": "quantity", "code": "invalid_union", "message": "Expected a decimal number" }
]
}
```
* Branch on **`code`**: it is stable. `detail` is for people and may change.
* **`param`** names the field at fault; **`errors`** lists every invalid field on `validation_failed`.
* **`type`** links to the page for that error, with advice.
* Log **`request_id`**; it identifies the call in your request log and for support.
## Retrying
[Section titled “Retrying”](#retrying)
| Status | Retry? |
| ----------------------------------------------- | ----------------------------------------------------------------- |
| `400`, `401`, `403`, `404`, `412`, `415`, `422` | No. Fix the request first. |
| `409 duplicate_resource` | No. The object exists; look it up and update it. |
| `409 resource_in_use` | No. Other records still refer to the object you tried to delete. |
| `409 idempotency_request_in_progress` | Yes, after a moment, with the same `Idempotency-Key`. |
| `429` | Yes, after `Retry-After` seconds. |
| `500`, `503`, network errors and timeouts | Yes, with exponential backoff and the **same** `Idempotency-Key`. |
A good default: retry up to 5 times, waiting 0.5 s, 1 s, 2 s, 4 s and 8 s plus random jitter, always reusing the idempotency key of the original request.
## All codes
[Section titled “All codes”](#all-codes)
| Status | Code | Meaning |
| ------ | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| 401 | [`authentication_required`](/errors/authentication_required/) | The request had no API key. Send it as `Authorization: Bearer flt_…`. |
| 401 | [`invalid_api_key`](/errors/invalid_api_key/) | The API key is malformed, unknown, or belongs to the other environment (live vs test). |
| 401 | [`api_key_revoked`](/errors/api_key_revoked/) | The API key was revoked in Settings → Developers. |
| 401 | [`api_key_expired`](/errors/api_key_expired/) | The API key passed its expiry date. Create or roll a key. |
| 403 | [`ip_not_allowed`](/errors/ip_not_allowed/) | The key has an IP allowlist and the request came from an address outside it. |
| 403 | [`insufficient_scope`](/errors/insufficient_scope/) | The API key lacks the scope this endpoint needs. The `required_scope` field names it. |
| 403 | [`permission_denied`](/errors/permission_denied/) | The organization's "API integrations" role does not allow this action. An admin can change it in Settings → Roles. |
| 400 | [`invalid_json`](/errors/invalid_json/) | The request body is not valid JSON. |
| 400 | [`validation_failed`](/errors/validation_failed/) | One or more parameters are missing or invalid. `errors` lists each one. |
| 400 | [`unsupported_version`](/errors/unsupported_version/) | The `Fleet-Version` header names a version this API does not serve. |
| 404 | [`resource_not_found`](/errors/resource_not_found/) | No object with that id exists in your organization. |
| 404 | [`route_not_found`](/errors/route_not_found/) | No endpoint matches this method and path. |
| 409 | [`duplicate_resource`](/errors/duplicate_resource/) | An object with the same unique value (for example a vehicle number) already exists. |
| 409 | [`resource_in_use`](/errors/resource_in_use/) | Other records still refer to this object (for example fuel logs that name this vendor), so it cannot be deleted. |
| 409 | [`invalid_state`](/errors/invalid_state/) | The object is not in a state that allows this action (for example approving a request that was never submitted). |
| 409 | [`idempotency_request_in_progress`](/errors/idempotency_request_in_progress/) | A request with this Idempotency-Key is still being processed. Retry shortly. |
| 412 | [`precondition_failed`](/errors/precondition_failed/) | The object changed since you read it: its ETag no longer matches `If-Match`. Fetch it again and retry. |
| 413 | [`payload_too_large`](/errors/payload_too_large/) | The request body exceeds 1 MB, or 10 MB for a file upload. |
| 415 | [`unsupported_media_type`](/errors/unsupported_media_type/) | Send request bodies as `application/json`. |
| 422 | [`reference_not_found`](/errors/reference_not_found/) | An id in the request (for example `vehicle_id`) does not exist in your organization. |
| 422 | [`idempotency_key_reused`](/errors/idempotency_key_reused/) | This Idempotency-Key was already used with a different request body or path in the last 24 hours. |
| 429 | [`rate_limited`](/errors/rate_limited/) | Too many requests for this API key. Wait for the number of seconds in `Retry-After`. |
| 500 | [`internal_error`](/errors/internal_error/) | Something went wrong on our side. Retrying with the same Idempotency-Key is safe. |
| 503 | [`service_unavailable`](/errors/service_unavailable/) | The API is temporarily unavailable. Retry with exponential backoff. |
# Idempotency
> Retry writes safely with Idempotency-Key.
Networks fail. If a `POST` times out, you can’t tell whether Fleet created the object. An idempotency key makes the retry safe: send the same `Idempotency-Key` header with the same request, and Fleet runs it at most once.
```sh
curl https://api.get-fleet.com/v1/fuel_logs \
-H "Authorization: Bearer $FLEET_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9d6a1c2e-6a0b-4b5e-8f1e-2c7d3b9a4e10" \
-d '{"vehicle_id": "6f1c…", "date": "2026-11-01", "quantity": 45.2, "unit_price": 196.5}'
```
How it works:
* Keys are per API key and last **24 hours**. Generate a new one (a UUID is ideal) for each distinct operation, and reuse it only to retry that operation.
* A retry with the same key and the same request returns the **original response**, with `Idempotent-Replayed: true`. Nothing runs twice.
* The same key with a different body or path is refused: `422 idempotency_key_reused`.
* If the first request is still running, the retry gets `409 idempotency_request_in_progress`. Wait and retry.
* Server errors (`5xx`), rate limits (`429`) and conflicts (`409`) are not stored, so retrying them really retries.
Idempotency keys work on `POST`, `PATCH` and `DELETE`. `GET` is always safe to repeat.
## Natural keys
[Section titled “Natural keys”](#natural-keys)
Some resources also deduplicate on your own identifiers, which protects you even across days:
* **Meter entries**: send `external_id` (for example your telematics event id). Sending the same `external_id` again returns the existing entry.
* **Fuel logs**: filter by `receipt_number` before importing to skip transactions you already sent.
# Pagination and sync
> Cursor pagination, and how to keep another system in sync with Fleet.
Every list endpoint returns pages of up to `limit` objects (default 25, maximum 100):
```json
{ "object": "list", "url": "/v1/fuel_logs", "data": [ … ], "has_more": true, "next_cursor": "WyItZGF0ZSIs…" }
```
To get the next page, repeat the request with `cursor` set to `next_cursor`, keeping every other parameter the same. When `has_more` is `false`, `next_cursor` is `null` and you have everything. Cursors are opaque; don’t build or parse them. A cursor only works with the `sort` it was created under (`400 validation_failed` otherwise).
Cursors point at a position in a sorted list rather than an offset, so pages stay correct while objects are being added, and page 500 is as fast as page 1.
* JavaScript
```js
async function* listAll(path, params = {}) {
let cursor
do {
const url = new URL(`https://api.get-fleet.com/v1/${path}`)
for (const [k, v] of Object.entries({ ...params, limit: 100, ...(cursor ? { cursor } : {}) })) url.searchParams.set(k, v)
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.FLEET_API_KEY}` } })
if (!res.ok) throw new Error(`${res.status} ${(await res.json()).code}`)
const page = await res.json()
yield* page.data
cursor = page.next_cursor
} while (cursor)
}
for await (const vehicle of listAll('vehicles', { status: 'active' })) console.log(vehicle.vehicle_number)
```
* Python
```python
def list_all(path, **params):
cursor = None
while True:
query = {**params, "limit": 100, **({"cursor": cursor} if cursor else {})}
res = requests.get(f"https://api.get-fleet.com/v1/{path}", params=query, headers=HEADERS)
res.raise_for_status()
page = res.json()
yield from page["data"]
cursor = page["next_cursor"]
if not cursor:
return
for vehicle in list_all("vehicles", status="active"):
print(vehicle["vehicle_number"])
```
## Incremental sync
[Section titled “Incremental sync”](#incremental-sync)
To mirror Fleet data into another system, don’t re-read everything each time. Read what changed:
1. On the first run, page through everything with `sort=updated_at`.
2. Remember the largest `updated_at` you saw.
3. On every later run, request `updated_since=&sort=updated_at` and page to the end.
`updated_since` is inclusive, so the boundary object comes back again; upsert by `id` and that is harmless. Deletions don’t appear in `updated_since` results, so reconcile them with a periodic full read, or use statuses (`disposed`, `cancelled`) which do.
## Totals
[Section titled “Totals”](#totals)
Add `include_total=true` for a `total_count` of all matching objects. It costs an extra count, so only ask for it when you show it.
# Rate limits
> How many requests you can make, and the headers that tell you where you stand.
Each API key may make **600 requests per minute** on average, with bursts of up to **100 requests** at once. Limits are per key, so one busy integration doesn’t slow down another. If you need more, contact us.
Every response tells you where you stand, using the IETF `RateLimit` headers:
```http
RateLimit-Policy: "default";q=600;w=60
RateLimit: "default";r=97;t=1
```
* `q=600;w=60`: 600 requests per 60 seconds.
* `r`: requests you can make right now.
* `t`: seconds until you are back to a full allowance.
Over the limit, you get `429 rate_limited` with a `Retry-After` header in seconds. Wait that long, then continue.
## Staying under the limit
[Section titled “Staying under the limit”](#staying-under-the-limit)
* Use **batch endpoints**: one `POST /v1/fuel_logs/batch` with 100 logs costs one request, not 100.
* Use **`limit=100`** when paging, and **`updated_since`** instead of full re-reads.
* Use `POST /v1/vehicle_positions`, which takes up to 1,000 positions per request.
* Spread scheduled jobs out instead of starting them all on the hour.
# Requests and responses
> Objects, IDs, dates, money, filtering, sorting, expanding related objects, your own ids, files and updating safely.
## Base URL and format
[Section titled “Base URL and format”](#base-url-and-format)
All endpoints live under `https://api.get-fleet.com/v1`. Send and receive JSON (`Content-Type: application/json`). Paths use lowercase with underscores: `/v1/fuel_logs`, `/v1/work_orders/{id}/line_items`.
Every response carries a `Fleet-Request-Id` header (`req_…`). Include it when you contact support; it is also searchable in the request log.
Send a `User-Agent` that names your integration, such as `User-Agent: acme-odoo-sync/1.4`. Requests with the default agent of Python’s `urllib` or Perl’s `libwww` are refused at the network edge with a `403` (error 1010) before they reach Fleet. Python’s `requests`, Node, Java, Go, .NET and Postman are not affected.
## Objects
[Section titled “Objects”](#objects)
Every object has an `id` (a UUID) and an `object` field naming its type, so you can tell objects apart in mixed lists:
```json
{ "id": "6f1c2b0e-8a43-4c1e-9d55-0c2b7f3e9a10", "object": "fuel_log", ... }
```
| Kind of value | Format | Example |
| -------------------- | ---------------------------------------- | ---------------------------- |
| Timestamps | ISO 8601, UTC | `"2026-11-01T08:30:00.000Z"` |
| Calendar dates | `YYYY-MM-DD` | `"2026-11-01"` |
| Money and quantities | Decimal strings, so no precision is lost | `"8881.80"` |
| Currencies | ISO 4217 | `"KES"` |
| Missing values | `null` (keys are always present) | `"vin": null` |
When you send decimals you can use a JSON number or a string: `45.2` and `"45.20"` are both accepted.
## Filtering, searching and sorting
[Section titled “Filtering, searching and sorting”](#filtering-searching-and-sorting)
List endpoints take filters as query parameters. Each endpoint’s filters are in the [API reference](/reference). Common ones:
| Parameter | Meaning |
| --------------------------------- | ------------------------------------------------------------------ |
| `q` | Free-text search over the main text fields (number, name, plate…). |
| `created_after`, `created_before` | Creation time range. |
| `updated_since` | Objects changed at or after a time. The basis of incremental sync. |
| `vehicle_id`, `status`, … | Exact matches. |
| `sort` | A sort field, `-` prefix for descending, e.g. `sort=-date`. |
```sh
curl "https://api.get-fleet.com/v1/work_orders?status=open&priority=urgent&sort=-issued_at" \
-H "Authorization: Bearer $FLEET_API_KEY"
```
## Expanding related objects
[Section titled “Expanding related objects”](#expanding-related-objects)
Objects refer to each other by id (`vehicle_id`, `driver_id`). Add `expand` to include a summary of the related object in the same response:
```sh
curl "https://api.get-fleet.com/v1/fuel_logs?expand=vehicle,driver" -H "Authorization: Bearer $FLEET_API_KEY"
```
```json
{
"id": "…",
"object": "fuel_log",
"vehicle_id": "6f1c…",
"vehicle": { "id": "6f1c…", "object": "vehicle", "vehicle_number": "KDA-001", "license_plate": "KDA 123A", "name": null, "make": "Toyota", "model": "Hilux" },
"driver": { "id": "…", "object": "user", "full_name": "Wanjiku Kamau", "email": "wanjiku@example.co.ke" }
}
```
Each endpoint lists what it can expand. `GET /v1/inspections/{id}?expand=responses` returns every answer on an inspection.
## Using your own ids
[Section titled “Using your own ids”](#using-your-own-ids)
Your ERP or source system has its own ids for vehicles, vendors, fuel transactions and bills. Give them to Fleet once and use them everywhere, so you never have to store Fleet’s ids.
**On create**, send `external_system` and `external_id`. Sending the same pair again returns the record Fleet already has (`200` with `Idempotent-Replayed: true`) instead of creating a duplicate, so re-running an import is always safe:
```json
POST /v1/fuel_logs
{ "vehicle_id": "6f1c…", "date": "2026-11-01", "quantity": 45.2, "unit_price": "196.50",
"external_system": "rubis_card", "external_id": "TXN-88231" }
```
**In paths**, write `system:id` wherever a Fleet id goes, on every resource that has `external_ids`:
* curl
```sh
curl https://api.get-fleet.com/v1/vehicles/odoo:77 -H "Authorization: Bearer $FLEET_API_KEY"
```
* JavaScript
```js
const vehicle = await (await fetch('https://api.get-fleet.com/v1/vehicles/odoo:77', {
headers: { Authorization: `Bearer ${process.env.FLEET_API_KEY}` },
})).json()
```
* Python
```python
vehicle = requests.get("https://api.get-fleet.com/v1/vehicles/odoo:77", headers=HEADERS).json()
```
**In lists**, filter with `external_id=odoo:77`. Every such object carries its ids in `external_ids` (`{"odoo": "77"}`). To link records that already exist, use `PUT /v1/external_references` (see [Costs and your ERP](/guides/costs-and-erp/#mapping)). URL-encode ids that contain `/`, such as `BILL%2F2026%2F0042`.
## Files
[Section titled “Files”](#files)
Receipts, invoices and photos attach to fuel logs, expenses, work orders, vehicle renewals and vehicles. Upload with `multipart/form-data` and a `file` field (up to 10 MB; PDF, JPEG, PNG, WebP, HEIC, GIF, CSV, Excel or Word). The file shows on the record in Fleet straight away, and behind its cost in the [cost ledger](/guides/costs-and-erp/).
* curl
```sh
curl https://api.get-fleet.com/v1/fuel_logs/$FUEL_LOG_ID/attachments \
-H "Authorization: Bearer $FLEET_API_KEY" \
-H "Idempotency-Key: receipt-TXN-88231" \
-F file=@receipt.pdf \
-F description="Pump receipt"
```
* JavaScript
```js
const form = new FormData()
form.append('file', new Blob([await fs.readFile('receipt.pdf')], { type: 'application/pdf' }), 'receipt.pdf')
form.append('description', 'Pump receipt')
const attachment = await (await fetch(`https://api.get-fleet.com/v1/fuel_logs/${fuelLogId}/attachments`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.FLEET_API_KEY}`, 'Idempotency-Key': 'receipt-TXN-88231' },
body: form, // fetch sets the multipart Content-Type with its boundary
})).json()
```
* Python
```python
with open("receipt.pdf", "rb") as f:
attachment = requests.post(
f"https://api.get-fleet.com/v1/fuel_logs/{fuel_log_id}/attachments",
headers={**HEADERS, "Idempotency-Key": "receipt-TXN-88231"},
files={"file": ("receipt.pdf", f, "application/pdf")},
data={"description": "Pump receipt"},
).json()
```
`GET …/attachments` lists them with signed download links valid for 15 minutes; `DELETE …/attachments/{id}` removes one. A retried upload with the same `Idempotency-Key` is stored once.
## Creating
[Section titled “Creating”](#creating)
`POST` to the collection. Unknown fields are rejected (`400 validation_failed`), which catches typos early. A successful create returns `201 Created`, the object, and a `Location` header.
Resources that integrations create in bulk (vehicles, contacts, vendors, fuel logs, meter entries) also have a `POST …/batch` endpoint that takes up to 100 objects as `{ "data": [ … ] }`.
## Updating
[Section titled “Updating”](#updating)
`PATCH` changes only the fields you send. Send `null` to clear an optional field.
```sh
curl -X PATCH https://api.get-fleet.com/v1/work_orders/$ID \
-H "Authorization: Bearer $FLEET_API_KEY" -H "Content-Type: application/json" \
-d '{"status": "completed", "actual_end": "2026-11-01T15:30:00Z"}'
```
### Avoiding lost updates
[Section titled “Avoiding lost updates”](#avoiding-lost-updates)
Every single-object response has an `ETag` header. Send it back as `If-Match` and the update only applies if the object has not changed since you read it; otherwise you get `412 precondition_failed`. Use this when people may edit the same object in Fleet while your integration does.
## Deleting
[Section titled “Deleting”](#deleting)
Where deletion is allowed, `DELETE` returns `{ "id": "…", "object": "contact", "deleted": true }`. Vehicles and work orders are not deleted through the API: set `status` to `disposed` or `cancelled` instead, which keeps their history.
# Sandbox and environments
> Test keys, sandbox organizations, and what is safe to do where.
Fleet has one API host, `https://api.get-fleet.com/v1`, and the key decides the environment:
| Key prefix | Environment | Acts on |
| ----------- | ----------- | ----------------------------------------------------------------------------------- |
| `flt_live_` | Live | Your real organization’s data. |
| `flt_test_` | Test | A **sandbox organization**: a separate Fleet organization for building and testing. |
The two never cross: a `flt_test_` key is rejected for a real organization and a `flt_live_` key for a sandbox, with `invalid_api_key`. Request logs record which environment each call used.
## Sandbox organizations
[Section titled “Sandbox organizations”](#sandbox-organizations)
Opening in the next release
Sandbox organizations open with the next release of the developer platform. Until then, start with a **read-only live key**: reads have no side effects, so you can build and test your sync logic safely, then add write scopes once you are ready.
A sandbox is an ordinary Fleet organization flagged as a sandbox. You can sign in to it in the Fleet app and see everything your integration creates there. In a sandbox:
* **No one is notified.** SMS, email, Slack, push notifications and Odoo pushes are switched off.
* **Nothing is billed**, and data is not counted towards plan limits.
* Everything else, including validation, permissions, rate limits and response shapes, behaves exactly as in live.
You create `flt_test_` keys in the sandbox’s **Settings → Developers**, exactly as you create live keys.
## What ships with sandboxes
[Section titled “What ships with sandboxes”](#what-ships-with-sandboxes)
* **Self-serve sandboxes** from this site, with realistic sample data (vehicles, drivers, fuel, inspections).
* **Reset** to restore the sample data.
* **Simulators** that trigger events such as a failed inspection or an overdue service, so you can test how your integration reacts.
See the [changelog](/changelog/) for progress.
# Versioning
> How the API changes without breaking your integration.
The API has a major version in the path (`/v1`) and dated versions within it. The current version is **2026-11-01**.
* Each key is **pinned** to the dated version that was current when it was created. Your integration keeps getting the same behaviour until you choose to upgrade.
* To try another version for one request, send `Fleet-Version: 2026-11-01`. Responses always include the `Fleet-Version` they were served with.
* An unknown version is refused with `400 unsupported_version`.
## What we change without a new version
[Section titled “What we change without a new version”](#what-we-change-without-a-new-version)
These are backwards-compatible, and your code should tolerate them:
* New endpoints, and new optional request parameters.
* New fields in responses, and new values in `object` types.
* New error codes (handle unknown codes by their HTTP status).
* New values in enums documented as open (for example `source` on meter entries).
## What needs a new version
[Section titled “What needs a new version”](#what-needs-a-new-version)
Removing or renaming a field, changing a type or format, making an optional parameter required, or changing default behaviour. Old versions keep working for at least **12 months** after a new one ships, and responses from a deprecated version will carry `Deprecation` and `Sunset` headers. Every change is in the [changelog](/changelog/).
# Webhooks
> Get told when fleet requests move and costs change, with signed, retried deliveries.
Fleet sends an HTTPS `POST` to your endpoint when something you subscribed to happens. Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) specification, so any of its libraries can verify them.
**Scopes:** `webhooks:write` to manage endpoints, plus the read scope for every event type you subscribe to (`fleet_requests:read`, `costs:read`, `accounting:read`).
## Events
[Section titled “Events”](#events)
| Type | When |
| ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `fleet_request.created`, `.submitted`, `.returned`, `.approved`, `.rejected`, `.in_service`, `.service_confirmed`, `.closed`, `.cancelled` | A fleet request moved in Fleet. `approved` is your cue to raise the PR or PO. |
| `fleet_request.financial_status_changed` | A financial event changed the request’s financial status. |
| `cost_entry.created`, `.updated`, `.voided`, `.superseded` | The cost ledger changed. Drain the [export queue](/guides/costs-and-erp/#exporting-exactly-once). |
| `accounting_period.closed` | `closed_through` moved. |
| `webhook.test` | Sent only by `POST /v1/webhook_endpoints/{id}/test`. |
These are the cost and finance events. Every record type (vehicles, fuel logs, expenses, work orders, purchase orders, parts…) also has `created`, `updated`, `deleted` and, where it has a status, `status_changed` events: see [all event types](/events/). `GET /v1/webhook_event_types` lists them with the scope each needs. Subscribe with exact types, a prefix such as `cost_entry.*`, or `*`.
Events are **thin**: they name the object and carry a few fields to route on, not the whole object. Fetch the object for its current state. That way an out-of-order or late delivery can never make you act on stale data.
```json
{
"type": "fleet_request.approved",
"timestamp": "2026-11-03T08:14:22.000Z",
"data": {
"object": "fleet_request",
"id": "6f1c2b0e-8a43-4c1e-9d55-0c2b7f3e9a10",
"number": "FR-000042",
"status": "approved",
"financial_status": "not_sent",
"vehicle_id": "…"
}
}
```
## Create an endpoint
[Section titled “Create an endpoint”](#create-an-endpoint)
```sh
curl https://api.get-fleet.com/v1/webhook_endpoints \
-H "Authorization: Bearer $FLEET_API_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://erp.example.com/hooks/fleet", "enabled_events": ["fleet_request.approved", "cost_entry.*"]}'
```
The response contains `secret` (`whsec_…`) **once**. Store it. The URL must be HTTPS on port 443 and resolve to a public address; Fleet does not follow redirects.
## Verify every delivery
[Section titled “Verify every delivery”](#verify-every-delivery)
Each request carries `webhook-id` (the event id, stable across retries), `webhook-timestamp` (Unix seconds) and `webhook-signature`. Reject requests older than five minutes and use the `webhook-id` to ignore duplicates.
* Node.js
```js
import { Webhook } from 'standardwebhooks' // npm i standardwebhooks
const wh = new Webhook(process.env.FLEET_WEBHOOK_SECRET) // "whsec_…"
app.post('/hooks/fleet', express.raw({ type: 'application/json' }), (req, res) => {
let event
try {
event = wh.verify(req.body, req.headers) // throws on a bad signature or old timestamp
} catch {
return res.status(400).end()
}
queue.add(req.headers['webhook-id'], event) // process asynchronously
res.status(204).end()
})
```
* Python
```python
from standardwebhooks.webhooks import Webhook # pip install standardwebhooks
wh = Webhook(os.environ["FLEET_WEBHOOK_SECRET"])
@app.post("/hooks/fleet")
def fleet_hook(request):
try:
event = wh.verify(request.body, dict(request.headers))
except Exception:
return Response(status=400)
enqueue(request.headers["webhook-id"], event)
return Response(status=204)
```
* By hand
```python
import base64, hashlib, hmac, time
def verify(secret: str, headers, body: bytes) -> bool:
msg_id, ts, sigs = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"]
if abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
# The header can hold several signatures ("v1,abc v1,def") while a secret is being rotated.
return any(hmac.compare_digest(expected, s.split(",", 1)[1]) for s in sigs.split(" ") if s.startswith("v1,"))
```
Verify against the raw body bytes, before any JSON parsing.
## Retries
[Section titled “Retries”](#retries)
Answer with any `2xx` within 15 seconds; do the work afterwards. Anything else (or a timeout) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, then every 10 hours, for about three days. An endpoint that keeps failing for three days is disabled and shows a `disabled_reason`; fix it, then `PATCH` it back to `"status": "enabled"`.
`GET /v1/webhook_deliveries?endpoint_id=…` shows every attempt’s status code, the start of your response body and any connection error. `POST /v1/webhook_deliveries/{id}/retry` sends one again now.
## Rotating the secret
[Section titled “Rotating the secret”](#rotating-the-secret)
`POST /v1/webhook_endpoints/{id}/rotate_secret` returns a new secret. For 24 hours by default (`expire_previous_in_hours`, up to 168) deliveries are signed with both secrets, so you can deploy the new one without missing an event.
## Catching up without webhooks
[Section titled “Catching up without webhooks”](#catching-up-without-webhooks)
`GET /v1/events` lists the last 30 days of events your key may read, newest first, with `type=` and `created_after=` filters. Use it after downtime, or poll it instead of receiving webhooks.
Caution
Webhooks tell you *that* something changed. For costs, the [export queue](/guides/costs-and-erp/#exporting-exactly-once) remains the source of truth: if you miss an event, the queue still has the entry.
# Quickstart
> Make your first Fleet API call in five minutes.
1. **Create an API key.** In Fleet, open **Settings → Developers** and choose **Create API key**. Give it a name (for example “Quickstart”), choose **Read and write**, and copy the key. Fleet shows it only once.
You need the *Developers → Manage* permission, which administrators have. `flt_live_` keys act on your real data; `flt_test_` keys belong to a [sandbox organization](/guides/sandbox/).
2. **Check your key.** `GET /v1/me` works with any key and tells you which organization, environment and scopes it acts with.
* curl
```sh
export FLEET_API_KEY=flt_live_...
curl https://api.get-fleet.com/v1/me -H "Authorization: Bearer $FLEET_API_KEY"
```
* JavaScript
```js
const me = await (await fetch('https://api.get-fleet.com/v1/me', {
headers: { Authorization: `Bearer ${process.env.FLEET_API_KEY}` },
})).json()
```
* Python
```python
import os, requests
HEADERS = {"Authorization": f"Bearer {os.environ['FLEET_API_KEY']}"}
me = requests.get("https://api.get-fleet.com/v1/me", headers=HEADERS).json()
```
```json
{
"object": "api_key",
"id": "11111111-1111-4111-8111-111111111111",
"environment": "live",
"scopes": ["vehicles:read", "issues:write"],
"api_version": "2026-11-01",
"organization": { "id": "…", "name": "Acme Logistics", "currency": "KES" }
}
```
3. **List your vehicles.**
* curl
```sh
curl https://api.get-fleet.com/v1/vehicles?limit=2 \
-H "Authorization: Bearer $FLEET_API_KEY"
```
* JavaScript
```js
const res = await fetch('https://api.get-fleet.com/v1/vehicles?limit=2', {
headers: { Authorization: `Bearer ${process.env.FLEET_API_KEY}` },
})
const page = await res.json()
console.log(page.data.map((v) => v.vehicle_number))
```
* Python
```python
import os, requests
res = requests.get(
"https://api.get-fleet.com/v1/vehicles",
params={"limit": 2},
headers={"Authorization": f"Bearer {os.environ['FLEET_API_KEY']}"},
)
res.raise_for_status()
print([v["vehicle_number"] for v in res.json()["data"]])
```
The response is a list object:
```json
{
"object": "list",
"url": "/v1/vehicles",
"data": [
{
"id": "6f1c2b0e-8a43-4c1e-9d55-0c2b7f3e9a10",
"object": "vehicle",
"vehicle_number": "KDA-001",
"name": "Pickup 1",
"status": "active",
"make": "Toyota",
"model": "Hilux",
"license_plate": "KDA 123A",
"meters": { "primary": { "reading": 48210, "unit": "kilometers" }, "secondary": { "reading": null, "unit": null } },
"created_at": "2026-10-01T08:00:00.000Z",
"updated_at": "2026-10-26T14:12:09.000Z"
}
],
"has_more": true,
"next_cursor": "WyItY3JlYXRlZF9hdCIsIjIwMjYtMTAtMDEi..."
}
```
4. **Report an issue against that vehicle.** Writes take an `Idempotency-Key`, so a retry after a timeout never creates a duplicate.
Caution
With a live key this creates a real issue that your team will see. Use a sandbox key, or delete the issue afterwards with `DELETE /v1/issues/{id}`.
* curl
```sh
curl https://api.get-fleet.com/v1/issues \
-H "Authorization: Bearer $FLEET_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"title": "Check engine light on", "priority": "high", "vehicle_id": "6f1c2b0e-8a43-4c1e-9d55-0c2b7f3e9a10"}'
```
* JavaScript
```js
const res = await fetch('https://api.get-fleet.com/v1/issues', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FLEET_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({ title: 'Check engine light on', priority: 'high', vehicle_id: vehicleId }),
})
const issue = await res.json() // 201, with issue.number like "ISS-7K2M9Q"
```
* Python
```python
import uuid
res = requests.post(
"https://api.get-fleet.com/v1/issues",
json={"title": "Check engine light on", "priority": "high", "vehicle_id": vehicle_id},
headers={
"Authorization": f"Bearer {os.environ['FLEET_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
)
issue = res.json() # 201, with issue["number"] like "ISS-7K2M9Q"
```
The issue appears in Fleet straight away, and the request in **Settings → Developers → Request log**.
Next
Read [Authentication & scopes](/guides/authentication/) before you ship, then pick a [recipe](/recipes/fuel-card-transactions/). Every endpoint is in the [API reference](/reference), where you can try calls with your test key.
# Fleet costs in Business Central
> Post fleet costs to Dynamics 365 Business Central as purchase invoices and journal lines, with dimensions.
This recipe posts Fleet’s cost ledger to Dynamics 365 Business Central through its API (v2.0): vendor costs as purchase invoices, everything else as general journal lines, all with BC dimensions. Read [Costs and your ERP](/guides/costs-and-erp/) first for the model. The [Odoo recipe](/recipes/odoo-costs/) shows the request-first flow; it works the same way against BC purchase orders.
**Scopes:** `costs:write`, `accounting:write`, `webhooks:write`, `vendors:read`, `vehicles:read`.
## 1. Map Fleet to Business Central once
[Section titled “1. Map Fleet to Business Central once”](#1-map-fleet-to-business-central-once)
1. **Dimensions.** Create a Fleet dimension per BC dimension you use on fleet costs, keyed in lower case (`department`, `project`), and push the values from BC’s `dimensionValues`:
```ts
const values = await bc(`dimensions(${deptDimensionId})/dimensionValues`)
await fleet('PUT', '/accounting_dimensions/department/values', {
values: values.value.map((v) => ({ code: v.code, name: v.displayName })),
deactivate_missing: true,
})
```
2. **External references.** Map vendors to BC vendor numbers, and cost categories to G/L accounts (and, if you use them, VAT product posting groups) in `metadata`:
```ts
await fleet('PUT', '/external_references', { data: [
{ object_type: 'vendor', object_key: rubisId, system: 'business_central', external_id: 'V00042' },
{ object_type: 'cost_category', object_key: 'fuel', system: 'business_central', external_id: '6110',
metadata: { account: '6110', tax_group: 'VAT16' } },
{ object_type: 'cost_category', object_key: 'maintenance.labor', system: 'business_central', external_id: '6125',
metadata: { account: '6125', tax_group: 'VAT16' } },
] })
```
3. **Defaults.** Put each branch’s default codes on it with `PUT /v1/branches/{id}/dimensions`, for example `{"dimensions": {"location": "NBO"}}`, so every cost at that branch carries them.
## 2. Post the export queue
[Section titled “2. Post the export queue”](#2-post-the-export-queue)
```ts
const FLEET = 'https://api.get-fleet.com/v1'
const BC = `https://api.businesscentral.dynamics.com/v2.0/${TENANT}/${ENV}/api/v2.0/companies(${COMPANY})`
export async function syncCosts() {
let cursor: string | undefined
do {
const page = await fleet('GET', `/cost_exports?system=business_central&limit=100${cursor ? `&cursor=${cursor}` : ''}`)
const acks = []
for (const { cost_entry: e, export_status, last_export } of page.data) {
if (export_status !== 'pending_new' && last_export) await reverse(last_export.external_id)
const externalId = export_status === 'pending_void' ? last_export.external_id : await post(e)
acks.push({ id: e.id, revision: e.revision, external_id: externalId })
}
if (acks.length) {
await fleet('POST', '/cost_exports', { system: 'business_central', entries: acks })
// "stale": the entry changed while we posted it. Fleet recorded what we posted, and the next run sees it as
// pending_update with last_export = this document, so it is corrected, not posted twice.
}
cursor = page.has_more ? page.next_cursor : undefined
} while (cursor)
}
async function post(e: CostEntry): Promise {
const map = await categoryMapping(e.category, e.subcategory) // from the cost_category references
const dims = Object.entries(e.dimensions).map(([key, code]) => ({ code: key.toUpperCase(), valueCode: code }))
if (e.settlement === 'vendor_bill' || e.settlement === 'fuel_card') {
const invoice = await bc('purchaseInvoices', 'POST', {
vendorNumber: await vendorNumber(e.payee?.id ?? e.vendor_id),
invoiceDate: e.posting_date,
vendorInvoiceNumber: e.references.tax_invoice_number ?? e.references.receipt_number ?? `FLEET-${e.id.slice(0, 8)}`,
currencyCode: e.currency === LCY ? '' : e.currency,
})
const line = await bc(`purchaseInvoices(${invoice.id})/purchaseInvoiceLines`, 'POST', {
lineType: 'Account',
lineObjectNumber: map.account,
description: `${e.category} ${e.subcategory ?? ''}`.trim().slice(0, 100),
quantity: 1,
directUnitCost: Number(e.amount), // set Prices Including VAT on the vendor, or post e.amount - e.tax.amount
})
for (const d of dims) await bc(`purchaseInvoices(${invoice.id})/purchaseInvoiceLines(${line.id})/dimensionSetLines`, 'POST', d)
await bc(`purchaseInvoices(${invoice.id})/Microsoft.NAV.post`, 'POST')
return invoice.number
}
// prepaid_card, employee_reimbursement, cash, stock_issue…: a balanced journal pair
const line = await bc(`journals(${FLEET_JOURNAL})/journalLines`, 'POST', {
accountType: 'G/L Account',
accountNumber: map.account,
postingDate: e.posting_date,
documentNumber: `FLEET-${e.id.slice(0, 8)}`,
externalDocumentNumber: e.id,
amount: Number(e.amount),
description: `${e.category} ${e.subcategory ?? ''}`.trim().slice(0, 100),
balanceAccountType: 'G/L Account',
balancingAccountNumber: settlementAccount(e.settlement), // prepaid card, employee payable, cash…
})
for (const d of dims) await bc(`journals(${FLEET_JOURNAL})/journalLines(${line.id})/dimensionSetLines`, 'POST', d)
return line.documentNumber
}
```
`reverse()` posts a purchase credit memo for an invoice you created, or a journal line with the opposite amount. Post with `posting_date`, never `incurred_on`: it is always in an open period.
Caution
A crash after posting but before acknowledging offers the entry again. Before posting, look for a document with `externalDocumentNumber` (journals) or your vendor invoice number equal to the Fleet id, and acknowledge instead of posting twice.
## 3. Keep it current
[Section titled “3. Keep it current”](#3-keep-it-current)
* Subscribe a webhook to `cost_entry.*` and run `syncCosts()` when one arrives (debounce to once a minute), plus a scheduled run every 15 minutes as a backstop.
* When you close a period in BC (Allow Posting From), mirror it with `PATCH /v1/accounting_settings` `{"closed_through": "…"}`.
* Use `GET /v1/cost_summaries?from=…&to=…&group_by=category,month` to reconcile BC’s G/L totals with Fleet each month. Group by `dimension:department` to check dimension coverage.
# Sync vehicles with your ERP
> Keep your ERP's asset register and Fleet in step, in both directions.
Most organizations keep vehicles in an ERP or asset register (Odoo, SAP, Dynamics, Sage) as well as in Fleet. This recipe keeps them aligned: the ERP owns purchase data, Fleet owns operational status and meters.
**Scopes:** `vehicles:write`, `branches:read`.
## ERP → Fleet: upsert by fleet number
[Section titled “ERP → Fleet: upsert by fleet number”](#erp--fleet-upsert-by-fleet-number)
Use the ERP’s asset number as Fleet’s `vehicle_number`, which must be unique. Look the vehicle up by it, then update it or create it.
```js
const API = 'https://api.get-fleet.com/v1'
const headers = { Authorization: `Bearer ${process.env.FLEET_API_KEY}`, 'Content-Type': 'application/json' }
async function upsertVehicle(asset, retried = false) {
const fields = {
vehicle_number: asset.number,
name: asset.description,
make: asset.make,
model: asset.model,
year: asset.year,
vin: asset.chassis_number,
license_plate: asset.registration,
purchase_date: asset.acquired_on, // "2024-03-15"
purchase_price: asset.cost, // "4250000.00"
purchase_currency: asset.currency,
}
const found = await (await fetch(`${API}/vehicles?vehicle_number=${encodeURIComponent(asset.number)}`, { headers })).json()
if (found.data.length === 0) {
const res = await fetch(`${API}/vehicles`, {
method: 'POST', headers: { ...headers, 'Idempotency-Key': `asset-${asset.number}-create` }, body: JSON.stringify(fields),
})
// 409: the number was taken between the lookup and the create. Look it up once more and update instead.
if (res.status === 409 && !retried) return upsertVehicle(asset, true)
if (!res.ok) throw new Error(`${asset.number}: ${(await res.json()).code}`)
return res.json()
}
const { vehicle_number, ...changes } = fields
const res = await fetch(`${API}/vehicles/${found.data[0].id}`, { method: 'PATCH', headers, body: JSON.stringify(changes) })
return res.json()
}
```
Map branches by listing `GET /v1/branches` once and matching on `code`, then send `branch_id`.
To retire a vehicle, set `"status": "disposed"`. Fleet keeps its history; vehicles are not deleted through the API.
## Fleet → ERP: incremental pull
[Section titled “Fleet → ERP: incremental pull”](#fleet--erp-incremental-pull)
Pull changes since your last run and write the fields Fleet owns (status, meters) back to the ERP:
```js
let since = await loadCheckpoint() // e.g. "2026-11-01T00:00:00.000Z"
let cursor
do {
const url = new URL(`${API}/vehicles`)
url.search = new URLSearchParams({ updated_since: since, sort: 'updated_at', limit: '100', ...(cursor && { cursor }) })
const page = await (await fetch(url, { headers })).json()
for (const v of page.data) {
await erp.updateAsset(v.vehicle_number, { status: v.status, odometer: v.meters.primary.reading })
since = v.updated_at
}
cursor = page.next_cursor
} while (cursor)
await saveCheckpoint(since)
```
If people edit vehicles in Fleet and the ERP pushes changes at the same time, send the `ETag` you read as `If-Match` on your `PATCH`. A `412` means someone changed the vehicle in between: read it again and reapply.
# Import fuel-card transactions
> Turn transactions from your fuel-card provider into Fleet fuel logs, without duplicates.
Fuel-card providers (Rubis, TotalEnergies, Vivo/Shell and others) export transactions with a card or plate number, a station, litres and a price. This recipe turns them into Fleet [fuel logs](/reference#tag/fuel-logs), so fuel cost, consumption and efficiency reports include them.
**Scopes:** `fuel_logs:write`, `vehicles:read`, `vendors:write` (to create stations).
1. **Match each transaction to a vehicle.** Most cards are tied to a plate. Look the vehicle up once and cache the id:
```sh
curl "https://api.get-fleet.com/v1/vehicles?license_plate=KDA%20123A" -H "Authorization: Bearer $FLEET_API_KEY"
```
If your cards are tied to drivers instead, list [users](/reference#tag/users) and match on `employee_id` or `email`, then find their vehicle with `GET /v1/vehicle_assignments?driver_id=…&active_at=`.
2. **Match the station to a vendor** (optional, but it makes vendor reports work). Search with `GET /v1/vendors?q=Rubis%20Westlands`, and create the vendor once if it doesn’t exist.
3. **Never import a transaction twice.** Send the provider’s transaction id as `external_id`, with `external_system` naming the provider. Sending the same pair again returns the fuel log already created instead of a duplicate, so you can safely re-run a whole day’s file.
4. **Create the fuel logs in batches of up to 100.** A batch is all or nothing, and an `Idempotency-Key` makes the retry of a timed-out batch safe. Each fuel log then appears in the [cost ledger](/guides/costs-and-erp/) with its VAT and card reference, ready for your ERP.
```python
import os, uuid, requests
API = "https://api.get-fleet.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['FLEET_API_KEY']}"}
def import_batch(transactions, vehicle_ids):
logs = [
{
"vehicle_id": vehicle_ids[t["plate"]],
"date": t["date"], # "2026-11-01"
"quantity": t["litres"], # 45.2
"unit_price": t["price_per_litre"], # "196.50"
"currency": "KES", # optional: defaults to the vehicle's branch currency
"fuel_type": "diesel",
"external_system": "rubis_card",
"external_id": t["transaction_id"],
"receipt_number": t["receipt_number"],
"tax_amount": t.get("vat"), # VAT included in the total, if the statement shows it
"tax_invoice_number": t.get("etims_invoice"),
"payment_method": "fuel_card",
"payment_reference": t["card_last4"],
"location": t["station"],
"odometer_reading": t.get("odometer"),
"is_partial": not t.get("full_tank", True),
}
for t in transactions
if t["plate"] in vehicle_ids
]
for i in range(0, len(logs), 100):
chunk = logs[i : i + 100]
res = requests.post(
f"{API}/fuel_logs/batch",
json={"data": chunk},
headers={**HEADERS, "Idempotency-Key": str(uuid.uuid5(uuid.NAMESPACE_URL, chunk[0]["receipt_number"] + str(len(chunk))))},
timeout=30,
)
if res.status_code == 400:
print("Rejected:", res.json()["errors"]) # fix the data, then re-run
continue
res.raise_for_status()
```
Tip
`total_cost` is computed by Fleet (`quantity × unit_price`), so you don’t send it. Set `is_partial` for top-ups: partial fills are left out of fuel-efficiency calculations until the next full tank.
# Act on failed inspections
> Pick up defects that drivers report in daily inspections, for example to raise jobs in another system.
When a driver fails an item in an inspection, Fleet records the answer with `has_issue: true` and raises an [issue](/reference#tag/issues). There are two ways to pick those up.
**Scopes:** `inspections:read`, `issues:read` (and `issues:write` to update them).
## Poll for new issues
[Section titled “Poll for new issues”](#poll-for-new-issues)
The simplest: every few minutes, ask for issues reported since your last check.
```sh
curl "https://api.get-fleet.com/v1/issues?reported_from=2026-11-01T06:00:00Z&status=open&expand=vehicle&sort=reported_at" \
-H "Authorization: Bearer $FLEET_API_KEY"
```
When your system has dealt with one, update it:
```sh
curl -X PATCH https://api.get-fleet.com/v1/issues/$ISSUE_ID \
-H "Authorization: Bearer $FLEET_API_KEY" -H "Content-Type: application/json" \
-d '{"status": "resolved", "resolution": "Bulb replaced under job 88213"}'
```
## Read the full inspection
[Section titled “Read the full inspection”](#read-the-full-inspection)
To see every answer, including photos and readings, expand the responses:
```sh
curl "https://api.get-fleet.com/v1/inspections?submitted_from=2026-11-01T00:00:00Z&expand=responses,vehicle" \
-H "Authorization: Bearer $FLEET_API_KEY"
```
Each response has the item `label`, the `value` (for example `"fail"`), structured `data`, and `has_issue`.
Webhooks are coming
Webhooks (`inspection.failed`, `issue.created` and more) are next on the roadmap, so you won’t need to poll. See the [changelog](/changelog/).
# Fleet costs in Odoo
> Approve fleet spend in Fleet, buy and bill in Odoo, and post every other fleet cost to Odoo with analytic codes.
This recipe connects Fleet to Odoo Enterprise (17, 18 or 19) in both directions that matter for cost: **fleet requests** go to Odoo for purchasing and come back with the PO, bill and payment; **recorded costs** (fuel, receipts, claims, renewals) are posted to Odoo as vendor bills or journal entries. Read [Costs and your ERP](/guides/costs-and-erp/) first for the model.
**Scopes:** `fleet_requests:read`, `financial_events:write`, `costs:write`, `accounting:write`, `webhooks:write`, `vehicles:read`, `vendors:read`.
## 0. Before you start
[Section titled “0. Before you start”](#0-before-you-start)
**API access.** Odoo’s external API is available on Odoo.sh and on-premise Enterprise. On Odoo Online it needs the **Custom** plan; Standard plans have no API access. Create a dedicated integration user in Odoo with only the Purchase and Accounting rights it needs, then an API key for it (*Preferences → Account Security → New API Key*).
**Which Odoo API.** Odoo 19 adds the JSON-2 API (`POST /json/2//`). XML-RPC and JSON-RPC still work on 17, 18 and 19 but are removed in Odoo 22 (Odoo Online 21.1). Write your Odoo calls behind one helper, so moving to JSON-2 is a one-function change. The rest of this recipe calls `odoo(model, method, **kwargs)`:
* Odoo 19+ (JSON-2)
```python
import os, requests
ODOO = os.environ["ODOO_URL"] # https://inkomoko.odoo.com
ODOO_KEY = os.environ["ODOO_API_KEY"]
def odoo(model, method, ids=None, **kwargs):
body = {**({"ids": ids} if ids else {}), **kwargs, "context": {"lang": "en_US"}}
res = requests.post(f"{ODOO}/json/2/{model}/{method}", json=body, timeout=60,
headers={"Authorization": f"bearer {ODOO_KEY}",
"X-Odoo-Database": os.environ.get("ODOO_DB", "")})
res.raise_for_status()
return res.json()
partners = odoo("res.partner", "search_read", domain=[["supplier_rank", ">", 0]], fields=["name", "vat"])
```
* Odoo 17–18 (XML-RPC)
```python
import os, xmlrpc.client
ODOO, DB = os.environ["ODOO_URL"], os.environ["ODOO_DB"]
USER, KEY = os.environ["ODOO_USER"], os.environ["ODOO_API_KEY"]
uid = xmlrpc.client.ServerProxy(f"{ODOO}/xmlrpc/2/common").authenticate(DB, USER, KEY, {})
models = xmlrpc.client.ServerProxy(f"{ODOO}/xmlrpc/2/object", allow_none=True)
def odoo(model, method, ids=None, **kwargs):
args = [ids] if ids else []
if "domain" in kwargs:
args.append(kwargs.pop("domain"))
if "vals_list" in kwargs:
args.append(kwargs.pop("vals_list"))
return models.execute_kw(DB, uid, KEY, model, method, args, kwargs)
```
**The Fleet side** is a small client:
```python
FLEET = requests.Session()
FLEET.headers["Authorization"] = f"Bearer {os.environ['FLEET_API_KEY']}"
class fleet:
@staticmethod
def get(path, **kw): return FLEET.get(f"https://api.get-fleet.com/v1{path}", timeout=30, **kw)
@staticmethod
def post(path, **kw): return FLEET.post(f"https://api.get-fleet.com/v1{path}", timeout=30, **kw)
@staticmethod
def put(path, **kw): return FLEET.put(f"https://api.get-fleet.com/v1{path}", timeout=30, **kw)
```
**How Odoo tells Fleet what happened.** Your integration needs to notice when a PO is confirmed, a bill is posted or a payment is made, and send it to Fleet. Two ways that work on every Odoo hosting option:
* **Poll Odoo** (simplest, recommended): every few minutes, `search_read` `purchase.order` and `account.move` records whose `write_date` is after your last run and that reference a fleet request, then post the events below. Re-sending an event is harmless.
* **Odoo automation rules** with *Send Webhook Notification*: Odoo POSTs the record’s fields to a URL when it changes. The action can’t add an `Authorization` header, so point it at your integration (a small cloud function), which checks a secret in the URL and calls Fleet with your key. Never put a Fleet API key in an Odoo webhook URL.
**Linking Odoo records to fleet requests.** Put the request number (`FR-000042`) somewhere your integration can read back: the purchase request’s or PO’s *Source Document* (`origin`), the bill’s *Reference*, or a custom field added with Studio (for example `x_studio_fleet_request`). Studio is part of Enterprise, so a dedicated field is the cleanest option.
## 1. Map Fleet to Odoo once
[Section titled “1. Map Fleet to Odoo once”](#1-map-fleet-to-odoo-once)
1. **Analytic plans become dimensions.** For each Odoo analytic plan you book fleet costs against (department, project, donor…), create a Fleet dimension and push its accounts as values. Re-run nightly with `deactivate_missing: true`.
```python
plans = odoo("account.analytic.account", "search_read",
domain=[["plan_id.name", "=", "Department"]], fields=["code", "name", "active"])
fleet.put("/accounting_dimensions/department/values", json={
"values": [{"code": a["code"], "name": a["name"], "is_active": a["active"]} for a in plans if a["code"]],
"deactivate_missing": True,
})
```
2. **Link vehicles, vendors and categories** with external references, so every Fleet id has its Odoo id. Put the expense account and purchase tax for each cost category in `metadata`:
```python
fleet.put("/external_references", json={"data": [
{"object_type": "cost_category", "object_key": "fuel", "system": "odoo", "external_id": "611000",
"metadata": {"account_id": 512, "tax_id": 23}},
{"object_type": "cost_category", "object_key": "maintenance.parts", "system": "odoo", "external_id": "612100",
"metadata": {"account_id": 518, "tax_id": 23}},
{"object_type": "vendor", "object_key": rubis_fleet_id, "system": "odoo", "external_id": "88",
"metadata": {"qualified": True}},
]})
```
3. **Tell Fleet which ERP handles requests:** `PATCH /v1/accounting_settings` with `{"primary_erp_system": "odoo"}`.
## 2. Fleet requests → Odoo purchasing
[Section titled “2. Fleet requests → Odoo purchasing”](#2-fleet-requests--odoo-purchasing)
A fleet manager approves the operational need in Fleet; Odoo handles budget, sourcing and payment.
1. **Subscribe to approvals.** Create a webhook endpoint for `fleet_request.approved` (and `fleet_request.cancelled`). As a safety net, also poll `GET /v1/fleet_requests?status=approved&financial_status=not_sent` every few minutes.
2. **Check readiness, then create the Odoo document.** `GET /v1/fleet_requests/{id}/readiness?system=odoo` lists anything Odoo would reject. If `ready` is false, write an `exception` event so the fleet team sees why:
```python
r = fleet.get(f"/fleet_requests/{rid}/readiness", params={"system": "odoo"}).json()
if not r["ready"]:
fleet.post(f"/fleet_requests/{rid}/financial_events", json={
"system": "odoo", "integration_transaction_id": f"readiness:{rid}:{int(time.time() // 3600)}",
"type": "exception", "code": r["issues"][0]["code"], "message": r["issues"][0]["message"],
})
return
```
Otherwise create a purchase request or RFQ from the request’s lines. Each line’s `cost_category` gives the product or account, and the request’s `dimensions` give the `analytic_distribution`.
3. **Write each outcome back** with an `integration_transaction_id` that names the Odoo record and state, so retries are harmless:
| In Odoo | Fleet event |
| ------------------------------------ | ------------------------------------------------------------------------------------ |
| Purchase request created / submitted | `committed` (with `references.purchase_request`) |
| PO confirmed | `po_issued` (with `references.purchase_order` and `amounts`) |
| Vendor bill posted | `invoiced` (with `references.bill`, `amounts.total`, `amounts.tax`, `document_date`) |
| Credit note posted | `credited` |
| Bill paid | `paid` |
| Refused by finance | `rejected` (with `reason`) |
```python
fleet.post(f"/fleet_requests/{rid}/financial_events", json={
"system": "odoo",
"integration_transaction_id": f"account.move:{bill.id}:posted",
"type": "invoiced",
"occurred_at": bill.write_date.isoformat() + "Z",
"references": {"purchase_order": po.name, "bill": bill.name},
"amounts": {"total": str(bill.amount_total), "tax": str(bill.amount_tax), "currency": bill.currency_id.name},
"document_date": bill.invoice_date.isoformat(),
})
```
Find what changed by polling (`write_date` after your last run) or from an automation rule (see [Before you start](#0-before-you-start)). Late or repeated events never move the status backwards.
4. **The bill becomes the cost.** Once `invoiced` arrives, Fleet records the bill in its cost ledger and marks the linked work order’s costs as `superseded`, so the vehicle’s cost is the billed amount, counted once. These ledger entries come from Odoo, so they are never offered back to Odoo’s export queue.
Tip
Emergencies can’t wait for a PO. Fleet lets a request with `priority: "emergency"` start service before any financial event; send `committed` with `route: "D"` when Odoo catches up.
## 3. Recorded costs → Odoo
[Section titled “3. Recorded costs → Odoo”](#3-recorded-costs--odoo)
Everything that was not bought through a fleet request (fuel-card transactions, driver receipts, approved claims, licence renewals) comes from the cost ledger.
```python
import base64
def sync_costs():
cursor = None
while True:
page = fleet.get("/cost_exports", params={"system": "odoo", "limit": 200, **({"cursor": cursor} if cursor else {})}).json()
acks = []
for item in page["data"]:
entry, status, last = item["cost_entry"], item["export_status"], item["last_export"]
if status == "pending_void":
reverse(last["external_id"]) # reverse the bill / entry you posted
acks.append({"id": entry["id"], "revision": entry["revision"], "external_id": last["external_id"]})
continue
if status == "pending_update" and last:
reverse(last["external_id"]) # simplest correct approach: reverse and re-post
move_id = post(entry)
acks.append({"id": entry["id"], "revision": entry["revision"], "external_id": str(move_id)})
if acks:
fleet.post("/cost_exports", json={"system": "odoo", "entries": acks})
if not page["has_more"]:
break
cursor = page["next_cursor"]
def analytic(dimensions):
"""Fleet dimension codes → Odoo analytic_distribution. One key holds an account from each plan."""
ids = [str(ANALYTIC_IDS[(key, code)]) for key, code in sorted(dimensions.items()) if (key, code) in ANALYTIC_IDS]
return {",".join(ids): 100} if ids else {}
def post(e):
if existing := odoo("account.move", "search", domain=[["x_studio_fleet_cost_entry", "=", e["id"]], ["state", "!=", "cancel"]]):
return existing[0] # posted before a crash: just acknowledge it
account = category_account(e["category"], e["subcategory"]) # from the cost_category references
line = {
"name": f'{e["category"]}: {e.get("description") or e["subcategory"] or ""}'.strip(": "),
"quantity": 1,
"price_unit": float(e["amount"]), # gross; use a tax-included purchase tax
"account_id": account["account_id"],
"tax_ids": [(6, 0, [account["tax_id"]])] if e["tax"] else [],
"analytic_distribution": analytic(e["dimensions"]),
}
common = {"x_studio_fleet_cost_entry": e["id"], "currency_id": currency_id(e["currency"])}
if e["settlement"] in ("vendor_bill", "fuel_card", "cash"):
[move_id] = odoo("account.move", "create", vals_list=[{
**common,
"move_type": "in_invoice",
"partner_id": partner_for(e),
"invoice_date": e["posting_date"],
"ref": e["references"].get("tax_invoice_number") or e["references"].get("receipt_number"),
"invoice_line_ids": [(0, 0, line)],
}])
else:
# prepaid_card, employee_reimbursement, stock_issue…: a journal entry against the right balance account
[move_id] = odoo("account.move", "create", vals_list=[{
**common,
"move_type": "entry",
"date": e["posting_date"],
"ref": f'Fleet {e["references"].get("document_number") or e["id"][:8]}',
"line_ids": [
(0, 0, {"name": line["name"], "account_id": line["account_id"], "debit": float(e["amount"]),
"analytic_distribution": line["analytic_distribution"]}),
(0, 0, {"name": line["name"], "account_id": settlement_account(e["settlement"]), "credit": float(e["amount"])}),
],
}])
attach_receipts(e["id"], move_id)
return move_id
def attach_receipts(cost_entry_id, move_id):
"""Copy Fleet's receipts onto the Odoo document (they also show in the Documents app)."""
for f in fleet.get(f"/cost_entries/{cost_entry_id}/attachments").json()["data"]:
content = requests.get(f["url"], timeout=60).content
odoo("ir.attachment", "create", vals_list=[{
"name": f["name"], "res_model": "account.move", "res_id": move_id,
"mimetype": f["mime_type"], "datas": base64.b64encode(content).decode(),
}])
```
Run it every few minutes, or when a `cost_entry.*` webhook arrives.
* **Exactly once.** Add a text field to journal entries with Studio (`x_studio_fleet_cost_entry` above) and fill it with the Fleet cost entry id. A crash between posting and acknowledging only means the entry is offered again; `post()` finds the move it already created and acknowledges that.
* **Analytic plans.** Since Odoo 17 one `analytic_distribution` key can name an account from several plans (`"12,34": 100`). `ANALYTIC_IDS` maps each Fleet `(dimension, code)` to the analytic account id, built from the same `search_read` you used to push the values.
* **Vehicles as assets.** If you track vehicles with Odoo Enterprise’s Assets (`account.asset`) or the Fleet app (`fleet.vehicle`), keep the link as an external reference (`object_type: "vehicle"`) and add the vehicle’s analytic account to the distribution, so cost per vehicle reads the same in both systems.
## 4. Close periods from Odoo
[Section titled “4. Close periods from Odoo”](#4-close-periods-from-odoo)
When you lock a period in Odoo, mirror it: `PATCH /v1/accounting_settings` with `{"closed_through": "2026-10-31"}`. Later changes to October costs arrive with a November `posting_date`.
# Send GPS positions and odometer readings
> Connect any telematics platform to Fleet's live map, trip history and meter-based maintenance.
Fleet has built-in connectors for Geotab, Motive, Tracksolid Pro and Pilot. For any other platform (or your own devices), send positions and meter readings through the API. They appear on the live map, in position history, and drive meter-based service reminders.
**Scopes:** `telematics:write`, `meter_entries:write`, `vehicles:read`.
## 1. Map devices to vehicles
[Section titled “1. Map devices to vehicles”](#1-map-devices-to-vehicles)
Keep a map from your device id to the Fleet vehicle `id`. Build it once by looking vehicles up by `vin`, `license_plate` or `vehicle_number`:
```sh
curl "https://api.get-fleet.com/v1/vehicles?vin=MR0FB8CD3P0123456" -H "Authorization: Bearer $FLEET_API_KEY"
```
## 2. Send positions
[Section titled “2. Send positions”](#2-send-positions)
Buffer positions and send them every 10 to 30 seconds, up to 1,000 per request, for any mix of vehicles:
```sh
curl https://api.get-fleet.com/v1/vehicle_positions \
-H "Authorization: Bearer $FLEET_API_KEY" -H "Content-Type: application/json" \
-d '{
"data": [
{ "vehicle_id": "6f1c2b0e-8a43-4c1e-9d55-0c2b7f3e9a10", "recorded_at": "2026-11-01T08:30:05Z",
"latitude": -1.2864, "longitude": 36.8172, "speed_kmh": 42, "heading": 90, "ignition": true },
{ "vehicle_id": "7f1c2b0e-8a43-4c1e-9d55-0c2b7f3e9a10", "recorded_at": "2026-11-01T08:30:07Z",
"latitude": -4.0435, "longitude": 39.6682, "speed_kmh": 0 }
]
}'
```
The response is `202` with `{"object": "vehicle_position_batch", "accepted": 2}`. A batch is all or nothing: if any `vehicle_id` isn’t one of your vehicles, nothing is stored and you get `422 reference_not_found` naming it.
## 3. Send odometer readings
[Section titled “3. Send odometer readings”](#3-send-odometer-readings)
Positions don’t move the odometer. Send readings as [meter entries](/reference#tag/meter-entries), at most every few minutes per vehicle (each one updates the vehicle and can trigger service reminders). Use your event id as `external_id`, so a resend never creates a duplicate:
```sh
curl https://api.get-fleet.com/v1/meter_entries/batch \
-H "Authorization: Bearer $FLEET_API_KEY" -H "Content-Type: application/json" \
-d '{"data": [{"vehicle_id": "6f1c…", "value": 48210.5, "recorded_at": "2026-11-01T08:30:05Z", "external_id": "evt_918273"}]}'
```
Note
A reading lower than the vehicle’s highest reading is still recorded, but voided automatically (`void: true`, with a `void_reason`), exactly as when someone mistypes an odometer in the app. Void a bad reading yourself with `POST /v1/meter_entries/{id}/void`.
## 4. Read positions back
[Section titled “4. Read positions back”](#4-read-positions-back)
* Latest position: `GET /v1/vehicles/{id}/location`
* History: `GET /v1/vehicle_positions?vehicle_id=…&recorded_from=2026-11-01T00:00:00Z&recorded_to=2026-11-02T00:00:00Z&sort=recorded_at`
History includes positions from every source, built-in connectors included; `source` says which.
# Run workshop jobs
> Open work orders from your workshop or dealer system, add parts and labour, and close them.
**Scopes:** `work_orders:write`, `vehicles:read`, `vendors:read`.
## Open a work order
[Section titled “Open a work order”](#open-a-work-order)
```sh
curl https://api.get-fleet.com/v1/work_orders \
-H "Authorization: Bearer $FLEET_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: job-88213-open" \
-d '{
"title": "60,000 km service",
"vehicle_id": "6f1c2b0e-8a43-4c1e-9d55-0c2b7f3e9a10",
"vendor_id": "1b7e…",
"priority": "medium",
"scheduled_start": "2026-11-03T07:00:00Z",
"po_number": "PO-2026-0441"
}'
```
Fleet assigns the number (`"number": "WO-000123"`). Priorities are `low`, `medium`, `high`, `urgent` and `emergency`.
## Add parts and labour
[Section titled “Add parts and labour”](#add-parts-and-labour)
```sh
curl https://api.get-fleet.com/v1/work_orders/$WO_ID/line_items \
-H "Authorization: Bearer $FLEET_API_KEY" -H "Content-Type: application/json" \
-d '{"type": "labor", "description": "Service labour", "quantity": 2.5, "unit_price": "3500"}'
curl https://api.get-fleet.com/v1/work_orders/$WO_ID/line_items \
-H "Authorization: Bearer $FLEET_API_KEY" -H "Content-Type: application/json" \
-d '{"type": "part", "description": "Oil filter", "quantity": 1, "unit_price": "1850", "part_id": "c41d…"}'
```
For labour, `quantity` is hours and `unit_price` the hourly rate. A part line with a `part_id` from your Fleet inventory draws the part from stock, as it does in the app.
## Progress and complete it
[Section titled “Progress and complete it”](#progress-and-complete-it)
```sh
curl -X PATCH https://api.get-fleet.com/v1/work_orders/$WO_ID \
-H "Authorization: Bearer $FLEET_API_KEY" -H "Content-Type: application/json" \
-d '{"status": "completed", "actual_end": "2026-11-03T15:10:00Z", "completion_odometer": 60112, "invoice_number": "INV-7731"}'
```
Statuses are `open`, `in_progress`, `completed` and `cancelled`. Cancel rather than delete.
## Find jobs for your workshop
[Section titled “Find jobs for your workshop”](#find-jobs-for-your-workshop)
```sh
curl "https://api.get-fleet.com/v1/work_orders?vendor_id=1b7e…&status=open&expand=vehicle" \
-H "Authorization: Bearer $FLEET_API_KEY"
```