Practical guide

Statuspage & Instatus JSON API Guide: Monitor Vendor Outages & SLA Credits

Last reviewed: October 25, 2026

Check public status-page policies, read official JSON feeds, and preserve dated vendor incident observations for alerts and SLA credit reviews. Begin with the manual requests and local examples.

Public SaaS status JSON endpoints without an API key

These official public feeds worked in the supplied October 25, 2026 verification. Check /robots.txt on the exact host before requesting JSON. A working HTTP response alone does not establish crawl permission. Statuspage-compatible describes the response format; OpenAI and Linear expose compatible endpoints through incident.io status pages.

Host or formatPublic JSON routesRecorded policy and coverage
www.cloudflarestatus.com/api/v2/summary.json and /api/v2/incidents.jsonRobots 200 with Allow: /. Both feeds returned 200. Cloudflare documents the public API and recent 50-incident history window.
status.openai.com/api/v2/summary.json and /api/v2/incidents.jsonBoth returned 200. Robots 404: no published restrictions, not an explicit Allow directive.
linearstatus.com/api/v2/summary.json and /api/v2/incidents.jsonBoth returned 200. Robots 404: no published restrictions.
Instatus legacy, e.g. site.instatus.com/summary.jsonDocumented public summary without authentication; supported routes permitted by the recorded robots check. Summary-only coverage. No verified public legacy history route.
www.githubstatus.com, status.npmjs.org, status.atlassian.comDo not poll their /api/ paths under the recorded policy.Each publishes Disallow: /api/. Exclude these routes even if an availability probe returns JSON.

Treat unreadable robots, redirects, 429 and server failures as a reason to stop and review. For this status workflow only robots 404 or 410 means no published policy. Identify your client and preserve vendor attribution. Public feeds are useful for customer monitoring, but they are not a universal content resale licence. Vendor terms, publisher rights and rate limits still apply. Instatus terms section 3.3 contemplates viewing, caching and copying published public-page/API content subject to those conditions.

Statuspage JSON API with curl and jq

Read the robots response and headers first. The commands after the review comment are conditional on that policy permitting both paths; do not paste them into an unattended cron job without a policy gate. Query the summary for overall health, active incidents, maintenance windows and affected components, then recent incident history for lifecycle timestamps.

# Run in Bash. Review the policy before running the JSON requests below.
set -euo pipefail
curl --silent --show-error --max-time 20 --dump-header robots.headers \
  --output robots.txt 'https://www.cloudflarestatus.com/robots.txt'
cat robots.headers
cat robots.txt
# Proceed only after a usable policy permits both API paths.
# The supplied 2026-10-25 review recorded "Allow: /" on this host.
sleep 1
curl --fail --silent --show-error --max-time 20 -A 'StatusGuide/1.0' \
  'https://www.cloudflarestatus.com/api/v2/summary.json' |
jq '{indicator: .status.indicator,
     activeIncidents: [.incidents[]? | {id, name, status, impact, created_at,
                       updated_at, resolved_at, components: [.components[]?.name]}],
     scheduledMaintenance: [.scheduled_maintenances[]? | {id, name, status,
                            scheduled_for, scheduled_until, components: [.components[]?.name]}],
     affectedComponents: [.components[]? | select(.status != "operational")
                          | {id, name, status, updated_at}]}'
sleep 1
curl --fail --silent --show-error --max-time 20 -A 'StatusGuide/1.0' \
  'https://www.cloudflarestatus.com/api/v2/incidents.json' |
jq '[.incidents[]? | {id, name, status, impact, created_at, updated_at, resolved_at,
                      components: [.components[]?.name],
                      updates: [.incident_updates[]? | select(.public != false)
                                | {id, status, created_at, updated_at, display_at}]}]'

status.indicator is overall page health, not the lifecycle of any individual incident. A scheduled maintenance window is not necessarily a current outage. Preserve created_at, updated_at and nullable resolved_at; scheduled_for and scheduled_until are plans. An active notice's future end time must not become a resolution timestamp. History and summary can contain the same ID, so deduplicate by host and native incident ID.

Python 3 and Node.js 20: check robots and normalize summaries

These dependency-free examples perform one polling pass over the verified Cloudflare Statuspage summary and the documented Instatus legacy summary. Each fetches robots first, spaces the JSON request, projects lifecycle metadata and records an observation time. Schedule successive passes sequentially. Both are summary-only examples; add the permitted Statuspage history request shown above to retain recently resolved incidents.

