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.
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.
POST or HEAD (both work; HEAD is convenient for curl --head).204 No Content. Anything else = treat as failure on your side.# 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>
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"]
}' 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\",...}",
...
} 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.
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}`);
} 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}') T. Monitor's intervalSec + heartbeatGraceSec tells us when it should have arrived by.T + intervalSec + heartbeatGraceSec, the monitor flips to down.alertThreshold (default 1), an incident opens and the configured alert channels fire (email / webhook / Slack / Discord / Teams / Telegram).up, resolvedAt is set, recovery email goes out to status-page subscribers if that monitor is on a public page.intervalSec: how often you ping. One of 30, 60, 300, 900, 1800, 3600. Plan tier sets the floor.heartbeatGraceSec: slack window (default 60s). Set higher than your worst-case job duration so a slow run isn't a false miss.alertThreshold: how many consecutive misses before paging (default 1). Raise it if your job is flaky-but-recovers.sha256(token) for the lookup. A DB dump does not yield live tokens.Heartbeat flapping or alerts not firing? See the runbook — sections "Heartbeat monitor flapping" and "Alerts aren't reaching me".
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.