1. Pull assets
GET /api/v1/assets
List the org's assets, cursor-paginated. Required scope: assets:read. Default page size is 50, max 200.
# Fetch the first page curl -sS "https://dirtfleet.app/api/v1/assets?limit=100" \ -H "Authorization: Bearer $DIRTFLEET_API_KEY" \ -H "Accept: application/json" \ | jq . # Walk the cursor curl -sS "https://dirtfleet.app/api/v1/assets?limit=100&cursor=$NEXT" \ -H "Authorization: Bearer $DIRTFLEET_API_KEY"
The $DIRTFLEET_API_KEY is the plaintext key shown once when an admin creates it at /settings → API keys. Keys begin with dfk_. Only the SHA-256 hash is stored server-side.
// Node 20+: pull every asset, paginate to exhaustion
import process from "node:process";
const API = "https://dirtfleet.app/api/v1";
const KEY = process.env.DIRTFLEET_API_KEY;
if (!KEY) throw new Error("set DIRTFLEET_API_KEY");
async function* assets() {
let cursor = "";
while (true) {
const url = `${API}/assets?limit=200${cursor ? `&cursor=${cursor}` : ""}`;
const r = await fetch(url, {
headers: { Authorization: `Bearer ${KEY}`, Accept: "application/json" },
});
if (!r.ok) throw new Error(`HTTP ${r.status} ${await r.text()}`);
const body = (await r.json()) as {
ok: true;
assets: Array<{ id: string; nickname: string; meterType: string }>;
nextCursor: string | null;
};
for (const a of body.assets) yield a;
if (!body.nextCursor) return;
cursor = body.nextCursor;
}
}
for await (const a of assets()) {
console.log(a.id, a.nickname, a.meterType);
}Errors you should handle
401with body{ ok: false, error: "missing_bearer" | "malformed_token" | "unknown_token" | "revoked" | "expired" }— fix the header or rotate the key.403with body{ ok: false, error: "missing_scope:assets:read" }— ask an admin to add the scope to the key.429withRetry-Afterheader — default cap is 60 req/min per key. Back off and retry.
2. Receive a webhook
Node.js + Express receiver
Webhooks POST a JSON body to your URL with three headers. You MUST verify the signature before trusting the payload — otherwise anyone who guesses your URL can forge an event.
X-Fleetgo-Signature: v1=<hex MAC over `${timestamp}.${rawBody}`>
X-Fleetgo-Timestamp: <unix seconds, when we signed>
X-Fleetgo-Event: <event type — e.g. flag.created>Critical: verify against the raw request body, not the parsed JSON. Re-serializing the parsed body changes whitespace and breaks the MAC. Use express.raw() on the route, not express.json().
// receiver.mjs — Node 20+, Express 5
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.WEBHOOK_SECRET; // whsec_… from /settings
if (!SECRET) throw new Error("set WEBHOOK_SECRET");
const TOLERANCE_SEC = 300; // 5-minute replay window
const app = express();
// IMPORTANT: raw body, not JSON-parsed. We need the exact bytes
// DirtFleet signed; re-stringified JSON has different whitespace.
app.post(
"/webhooks/dirtfleet",
express.raw({ type: "application/json", limit: "1mb" }),
(req, res) => {
const sig = req.header("x-fleetgo-signature") ?? "";
const ts = Number(req.header("x-fleetgo-timestamp") ?? 0);
const event = req.header("x-fleetgo-event") ?? "";
const raw = req.body as Buffer; // raw bytes
// 1. Clock-skew window
const now = Math.floor(Date.now() / 1000);
if (!Number.isFinite(ts) || Math.abs(now - ts) > TOLERANCE_SEC) {
return res.status(401).send("stale_or_missing_timestamp");
}
// 2. Recompute the expected signature
const expected =
"v1=" +
createHmac("sha256", SECRET).update(`${ts}.${raw.toString("utf8")}`).digest("hex");
// 3. Constant-time compare (defeats timing-oracle attacks)
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(sig, "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send("bad_signature");
}
// 4. Verified — parse + handle
const payload = JSON.parse(raw.toString("utf8")) as {
id: string; // delivery id — use to dedupe on retry
event: string; // matches X-Fleetgo-Event
organizationId: string;
sentAt: string;
data: Record<string, unknown>;
};
// 5. Respond fast (200 within 10s); do the real work async
res.status(200).end();
process.nextTick(() => handle(payload).catch(console.error));
},
);
async function handle(p: {
id: string; event: string; data: Record<string, unknown>;
}) {
// DirtFleet retries non-2xx with exponential backoff: 30s → 2m → 8m → 30m → 2h → 8h.
// Use `p.id` as the idempotency key in your DB so a retry doesn't double-fire.
if (p.event === "flag.created") {
// ... post to Slack, page the on-call mechanic, etc.
}
}
app.listen(3000);Idempotency: the one thing receivers get wrong
A failed delivery will be retried. If your receiver returns a 200 but crashes before persisting, DirtFleet thinks it landed and won't retry. If your receiver returns a 500 after side-effects, DirtFleet will retry and you'll double-fire. The fix is the same in both cases: persist delivery.id into a unique-indexed processed_webhooks table inside the same DB transaction as your side effect. Second attempt sees the row, returns 200, no-ops.
3. Sample webhook payloads
flag.created
{
"id": "clx7delivery_abc…",
"event": "flag.created",
"organizationId": "clx7org_xyz…",
"sentAt": "2026-05-14T18:42:31.001Z",
"data": {
"flagId": "clx7flag_…",
"assetId": "clx7asset_…",
"assetNickname": "Excavator 47",
"severity": "RED",
"reason": "Hydraulic leak — driver flagged from cab",
"photoUrl": "https://photos.dirtfleet.app/…",
"raisedBy": { "userId": "clx7user_…", "name": "Jordan T." },
"raisedAt": "2026-05-14T18:42:30.514Z"
}
}Same envelope shape for every event type. Here's a tool.created delivery — useful for mirroring new tools into an accounting or asset-register system the moment they're imported:
{
"id": "clx7delivery_def…",
"event": "tool.created",
"organizationId": "clx7org_xyz…",
"sentAt": "2026-05-14T18:55:12.301Z",
"data": {
"toolId": "clx7tool_…",
"name": "Milwaukee M18 Sawzall",
"category": "power tool",
"isConsumable": false,
"scanToken": "AbCdEf1234",
"purchaseCost": 249.99,
"actorId": "clx7user_…",
"at": "2026-05-14T18:55:12.301Z"
}
}Bulk imports emit one delivery per row — a 50-tool CSV import fires 50 of these. Use the id as your idempotency key (the same as flag.created and every other event). Full event catalog at /docs/api/webhooks.
4. Test locally before going live
Use the test-deliver button
The webhook subscription page at /settings → Webhooks → your subscription has a Send test delivery button. It POSTs a synthetic payload with a real signature so you can confirm your receiver accepts it without waiting for a real event. The delivery shows up in the history table with the response status + first 500 bytes of your response body — useful for diagnosing 4xx/5xx regressions without poking through nginx logs.
For local development, expose your laptop with ngrok http 3000 (or cloudflared tunnel), then point the subscription at https://your-tunnel.ngrok.app/webhooks/dirtfleet. Edit + re-test in seconds.
Next
Where to go from here
- → Full REST API reference — all endpoints, error codes, rate limits, versioning policy.
- → Integrations directory — what we built with this API ourselves.
- → Why integration-first — the design philosophy behind the API surface.
- → Support — open a ticket if an endpoint you need isn't live yet. We prioritize by integrator demand.