API reference, v1

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

Entity glossary. A fault report submitted by a club member becomes a job while it is open (visible in the app's Pending jobs panel). When a staff member clicks Mark Completed, the job is closed and a maintenance-log record is written (visible in the app's Completed jobs panel). The API reflects this: open and in-progress work lives under /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.

Base URL: https://fix.fleetfixer.io/api/v1
Read-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.

Treat the token like a database password. Don't commit it. Don't paste it into a browser dev console on a shared machine. Revoke immediately if leaked.

Generating a key

  1. Sign in to the Fix admin app at fix.fleetfixer.io with an admin account.
  2. Open the Config tab (gear icon), then Integrations in the sidebar.
  3. Click Create key, give it a name (e.g. Asset register sync), select the scopes the integration needs, and confirm.
  4. 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.

ScopeGrants access to
read:club Club name, notes, plan, branding colours.
read:fleet Boats, classes, service schedule.
read:jobs Open and completed maintenance jobs.
read:maintenanceService 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_cursor is 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=ISO8601 on endpoints that support it (/jobs, /maintenance, /orders). If the collection has no updatedAt index yet, the API silently falls back to id-only ordering and ignores since; 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..."
  }
}
HTTPcodeWhen
401unauthorizedMissing, malformed, unrecognised, or revoked API key.
403forbidden Token lacks the scope required for this route.
404not_found Resource id does not exist in this club.
405invalid_requestUnsupported method. Only /webhook accepts POST and DELETE; every other route is GET.
429rate_limitedToo many requests. Back off and retry after the value in Retry-After.
5xxserver_errorTransient. 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:

HeaderMeaning
X-RateLimit-LimitRequests allowed in the current window.
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetUnix 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

GET/meta
Self-describes the API key. Useful for confirming auth, scopes, and the club id without exposing data. No scope required.

Club

GET/clubread:club
Club metadata: name, notes, orders email, branding colours, plan tier.

Fleet

To retrieve a single boat by its hull number instead of its internal record ID, use the webhook hull lookup.

GET/fleetread:fleet
All fleet boats, paginated.
page_size integer, 1-200, default 50.
cursor opaque string from previous next_cursor.
GET/fleet/{boatId}read:fleet
A single boat by Firestore document id.

Jobs

GET/jobsread:jobs
Open fault reports and in-progress jobs (the Pending jobs panel in the app). Pass 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.
GET/jobs/{jobId}read:jobs
A single job by id. Looks in active (pending) jobs first, then the completed archive.

Maintenance log

GET/maintenanceread:maintenance
Completed maintenance records (the Completed jobs panel in the app). Each record is created when a job is marked completed and carries 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

GET/inventoryread:inventory
Parts and consumables the club tracks: name, SKU, stock level, supplier, location.

Purchase orders

GET/ordersread:orders
Purchase orders raised by the club: status, supplier, line items, totals.
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

GET/sailorsread:sailors
Sailor roster. Personally identifying data; only enable this scope when the integration genuinely needs identifiable sailors.
Auth credentials and Firebase UIDs are stripped from the response. If you need different fields hidden, contact FleetFixer.

Staff

GET/staffread:staff
Staff names and roles. Passwords and any temporary credentials are never returned.

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_id is 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=true to include all child units. Empty groups return an empty list. Unknown group IDs return 404 not_found; blank IDs or invalid boolean options return 400 invalid_request.
  • Group filtering applies to the fleet list, not individual /fleet/{boatId} reads. Each boat's fleetGroupId identifies its unit. Archived boats remain included, as in an unfiltered export; use archived to distinguish them.
  • Follow page.next_cursor until null, keeping group_id and include_descendants unchanged 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"
}
FieldTypeRequiredNotes
idstringalwaysFirestore document id, added by the API. Use this as your foreign key.
boatTypeIdstringalwaysCanonical class id, e.g. optimist, rs-zest, ilca-6. Stable across renames.
boatTypeLabelstringalwaysHuman-readable class name. Suitable for display.
hullIdstringalwaysThe unique hull / sail number. Unique within (clubId, boatTypeId).
friendlyNumberstringalwaysThe on-the-water identifier the club uses, e.g. "Zest 5". What staff and sailors actually call the boat.
assetCodestring | nulloptionalAsset tracking key from your system. The club enters it once you provide it.
statusenumalwaysSee status enum.
notesstring | nulloptionalFree-form notes. Not intended for machine parsing.
createdAtISO 8601alwaysWhen the boat was added.
updatedAtISO 8601alwaysLast modified at server.
archivedbooleanonly if archivedWhen true, the boat is no longer in service.
archivedAtISO 8601 | nullonly if archivedWhen archival happened.
archivedReasonenum | nullonly if archived"binned", "sold", or "frankensteined".
archivedNotesstring | nullonly if archivedFree-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.

