24observe
checking… Sign in Start free
Docs · heartbeats

Heartbeat / cron monitors.

Inverted from the active checks: your job hits a URL on every successful cycle. If we don't see a hit within intervalSec + heartbeatGraceSec, an incident opens and your configured alert channels fire. The next successful hit auto-resolves the incident.

Receive URL

https://api.24observe.com/api/v1/heartbeats/<TOKEN>

Token is a 64-character lowercase hex string (32 random bytes). We generate it server-side when you create a heartbeat monitor — you never choose it.

Calling it

# POST
curl -fsS -X POST https://api.24observe.com/api/v1/heartbeats/<TOKEN>

# HEAD (no body either way — both return 204)
curl -fsS --head https://api.24observe.com/api/v1/heartbeats/<TOKEN>

Two-step token retrieval (programmatic flow)

Heartbeat tokens are encrypted at rest. POST /api/v1/monitors with type: "heartbeat" creates the monitor and returns the row, but the heartbeatToken field in that response is the encrypted ciphertext envelope — not what you POST to. To get the plaintext token your job will use, follow up with the role-gated secrets read.

This requires an owner-or-admin PAT with the secrets:read scope (or wildcard *). If you mint a tightly-scoped PAT for this flow:

curl -X POST https://api.24observe.com/api/v1/me/tokens \
  -H 'Authorization: Bearer obs_<admin>' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "aeoniti-heartbeat-bootstrap",
    "scopes": ["monitors:write", "secrets:read"]
  }'

Step 1 — create the monitor

curl -X POST https://api.24observe.com/api/v1/monitors \
  -H 'Authorization: Bearer obs_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Aeoniti nightly job",
    "url": "https://aeoniti.example/heartbeat-source",
    "type": "heartbeat",
    "intervalSec": 300,
    "timeoutMs": 10000,
    "heartbeatGraceSec": 60,
    "alertEmail": "[email protected]"
  }'

# Response (201) — note the encrypted heartbeatToken; do not POST to it.
{
  "id": 17,
  "name": "Aeoniti nightly job",
  "type": "heartbeat",
  "intervalSec": 300,
  "heartbeatToken": "{\"v\":2,\"kid\":\"default\",...}",
  ...
}

Step 2 — fetch the plaintext token

curl https://api.24observe.com/api/v1/monitors/17/secrets \
  -H 'Authorization: Bearer obs_<owner-or-admin>'

# Response (200)
{
  "id": 17,
  "heartbeatToken": "a1b2c3...<64 hex chars>",
  ...
}

# Build the receive URL
HEARTBEAT_URL="https://api.24observe.com/api/v1/heartbeats/a1b2c3..."

Every /secrets read is recorded in the audit log with action: READ_MONITOR_SECRETS — useful when you want to prove which integrations have ever fetched the token.

End-to-end agent example

TypeScript (Node 22)

const PAT = process.env.OBSERVE24_PAT!; // owner-or-admin
const BASE = 'https://api.24observe.com/api/v1';

async function provisionHeartbeat(name: string, intervalSec: 60 | 300 | 900) {
  // 1. Create the monitor
  const create = await fetch(`${BASE}/monitors`, {
    method: 'POST',
    headers: { authorization: `Bearer ${PAT}`, 'content-type': 'application/json' },
    body: JSON.stringify({
      name,
      url: `internal://${name}`, // url is required by schema; not used for heartbeats
      type: 'heartbeat',
      intervalSec,
      timeoutMs: 10000,
      heartbeatGraceSec: 60,
      alertEmail: process.env.OPS_EMAIL,
    }),
  });
  if (!create.ok) throw new Error(`create: ${create.status} ${await create.text()}`);
  const monitor = await create.json();

  // 2. Fetch the plaintext heartbeat token
  const sec = await fetch(`${BASE}/monitors/${monitor.id}/secrets`, {
    headers: { authorization: `Bearer ${PAT}` },
  });
  if (!sec.ok) throw new Error(`secrets: ${sec.status}`);
  const { heartbeatToken } = await sec.json();

  return {
    monitorId: monitor.id,
    heartbeatUrl: `${BASE}/heartbeats/${heartbeatToken}`,
  };
}

// Use in your job loop
async function ping(url: string) {
  const r = await fetch(url, { method: 'POST' });
  if (r.status !== 204) throw new Error(`heartbeat: ${r.status}`);
}

Python

import os, httpx

PAT = os.environ['OBSERVE24_PAT']  # owner-or-admin
BASE = 'https://api.24observe.com/api/v1'
H = {'authorization': f'Bearer {PAT}', 'content-type': 'application/json'}

def provision_heartbeat(name: str, interval_sec: int) -> str:
    create = httpx.post(f'{BASE}/monitors', headers=H, json={
        'name': name,
        'url': f'internal://{name}',
        'type': 'heartbeat',
        'intervalSec': interval_sec,
        'timeoutMs': 10000,
        'heartbeatGraceSec': 60,
        'alertEmail': os.environ['OPS_EMAIL'],
    })
    create.raise_for_status()
    monitor_id = create.json()['id']

    sec = httpx.get(f'{BASE}/monitors/{monitor_id}/secrets',
                    headers={'authorization': f'Bearer {PAT}'})
    sec.raise_for_status()
    token = sec.json()['heartbeatToken']
    return f'{BASE}/heartbeats/{token}'

# In your job loop
def ping(url: str):
    r = httpx.post(url, timeout=10)
    if r.status_code != 204:
        raise RuntimeError(f'heartbeat: {r.status_code}')

What happens when pings stop

  1. Last heartbeat was at T. Monitor's intervalSec + heartbeatGraceSec tells us when it should have arrived by.
  2. The scheduler ticks every 15 seconds; on the first tick after T + intervalSec + heartbeatGraceSec, the monitor flips to down.
  3. Once consecutive missed checks ≥ alertThreshold (default 1), an incident opens and the configured alert channels fire (email / webhook / Slack / Discord / Teams / Telegram).
  4. The next successful POST automatically resolves the open incident — last_status flips back to up, resolvedAt is set, recovery email goes out to status-page subscribers if that monitor is on a public page.

Tuning

Security

Troubleshooting

Heartbeat flapping or alerts not firing? See the runbook — sections "Heartbeat monitor flapping" and "Alerts aren't reaching me".

Confirming the alert channel works

Don't wait for a real outage to find out your channel is misconfigured. Open the monitor in the dashboard and use Send test alert to all configured channels — each channel reports per-channel pass/fail with the exact error.