The small robots gate deliberately overblocks: any matching Disallow in any group stops collection, even if a specific user-agent group or Allow exception would permit it. It supports ASCII path prefixes, wildcard * and terminal $, and refuses percent-encoded or non-ASCII rules for manual review. It does not implement full RFC group selection or Allow precedence. Use a tested full parser for those policies, and honor the applicable crawl delay. It never bypasses a matching restriction.

# Save as status_summary.py; run: python3 status_summary.py
import json
import re
import time
from datetime import datetime, timezone
from urllib.error import HTTPError
from urllib.request import Request, build_opener, HTTPRedirectHandler

class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None

opener = build_opener(NoRedirect())

def get_text(url, limit=4 * 1024 * 1024):
    request = Request(url, headers={"User-Agent": "StatusGuide/1.0",
                                   "Accept": "application/json, text/plain"})
    with opener.open(request, timeout=20) as response:
        body = response.read(limit + 1)
        kind = response.headers.get("Content-Type", "").lower()
    if len(body) > limit or "html" in kind or b"<html" in body.lower():
        raise ValueError("Oversized or HTML response")
    return body.decode("utf-8-sig")

def robots_delay(origin, path):
    try:
        text = get_text(origin + "/robots.txt", 512 * 1024)
    except HTTPError as error:
        if error.code in (404, 410):
            return 0.75  # No published policy, not an explicit Allow.
        raise
    delay = 0.75
    # Conservative subset: a matching Disallow in ANY group stops this demo.
    # It does not try to resolve named groups or Allow exceptions.
    for line in text.splitlines():
        key, sep, value = line.split("#", 1)[0].partition(":")
        key, value = key.strip().lower(), value.strip()
        if not sep:
            continue
        if key == "crawl-delay":
            delay = max(delay, float(value))
        if key == "disallow" and value:
            if not value.isascii() or "%" in value:
                raise ValueError("Review complex robots rules with a full parser")
            end = value.endswith("$")
            pattern = "^" + ".*".join(re.escape(x) for x in value.rstrip("$").split("*"))
            if re.search(pattern + ("$" if end else ""), path):
                raise ValueError("Robots restriction: no JSON request made")
    if not 0 <= delay <= 60:
        raise ValueError("Review crawl delay before scheduling")
    return delay

STATUSES = {x: x for x in ("investigating", "identified", "monitoring", "resolved",
                          "scheduled", "in_progress", "completed", "verifying", "postmortem")}
STATUSES.update({"notstartedyet": "scheduled", "inprogress": "in_progress"})
IMPACTS = {x: x for x in ("none", "minor", "major", "critical", "maintenance")}
IMPACTS.update({"operational": "none", "degradedperformance": "minor", "partialoutage": "major",
                "majoroutage": "critical", "undermaintenance": "maintenance"})

def stamp(value):
    try:
        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
        return parsed.astimezone(timezone.utc) if parsed.tzinfo else None
    except (AttributeError, TypeError, ValueError):
        return None

def normalize(raw, origin, observed_at):
    status = STATUSES.get(str(raw.get("status", "")).lower().replace("-", "_").replace(" ", "_"))
    if not status or not raw.get("id"):
        return None
    maintenance = status in ("scheduled", "in_progress", "verifying", "completed")
    created = stamp(raw.get("created_at") or raw.get("createdAt") or raw.get("started") or raw.get("start"))
    if not created:
        return None
    active = status not in ("resolved", "completed", "postmortem")
    resolved = None if active else stamp(raw.get("resolved_at") or raw.get("resolvedAt") or raw.get("ended") or raw.get("end"))
    updates = raw.get("incident_updates", raw.get("updates", [raw["lastUpdate"]] if raw.get("lastUpdate") else []))
    public = [x for x in updates if x.get("public") is not False and x.get("isPublic") is not False]
    dates = [stamp(raw.get("updated_at") or raw.get("updatedAt"))]
    dates += [stamp(x.get("updated_at") or x.get("updatedAt") or x.get("display_at")
                    or x.get("created_at") or x.get("createdAt")) for x in public]
    updated = max((x for x in dates if x), default=None)
    impact_key = re.sub(r"[ _-]", "", str(raw.get("impact") or raw.get("severity") or "none").lower())
    return {"id": origin + ":" + str(raw["id"]), "sourceUrl": origin,
            "status": status, "impact": "maintenance" if maintenance else IMPACTS.get(impact_key),
            "createdAt": created.isoformat(), "updatedAt": updated.isoformat() if updated else None,
            "resolvedAt": resolved.isoformat() if resolved else None,
            "durationMinutes": (resolved - created).total_seconds() / 60 if resolved and resolved >= created else None,
            "updatesCount": len(public), "observedAt": observed_at}

