← Back to home

Docs · REST API v1

Public REST API.

Token-authenticated, scoped, JSON. Outbound webhooks signed with HMAC-SHA256. Growing endpoint surface as integrators land — reach out at /support if a specific endpoint blocks you.

Authentication

API keys are issued by org admins at /settings → API keys. Keys carry a label, a list of scopes (CSV), and an optional expiry. Plaintext is shown once on creation — only the SHA-256 hash is stored.

# Header on every request
Authorization: Bearer dfk_<43-char base64url>

# Example
curl https://dirtfleet.app/api/v1/assets \
  -H "Authorization: Bearer dfk_abcdef…" \
  -H "Accept: application/json"

Scopes

  • assets:read · assets:write
  • hours:read · hours:write
  • flags:read · flags:write
  • workorders:read · workorders:write
  • tools:read · tools:write
  • projects:read · projects:write
  • yards:read · yards:write
  • events:read · events:write (list / manage webhook subscriptions)

Failed-auth responses return 401 with a precise reason: missing_bearer, malformed_token, unknown_token, revoked, or expired. Insufficient scope returns 403 with missing_scope:<scope>.

Endpoints

GET /api/v1/health

Unauthenticated. Lighter than /api/health — only checks DB reachability + uptime. Uptime monitors should target the v1 path; the legacy path may drift between deploys.

curl -sS https://dirtfleet.app/api/v1/health | jq .

# 200 OK when healthy, 503 when DB ping errors or times out
{
  "ok": true,
  "database": "connected",
  "apiVersion": "v1",
  "commit": "abc123def456",
  "uptimeSec": 42117
}

GET /api/v1/version

Public — no auth. Returns the deployed commit short SHA, branch, process uptime, and node version. Useful in CI ("did my deploy land?") and in support tickets ("we're on commit abc123").

curl -sS https://dirtfleet.app/api/v1/version | jq .

GET /api/v1/me/usage

Returns the current key's rate-limit window state as a parseable body — the same numbers as the X-RateLimit-* response headers. Useful as a "how much budget do I have left?" probe from dashboards that want to display burn-rate to operators.

curl -sS https://dirtfleet.app/api/v1/me/usage \
  -H "Authorization: Bearer dfk_…" | jq .

# Response
{
  "ok": true,
  "keyId": "clx7key…",
  "rateLimit": {
    "limit": 60,
    "remaining": 58,
    "resetEpochSec": 1747260000,
    "resetIn": 47,
    "windowSec": 60
  }
}

GET /api/v1/me

Identifies the org + key behind the bearer token. No scope required — useful as a wiring sanity check before touching real data.

curl -sS https://dirtfleet.app/api/v1/me \
  -H "Authorization: Bearer dfk_…"

# Response
{
  "ok": true,
  "organization": {
    "id": "clx7org…",
    "name": "Acme Earthmoving",
    "slug": "acme-earthmoving",
    "createdAt": "2026-01-04T12:00:00Z"
  },
  "apiKey": {
    "id": "clx7key…",
    "label": "Zapier production",
    "scopes": ["assets:read", "flags:read", "hours:write"],
    "createdAt": "2026-04-12T08:14:22Z",
    "lastUsedAt": "2026-05-14T18:42:30Z",
    "expiresAt": null,
    "createdBy": { "id": "clx7user…", "name": "Jordan T.", "email": "jordan@acme.example" }
  }
}

POST /api/v1/assets/batch

Bulk import — up to 50 entries per request, partial-success semantics matching /api/v1/hours/batch. Cap is lower than hours because each create kicks a Stripe quantity sync; bigger fleets call multiple times. The most common path for "switching from Fleetio" migrations.

curl -sS -X POST https://dirtfleet.app/api/v1/assets/batch \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '[
    {"nickname":"Excavator 47","assetClass":"off-road","serial":"CAT320-12345"},
    {"nickname":"F-150 #4","assetClass":"on-road","vin":"1FTFW1ET5BFA12345"},
    {"nickname":"Tag-along trailer","assetClass":"trailer"}
  ]'

# Response
{
  "ok": true,
  "results": [
    {"index":0,"ok":true,"assetId":"clx7asset_a"},
    {"index":1,"ok":true,"assetId":"clx7asset_b"},
    {"index":2,"ok":true,"assetId":"clx7asset_c"}
  ],
  "summary": { "received": 3, "succeeded": 3, "failed": 0 }
}

POST /api/v1/assets

Required scope: assets:write. Creates an asset with a default meter type derived from assetClass (on-road → ODOMETER, off-road → HOURS, trailer → NONE). Fires a Stripe quantity sync in the background so the org's bill reflects the new billable asset within seconds.

curl -sS -X POST https://dirtfleet.app/api/v1/assets \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "nickname":"Excavator 47",
    "assetClass":"off-road",
    "serial":"CAT320-12345",
    "customerAssetNumber":"EX-047"
  }'

