← Back to home

Docs · API · Recipes

Complete scripts for common problems.

Each recipe below is a complete, runnable script — set the env vars, run it. No fragments, no missing imports. Pick the one that matches your problem; modify from there.

Recipe 1

Import 200 assets from a CSV

The most common one-shot migration: you have a spreadsheet of assets from Fleetio / Samsara / a custom system, and you want to seed them in DirtFleet. The bulk-import endpoint handles up to 50 per call, so we chunk.

#!/usr/bin/env node
// node 20+ — set DIRTFLEET_API_KEY in env, then: node import-assets.mjs fleet.csv
import { readFile } from "node:fs/promises";

const KEY = process.env.DIRTFLEET_API_KEY;
if (!KEY) throw new Error("Set DIRTFLEET_API_KEY");
const [, , file] = process.argv;
if (!file) throw new Error("Usage: node import-assets.mjs <fleet.csv>");

// CSV columns expected: nickname,assetClass,serial,vin,customerAssetNumber
const text = await readFile(file, "utf8");
const [header, ...lines] = text.trim().split(/\r?\n/);
const cols = header.split(",").map((c) => c.trim());
const rows = lines.map((line) => {
  const cells = line.split(",");
  return Object.fromEntries(cols.map((c, i) => [c, cells[i]?.trim() ?? ""]));
});

const CHUNK = 50;
let total = 0;
for (let i = 0; i < rows.length; i += CHUNK) {
  const batch = rows.slice(i, i + CHUNK).map((r) => ({
    nickname: r.nickname,
    assetClass: r.assetClass,
    serial: r.serial || null,
    vin: r.vin || null,
    customerAssetNumber: r.customerAssetNumber || null,
  }));

  const res = await fetch("https://dirtfleet.app/api/v1/assets/batch", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(batch),
  });
  const body = await res.json();
  if (!body.ok) {
    console.error("Batch failed:", body);
    break;
  }
  total += body.summary.succeeded;
  console.log(`Chunk ${i / CHUNK + 1}: ${body.summary.succeeded} ok, ${body.summary.failed} failed`);
  for (const r of body.results) {
    if (!r.ok) console.error(`  Row ${i + r.index}:`, r.fieldErrors ?? r.formError);
  }
}
console.log(`Done. Imported ${total} assets.`);

Recipe 2

Export last week's hours to a TSV

Accounting wants a copy of every meter reading for the prior week, dropped on a shared drive. Cursor pagination keeps the script working when the fleet grows past 100 logs.

#!/usr/bin/env node
// node 20+ — node export-hours-weekly.mjs > weekly-hours.tsv
const KEY = process.env.DIRTFLEET_API_KEY;
if (!KEY) throw new Error("Set DIRTFLEET_API_KEY");

const sinceDate = new Date();
sinceDate.setDate(sinceDate.getDate() - 7);
const since = sinceDate.toISOString();

let cursor = "";
let printed = 0;
console.log("loggedAt\tassetId\thoursReading\tdelta\tnote");

while (true) {
  const url = new URL("https://dirtfleet.app/api/v1/hours");
  url.searchParams.set("since", since);
  url.searchParams.set("limit", "200");
  if (cursor) url.searchParams.set("cursor", cursor);

  const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
  const body = await res.json();
  if (!body.ok) { console.error(body); process.exit(1); }
  for (const log of body.hoursLogs) {
    console.log([
      log.loggedAt, log.assetId, log.hoursReading ?? "", log.delta ?? "",
      (log.note ?? "").replace(/\t/g, " "),
    ].join("\t"));
    printed++;
  }
  if (!body.nextCursor) break;
  cursor = body.nextCursor;
}
console.error(`Exported ${printed} logs since ${since}`);

Recipe 3

Mirror open flags to a Postgres table

Nightly cron pulls the current open-flags set and upserts into your data warehouse for BI tooling. Webhooks would be push-based; this is the pull alternative for analysts who don't want a receiver process.