def main():
    targets = [("https://www.cloudflarestatus.com", "/api/v2/summary.json"),
               ("https://site.instatus.com", "/summary.json")]
    for origin, path in targets:
        try:
            delay = robots_delay(origin, path)  # Always before the JSON request.
            time.sleep(delay)
            summary = json.loads(get_text(origin + path))
            if not isinstance(summary.get("page"), dict) or not isinstance(summary["page"].get("name"), str):
                raise ValueError("Unexpected summary shape")
            observed = datetime.now(timezone.utc).isoformat()
            rows = {}
            for key in ("incidents", "activeIncidents", "scheduled_maintenances", "maintenances", "activeMaintenances"):
                for raw in summary.get(key, []):
                    row = normalize(raw, origin, observed)
                    if row and (row["id"] not in rows or (row["updatedAt"] or row["createdAt"]) >=
                                (rows[row["id"]]["updatedAt"] or rows[row["id"]]["createdAt"])):
                        rows[row["id"]] = row
            print(json.dumps({"sourceUrl": origin + path, "observedAt": observed,
                              "coverage": "summary_only", "incidents": list(rows.values())}))
        except Exception as error:
            # A failure is a separate observation, never an empty healthy snapshot.
            print(json.dumps({"sourceUrl": origin + path, "error": type(error).__name__}))

if __name__ == "__main__":
    main()
// Save as status-summary.mjs; run: node status-summary.mjs (Node.js 20).
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function getText(url, limit = 4 * 1024 * 1024) {
  const response = await fetch(url, {redirect: 'error', signal: AbortSignal.timeout(20000),
    headers: {'User-Agent': 'StatusGuide/1.0', Accept: 'application/json, text/plain'}});
  if (!response.ok) { await response.body?.cancel(); throw Object.assign(new Error('HTTP error'), {status: response.status}); }
  const chunks = []; let size = 0;
  for await (const chunk of response.body) {
    size += chunk.length;
    if (size > limit) throw new Error('Response too large');
    chunks.push(chunk);
  }
  const text = Buffer.concat(chunks).toString('utf8').replace(/^\uFEFF/, '');
  if ((response.headers.get('content-type') || '').includes('html') || /<html/i.test(text)) {
    throw new Error('HTML response');
  }
  return text;
}
async function robotsDelay(origin, path) {
  let text;
  try { text = await getText(origin + '/robots.txt', 512 * 1024); }
  catch (error) { if ([404, 410].includes(error.status)) return 750; throw error; }
  let delay = 750;
  // Conservative subset: matching Disallow in ANY group stops this demo.
  // Named groups and Allow exceptions need a full parser and manual review.
  for (const line of text.split(/\r?\n/)) {
    const match = line.split('#')[0].match(/^\s*([^:]+):\s*(.*?)\s*$/);
    if (!match) continue;
    const key = match[1].trim().toLowerCase(), value = match[2];
    if (key === 'crawl-delay') {
      if (!value || !Number.isFinite(Number(value))) throw new Error('Invalid crawl delay');
      delay = Math.max(delay, Number(value) * 1000);
    }
    if (key === 'disallow' && value) {
      if (/[^\x00-\x7f]|%/.test(value)) throw new Error('Review complex robots rule');
      const end = value.endsWith('$');
      const escape = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      const pattern = '^' + value.replace(/\$$/, '').split('*').map(escape).join('.*') + (end ? '$' : '');
      if (new RegExp(pattern).test(path)) throw new Error('Robots restriction; JSON not fetched');
    }
  }
  if (delay > 60000) throw new Error('Review crawl delay before scheduling');
  return delay;
}
const statuses = new Set(['investigating', 'identified', 'monitoring', 'resolved', 'scheduled',
  'in_progress', 'completed', 'verifying', 'postmortem']);
const impacts = {none: 'none', minor: 'minor', major: 'major', critical: 'critical', maintenance: 'maintenance',
  operational: 'none', degradedperformance: 'minor', partialoutage: 'major', majoroutage: 'critical', undermaintenance: 'maintenance'};