# Response
{ "ok": true, "assetId": "clx7asset…" }

Use the returned assetId with PATCH below to fill in plates, renewal dates, financial fields, or a custom lifecycle status.

PATCH /api/v1/assets/{id}

Required scope: assets:write. Updates a focused field set: identity, plates, renewals, financial summary, and lifecycleStatus (with optional disposition fields when flipping to SOLD). Meter / PM / operator-assignment fields are not here — those flow through specialized in-app paths that need side-effects (Stripe quantity sync, billable recompute) we won't bypass with a bare PATCH.

# Renew the plate
curl -sS -X PATCH https://dirtfleet.app/api/v1/assets/clx7asset… \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{"registrationExpiresAt":"2027-05-01T00:00:00Z"}'

# Mark sold
curl -sS -X PATCH https://dirtfleet.app/api/v1/assets/clx7asset… \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "lifecycleStatus":"SOLD",
    "soldAt":"2026-05-14T00:00:00Z",
    "soldPrice":42000,
    "soldTo":"Acme Equipment Auctions"
  }'

GET /api/v1/assets

Required scope: assets:read. Cursor-paginated.

# Request
GET /api/v1/assets?limit=100&cursor=clx7…
Authorization: Bearer dfk_…

# Response
{
  "ok": true,
  "assets": [
    {
      "id": "clx7abc…",
      "nickname": "Excavator 47",
      "customerAssetNumber": "EX-047",
      "assetClass": "off-road",
      "vin": null,
      "serial": "CAT320-12345",
      "meterType": "HOURS",
      "meterUnit": "hrs",
      "lifecycleStatus": "IN_SERVICE",
      "yardId": "clx7yard…",
      "createdAt": "2026-01-15T10:00:00Z",
      "updatedAt": "2026-05-13T14:22:31Z"
    }
  ],
  "nextCursor": "clx7next…"
}

GET /api/v1/flags

Required scope: flags:read. Cursor-paginated. Defaults to status=OPEN — pass ?status=OPEN,IN_PROGRESS,RESOLVED to widen. Optional severity CSV (RED,YELLOW,AUTO_PM) and assetId filters.

# Open red flags, newest first
GET /api/v1/flags?severity=RED&limit=50
Authorization: Bearer dfk_…

# Response
{
  "ok": true,
  "flags": [
    {
      "id": "clx7flag…",
      "assetId": "clx7asset…",
      "severity": "RED",
      "status": "OPEN",
      "note": "Hydraulic leak — flagged from cab",
      "raisedById": "clx7user…",
      "resolvedById": null,
      "resolvedAt": null,
      "resolutionNote": null,
      "lat": 39.7392,
      "lng": -104.9903,
      "createdAt": "2026-05-14T18:42:30Z",
      "updatedAt": "2026-05-14T18:42:30Z"
    }
  ],
  "nextCursor": null
}

GET /api/v1/hours

Required scope: hours:read. Newest-first, cursor-paginated. Optional assetId + since filters. The delta field is server-computed (current reading minus previous reading on the same asset).

# Last week's logs for one excavator
GET /api/v1/hours?assetId=clx7asset…&since=2026-05-07T00:00:00Z
Authorization: Bearer dfk_…

# Response (excerpt)
{
  "ok": true,
  "hoursLogs": [
    {
      "id": "clx7log…",
      "assetId": "clx7asset…",
      "loggedById": "clx7user…",
      "loggedByDeviceId": null,
      "hoursReading": 1342.7,
      "delta": 8.2,
      "state": null,
      "fuelVolume": 22.1,
      "fuelUnit": "GALLON",
      "fuelCostTotal": 92.34,
      "note": "End-of-shift",
      "lat": 39.7392,
      "lng": -104.9903,
      "loggedAt": "2026-05-13T22:14:10Z",
      "createdAt": "2026-05-13T22:14:10Z"
    }
  ],
  "nextCursor": null
}

POST /api/v1/hours/batch

Bulk import — accept up to 100 entries in one request. Each entry is validated and written independently; the response carries a parallel results[] array so partial success doesn't require all-or-nothing retries. A bad entry doesn't fail the batch. Per-entry clientMutationId works as the idempotency key.

curl -sS -X POST https://dirtfleet.app/api/v1/hours/batch \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '[
    {"assetId":"clx7asset_a","hoursReading":100,"clientMutationId":"c1"},
    {"assetId":"clx7asset_b","hoursReading":200,"clientMutationId":"c2"}
  ]'

# Response
{
  "ok": true,
  "results": [
    {"index":0,"ok":true,"logId":"clx7log_a","autoFlagId":null},
    {"index":1,"ok":true,"logId":"clx7log_b","autoFlagId":"clx7flag_x"}
  ],
  "summary": { "received": 2, "succeeded": 2, "failed": 0 }
}

POST /api/v1/hours

