Pick a pattern
Effort vs. flexibility.
The simplest pattern is 10 minutes and no code. The richest is a real Slack app with slash commands. All three are valid; pick the one matching what you already have running.
Pattern 1 — Slack Incoming Webhook (simplest, 10 min)
Slack's per-channel Incoming Webhook is a single URL. Register that URL as a DirtFleet webhook subscription and you're done. Slack auto-renders the JSON if you shape it with their text/blocks contract.
When to pick this
Right when you want a single #flags or #ops channel to get every event. Zero infrastructure on your side.
Show the code
# 1. In Slack: Apps → "Incoming Webhooks" → new webhook for #flags # You get a URL like https://hooks.slack.com/services/T00.../B00.../... # # 2. In DirtFleet: /settings → Webhooks → New # URL: <the Slack hook URL> # Events: flag.created # Save (store the DirtFleet secret if you'll do step 3). # # 3. The problem: Slack's Incoming Webhook expects { "text": "..." }, # not DirtFleet's envelope shape. You have two options: # # (a) Use Zapier as a transformer (see /integrations/zapier). # (b) Run a tiny shim — see Pattern 2 below.Pattern 2 — Slack Block Kit via a tiny shim (most flexible)
Stand up a 30-line Node/Express receiver that converts DirtFleet's envelope into Slack Block Kit. Verify the signature on the way in. Best for shops that already have a deployed app to add a route to.
When to pick this
When the shop already has any deployed server and wants rich Slack messages (action buttons, links, color bars) instead of plain text.
Show the code
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!; const app = express(); app.post( "/dirtfleet-to-slack", express.raw({ type: "application/json", limit: "256kb" }), async (req, res) => { // Verify signature against the raw body 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 as Buffer).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); const slackBody = renderSlack(env); await fetch(SLACK_HOOK, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(slackBody), }); res.status(200).end(); }, ); function renderSlack(env: { event: string; data: Record<string, unknown> }) { if (env.event === "flag.created") { const d = env.data as { severity: string; assetNickname?: string; reason?: string }; return { text: `${d.severity} flag on ${d.assetNickname ?? "an asset"}`, blocks: [ { type: "section", text: { type: "mrkdwn", text: `*${d.severity}* flag on *${d.assetNickname}*\n>${d.reason ?? "—"}` } }, ], }; } return { text: `DirtFleet: ${env.event}` }; } app.listen(3000);Pattern 3 — Dedicated Slack App with slash commands
For shops that want bidirectional — receive flag events AND let mechanics resolve flags from Slack — install a real Slack App that hits the DirtFleet REST API both directions.
When to pick this
When the shop wants /flag resolve <id> as a Slack slash command, asset lookups via /asset CAT320, and similar two-way workflows.
Show the code
# Bidirectional, simplified # # A. Outbound (DirtFleet → Slack): same shim as Pattern 2. # # B. Inbound (Slack → DirtFleet): handle slash command POSTs from Slack # and call the DirtFleet REST API. # # Express handler for /flag-resolve <flag-id> <resolution note>: app.post("/slack/flag-resolve", express.urlencoded({ extended: true }), async (req, res) => { const text = (req.body.text ?? "").trim(); const [flagId, ...noteWords] = text.split(/\s+/); if (!flagId) return res.send("Usage: /flag-resolve <flag-id> <note>"); const note = noteWords.join(" "); const r = await fetch( `https://dirtfleet.app/api/v1/flags/${flagId}`, { method: "PATCH", headers: { Authorization: `Bearer ${process.env.DIRTFLEET_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ status: "RESOLVED", resolutionNote: note }), }, ); if (!r.ok) { const body = (await r.json().catch(() => ({}))) as { formError?: string }; return res.send(`Failed: ${body.formError ?? r.status}`); } res.send(`Resolved flag ${flagId}`); });
Don't want to build this yourself?
Use Zapier.
The Zapier path skips the shim entirely. /integrations/zapier has the field mappings for the "Red flag → Slack channel" pattern (Pattern 1 above) with no code required. Trade-off: Zapier adds a per-event cost and 30-90s latency for the relay; direct integration is free and near-instant.
Reference
The API surface you're hitting.
- Webhook payloads: /docs/api/webhooks (signing, replay window, retries) — AsyncAPI spec at /asyncapi.yaml.
- REST endpoints for slash-command handlers: /docs/api — OpenAPI at /openapi.yaml.
- Local testing:
POST /api/v1/webhooks/{id}/testfires a synthetic delivery with the live signature — pair with ngrok or cloudflared to develop locally.