const stamp = value => typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : null;
function normalize(raw, origin, observedAt) {
  const key = String(raw.status || '').toLowerCase().replace(/[- ]/g, '_');
  const status = ({notstartedyet: 'scheduled', inprogress: 'in_progress'})[key] || key;
  if (!raw.id || !statuses.has(status)) return null;
  const maintenance = ['scheduled', 'in_progress', 'verifying', 'completed'].includes(status);
  const created = stamp(raw.created_at ?? raw.createdAt ?? raw.started ?? raw.start);
  if (created === null) return null;
  const active = !['resolved', 'completed', 'postmortem'].includes(status);
  const resolved = active ? null : stamp(raw.resolved_at ?? raw.resolvedAt ?? raw.ended ?? raw.end);
  const updates = (raw.incident_updates ?? raw.updates ?? (raw.lastUpdate ? [raw.lastUpdate] : []))
    .filter(x => x.public !== false && x.isPublic !== false);
  const dates = [stamp(raw.updated_at ?? raw.updatedAt), ...updates.map(x => stamp(x.updated_at ?? x.updatedAt
    ?? x.display_at ?? x.created_at ?? x.createdAt))].filter(x => x !== null);
  const updated = dates.length ? Math.max(...dates) : null;
  const impactKey = String(raw.impact ?? raw.severity ?? 'none').toLowerCase().replace(/[ _-]/g, '');
  return {id: origin + ':' + raw.id, sourceUrl: origin, status,
    impact: maintenance ? 'maintenance' : impacts[impactKey] ?? null,
    createdAt: new Date(created).toISOString(), updatedAt: updated === null ? null : new Date(updated).toISOString(),
    resolvedAt: resolved === null ? null : new Date(resolved).toISOString(),
    durationMinutes: resolved !== null && resolved >= created ? (resolved - created) / 60000 : null,
    updatesCount: updates.length, observedAt};
}
for (const [origin, path] of [['https://www.cloudflarestatus.com', '/api/v2/summary.json'],
                             ['https://site.instatus.com', '/summary.json']]) {
  try {
    await sleep(await robotsDelay(origin, path));
    const summary = JSON.parse(await getText(origin + path));
    if (typeof summary.page?.name !== 'string') throw new Error('Unexpected summary shape');
    const observedAt = new Date().toISOString(), rows = new Map();
    for (const key of ['incidents', 'activeIncidents', 'scheduled_maintenances', 'maintenances', 'activeMaintenances']) {
      for (const raw of summary[key] || []) {
        const row = normalize(raw, origin, observedAt);
        const previous = row && rows.get(row.id);
        if (row && (!previous || (row.updatedAt || row.createdAt) >= (previous.updatedAt || previous.createdAt))) rows.set(row.id, row);
      }
    }
    console.log(JSON.stringify({sourceUrl: origin + path, observedAt, coverage: 'summary_only', incidents: [...rows.values()]}));
  } catch (error) {
    console.log(JSON.stringify({sourceUrl: origin + path, error: error.name}));
  }
}

Normalize lifecycle values to investigating, identified, monitoring, resolved, scheduled, in_progress and completed; the examples also retain verifying and postmortem. Instatus NOTSTARTEDYET maps to scheduled and INPROGRESS to in_progress. Normalize impact to none, minor, major, critical or maintenance. Unknown lifecycle records are skipped; an unknown severity stays null for review.

durationMinutes is creation-to-resolution only, and stays null for active notices, missing dates or a negative interval. Legacy maintenance without creation uses scheduled start. lastUpdate may supply just one published update, so updatesCount is the number returned, not lifetime history. The examples omit titles, component prose, author objects and update text to avoid copying contact-bearing free text; use the curl projection locally for component context and review it before sharing.

Each script targets fixed public origins, refuses redirects and stops that source on failed or malformed responses. It emits a separate error record instead of a healthy empty snapshot. It does not archive state or deliver alerts. A service accepting arbitrary targets also needs destination validation and stricter schema validation. Instatus history and current v3-only routes are outside these examples.

Dated outage evidence, incident diffs and SLA credit review

Save normalized observations with observedAt, the exact queried endpoint, source host, native incident ID and attribution. Keep successful snapshots separate from collection errors. For a credit review, connect dated vendor notices to your own affected tenant, component, region, service telemetry and contract period. Published creation-to-resolution duration is not a contractual SLA uptime calculation: agreements can exclude maintenance, define different start times, or require a claim before a deadline.

