batchwatch

batchwatch › Outage alerts

Outage alerts

Batchwatch watches every model it measures and opens an outage when the queue for one of them degrades against its own baseline. This page is how you get told about it.

Batchwatch watches every model it measures and opens an outage when the queue for one of them degrades against its own baseline. This page is how you get told about it.

Three routes, all keyed. A subscription belongs to the API key that created it: your key sees your subscriptions and nobody else's.

MethodPathWhat
POST/v1/subscriptionsSubscribe (webhook or Slack)
GET/v1/subscriptionsList your own
DELETE/v1/subscriptions/{id}Revoke one of your own

What triggers an alert

An outage is not a slow job. It is a sustained deviation confirmed by several independent contributors, held for a minimum duration before anything is sent:

The result is that you get few alerts and they mean something. A single slow batch will never page you.


POST /v1/subscriptions

curl -X POST https://batchwatch.dev/v1/subscriptions \
  -H "authorization: Bearer $BATCHWATCH_KEY" \
  -H 'content-type: application/json' \
  -d '{
        "channel": "webhook",
        "target": "https://example.com/hooks/batchwatch",
        "providers": ["openai", "anthropic"],
        "min_severity": "degraded"
      }'

Body

FieldTypeRequiredNotes
channelstringyeswebhook or slack.
targetstringyesMust be https. For slack, your incoming-webhook URL.
secretstringnoWebhook only. The shared HMAC secret. Omit it and we generate one.
providerslist or csvnoOnly alert on these. Omit for all. ["openai"] and "openai" are both accepted.
modelslist or csvnoOnly alert on these models. Omit for all.
min_severitystringnodegraded or severe. Omit for all severities.

Response — 201

{
  "id": 1,
  "channel": "webhook",
  "target": "https://example.com/hooks/batchwatch",
  "providers": "openai,anthropic",
  "models": null,
  "min_severity": "degraded",
  "created_at": 1787727392,
  "has_secret": true,
  "secret": "SwQ4nhynWnKPBF8v99Ou91Xs-i7MoIH9YjPEoN6vBG8",
  "secret_note": "stored encrypted; shown here once and never again"
}

secret appears once and never again. Copy it now. It is stored encrypted (AES-256-GCM under a key that is not in the database), so a dump of the database alone does not yield it — and neither can we read it back out to you later. If you lose it, delete the subscription and create a new one.

If you supplied your own secret, it is not echoed back. We do not return a secret you already have.

Other responses

StatusWhen
400A field failed validation. The body names the exact field.
401No API key.
409You already have a subscription with this channel + target.
503Server-side encryption is not configured, so we will not store a webhook secret at all. We would rather refuse than write it in the clear.

Self-monitoring alerts (operators)

batchwatch also watches itself. Three self-alerts today:

These self-alerts also email the operator directly. Whenever a self-alert fires and the deployment has RESEND_API_KEY configured, batchwatch emails it to the fixed operator address hello@batchwatch.dev (src/self_alert_email.js, #330) — keyed off configuration, not off a subscription row, so an operator is reached even when outage_sub is empty. This is the fix for the #330 "empty room": a self-alert that matched no subscriber used to reach no one.

To also receive these on a webhook or Slack channel, subscribe with the sentinel provider batchwatch in providers:

{ "channel": "webhook", "target": "https://ops.example/hook",
  "providers": "batchwatch" }

Only a subscription that explicitly names batchwatch gets them — a subscription that omits providers (all provider outages) does not, so a customer watching real-provider outages is never sent our internal plumbing alerts. (The operator email leg is separate from the subscription table entirely.) The body is a distinct batchwatch.self_alert.v1 schema (a type, a human message, and the measured detail — the newest-rollup age, or the route/status/reason), not the outage schema, so a receiver never mistakes "batchwatch is down" for "a provider had an outage".

Three types reach this schema, because the pipeline can fail three ways:

typeFired whenDetected from
rollup_stalledThe cron ran, but the rollup write path stalled (a hung combo, nothing written) — the newest rollup is ~20 min stale.Inside the cron (rollupTick), at ~20 min.
homepage_unreachableThe real public site (/, /pricing, /docs) returns a non-200, a body under its byte floor, or a 200 missing its content marker, for N consecutive probes (default 3, ~10–15 min).A synthetic probe of the public site (#307).
cron_stalledThe whole cron stopped — no tick has run, so no rollup has advanced past the dead-cron threshold (~30 min / 3 missed 10-min ticks).The request path, which keeps serving while the cron is dead.

The distinction matters: an in-cron watchdog is fail-silent when the whole cron stops, because the tick that would alert never runs either. cron_stalled closes that hole — it is measured on a sampled fraction of ordinary requests and on every /v1/status poll, both of which keep flowing when the cron is dead, so the alert fires even when the cron cannot fire it for itself. You receive at most one cron_stalled page per stall episode; it re-arms after the cron recovers.


GET /v1/subscriptions

{
  "subscriptions": [
    {
      "id": 1,
      "channel": "webhook",
      "target": "https://example.com/hooks/batchwatch",
      "providers": ["openai", "anthropic"],
      "models": null,
      "min_severity": "degraded",
      "created_at": 1787727392,
      "last_ok_at": 1787730000,
      "fail_streak": 0,
      "has_secret": true
    }
  ]
}

The secret is never in this response — has_secret tells you whether one exists, nothing more.

fail_streak is how many consecutive deliveries have failed, and last_ok_at is the last one that succeeded. A failing subscription is never disabled automatically. A channel that switches itself off in silence cannot be told apart from an alert that never had to fire, which is the one failure mode an alerting system must not have. Watch fail_streak instead.

Note that providers comes back as a list, and POST accepts a list — so fetch, edit, and send back works.


DELETE /v1/subscriptions/{id}

curl -X DELETE https://batchwatch.dev/v1/subscriptions/1 \
  -H "authorization: Bearer $BATCHWATCH_KEY"

200 with {"id": 1, "revoked": true} on success.

404 if the id is unknown or belongs to another key. The two are deliberately indistinguishable: otherwise one key could confirm the existence of another key's subscription by probing ids.


Receiving a webhook

POST to your target, content-type: application/json, with two headers:

HeaderValue
x-batchwatch-eventoutage_started or outage_ended
x-batchwatch-signaturesha256= + HMAC-SHA256 of the raw body using your secret

Verifying the signature

Compute the HMAC over the exact bytes you received, before any JSON parsing.

import hmac, hashlib

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, header, secret) {
  const expected = 'sha256=' + createHmac('sha256', secret)
    .update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(header || '');
  return a.length === b.length && timingSafeEqual(a, b);
}

Use a constant-time comparison (compare_digest / timingSafeEqual), not ==. A plain string compare leaks how much of the signature was correct.

If the signature header is missing

Treat the message as unverified and drop it. We send unsigned rather than wrongly signed if we cannot read your secret — a wrong signature would look like an attack at your end, which is worse than an obvious absence. The delivery is logged as signed: false on our side either way.

Slack subscriptions carry no signature. The webhook URL is the secret.


Delivery behaviour


Public alternatives, no key needed

If you would rather poll than subscribe:

Both are open, and neither is subject to the freshness delay that applies to the measurement routes. An outage feed that is fifteen minutes late is not a feed.