FleetFixer Maintenance Public API
Read your club's fleet, jobs, maintenance log, inventory, and orders straight out of FleetFixer. Designed for booking systems, asset registers, dashboards, and any other tool that needs to know what state your boats are in right now.
Looking for hull, rental, and charter fields? Open the v2 reference →
01 Overview
/jobs; completed work lives under /maintenance.FleetFixer is the source of truth for boat existence and operational state: which hulls the club owns, their identifiers, and whether each one is sailable today. Operational state changes when a boat is added, archived, sent for maintenance, taken out of service, or restored.
If you're integrating a booking, lesson, or rental system, your system is the source of truth for assignments. You read fleet data from FleetFixer; you do not write back. If a boat goes for maintenance, the club marks it as such inside FleetFixer; your system picks that up on the next fetch and stops offering it.
The assetCode on each boat is a free-text field intended to hold the asset key your system uses, so a boat can be matched between systems without a separate mapping table.
https://fix.fleetfixer.io/api/v1Read-only on launch. Write endpoints will be added behind
write:* scopes.02 Authentication
Every request needs a per-club Bearer token issued from the FleetFixer admin app's Integrations tab.
Authorization: Bearer ff_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Tokens are stored hashed on the server, identify the club they belong to, and can be revoked from the same UI. The full secret is shown to the issuing admin once, at creation time, and never again.
Generating a key
- Sign in to the Fix admin app at
fix.fleetfixer.iowith an admin account. - Open the Config tab (gear icon), then Integrations in the sidebar.
- Click Create key, give it a name (e.g. Asset register sync), select the scopes the integration needs, and confirm.
- Copy the secret token. It is shown once; you cannot recover it later.
03 Scopes
Each key is granted one or more scopes at creation time. Requests that target an endpoint outside the key's scopes return 403 forbidden.
| Scope | Grants access to |
|---|---|
read:club | Club name, notes, plan, branding colours. |
read:fleet | Boats, classes, service schedule. |
read:jobs | Open and completed maintenance jobs. |
read:maintenance | Service history entries. |
read:inventory | Parts and stock levels. |
read:orders | Purchase orders. |
read:sailors | Sailor roster. Personally identifying data. |
read:staff | Staff names and roles. No credentials. |
Issue the narrowest set the integration genuinely needs. Adding a scope later means rotating the key.
04 Response envelope
Single-resource responses:
{
"data": { /* the resource */ },
"meta": {
"club_id": "dosc",
"request_id": "req_01HX...",
"generated_at": "2026-05-05T12:00:00.000Z"
}
}
List responses also include a page block:
{
"data": [ /* items */ ],
"meta": { "club_id": "dosc", "request_id": "req_01HX...", "generated_at": "..." },
"page": { "size": 50, "next_cursor": "eyJpZCI6IjVvTjcuLi4ifQ" }
}
The X-Request-Id header is echoed on every response. Log it; if you need to ask FleetFixer about a specific request, that's the handle.
05 Pagination
- Default page size: 50. Maximum: 200. Override with
?page_size=100. - When
next_cursoris non-null, pass it back as?cursor=...to get the next page. When it's null, you've reached the end. - Cursors are opaque. Don't decode or modify them.
- Default order is by document id, which is stable but arbitrary. For changes-only delta sync, use
?since=ISO8601on endpoints that support it (/jobs,/maintenance,/orders). If the collection has noupdatedAtindex yet, the API silently falls back to id-only ordering and ignoressince; check the timestamps in the response before treating it as a real delta.
06 Errors
{
"error": {
"code": "unauthorized",
"message": "API key has been revoked.",
"request_id": "req_01HX..."
}
}
| HTTP | code | When |
|---|---|---|
| 401 | unauthorized | Missing, malformed, unrecognised, or revoked API key. |
| 403 | forbidden | Token lacks the scope required for this route. |
| 404 | not_found | Resource id does not exist in this club. |
| 405 | invalid_request | Unsupported method. Only /webhook accepts POST and DELETE; every other route is GET. |
| 429 | rate_limited | Too many requests. Back off and retry after the value in Retry-After. |
| 5xx | server_error | Transient. Retry with exponential backoff. |
07 Rate limits
Each API key is limited to 120 requests per minute by default, measured in a fixed 60 second window. Unauthenticated and failed-authentication traffic is limited per IP. Need a higher ceiling? Ask us and we can raise the limit on a specific key.
Every successful response carries the current limit state in its headers, so a well-behaved client never has to guess:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the current window. |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | Unix epoch second at which the window resets. |
When you exceed the limit you get 429 rate_limited with a Retry-After header (seconds until the window resets). Honour it: sleep for that long, then retry. For bulk pulls, paginate with page_size=200 and follow next_cursor rather than firing many small requests in parallel.
08 Endpoint reference
All endpoints are GET. The base URL is https://fix.fleetfixer.io/api/v1. Send the API key as Authorization: Bearer ff_live_....
Meta
Club
Fleet
To retrieve a single boat by its hull number instead of its internal record ID, use the webhook hull lookup.
page_size integer, 1-200, default 50.cursor opaque string from previous next_cursor.Jobs
status=completed to read closed jobs from the archive.status one of open (default), completed, archived.since ISO 8601 timestamp. Returns jobs whose updatedAt is on or after this. Falls back to id ordering if no updatedAt index is present.page_size, cursor as elsewhere.Maintenance log
fleetBoatId linking it back to a fleet doc.since ISO 8601 timestamp. Returns records whose updatedAt is on or after this. Falls back to id ordering if the collection has no updatedAt index.page_size, cursor as elsewhere.Inventory
Purchase orders
since ISO 8601 timestamp. Returns orders whose updatedAt is on or after this. Falls back to id ordering if the collection has no updatedAt index.page_size, cursor as elsewhere.Sailors
Staff
Optional group exports · v2.1 / v1.1
This is a backward-compatible minor update. Keep the existing /api/v2 or /api/v1 base URL and API key. The metadata endpoint reports revision as 2.1 or 1.1. No URL migration is needed.
GET /fleet-groups requires read:fleet and returns the club's complete organisational-unit directory in data. Each entry contains id, name, and parentId (empty for top-level units). Use stable IDs rather than names; renaming a unit does not break your integration. This small configuration list is not paginated.
GET /api/v2/fleet-groups
GET /api/v2/fleet?group_id=GROUP_ID&page_size=200
GET /api/v2/fleet?group_id=GROUP_ID&include_descendants=true&page_size=200
group_idis optional. Omit it to read the full fleet, including unassigned boats, exactly as before.- A supplied ID returns only boats assigned directly to that group by default. Set
include_descendants=trueto include all child units. Empty groups return an empty list. Unknown group IDs return404 not_found; blank IDs or invalid boolean options return400 invalid_request. - Group filtering applies to the fleet list, not individual
/fleet/{boatId}reads. Each boat'sfleetGroupIdidentifies its unit. Archived boats remain included, as in an unfiltered export; usearchivedto distinguish them. - Follow
page.next_cursoruntil null, keepinggroup_idandinclude_descendantsunchanged on every request. A cursor for a deleted or moved boat outside the selected scope returns 400; restart the export. These are live reads, not a frozen snapshot. - The API key remains scoped to its club. This filter selects export content; it does not restrict the key's permissions.
const url = new URL('https://fix.fleetfixer.io/api/v2/fleet');
url.searchParams.set('group_id', selectedGroupId); // omit for the entire fleet
url.searchParams.set('include_descendants', 'true');
url.searchParams.set('page_size', '200');
const boats = [];
let cursor;
do {
if (cursor) url.searchParams.set('cursor', cursor);
const response = await fetch(url, {headers: {Authorization: `Bearer ${apiKey}`}});
if (!response.ok) throw new Error(`Fleet export failed: ${response.status}`);
const page = await response.json();
boats.push(...page.data);
cursor = page.page.next_cursor;
} while (cursor);
09 Fleet document schema
A single fleet document represents one specific physical boat the club owns. The API returns this shape verbatim with the Firestore document id added.
{
"id": "5oN7c2YQa3wWk2bD8kQa",
"boatTypeId": "optimist",
"boatTypeLabel": "Optimist",
"hullId": "GBR4567",
"friendlyNumber": "Opi 1",
"assetCode": "DOSC-OPI-001",
"status": "available",
"notes": "Good condition",
"createdAt": "2024-05-03T08:00:00.000Z",
"updatedAt": "2026-05-05T11:42:18.000Z"
}
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | always | Firestore document id, added by the API. Use this as your foreign key. |
boatTypeId | string | always | Canonical class id, e.g. optimist, rs-zest, ilca-6. Stable across renames. |
boatTypeLabel | string | always | Human-readable class name. Suitable for display. |
hullId | string | always | The unique hull / sail number. Unique within (clubId, boatTypeId). |
friendlyNumber | string | always | The on-the-water identifier the club uses, e.g. "Zest 5". What staff and sailors actually call the boat. |
assetCode | string | null | optional | Asset tracking key from your system. The club enters it once you provide it. |
status | enum | always | See status enum. |
notes | string | null | optional | Free-form notes. Not intended for machine parsing. |
createdAt | ISO 8601 | always | When the boat was added. |
updatedAt | ISO 8601 | always | Last modified at server. |
archived | boolean | only if archived | When true, the boat is no longer in service. |
archivedAt | ISO 8601 | null | only if archived | When archival happened. |
archivedReason | enum | null | only if archived | "binned", "sold", or "frankensteined". |
archivedNotes | string | null | only if archived | Free-form context. |
Identifier model
Each boat carries layers of identifier:
- Class (
boatTypeId/boatTypeLabel): "what kind of boat is this?" Many physical boats share a class. - Hull (
hullId): the formal unique number on the hull or sail. Used for paperwork. - Friendly (
friendlyNumber): the casual name on the transom and in conversation, like"Zest 5". - Asset code (
assetCode): your key. Optional, set by the club from your system.
For a booking UI you will almost always show friendlyNumber and group by boatTypeLabel. Use id as the foreign key and assetCode for cross-system reconciliation.
10 Status enum
status is the declared operational state of an active (non-archived) boat.
status is the boat's maintenance state. Usage and bookings are tracked elsewhere, so there is no "in use" value; from this API's point of view a sailable boat is just available.
| Value | Meaning | Sailable? |
|---|---|---|
available | Fit to sail. Default. | Yes |
in-repair | On the bench. Actively being worked on; expected to return. | No |
out-of-action | Broken or unsafe. Not in active repair; awaiting a decision. | No |
archived: true is a separate, terminal state and supersedes status. Always check archived before status. status defaults to "available" when missing, so older docs that pre-date the field still behave correctly. Values written by earlier builds (maintenance, out-of-service, in-use) are normalised by the API to the current vocabulary.
11 "Is this boat bookable?" recipe
Minimal check, using only /v1/fleet:
boat.archived !== true && boat.status === "available"
Stronger check that excludes boats with an open critical fault still sitting in FleetFixer (requires read:jobs):
- Fetch
/v1/fleet. - Fetch
/v1/jobs?status=open. - Group jobs by
boatNumberId. For each fleet boat, computeopenFaultCountand the highest priority among its open jobs (urgent>high>medium>low). - A boat is bookable when:
archived !== true && status === "available" && (openFaultCount === 0 || highestOpenPriority in {"low", "medium"})
Choose your own threshold. Some clubs are happy to book a boat with a medium-priority job pending; nobody should be booking one with an urgent job.
12 Where boats are referenced elsewhere
You don't need to read these for booking, but it's useful to know in case you build features that surface fault history or maintenance records. {boatId} is the same Firestore id you get from /v1/fleet.
| Collection | Field | Cardinality | Represents |
|---|---|---|---|
clubs/{clubId}/jobs | boatNumberId | one | Open or in-progress fault for this boat. |
clubs/{clubId}/jobs_archive | boatNumberId | one | Completed or archived fault. |
clubs/{clubId}/maintenance | fleetBoatId | one | Completed maintenance / work record. |
Class-level references on jobs, jobs_archive, and maintenance (boatIds[], boatId) point at the class, not the specific hull. Ignore those for hull-level integration.
13 Worked examples
List the fleet
GET https://fix.fleetfixer.io/api/v1/fleet?page_size=100
Authorization: Bearer ff_live_...
{
"data": [
{
"id": "5oN7c2YQa3wWk2bD8kQa",
"boatTypeId": "optimist",
"boatTypeLabel": "Optimist",
"hullId": "GBR4567",
"friendlyNumber": "Opi 1",
"assetCode": "DOSC-OPI-001",
"status": "available",
"notes": "Good condition",
"createdAt": "2024-05-03T08:00:00.000Z",
"updatedAt": "2026-05-05T11:42:18.000Z"
},
{
"id": "9bCk2vQrZ1pT6sN0fH3y",
"boatTypeId": "rs-zest",
"boatTypeLabel": "RS Zest",
"hullId": "GBR2199",
"friendlyNumber": "Zest 5",
"assetCode": null,
"status": "in-repair",
"notes": "Cracked vang block, ordered replacement.",
"createdAt": "2023-09-12T08:00:00.000Z",
"updatedAt": "2026-05-04T16:21:09.000Z"
}
],
"meta": { "club_id": "dosc", "request_id": "req_01HX...", "generated_at": "2026-05-05T12:00:00Z" },
"page": { "size": 100, "next_cursor": null }
}
Get a single boat
GET https://fix.fleetfixer.io/api/v1/fleet/5oN7c2YQa3wWk2bD8kQa
Authorization: Bearer ff_live_...
Bookable filter, JavaScript
const headers = { Authorization: `Bearer ${apiKey}` };
const fleet = await fetch(
'https://fix.fleetfixer.io/api/v1/fleet?page_size=200',
{ headers }
).then(r => r.json());
const bookable = fleet.data.filter(
b => b.archived !== true && b.status === 'available'
);
Bookable filter joined with open faults
const [fleet, jobs] = await Promise.all([
fetch('/api/v1/fleet?page_size=200', { headers }).then(r => r.json()),
fetch('/api/v1/jobs?status=open&page_size=200', { headers }).then(r => r.json()),
]);
const PRIORITY_RANK = { low: 1, medium: 2, high: 3, urgent: 4 };
const faultsByBoat = {};
for (const job of jobs.data) {
if (!job.boatNumberId) continue;
const cur = faultsByBoat[job.boatNumberId] || { count: 0, max: 0 };
cur.count += 1;
cur.max = Math.max(cur.max, PRIORITY_RANK[job.priority] || 0);
faultsByBoat[job.boatNumberId] = cur;
}
const bookable = fleet.data.filter(b => {
if (b.archived === true) return false;
if (b.status !== 'available') return false;
const f = faultsByBoat[b.id];
if (f && f.max >= PRIORITY_RANK.high) return false;
return true;
});
Curl, smoke-test the key
curl -H "Authorization: Bearer ff_live_..." \
https://fix.fleetfixer.io/api/v1/meta
14 CSV export (one-off)
If you'd rather work from a snapshot than the live API, the FleetFixer admin can export the fleet as CSV from the Fleet tab. The header is the canonical importer header, so the file is round-trippable.
boatType,boatTypeLabel,hullId,friendlyNumber,assetCode,status,notes,archived,archivedReason,added
optimist,Optimist,GBR4567,Opi 1,DOSC-OPI-001,available,Good condition,,,2024-05-03
rs-zest,RS Zest,GBR2199,Zest 5,,in-repair,"Cracked vang block, ordered replacement.",,,2023-09-12
Column meanings match the JSON schema. archived is empty unless the boat is archived. added is the createdAt date in YYYY-MM-DD.
For the opt-in v2.2 single-payload fleet push, see the fleet snapshot contract. Choose Single fleet payload (v2.2) in the webhook editor, or set fleetPushFormat to snapshot through this webhook management API. Existing subscriptions retain individual events until explicitly changed.
15 Webhooks
Rather than polling the read endpoints on a timer, register a callback URL and we will POST a signed event to it the moment a record changes. No cron job, no since= sweeps.
Registering a callback
Set it in the admin app (Config → Integrations: open a key and click Add webhook), or manage it programmatically on the same key using these routes. Auth is the same Authorization: Bearer ff_live_... header.
Push fleet data opens a boat selector with organisational-unit, vessel-type, archive and search filters. Select the records to send, then click Push selected boats. Each selected boat includes its full fleet record. Selections persist when filters change; the count identifies selected boats outside the current filters. For snapshot subscriptions, selected exports have data.complete: false: update the included boats without deleting boats omitted from the payload.
This lookup is also available at /api/v2/webhook/fleet, returning the v2 fleet fields. Existing v1 / v1.1 and v2 / v2.1 clients keep their base URLs and response formats. v2.2 snapshot subscribers use the v2 REST URL.
data, matching GET /fleet/{boatId}. It does not send a callback or change webhook settings; a configured callback URL is not required.hullId required string: exact, case-sensitive hull / sail number. Preserve leading zeros; this is not the Firestore document ID or hull type. Surrounding whitespace is trimmed.boatTypeId optional string: restrict to a boat type when a hull number occurs on multiple types. Use the boat's stored boatTypeId.curl --get 'https://fix.fleetfixer.io/api/v1/webhook/fleet' \
--header 'Authorization: Bearer ff_live_YOUR_KEY' \
--data-urlencode 'hullId=00123' \
--data-urlencode 'boatTypeId=laser'
Results are limited to the API key's club and include archived boats. A successful response is 200 with {"data": {"id": "…", "hullId": "00123", …}, "meta": {…}}. Standard API authentication and rate limits apply. Missing, empty or repeated lookup parameters return 400 invalid_request; missing read:fleet returns 403; no match returns 404 not_found; multiple matches return 409 ambiguous_hull_id. For duplicates, supply boatTypeId or use the internal ID with /fleet/{boatId}. Only GET is supported (405 for other methods).
url, events, status, the allowed_events its scopes permit, and the last delivery result.events (or ["*"]) means every event the key's scopes permit.{
"url": "https://your-app.example.com/hooks/fleetfixer",
"events": ["jobs.updated", "jobs.completed"]
}
Event types
Every change to a record the key can read fires an event named resource.change. A key only receives an event if it holds the matching read scope.
| Events | Fires when | Scope |
|---|---|---|
fleet.created, fleet.updated, fleet.deleted | A boat is added, edited, or removed. | read:fleet |
jobs.created, jobs.updated, jobs.deleted | A maintenance job changes. | read:jobs |
jobs.completed | A job is completed (moved to the archive). You get this instead of a misleading jobs.deleted. | read:jobs |
maintenance.created, maintenance.updated, maintenance.deleted | A maintenance-log entry changes. | read:maintenance |
inventory.created, inventory.updated, inventory.deleted | A part or stock level changes. | read:inventory |
orders.created, orders.updated, orders.deleted | A purchase order changes. | read:orders |
sailors.created, sailors.updated, sailors.deleted | A sailor record changes (PII, see below). | read:sailors |
webhook.ping | Sent only by the Send test button. A no-op you can ignore. | any |
Payload
The body is JSON. object is the full record in exactly the shape the matching GET endpoint returns. On a deleted event it is the record's last-known state.
{
"id": "9c1f0a7b3d2e4f60",
"type": "fleet.updated",
"created_at": "2026-06-10T12:00:00.123456+00:00",
"club_id": "dosc",
"data": {
"resource": "fleet",
"id": "boat_018f2c",
"change": "updated",
"object": { /* same shape as GET /fleet/{id} */ }
}
}
Headers
| Header | Meaning |
|---|---|
X-FleetFixer-Event | The event type, e.g. fleet.updated. |
X-FleetFixer-Delivery | Stable id for this event. Identical across retries, so dedupe on it. |
X-FleetFixer-Timestamp | Unix second the delivery was signed. |
X-FleetFixer-Signature | t=<timestamp>,v1=<hex>. Verify before trusting the body. |
Verifying the signature
The signing key is sha256(your API key) as a hex string (the same hash we store). The signed message is "<timestamp>." + raw_request_body. Compute the HMAC-SHA256 and compare it to v1 with a constant-time check.
import hashlib, hmac, time
def verify(api_key, headers, raw_body):
parts = dict(p.split("=", 1) for p in headers["X-FleetFixer-Signature"].split(","))
ts, got = parts["t"], parts["v1"]
key = hashlib.sha256(api_key.encode()).hexdigest() # your stored key, hashed
expected = hmac.new(key.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, got):
raise ValueError("bad signature")
if abs(time.time() - int(ts)) > 300:
raise ValueError("stale timestamp") # replay guard
The same check at the shell:
KEY=$(printf '%s' "$API_KEY" | openssl dgst -sha256 -r | cut -d' ' -f1)
printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$KEY"
Delivery, retries, and auto-disable
- We POST with a 10 second timeout and expect a
2xxwithin it. Anything else is a failure. - We retry up to three times per event on a network error or a
5xx. A4xxis treated as a permanent rejection and is not retried. - Delivery is at-least-once, so the same event can arrive more than once. Dedupe on
X-FleetFixer-Delivery, which is stable across retries. - After 10 consecutive failures the endpoint is auto-disabled. Re-enable it by saving the URL again (admin app) or with another
POST /webhook.
GET /sailors. A key only receives them if it holds read:sailors. Point webhooks only at endpoints you control.16 Changelog
GET/POST/DELETE /webhook. Backwards compatible: existing read clients are unaffected.