Tutorial

Send Australian Emergency Alerts to Slack

Post live bushfire, flood and storm incidents into a Slack channel using an incoming webhook and a scheduled script. The whole thing is about thirty lines. Most of this guide is about the two parts that are easy to get wrong: not posting the same incident twice, and choosing what is worth interrupting people for.

Time
~20 minutes
Requirements
a free DataQuoll key, a Slack workspace you can add an app to, and somewhere to run a script on a schedule

Why polling, and not a push subscription

There is no webhook to subscribe to. DataQuoll aggregates feeds published by state agencies, and those agencies publish by making a document available, not by calling you when something changes. We poll them, you poll us. A five minute schedule is well inside the free tier and is faster than most of the upstream feeds update anyway.

That shapes the design. Because you are asking what is active right now rather than being told what just changed, every run sees the same long-running fire again. Deduplication is not an optimisation here, it is the difference between a useful channel and one everybody mutes.

Step 1: Create a Slack incoming webhook

In Slack, go to api.slack.com/apps, create an app in your workspace, turn on Incoming Webhooks, and add a webhook to the channel you want. You get a URL ending in a long secret path.

Step 2: Get an API key

Sign up for a free DataQuoll account and copy your key from the dashboard. The free tier is 5,000 calls a month. Polling every five minutes is about 8,900 a month, which is over that, so either poll every ten minutes (roughly 4,400) or move to a paid plan. This script runs server side, so unlike a browser map the key is never exposed and you do not need to restrict it by origin.

Step 3: Decide what is worth an alert

This is the part worth thinking about before you write any code. A channel that posts every incident in Australia will carry a few hundred messages a day, most of them routine. People will mute it, and then it is worse than nothing, because now there is a channel everyone believes is covering them and nobody is reading.

Three filters do most of the work, and they compose:

  • warningLevel=emergency_warning,watch_and_act drops advice and routine activity. These two levels are the ones that mean act now or prepare to act.
  • state=nsw narrows to where your people actually are.
  • eventType=bushfire,flood narrows to what you care about. The full list is on the API reference.

If you need everything in a region rather than everything at a warning level, drop the level filter and use the nearby endpoint with a radius instead.

Step 4: The script

Node, no dependencies. It keeps a set of incident ids it has already posted, on disk, so a restart does not replay the channel.

alerts.mjs
import { readFileSync, writeFileSync } from 'node:fs';

const API_KEY = process.env.DATAQUOLL_KEY;
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL;
const SEEN_FILE = './seen-incidents.json';

// Which incidents are worth interrupting people for. See step 3.
const QUERY = new URLSearchParams({
  state: 'nsw',
  warningLevel: 'emergency_warning,watch_and_act',
  limit: '100',
});

// ON DISK, not in memory. A process restart with an in-memory set replays every
// active incident into the channel, which is how these integrations get muted.
function loadSeen() {
  try {
    return new Set(JSON.parse(readFileSync(SEEN_FILE, 'utf8')));
  } catch {
    return new Set();   // first run, or the file was removed
  }
}

async function run() {
  const seen = loadSeen();

  const res = await fetch(`https://dataquoll.io/api/v1/incidents?${QUERY}`, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });

  // FAIL LOUD. Swallowing this leaves a silent channel that looks like a quiet
  // day, which is the worst failure an alerting integration can have.
  if (!res.ok) {
    throw new Error(`DataQuoll returned ${res.status}: ${await res.text()}`);
  }

  const { features } = await res.json();

  for (const f of features) {
    if (seen.has(f.id)) continue;             // already posted, skip
    await post(f);
    seen.add(f.id);
  }

  // Only ids still present upstream are kept, so the file cannot grow forever.
  // An incident that ends and later returns is a new alert, which is correct.
  const live = new Set(features.map((f) => f.id));
  writeFileSync(SEEN_FILE, JSON.stringify([...seen].filter((id) => live.has(id))));
}

async function post(feature) {
  const p = feature.properties;
  const place = p.location?.suburb ?? p.location?.address ?? p.source_state.toUpperCase();

  await fetch(SLACK_WEBHOOK, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `${p.warningLevel?.replace(/_/g, ' ')}: ${p.title} at ${place}`,
      blocks: [
        {
          type: 'section',
          text: {
            type: 'mrkdwn',
            text:
              `*${p.title}*\n` +
              `${place} \u00b7 ${p.source_agency} ${p.source_state.toUpperCase()}\n` +
              `Warning level: ${p.warningLevel ?? 'none'} \u00b7 Status: ${p.status}\n` +
              `<https://dataquoll.io/i/${encodeURIComponent(feature.id)}|Full detail>`,
          },
        },
      ],
    }),
  });
}

run().catch((err) => {
  console.error('[slack-alerts]', err);
  process.exit(1);   // a non-zero exit is what makes your scheduler tell you
});

Step 5: Run it on a schedule

Anything that runs a command on a timer will do. A cron entry for every ten minutes:

crontab
*/10 * * * * cd /opt/slack-alerts && \
  DATAQUOLL_KEY=... SLACK_WEBHOOK_URL=... /usr/bin/node alerts.mjs >> alerts.log 2>&1

Put the two secrets in an environment file that only the running user can read rather than inline as above, which is written out here only to show which variables the script needs.

The failure worth guarding against

An alerting integration fails silently by default. If the script starts erroring, the channel simply goes quiet, and a quiet channel looks exactly like a quiet day. Nobody notices until the day it matters.

Two cheap guards. The process.exit(1) above means your scheduler knows the run failed, so wire it to something that tells you. And post a short heartbeat into a separate low-traffic channel once a day, so you are looking at evidence the thing still runs rather than assuming it.

The same reasoning applies upstream. A state feed can stop publishing while still answering requests, so a quiet API is not proof of a quiet day either. Feed health per state is on the status page and in the /api/v1/status response.

Where to go next