Change typeComparison against the last successful observation
new_incidentHost plus incident ID has no acknowledged baseline. The Actor emits current matches on its first monitor run.
resolvedAn observed lifecycle enters resolved, postmortem or completed. A notice disappearing from a summary is not proof of recovery.
status_changedLifecycle or normalized impact changed without a higher-priority transition.
update_addedLatest published timestamp changed without a status/impact change. This is a timestamp signal; it does not prove that the endpoint exposed a new lifetime update.

Use one prioritized category per notice per pass. Deduplicate history and summary before comparing, prefer the newest returned version, and retain the old baseline when a source fails. A summary can drop a resolved incident, so poll documented recent history where permitted and archive observations over time. Never infer resolution from absence. Text-only edits with no timestamp change require a separate reviewed content comparison.

For unattended polling, serialize requests per host, use a one-minute cache as a starting point, and honor longer crawl delays and Retry-After. The Actor's default spacing is 750 ms; these are client settings, not a published vendor quota. Back off on 429 or 5xx and avoid overlapping schedules. Keep response/update text out of shared evidence until reviewed for personal information. No workflow here submits credit claims automatically.

The easy option: Vendor Status Page & Incident Monitor

Vendor Status Page & Incident Monitor combines recent public incident history, summary maintenance and unmatched degraded-component observations. It checks robots, normalizes timestamps, lifecycle and impact, preserves source attribution, and supports age, severity, keyword and active-only filters. Export JSON or CSV. Planned price: $2.00 per 1,000 incidents; each emitted row, including maintenance, component observations and changed versions, is one incident event. The run also has an Actor start event; confirm final Store pricing.

{
  "statusPages": [
    "https://www.cloudflarestatus.com",
    "https://status.openai.com",
    "https://linearstatus.com"
  ],
  "activeOnly": false,
  "minImpact": "all",
  "updatedWithinDays": 14,
  "includeUpdateText": false,
  "maxIncidentsPerPage": 15,
  "maxResults": 30,
  "monitorMode": true,
  "monitorKey": "vendor-watch",
  "requestDelayMs": 750
}

Reuse monitorKey and filters to emit a baseline followed by changed versions. Status or impact changes take precedence over timestamp updates, and firstSeenAt persists for acknowledged versions. A one-minute shared cache means data can be up to one minute old. Run one schedule at a time. Delivery reservations reduce repeat charging but an ambiguous write can strand a version, so delivery is not exactly once.

The Actor retains at most 50 recent candidates per vendor before filters, including current maintenance and component observations; it does no historical pagination. Instatus is legacy-summary-only with an explicit coverage warning. Completed maintenance missing from summary is not fetched from separate maintenance history routes. Component-only rows are observations, not vendor-authored incident notices; disappearance does not imply resolution.

Inspect RUN_SUMMARY for cache age, blocked or unavailable targets, coverage warnings and time limits. All-target failure can finish successfully with zero rows and upstream_unavailable. The Actor collects data; Apify schedules, webhooks and your chosen integrations deliver alerts or evidence archives. It does not send alerts or submit SLA credit claims itself. Automated text redaction is best effort, so review evidence before sharing.

Frequently asked questions

Do public Statuspage and Instatus feeds need an API key?

The reviewed public Statuspage-compatible summary and incident feeds and the documented Instatus legacy summary work without an API key. Check the exact host robots policy and vendor terms before polling.

Can I poll GitHub Status if its API returns JSON?

The recorded GitHub Status robots policy disallows /api/. The guide excludes that route even when an availability probe returns JSON. npm Status and Atlassian Status had the same recorded restriction.

Does incident duration establish entitlement to SLA credits?

No. Published creation-to-resolution time is not measured downtime or a contractual uptime calculation. Review your agreement, affected service telemetry, exclusions and submission deadline independently.

Does the Instatus example include incident history?

No. The verified legacy Instatus coverage is summary-only. The legacy history route has no verified public documentation, and notices absent from a later summary must not be assumed resolved.

Does the monitor send alerts or submit SLA claims?

No. It collects dated observations and changed incident versions. Configure delivery through your chosen integration and review any credit request against your own contract.

Sources

Official sources below were checked in the supplied October 25, 2026 actor documentation. This guide uses that recorded review; no new network checks were made while preparing it.

Tool behavior, pricing qualifications and limitations follow the supplied Actor README: Vendor Status Page & Incident Monitor documentation.