Required scope: hours:write. Logs a meter reading (or state-only entry, for NONE-meter assets) against an asset. Goes through the same lib path as the in-app form, so anomaly detection + AUTO_PM flagging + renewal heads-ups all run. Returns 201 with logId + autoFlagId (non-null when the reading crossed a PM threshold).

# Numeric meter
curl -sS -X POST https://dirtfleet.app/api/v1/hours \
  -H "Authorization: Bearer dfk_…" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"assetId":"clx7asset…","hoursReading":1342.7,"note":"end-of-shift"}'

# Response
{
  "ok": true,
  "logId": "clx7log…",
  "autoFlagId": null
}

The Idempotency-Key header (any opaque string up to 128 chars) makes a retry safe — a second POST with the same key returns the original log id without writing a second row. Body-level clientMutationId works too; the header wins if both are set.

Validation failures return 422 withfieldErrors (e.g. { "assetId": "Pick an asset" }) instead of 400 — they aren't syntax errors in the request, just input the server rejected.

GET /api/v1/assets/{id}

Required scope: assets:read. Single-asset superset shape — adds PM thresholds, plate / renewal dates, financial summary (purchase cost, depreciation method, current value), parent-attachment pointer, and primary operator id. Cross-tenant ids return 404, not403 — we don't leak existence.

curl -sS https://dirtfleet.app/api/v1/assets/clx7asset… \
  -H "Authorization: Bearer dfk_…"

GET /api/v1/work-orders/{id}

Required scope: workorders:read. Same shape as one row from the list endpoint. Same 404-on-cross-tenant rule as above.

curl -sS https://dirtfleet.app/api/v1/work-orders/clx7wo… \
  -H "Authorization: Bearer dfk_…"

GET /api/v1/changelog

Public — no auth, no scope. The same array /changelog renders, in JSON. Edge-cached for an hour. Useful for status-page integrations and customer-success automations.

curl -sS https://dirtfleet.app/api/v1/changelog | jq .entries[0]

PATCH /api/v1/flags/{id}

Required scope: flags:write. Resolves an open flag — only status=RESOLVED is currently writable (re-open / re-assign would muddy the audit trail). Goes through the same lib path as the in-app form, so AUTO_PM flags advance the service baseline correctly.

curl -sS -X PATCH https://dirtfleet.app/api/v1/flags/clx7flag… \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{"status":"RESOLVED","resolutionNote":"Replaced hydraulic line"}'

# Response
{ "ok": true, "flagId": "clx7flag…", "status": "RESOLVED" }

POST /api/v1/flags

Required scope: flags:write. Raises a YELLOW or RED flag against an asset; AUTO_PM is reserved for the engine. Push + email notifications fire identically to the in-app flow.

curl -sS -X POST https://dirtfleet.app/api/v1/flags \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "assetId":"clx7asset…",
    "severity":"RED",
    "note":"Hydraulic leak — flagged from cab"
  }'

# Response
{ "ok": true, "flagId": "clx7flag…" }

GET /api/v1/work-orders

Required scope: workorders:read. Defaults to the active queue (OPEN, ASSIGNED, IN_PROGRESS, ON_HOLD). Passstatus=DONE for the recently-completed list, orassignedToId=<userId> for one mechanic's queue. Sorted by priority desc, due asc, created desc — the same order the in-app shop dashboard uses.

# Just the URGENT + HIGH queue
GET /api/v1/work-orders?priority=URGENT,HIGH&limit=50
Authorization: Bearer dfk_…

# Response
{
  "ok": true,
  "workOrders": [
    {
      "id": "clx7wo…",
      "number": 1847,
      "assetId": "clx7asset…",
      "projectId": null,
      "flagId": "clx7flag…",
      "title": "Replace failed hydraulic line",
      "description": null,
      "status": "ASSIGNED",
      "priority": "URGENT",
      "dueAt": "2026-05-15T16:00:00Z",
      "estimateLaborHours": 4,
      "estimateCost": 480,
      "createdById": "clx7user…",
      "assignedToId": "clx7mech…",
      "createdAt": "2026-05-14T18:50:00Z",
      "updatedAt": "2026-05-14T19:02:11Z"
    }
  ],
  "nextCursor": null
}

POST /api/v1/work-orders

Required scope: workorders:write. Creates a new WO and allocates the next per-org number server-side (race-safe). Common pattern: promote a field flag into a WO with flagId + assetId set — when the WO later closes with a repair log, the flag auto-resolves.

curl -sS -X POST https://dirtfleet.app/api/v1/work-orders \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "title":"Replace failed hydraulic line",
    "flagId":"clx7flag_abc",
    "assetId":"clx7asset_excavator",
    "priority":"URGENT",
    "assignedToId":"clx7mech_jordan",
    "status":"ASSIGNED",
    "estimateLaborHours": 4,
    "estimateCost": 480
  }'

# Response
{ "ok": true, "workOrderId": "clx7wo…", "number": 1847 }