#!/usr/bin/env python3
# pip install httpx psycopg[binary]
import os, httpx, psycopg

KEY = os.environ["DIRTFLEET_API_KEY"]
PG = os.environ["DATABASE_URL"]

with psycopg.connect(PG, autocommit=True) as conn:
    with conn.cursor() as cur:
        cur.execute("""
            CREATE TABLE IF NOT EXISTS df_open_flags (
              id TEXT PRIMARY KEY,
              asset_id TEXT NOT NULL,
              severity TEXT NOT NULL,
              note TEXT,
              raised_by_id TEXT,
              created_at TIMESTAMPTZ NOT NULL,
              synced_at TIMESTAMPTZ NOT NULL DEFAULT now()
            )
        """)

        cursor = ""
        synced_ids = set()
        while True:
            params = {"limit": 200, "status": "OPEN"}
            if cursor: params["cursor"] = cursor
            r = httpx.get(
                "https://dirtfleet.app/api/v1/flags",
                params=params,
                headers={"Authorization": f"Bearer {KEY}"},
                timeout=30,
            )
            r.raise_for_status()
            body = r.json()
            for f in body["flags"]:
                cur.execute("""
                    INSERT INTO df_open_flags (id, asset_id, severity, note, raised_by_id, created_at)
                    VALUES (%s, %s, %s, %s, %s, %s)
                    ON CONFLICT (id) DO UPDATE
                      SET severity = EXCLUDED.severity,
                          note = EXCLUDED.note,
                          synced_at = now()
                """, (f["id"], f["assetId"], f["severity"], f["note"], f["raisedById"], f["createdAt"]))
                synced_ids.add(f["id"])
            if not body.get("nextCursor"): break
            cursor = body["nextCursor"]

        # Delete rows that are no longer OPEN in DirtFleet
        if synced_ids:
            cur.execute(
              "DELETE FROM df_open_flags WHERE id != ALL(%s)",
              (list(synced_ids),),
            )
print(f"Synced {len(synced_ids)} open flags")

Recipe 4

Slack alert on URGENT work-order creation

Receiver-side: when DirtFleet POSTs a workorder.created with priority URGENT, message the on-call Slack channel. Signature verification included.

#!/usr/bin/env node
// node 20+ — set DIRTFLEET_WEBHOOK_SECRET + SLACK_WEBHOOK_URL
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.DIRTFLEET_WEBHOOK_SECRET;
const SLACK_HOOK = process.env.SLACK_WEBHOOK_URL;
if (!SECRET || !SLACK_HOOK) throw new Error("Set both env vars");

const app = express();
app.post(
  "/dirtfleet",
  express.raw({ type: "application/json", limit: "256kb" }),
  async (req, res) => {
    const sig = req.header("x-fleetgo-signature") ?? "";
    const ts = Number(req.header("x-fleetgo-timestamp") ?? 0);
    if (Math.abs(Math.floor(Date.now() / 1000) - ts) > 300)
      return res.status(401).end();
    const raw = (req.body).toString("utf8");
    const expected =
      "v1=" + createHmac("sha256", SECRET).update(`${ts}.${raw}`).digest("hex");
    if (
      expected.length !== sig.length ||
      !timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
    )
      return res.status(401).end();

    const env = JSON.parse(raw);
    if (env.event === "workorder.created" && env.data?.priority === "URGENT") {
      await fetch(SLACK_HOOK, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          text: `🚨 URGENT WO #${env.data.number}: ${env.data.title}`,
        }),
      });
    }
    res.status(200).end();
  },
);
app.listen(3000, () => console.log("listening on :3000"));

Recipe 5

Daily fleet-snapshot email

One-call summary email each morning: open RED flags, work orders due today, assets whose registration expires within 30 days. Pulls from three endpoints and renders one email.