ValueMeaningSailable?
available Fit to sail. Default. Yes
in-repair On the bench. Actively being worked on; expected to return. No
out-of-actionBroken 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):

  1. Fetch /v1/fleet.
  2. Fetch /v1/jobs?status=open.
  3. Group jobs by boatNumberId. For each fleet boat, compute openFaultCount and the highest priority among its open jobs (urgent > high > medium > low).
  4. 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.

CollectionFieldCardinalityRepresents
clubs/{clubId}/jobs boatNumberIdoneOpen or in-progress fault for this boat.
clubs/{clubId}/jobs_archive boatNumberIdoneCompleted or archived fault.
clubs/{clubId}/maintenance fleetBoatId oneCompleted 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.

The API key is the subscription. A key already has a club and scopes, so adding a webhook URL to it is all it takes. Events are filtered to what the key can read, and we sign each delivery with a secret derived from the key, so there is nothing extra to store or rotate. Revoking the key also stops its webhooks.

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.

GET/webhook/fleet?hullId={hullId}read:fleet
Fetch one boat's current data on demand using the same API key as your webhook subscription. This returns a normal API response with a single boat in 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).

GET/webhook
Returns this key's webhook config: url, events, status, the allowed_events its scopes permit, and the last delivery result.
POST/webhook
Set or replace the callback. Body below. An empty or omitted events (or ["*"]) means every event the key's scopes permit.
{
  "url": "https://your-app.example.com/hooks/fleetfixer",
  "events": ["jobs.updated", "jobs.completed"]
}
DELETE/webhook
Remove the callback. We stop sending events to it immediately.

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.

EventsFires whenScope
fleet.created, fleet.updated, fleet.deletedA boat is added, edited, or removed.read:fleet
jobs.created, jobs.updated, jobs.deletedA maintenance job changes.read:jobs
jobs.completedA job is completed (moved to the archive). You get this instead of a misleading jobs.deleted.read:jobs
maintenance.created, maintenance.updated, maintenance.deletedA maintenance-log entry changes.read:maintenance
inventory.created, inventory.updated, inventory.deletedA part or stock level changes.read:inventory
orders.created, orders.updated, orders.deletedA purchase order changes.read:orders
sailors.created, sailors.updated, sailors.deletedA sailor record changes (PII, see below).read:sailors
webhook.pingSent 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

HeaderMeaning
X-FleetFixer-EventThe event type, e.g. fleet.updated.
X-FleetFixer-DeliveryStable id for this event. Identical across retries, so dedupe on it.
X-FleetFixer-TimestampUnix second the delivery was signed.
X-FleetFixer-Signaturet=<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"
Reject anything that fails the signature, and reject timestamps older than a few minutes to block replays. Verify against the raw request bytes, before any JSON re-encoding.

Delivery, retries, and auto-disable

  • We POST with a 10 second timeout and expect a 2xx within it. Anything else is a failure.
  • We retry up to three times per event on a network error or a 5xx. A 4xx is 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.
Sailor events carry personal data, redacted to the same shape as GET /sailors. A key only receives them if it holds read:sailors. Point webhooks only at endpoints you control.

16 Changelog

v1.1.0
Outbound webhooks. Add a callback URL to any API key and receive signed events on change, instead of polling. Managed in the Integrations tab or via GET/POST/DELETE /webhook. Backwards compatible: existing read clients are unaffected.
v1.0.0
Initial release. Read-only endpoints for club, fleet, jobs, maintenance, inventory, orders, sailors, staff. Bearer-token auth with scope-based access control.
API v2 adds hull and rig catalogue information and consistent rental and charter fields to fleet responses. Continue using v1 for other resources and webhook management. Clients should ignore unknown fields.