PATCH /api/v1/work-orders/{id}

Required scope: workorders:write. Omitted fields stay unchanged; pass null to clear a nullable field (e.g. unassign with assignedToId: null).

# Mark in-progress and re-assign
curl -sS -X PATCH https://dirtfleet.app/api/v1/work-orders/clx7wo… \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{"status":"IN_PROGRESS","assignedToId":"clx7mech_jordan"}'

# Close it
curl -sS -X PATCH https://dirtfleet.app/api/v1/work-orders/clx7wo… \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{"status":"DONE"}'

GET /api/v1/tools

Required scope: tools:read. Defaults to active inventory (GOOD, NEEDS_ATTENTION, BROKEN, MISSING — excludes RETIRED). Filter by category, assignedUserId, assignedVehicleId, or parentKitId to drill into kits. ?lowStock=true narrows to consumables under their per-tool reorder threshold; ?pmOverdue=true narrows to tools past their PM interval; ?warrantyExpiringDays=N (1-365) narrows to tools whose warranty expires in the next N days (excludes already-expired and tools without a warranty date). ?format=csv returns a spreadsheet-ready download (22-column stable layout) instead of the JSON response, applying the same filters.

# Hand wrenches needing attention
GET /api/v1/tools?category=wrench&status=NEEDS_ATTENTION
Authorization: Bearer dfk_…

# Dump all low-stock consumables to CSV for finance
GET /api/v1/tools?lowStock=true&format=csv
Authorization: Bearer dfk_…
# → text/csv; Content-Disposition: attachment;
#   filename="dirtfleet-tools-2026-05-14.csv"

# Tools whose warranty expires in the next 60 days
GET /api/v1/tools?warrantyExpiringDays=60
Authorization: Bearer dfk_…

POST /api/v1/tools

Required scope: tools:write. Creates a tool and returns the auto-generated scanToken — that token goes on the QR/NFC label and resolves at /scan/{token}. For label printers, hit GET /tools/{id}/qr for a print-ready PNG / SVG.

Also fires a tool.created webhook with the new tool's id, name, category, scanToken, optional purchaseCost, and the actor — subscribe to it (or to the batch path's per-row deliveries) to keep an accounting or asset-register ledger in lockstep without polling.

curl -sS -X POST https://dirtfleet.app/api/v1/tools \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name":"Milwaukee M18 Sawzall",
    "category":"power tool",
    "serial":"M18-77234",
    "assignedVehicleId":"clx7asset_truck32"
  }'

# Response
{ "ok": true, "toolId": "clx7tool…", "scanToken": "abcdef123" }

GET /api/v1/projects

Required scope: projects:read. Defaults to status=ACTIVE. Use this to populate a project picker in your integration without scraping the in-app UI.

GET /api/v1/projects?status=ACTIVE,ON_HOLD&limit=100
Authorization: Bearer dfk_…

# Response
{
  "ok": true,
  "projects": [
    {
      "id": "clx7proj…",
      "name": "City of Boulder — 17th Ave",
      "code": "J-2026-014",
      "customerName": "City of Boulder",
      "status": "ACTIVE",
      "startDate": "2026-04-01T00:00:00Z",
      "endDate": "2026-08-15T00:00:00Z",
      "hourlyRate": 185,
      "notes": null,
      "createdAt": "2026-03-12T16:22:00Z",
      "updatedAt": "2026-05-14T09:01:11Z"
    }
  ],
  "nextCursor": null
}

GET / PATCH /api/v1/tools/{id}

Scopes tools:read and tools:write. GET returns the single-tool shape including the scanToken (label-print value). PATCH is a partial update — omit fields to leave unchanged, pass null to clear (unassign with assignedUserId: null).

GET accepts ?includeBookValue=true to tack a bookValue sub-object on the response carrying the straight-line depreciated value (purchaseCost × max(0, 1 - elapsedYears / usefulLifeYears)). Useful-life defaults are 5 years for non-consumables and 1 year for consumables — override with ?usefulLifeYears=10 (clamped to 0.25–50) for industrial-tool models with longer service lives. Returns bookValue: null when purchaseDate or purchaseCost is missing so accounting consumers know to backfill instead of seeing a fake $0.

curl -sS "https://dirtfleet.app/api/v1/tools/clx7t…?includeBookValue=true" \
  -H "Authorization: Bearer dfk_…"

# Response (purchaseDate 1 year ago, purchaseCost $5,000)
{
  "ok": true,
  "tool": { "id": "clx7t…", "name": "Air compressor", "purchaseCost": 5000, … },
  "bookValue": {
    "bookValue": 4000.00,
    "depreciationPct": 0.2,
    "usefulLifeYears": 5,
    "elapsedYears": 1.001
  }
}

POST /api/v1/tools/{id}/checkout

Required scope: tools:write. Checks the tool out to a user / vehicle and records a CHECK_OUT event. Idempotent per (toolId, target user). Pass force: true to override an existing checkout to another user — returns 409 otherwise. Returns 409 when the tool is RETIRED or MISSING.