#!/usr/bin/env node
// node 20+ — set DIRTFLEET_API_KEY + RESEND_API_KEY (or substitute your mailer)
const KEY = process.env.DIRTFLEET_API_KEY;
const RESEND = process.env.RESEND_API_KEY;
const TO = process.env.DAILY_EMAIL_TO ?? "fleet-ops@example.com";
if (!KEY || !RESEND) throw new Error("Set DIRTFLEET_API_KEY + RESEND_API_KEY");

async function df(path, params = {}) {
  const url = new URL(`https://dirtfleet.app${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const r = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
  if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`);
  return r.json();
}

const today = new Date().toISOString().slice(0, 10);

const [flags, urgentWO] = await Promise.all([
  df("/api/v1/flags", { severity: "RED", limit: 100 }),
  df("/api/v1/work-orders", { priority: "URGENT,HIGH", limit: 100 }),
]);

const body = `
<h2>DirtFleet daily — ${today}</h2>
<h3>Open RED flags (${flags.flags.length})</h3>
<ul>
  ${flags.flags.map((f) =>
    `<li>${f.assetId} — ${(f.note ?? "").slice(0, 80)}</li>`,
  ).join("")}
</ul>
<h3>URGENT/HIGH work orders (${urgentWO.workOrders.length})</h3>
<ul>
  ${urgentWO.workOrders.map((w) =>
    `<li>WO#${w.number} — ${w.title} (${w.priority})</li>`,
  ).join("")}
</ul>
`;

await fetch("https://api.resend.com/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${RESEND}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "DirtFleet Daily <daily@your-domain.example.com>",
    to: TO,
    subject: `DirtFleet daily — ${flags.flags.length} red, ${urgentWO.workOrders.length} urgent`,
    html: body,
  }),
});
console.log("Sent.");

Recipe 6

Auto-reorder consumables on tool.low_stock

When a consumable tool crosses its reorder threshold, fire a POST to a supplier endpoint (Grainger / Fastenal / Amazon Business) with the SKU + quantity-to-order. Closes the loop from "welding rods just hit 3 of 10" to "new box ships in 24h" without anyone watching the dashboard. The receiver verifies the HMAC signature first (same pattern as the asset webhook receiver — see /docs/api/example).

// Express receiver — on tool.low_stock, POST to your supplier.
// Set DIRTFLEET_WEBHOOK_SECRET + SUPPLIER_URL in env.
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";

const app = express();
app.use(express.raw({ type: "application/json" }));

const SECRET = process.env.DIRTFLEET_WEBHOOK_SECRET;
const SUPPLIER = process.env.SUPPLIER_URL;
const REPLAY_WINDOW_MS = 5 * 60 * 1000;

app.post("/webhooks/dirtfleet", async (req, res) => {
  const sig = req.header("x-fleetgo-signature");
  const ts = Number(req.header("x-fleetgo-timestamp"));
  const event = req.header("x-fleetgo-event");
  if (!sig || !ts || !event) return res.status(400).send();
  if (Math.abs(Date.now() / 1000 - ts) * 1000 > REPLAY_WINDOW_MS) {
    return res.status(401).send("stale");
  }
  const expected =
    "v1=" +
    createHmac("sha256", SECRET).update(`${ts}.${req.body}`).digest("hex");
  if (!timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
    return res.status(401).send("bad sig");
  }
  if (event !== "tool.low_stock") return res.status(200).send("ignored");

  const { data } = JSON.parse(req.body.toString("utf8"));
  // Order back to 4× threshold so we don't ping-pong on the next use.
  const reorderTo = (data.thresholdUsed ?? 5) * 4;
  await fetch(SUPPLIER, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      tool_id: data.toolId,
      current_qty: data.newStockLevel,
      reorder_qty: reorderTo - data.newStockLevel,
    }),
  });
  res.status(200).send("ok");
});

app.listen(3000);

More

Where to go from here

Have a recipe you wish was here? Email support@dirtfleet.app — common asks get added to this page.