Developers

A free, read-only JSON API for the same data as the website, and the webhooks we send when one of your monitors goes down.

The API

Base address: https://api.netoutages.com/api/v1

  • Everything is a GET request and needs no key or account.
  • Answers are JSON, wrapped in data. Lists add links and meta for paging.
  • Answers are cacheable for 15 seconds (Cache-Control: public, max-age=15).
  • The limit is 300 requests a minute per address. Over it you get HTTP 429.
  • An unknown service is HTTP 404, a bad parameter is HTTP 422 with the reasons in errors.
  • Statuses are up, degraded, down and unknown. monitoring is limited when a site blocks our checks.
EndpointParametersReturns
GET /stats—Counts: services monitored, down, degraded, open incidents, and the time of the last check.
GET /categories—Every category with its number of services and how many have problems.
GET /servicescategory, status (up, degraded, down, unknown), q (name), problems (1), reported (1), per_page (1–100), pageA page of services, problems first. Each has its status, last check, response time and, here, the reports of the last hour.
GET /services/{slug}—One service with its latest check, uptime (24 hours, 7 days, 30 days), open incident and recent reports.
GET /services/{slug}/historyrange (1h, 24h, 7d, 30d)Response times and failures in fixed time buckets, with empty buckets included.
GET /services/{slug}/reports—What users reported in the last 24 hours: volume, kinds of problem, countries and cities.
GET /incidentsstate (open, resolved, all), service (slug), per_page, pageOutages found by our checks or by users' reports (confirmed_by_probes and confirmed_by_reports say which), newest first.
GET /heatmap—Where users report problems right now, and the services reported most in the last hour.

Example

curl https://api.netoutages.com/api/v1/services/discord
{
  "data": {
    "slug": "discord",
    "name": "Discord",
    "status": "up",
    "monitoring": "active",
    "last_checked_at": "2026-10-25T14:02:11+00:00",
    "response_ms": 480,
    "uptime": { "24h": 100, "7d": 99.94, "30d": 99.9 },
    "open_incident": null
  }
}

Webhooks

When you add a webhook channel in your alert settings, we send a POST with a JSON body when a monitor of yours goes down and when it recovers. A test button sends the same request with the type test.

  • type is monitor.down, monitor.recovered or test.
  • Headers: X-NetOutages-Event and X-NetOutages-Delivery (the delivery id).
  • Answer with any 2xx status. We wait 10 seconds, do not follow redirects and try again twice (after 30 seconds and after 2 minutes) if the answer is not a success.
  • The address must be public: private networks are refused.
{
  "id": "evt_1042",
  "type": "monitor.down",
  "created_at": "2026-10-25T14:05:00+00:00",
  "monitor": { "id": 7, "name": "My shop", "url": "https://shop.example.com" },
  "incident": {
    "id": 31,
    "started_at": "2026-10-25T14:04:58+00:00",
    "resolved_at": null,
    "duration_seconds": 2,
    "error": "Timed out after 10s."
  },
  "message": "My shop is down. Our checks keep failing. Latest result: Timed out after 10s."
}

Check that a request is really from us

If you set a signing secret on the channel, every request carries X-NetOutages-Signature: sha256=<hex>, the HMAC-SHA256 of the exact request body with your secret. Compute it over the raw body, not over parsed JSON, and compare in constant time.

import { createHmac, timingSafeEqual } from "node:crypto";

// `rawBody` is the request body exactly as received, before any JSON parsing.
function isFromNetOutages(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader ?? "");

  return a.length === b.length && timingSafeEqual(a, b);
}
$expected = 'sha256='.hash_hmac('sha256', $request->getContent(), $secret);

if (! hash_equals($expected, $request->header('X-NetOutages-Signature', ''))) {
    abort(401);
}