Pass includeChildren: true on a kit tool (one that has children via parentKitId) to fan the action out to the kit + every child in one HTTP call. Response shape switches to { ok, kitId, succeeded[], skipped[] } — per-child failures (already-out, RETIRED, MISSING) land inskipped[] with a reason and don't abort the rest of the batch.

# Single tool
curl -sS -X POST https://dirtfleet.app/api/v1/tools/clx7t…/checkout \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{ "toUserId": "clx7u…", "linkedAssetId": "clx7a…", "note": "for the City of Boulder job" }'

# Response
{ "ok": true, "toolId": "clx7t…", "eventId": "clx7evt…" }

# Whole kit (socket set + 30 sockets in one call)
curl -sS -X POST https://dirtfleet.app/api/v1/tools/clx7kit…/checkout \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{ "includeChildren": true, "toUserId": "clx7u…", "linkedAssetId": "clx7a…" }'

# Response
{
  "ok": true,
  "kitId": "clx7kit…",
  "succeeded": [
    { "id": "clx7kit…", "eventId": "clx7evt_kit…" },
    { "id": "clx7sock_a…", "eventId": "clx7evt_a…" }
  ],
  "skipped": [
    { "id": "clx7sock_b…", "reason": "Tool is retired; resolve before checking out" }
  ]
}

POST /api/v1/tools/{id}/checkin

Required scope: tools:write. Records a CHECK_IN event and downgrades the tool's status to match the observed condition (GOOD / NEEDS_ATTENTION / BROKEN). A BROKEN check-in auto-spawns a HIGH-priority WorkOrder; the new workOrderId is returned on the response so you can deep-link the shop into it.

curl -sS -X POST https://dirtfleet.app/api/v1/tools/clx7t…/checkin \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{ "condition": "BROKEN", "note": "wheel exploded mid-cut" }'

# Response
{ "ok": true, "toolId": "clx7t…", "eventId": "clx7evt…", "workOrderId": "clx7wo…" }

POST /api/v1/tools/{id}/report

Required scope: tools:write. Marks the tool BROKEN (or MISSING when markMissing: true) and spawns a WorkOrder so the shop sees it in the queue. kind defaults to FAILURE_REPORT (HIGH priority); pass REPLACEMENT_REQUEST for the "buy a new one" flow at NORMAL priority.

curl -sS -X POST https://dirtfleet.app/api/v1/tools/clx7t…/report \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{ "description": "burned-out motor", "kind": "REPLACEMENT_REQUEST", "cost": 240 }'

# Response
{ "ok": true, "toolId": "clx7t…", "eventId": "clx7evt…", "workOrderId": "clx7wo…" }

POST /api/v1/tools/{id}/mark-serviced

Required scope: tools:write. Records that a PM service was completed: updates the tool's lastServicedAt to now (or the optional at body field for back-dating historical records) and creates an AUDIT event with the structured pm_serviced: description prefix. Pairs naturally with the ?pmOverdue=true filter — a mechanic finishing service taps this and the PM-overdue chip + dashboard widget clear on the next render.

Also fires a tool.serviced webhook with the full before/after lastServicedAt + interval + note — subscribe to it instead of polling /tools/{id}/events if you mirror PM history into a depreciation, warranty, or CMMS ledger.

Returns 422 when the tool has no pmIntervalDays set — recording a service on a tool with no PM cadence is almost always a mistake; the user probably meant to add the interval first via PATCH /tools/{id}.

# Mark serviced now
curl -sS -X POST https://dirtfleet.app/api/v1/tools/clx7t…/mark-serviced \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{ "note": "Changed oil + air filter + spark plugs" }'

# Or back-date a historical service record
curl -sS -X POST https://dirtfleet.app/api/v1/tools/clx7t…/mark-serviced \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{ "at": "2026-03-15T00:00:00Z", "note": "Per the paper log book" }'

# Response
{
  "ok": true,
  "toolId": "clx7t…",
  "eventId": "clx7evt…",
  "previousLastServicedAt": "2026-01-12T00:00:00.000Z",
  "newLastServicedAt": "2026-05-14T15:42:00.000Z"
}

POST /api/v1/tools/{id}/adjust-stock

Required scope: tools:write. Records an AUDIT ToolEvent and updates the tool's stockLevel by the signed delta on the body — positive = restock, negative = usage. Consumables only (409 on non-consumable tools). The resulting stock level is clamped to zero.

When the adjustment causes a crossing from above-threshold to at-or-below-threshold (stockLevel <= COALESCE(minStockLevel, 5)), the response carries crossedThreshold: true and a tool.low_stock webhook fires automatically. Wire it to reorder automation, a Slack channel, or a daily summary — only the negative crossing fires (going further below or restocking back above does not retrigger).

