← Back to home

Docs · REST API · examples

Copy-paste integration.

Two snippets: a curl that pulls assets, and a Node.js webhook receiver with constant-time HMAC verification. Both ready to drop into your codebase.

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

  • 401 with body { ok: false, error: "missing_bearer" | "malformed_token" | "unknown_token" | "revoked" | "expired" } — fix the header or rotate the key.
  • 403 with body { ok: false, error: "missing_scope:assets:read" } — ask an admin to add the scope to the key.
  • 429 with Retry-After header — 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

← Back to the API reference