Raw HTTP
For Go, Rust, Java, C#, PHP, Ruby — and any other language. The heartbeat protocol by hand.
When there's no official SDK for your language, you write the heartbeat loop yourself. It's one HTTP POST every N seconds. The dashboard's Heartbeat tab generates a ready-to-paste snippet for JavaScript, Python, curl, Go, Rust, C# / .NET, Java, and Ruby — copy from there if you can. This page documents the wire protocol so you can write it in anything else.
Endpoint
POST https://cloudline.kescohhtwitch.workers.dev/api/bots/{botId}/heartbeatHeaders:
Authorization: Bearer <your clb_live_… secret>
Content-Type: application/jsonBody: JSON. Every field is optional — send what you can measure, leave the rest as null or omit it entirely.
Body fields
| Field | Type | Meaning |
|---|---|---|
latency_ms | number | Discord gateway ping in ms. |
memory_mb | number | Process RAM in MB (resident set size). |
cpu_pct | number | Process CPU % (one core = 100). |
uptime_sec | number | Process uptime in seconds. |
guilds | number | Server (guild) count. |
event_loop_lag_ms | number | Event-loop / async-runtime lag in ms. SDK-only on most runtimes; skip for raw fetch. |
slash_p50_ms / slash_p95_ms / slash_count | number | Slash command timing percentiles + count since last beat. |
component_p50_ms / component_p95_ms / component_count | number | Same, for buttons + select menus. |
autocomplete_p50_ms / autocomplete_p95_ms / autocomplete_count | number | Same, for slash-command autocomplete. |
shards_total / shards_connected | number | For sharded bots. Leave null on single-process bots. |
gateway_ok | boolean | true when your gateway connection is healthy. Drives zombie detection. |
gateway_stale_sec | number | How many seconds the gateway has been unhealthy. |
shard_detail | array | Per-shard health: [{ id: 0, ok: true, ping: 47 }, ...]. Sharded bots only. |
discord_rate_limit_hits | number | How many 429 responses Discord returned since last beat. |
custom_metrics | object | Your own gauges + counters. See Custom metrics. |
seq | number | Monotonic counter that increments each beat. Lets the server detect restarts (seq drops to 1). |
sent_at | number | Send timestamp in milliseconds since epoch. The server uses this with its own receive time to compute delivery delay, clock-skew-tolerant. |
All numeric fields are server-side range-clamped — out-of-range values become null rather than failing the request.
Minimal payload
The smallest meaningful body is just seq + sent_at:
{
"seq": 1,
"sent_at": 1735862400000
}That registers the bot as online but leaves every metric blank. Add fields as you can measure them.
Typical payload (single-process bot)
{
"latency_ms": 42,
"memory_mb": 180,
"uptime_sec": 3600,
"guilds": 127,
"seq": 1,
"sent_at": 1735862400000
}Responses
200 OK— beat accepted. Body is empty.401 Unauthorized— bad or missing secret. Do not retry, fix the credential.429 Too Many Requests— you're heartbeating too fast or hit a rate limit on the public ingestion endpoint. Back off and try again.5xx— server-side problem. Retry with backoff (we recommend 250 ms → 500 ms → 1 s, max 2 retries).
Heartbeat loop pattern (pseudocode)
on bot start:
seq = 0
start_time = now
every N seconds:
seq += 1
payload = {
"latency_ms": measure_gateway_ping(),
"memory_mb": measure_rss_mb(),
"uptime_sec": seconds_since(start_time),
"guilds": count_guilds(),
"seq": seq,
"sent_at": now_in_milliseconds(),
}
for attempt in 1..3:
try:
response = POST endpoint with Bearer secret, body=json(payload)
if response.status == 200: break
if response.status == 401: log("bad secret"); break
if response.status in (429, 5xx) and attempt < 3:
sleep(250ms * 2^(attempt-1)); continue
log("HTTP " + response.status); break
catch network_error:
if attempt < 3: sleep(250ms * 2^(attempt-1)); continue
log("network error: " + error)Things to get right
A few details that hurt if you skip them:
- Use a per-request timeout. Without one, a hung TCP connection blocks your bot for minutes. We recommend 10 seconds.
- Don't run two heartbeats in parallel. If one beat hasn't finished by the time the next interval fires, skip the new one instead of stacking. Otherwise
seqcan arrive out of order at the server. seqstarts at 0 each process start. That's the feature — when the server seesseqdrop back to 1, it knows the bot restarted.- Re-stamp
sent_aton each retry. Don't reuse the original. The server usesreceived_at − sent_atto compute delivery delay, and you want that to reflect the actual wire moment. - Don't retry on 401. It will never succeed; you'll burn rate-limit quota for nothing.
Discord ping mapping
| Library | Where to get latency in ms |
|---|---|
| discord.js | client.ws.ping (returns -1 before the first ack — send null) |
| discord.py | bot.latency * 1000 (returns NaN before the first ack — send null) |
| DiscordGo (Go) | session.HeartbeatLatency().Milliseconds() |
| serenity (Rust) | shard.latency() |
| Discord.Net (C#) | client.Latency |
| JDA (Java) | jda.getGatewayPing() |
| discordrb (Ruby) | bot.heartbeat_latency * 1000 |
Error reporting
Errors go to a separate endpoint:
POST /api/bots/{botId}/errors
Authorization: Bearer <secret>
Content-Type: application/json
{
"message": "TypeError: cannot read property 'foo' of undefined",
"stack": "<full stack trace, optional>",
"level": "error",
"context": { "userId": "123" }
}The dashboard's Heartbeat tab also generates a ready-to-paste error reporter for the same 8 languages, complete with per-minute caps, message dedupe, and retry logic. If you write your own, mirror those guards or a crash loop will flood the endpoint.