curl -sS -X POST https://dirtfleet.app/api/v1/tools/clx7t…/adjust-stock \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{ "delta": -3, "note": "used on the John Deere" }'

# Response when the adjustment crossed the threshold
{
  "ok": true,
  "toolId": "clx7t…",
  "eventId": "clx7evt…",
  "previousStockLevel": 7,
  "newStockLevel": 4,
  "crossedThreshold": true
}

GET /api/v1/tools/{id}/events

Required scope: tools:read. Paginated audit trail for one tool. Use this when you want to mirror tool history into accounting / CMMS without paging through the full detail object on every read. Cursor-paginated, ordered createdAt desc; filter by kind (CSV of CHECK_OUT / CHECK_IN / AUDIT / REPAIR_LOGGED / ...) and since (ISO date-time).

The description field uses structured prefixes for the events that fan out from typed endpoints: stock_adjusted: from POST /adjust-stock (carries delta=N new_level=M), pm_serviced: from POST /mark-serviced (carries interval=Nd serviced_at=ISO), tool_created: on the AUDIT row written by POST /tools (carries name=... category=... consumable=true — the oldest event on every tool), status_changed: on PATCH transitions that actually move the status enum (carries from=GOOD to=RETIRED — catches retirement, missing, needs-attention), and assignment_changed: on the ASSIGNMENT kind that fires whenever a PATCH to assignedUserId / assignedVehicleId / assignedYardId / parentKitId changes the value — carries type=user|vehicle|yard|kit from=A to=B so consumers can mirror the move into a CMMS without joining back to the Tool table. The rest of the surface (CHECK_OUT / CHECK_IN / FAILURE_REPORT) uses free-form descriptions.

# Recent 50 events for a tool
curl -sS "https://dirtfleet.app/api/v1/tools/clx7t…/events" \
  -H "Authorization: Bearer dfk_…"

# Only audit events (stock adjustments + PM service completions)
curl -sS "https://dirtfleet.app/api/v1/tools/clx7t…/events?kind=AUDIT&limit=200" \
  -H "Authorization: Bearer dfk_…"

# Page forward from the previous nextCursor
curl -sS "https://dirtfleet.app/api/v1/tools/clx7t…/events?cursor=clx7evt…" \
  -H "Authorization: Bearer dfk_…"

# Only since the start of the quarter
curl -sS "https://dirtfleet.app/api/v1/tools/clx7t…/events?since=2026-04-01T00:00:00Z" \
  -H "Authorization: Bearer dfk_…"

# Every checkout this mechanic recorded on the Excavator
curl -sS "https://dirtfleet.app/api/v1/tools/clx7t…/events?actorId=clx7u…&linkedAssetId=clx7a…&kind=CHECK_OUT" \
  -H "Authorization: Bearer dfk_…"

# Response
{
  "ok": true,
  "toolId": "clx7t…",
  "events": [
    {
      "id": "clx7evt_a…",
      "kind": "AUDIT",
      "condition": null,
      "description": "stock_adjusted: delta=-3 new_level=17 note=used on the JD",
      "cost": null,
      "linkedAssetId": null,
      "linkedWorkOrderId": null,
      "photoId": null,
      "actor": { "id": "clx7u…", "name": "Tom", "email": "tom@…" },
      "createdAt": "2026-05-14T15:42:00.000Z"
    }
  ],
  "nextCursor": "clx7evt_a…"
}

GET /api/v1/tools/{id}/utilization

Required scope: tools:read. Pairs the tool's CHECK_OUT events with the next CHECK_IN, clamps to the window, and returns total checkout-ms + percentage utilization + top-N users by time. Currently-open checkouts close at now and the response flags openAtWindowEnd: true. Use this to answer "is this air compressor checked out 80% of the time and we need a second one?" without paging through the audit log.

Query params: days (window size, default 30, clamped to [1, 365]) and topN (default 3, clamped to [0, 10]; 0 omits the per-user breakdown).

curl -sS https://dirtfleet.app/api/v1/tools/clx7t…/utilization?days=30 \
  -H "Authorization: Bearer dfk_…"

# Response
{
  "ok": true,
  "utilization": {
    "toolId": "clx7t…",
    "windowStart": "2026-04-14T00:00:00.000Z",
    "windowEnd": "2026-05-14T00:00:00.000Z",
    "windowMs": 2592000000,
    "checkedOutMs": 648000000,
    "checkoutCount": 12,
    "openAtWindowEnd": true,
    "utilizationPct": 0.25,
    "topUsers": [
      { "userId": "clx7u…", "name": "Tom", "email": "tom@x.test",
        "checkedOutMs": 432000000, "checkoutCount": 8 }
    ]
  }
}

GET /api/v1/tools/{id}/qr

Required scope: tools:read. Print-ready QR code encoding a deep-link to /scan/{scanToken}. PNG by default, SVG with ?fmt=svg. Default 600px width prints clean at 1.5-2 inches on a 300-DPI label. Designed for label-printing scripts: pull the tool roster, loop, save the PNG, send to the Zebra / Brother / Dymo.

# Save a 600x600 PNG ready to print
curl -sS -OJ "https://dirtfleet.app/api/v1/tools/clx7t…/qr" \
  -H "Authorization: Bearer dfk_…"

# Or pull SVG at 800px for a crisper print
curl -sS "https://dirtfleet.app/api/v1/tools/clx7t…/qr?fmt=svg&size=800" \
  -H "Authorization: Bearer dfk_…" > tool.svg

POST /api/v1/projects/batch

Bulk-import up to 50 projects with partial-success semantics matching the other batch endpoints. Each entry's code uniqueness is enforced; collisions surface per-entry (the rest of the batch still succeeds).

POST /api/v1/tools/batch

Bulk-import up to 100 tools. Each successful entry returns its scanToken in the result so receivers can immediately render printable QR/NFC labels for the entire batch — the typical "seed a shop's inventory" flow.

POST /api/v1/projects

Required scope: projects:write. Creates a project. The optional code field must be unique per org — duplicate codes return 422.

curl -sS -X POST https://dirtfleet.app/api/v1/projects \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name":"City of Boulder — 17th Ave",
    "code":"J-2026-014",
    "customerName":"City of Boulder",
    "startDate":"2026-04-01T00:00:00Z",
    "hourlyRate": 185
  }'

# Response
{ "ok": true, "projectId": "clx7proj…" }

GET / PATCH /api/v1/projects/{id}

Scopes projects:read and projects:write. Same shape pattern as the tools detail. PATCH that changes code to one already used by another project in the org returns 400 with the lib's "A project with that code already exists" message — distinguishable from generic 422.

Prefer a typed contract? The full OpenAPI 3.1 spec lives at /openapi.yaml — feed it to openapi-generator / openapi-typescript / oapi-codegen for a client in your language. More endpoints land per integrator demand — open a support ticket to request a specific surface.

GET / POST /api/v1/yards

Scopes yards:read and yards:write. A yard is a physical site / depot / job-trailer location. Names are unique within an org — duplicate POSTs return 422 with a clear message.

curl -sS -X POST https://dirtfleet.app/api/v1/yards \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{"name":"Yard A","lat":39.7392,"lng":-104.9903}'

# Response
{ "ok": true, "yardId": "clx7yard…", "createdAt": "2026-05-14T22:00:00Z" }

GET / PATCH /api/v1/webhooks/{id}

Single-subscription detail and partial update. Scopes events:read and events:write. Use PATCH to pause without losing the secret ({"active": false}), move the receiver to a new URL, or swap the event set. Secret rotation still requires delete + recreate.

# Pause without delete
curl -sS -X PATCH https://dirtfleet.app/api/v1/webhooks/clx7sub… \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{"active":false}'

# Move to a new receiver, keep same secret
curl -sS -X PATCH https://dirtfleet.app/api/v1/webhooks/clx7sub… \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://new-receiver.example.com/hook"}'

GET /api/v1/webhooks/{id}/deliveries

Newest-first delivery history for a subscription. Required scope: events:read. Default 50 rows, max 200. Filter by status (PENDING,DELIVERED,FAILED,ABANDONED) to drill into failures. Response body is truncated to 500 chars per row to keep the listing snappy. Test deliveries from /test are not in here.

# Show me what failed
curl -sS \
  "https://dirtfleet.app/api/v1/webhooks/clx7sub…/deliveries?status=FAILED,ABANDONED" \
  -H "Authorization: Bearer dfk_…"

POST /api/v1/webhooks/{id}/test

Required scope: events:write. Fires a synthetic flag.created envelope at the subscription URL, signed with the live secret + an X-Fleetgo-Test: 1 header. Returns the receiver's status + the first 500 bytes of its response body so you can debug end-to-end without waiting for a real event. Bypasses the regular delivery queue and isn't recorded in WebhookDelivery history.

curl -sS -X POST \
  https://dirtfleet.app/api/v1/webhooks/clx7sub…/test \
  -H "Authorization: Bearer dfk_…"

# Response
{
  "ok": true,
  "delivered": true,
  "responseStatus": 200,
  "responseBody": "OK",
  "networkError": null,
  "sentAt": "2026-05-14T22:00:01Z",
  "signaturePreview": "v1=abc123def456…"
}

POST /api/v1/repairs

Required scope: workorders:write. Logs a completed repair. At least one of assetId or workOrderId is required so the row attaches to something traceable. When workOrderId is set, the underlying lib path also marks that WO as DONE (in-app parity). Cost-anomaly check fires async when assetId + positive cost are both set.

curl -sS -X POST https://dirtfleet.app/api/v1/repairs \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "assetId":"clx7asset…",
    "workOrderId":"clx7wo…",
    "laborHours": 4,
    "cost": 480,
    "parts":"hyd line 1/2in, brass fittings",
    "notes":"Replaced leaking line on stick boom."
  }'

# Response
{ "ok": true, "repairId": "clx7repair…" }

GET /api/v1/repairs

Read-only repair history. Required scope: workorders:read (same scope — repairs are the completion-side of work orders, not a separate domain). Filter by assetId, projectId, workOrderId, or since (ISO timestamp). Cursor-paginated, newest first.

# This year's repairs on one excavator
GET /api/v1/repairs?assetId=clx7asset…&since=2026-01-01T00:00:00Z
Authorization: Bearer dfk_…

GET / POST / DELETE /api/v1/webhooks

Manage subscriptions programmatically. Scopes: events:read (list) and events:write (create + delete). The signing secret is returned ONCE on POST — store it immediately, you can't fetch it back. Rotate by delete + recreate.

# Subscribe a receiver to flag events
curl -sS -X POST https://dirtfleet.app/api/v1/webhooks \
  -H "Authorization: Bearer dfk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url":"https://your-app.example.com/webhooks/dirtfleet",
    "events":["flag.created","flag.resolved","workorder.completed"],
    "description":"Zapier prod"
  }'

# Response (store the secret NOW)
{
  "ok": true,
  "subscriptionId": "clx7sub…",
  "secret": "whsec_abcdef…",
  "secretWarning": "Store this secret now — it is never returned again.",
  "events": ["flag.created","flag.resolved","workorder.completed"],
  "createdAt": "2026-05-14T22:00:00Z"
}

# Later: delete
curl -sS -X DELETE https://dirtfleet.app/api/v1/webhooks/clx7sub… \
  -H "Authorization: Bearer dfk_…"

Outbound webhooks

The complete webhook surface — every event type and payload shape — has its own page at /docs/api/webhooks, with an AsyncAPI 2.6 spec at /asyncapi.yaml for code generation. The short version below.

Org admins create webhook subscriptions at /settings → Webhooks. Each subscription is an HTTPS URL plus a CSV of event types. We POST JSON with an HMAC-SHA256 signature header on every event match.

Available event types

  • flag.created — driver or anomaly raises a flag
  • flag.resolved — flag cleared by a user
  • hours.logged — new HoursLog row
  • asset.created
  • asset.updated
  • workorder.created
  • workorder.completed
  • tool.failure — tool reported BROKEN

Signature verification

// Headers DirtFleet sends
X-Fleetgo-Signature: v1=<hex MAC>
X-Fleetgo-Timestamp: <unix seconds>
X-Fleetgo-Event: flag.created

// Verify
const expected =
  "v1=" +
  hmacSha256(secret, `${timestamp}.${rawBody}`).toString("hex");
const ok = constantTimeEqual(expected, receivedHeader);
if (!ok || Math.abs(now - timestamp) > 300) reject();

The 300-second tolerance window guards against replay attacks. Rotate the subscription secret from /settings if you suspect compromise.

Retry behavior

Failed deliveries (network error or non-2xx response) requeue with exponential backoff: 30s → 2m → 8m → 30m → 2h → 8h, then ABANDONED. Inspect delivery history in the settings UI; the response status + body (truncated) is preserved for ~30 days.

Rate limits

Per-key default is 60 requests / minute, bucketed by API key id. Webhook endpoints dispatch up to 50 deliveries per cron tick (every minute). Higher caps negotiated commercially on Professional + Enterprise plans — email hello@dirtfleet.app.

Every response (success and 429) includes:

  • X-RateLimit-Limit — your per-window cap.
  • X-RateLimit-Remaining — calls left in the current window.
  • X-RateLimit-Reset — unix epoch seconds when the window resets.

When the cap is exceeded you get 429 with a body of { ok: false, error: "rate_limited", retryAfterSec: N } and a Retry-After header. Back off until the reset, don't hammer.

CORS + browser-direct usage

Every /api/v1/* route accepts cross-origin requests. The Bearer-token auth model means there's no cookie / CSRF surface to protect, so we allow:

  • Access-Control-Allow-Origin: * — any origin.
  • Access-Control-Allow-Methods: GET, POST, PATCH, OPTIONS.
  • Access-Control-Allow-Headers: Authorization, Content-Type, Idempotency-Key, and the X-Fleetgo-* webhook headers.
  • Access-Control-Expose-Headers: X-RateLimit-* and Retry-After, so browser code can read them.
  • Access-Control-Max-Age: 86400 — preflight is cached for a day.

That said, putting an API key in browser code generally means shipping a key with the scopes your worst customer would use. Server-side proxy is almost always the better pattern; we enable browser-direct because the alternative (an obscure CORS rejection in DevTools) is worse than the security trade-off.

Versioning

The current major is v1. Breaking changes go to a new major (/api/v2/...) with a deprecation window of at least 6 months. Additive changes (new optional fields, new endpoints) ship without a major bump.

← Back to docs