← Back to API docs · What Heartbeat is
Heartbeat for programmers
A heartbeat slot is a secret URL that your code hits regularly. Fresh Jots checks every switched-on
slot once a minute, reports after 2 silent minutes by
email (and a JSON webhook, if you set one), repeats every 9
minutes while the silence lasts, and writes a permanent DAY /
OFF / ON
record into the slot's note. Your code never writes to the note. It only pings.
GET or
POST, no headers, no body, no API token, and the
answer is an empty 200. An unknown token, a slot
that is switched off, or a trashed note all answer an empty 404,
so a typo shows up in your own logs. Slots come with the Personal, Dev and Team plans and the 14-day API
trial (1 / 25 /
25 per seat / 10).
1. Get the URL
- Open Settings → Heartbeats. Your account already holds a Heartbeats
folder with
heartbeat_log_1…heartbeat_log_N; each is one slot for one source. - Pick a slot, press Turn on. It is now armed: the two-minute countdown starts at once, so point a source at it right away (or turn it on after the cron line is in place).
- Copy the ping URL. It looks like
https://freshjots.com/hb/<token>. - Optional but worth it with many slots: give the slot a description (what it watches) and a short label (the VPS, client or service name). Both sit under the note's title, and the Heartbeats page and the Heartbeats folder filter on them.
Every example below keeps the URL in an environment variable so the token never lands in a repo:
export HEARTBEAT_URL="https://freshjots.com/hb/<token>"
Treat the URL like a password. If it leaks, press Rotate URL on the slot page: the old URL stops working immediately, the slot keeps running, and you update the source.
2. Shell, cron, systemd
The one-liner
-f makes a 404 a non-zero exit (so a rotated or
switched-off slot is visible in the caller's status), -sS
keeps it quiet except for real errors, and the timeouts stop a network hiccup from hanging the caller.
curl -fsS --connect-timeout 5 --max-time 10 "$HEARTBEAT_URL" >/dev/null
Cron: "this box is alive"
Once a minute is the natural cadence for a two-minute threshold. Put the URL in the crontab's own
environment or read it from a root-only file; either way it stays out of ps output.
HEARTBEAT_URL=https://freshjots.com/hb/<token>
* * * * * curl -fsS --max-time 10 "$HEARTBEAT_URL" >/dev/null 2>&1
Cron: "this job succeeded"
Chain the ping after the real work with &&: a failed
dump never pings, so the slot goes silent and you get the report. For a job that runs less often than every
two minutes, use the dead-man's switch
instead; Heartbeat is for sources that should be alive continuously.
* * * * * /usr/local/bin/sync-queue.sh && curl -fsS --max-time 10 "$HEARTBEAT_URL" >/dev/null
systemd timer
Two units: a oneshot service that pings, and a timer that fires it every minute. The URL lives in an
EnvironmentFile readable by root only.
# /etc/systemd/system/heartbeat.service
[Unit]
Description=Fresh Jots heartbeat ping
[Service]
Type=oneshot
EnvironmentFile=/etc/heartbeat.env
ExecStart=/usr/bin/curl -fsS --max-time 10 ${HEARTBEAT_URL}
# /etc/systemd/system/heartbeat.timer
[Unit]
Description=Ping the heartbeat slot every minute
[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=5s
[Install]
WantedBy=timers.target
sudo systemctl enable --now heartbeat.timer
Windows: PowerShell + Task Scheduler
Zero install. Register a task that runs the one-liner every minute; the URL comes from a machine-level environment variable set once as Administrator.
Invoke-WebRequest -Uri $env:HEARTBEAT_URL -TimeoutSec 10 | Out-Null
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -Command Invoke-WebRequest -Uri `$env:HEARTBEAT_URL -TimeoutSec 10 | Out-Null"
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 1)
Register-ScheduledTask -TaskName "FreshJotsHeartbeat" -Action $action -Trigger $trigger
3. From inside your application
Pinging from inside the process proves more than "the host is up": it proves the worker loop, the scheduler, or the request path is actually running. Two rules keep it honest: ping after the unit of work succeeded, never before, and never let the ping raise into the work it reports on.
Ruby / Rails
A tiny helper, called at the end of a Solid Queue / Sidekiq recurring job (every minute) or at the end of each worker loop iteration. Errors are swallowed on purpose.
# app/lib/heartbeat.rb
require "net/http"
module Heartbeat
URL = ENV["HEARTBEAT_URL"]
def self.ping
return if URL.blank?
uri = URI(URL)
Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
http.get(uri.path)
end
rescue StandardError => e
Rails.logger.warn("heartbeat ping failed: #{e.class}: #{e.message}")
end
end
# app/jobs/heartbeat_job.rb — schedule it every minute (config/recurring.yml)
class HeartbeatJob < ApplicationJob
def perform
Heartbeat.ping
end
end
Python
Standard library only, so it works in a bare cron-driven script or inside a long-running service.
import os, urllib.request, logging
HEARTBEAT_URL = os.environ.get("HEARTBEAT_URL")
def heartbeat():
if not HEARTBEAT_URL:
return
try:
urllib.request.urlopen(HEARTBEAT_URL, timeout=10).close()
except Exception as e: # never let monitoring break the work
logging.warning("heartbeat ping failed: %s", e)
# a worker loop
while True:
process_one_batch()
heartbeat()
Node.js
Built-in fetch (Node 18+). Here it runs on an
interval inside a server process, so the ping stops the moment the process dies or the event loop stalls.
const HEARTBEAT_URL = process.env.HEARTBEAT_URL;
async function heartbeat() {
if (!HEARTBEAT_URL) return;
try {
await fetch(HEARTBEAT_URL, { signal: AbortSignal.timeout(10_000) });
} catch (err) {
console.warn("heartbeat ping failed:", err.message);
}
}
setInterval(heartbeat, 60_000);
heartbeat();
Go
package main
import (
"log"
"net/http"
"os"
"time"
)
func heartbeat(client *http.Client) {
url := os.Getenv("HEARTBEAT_URL")
if url == "" {
return
}
resp, err := client.Get(url)
if err != nil {
log.Printf("heartbeat ping failed: %v", err)
return
}
resp.Body.Close()
}
func main() {
client := &http.Client{Timeout: 10 * time.Second}
for range time.Tick(time.Minute) {
heartbeat(client)
}
}
PHP
For a site with no long-running process, ping from a cron-driven script; the same line also works at the end of a queue worker's loop.
<?php
$url = getenv("HEARTBEAT_URL");
if ($url) {
$ctx = stream_context_create(["http" => ["timeout" => 10]]);
@file_get_contents($url, false, $ctx);
}
4. Containers, Kubernetes, CI
Docker Compose sidecar
A throwaway container that pings every minute for as long as the stack is up. If the host or the compose project goes down, so does the ping.
services:
heartbeat:
image: curlimages/curl:latest
restart: unless-stopped
environment:
HEARTBEAT_URL: ${HEARTBEAT_URL}
command: >
sh -c 'while true; do curl -fsS --max-time 10 "$$HEARTBEAT_URL" >/dev/null; sleep 60; done'
Kubernetes CronJob
Proves the cluster's scheduler and egress work every minute. The URL comes from a Secret.
apiVersion: batch/v1
kind: CronJob
metadata:
name: freshjots-heartbeat
spec:
schedule: "* * * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: ping
image: curlimages/curl:latest
args: ["-fsS", "--max-time", "10", "$(HEARTBEAT_URL)"]
env:
- name: HEARTBEAT_URL
valueFrom:
secretKeyRef:
name: freshjots-heartbeat
key: url
GitHub Actions: prove a scheduled workflow keeps running
GitHub silently disables scheduled workflows on inactive repositories. A slot set to expect this ping tells you when that happens. Because Actions schedules are at best every 5 minutes, pair it with a less chatty expectation: keep the slot for continuous sources, and watch a workflow like this with the dead-man's switch (26-hour deadline on a nightly job). Where a workflow runs every few minutes anyway, the ping is one step:
- name: Heartbeat
if: success()
run: curl -fsS --max-time 10 "$HEARTBEAT_URL"
env:
HEARTBEAT_URL: ${{ secrets.HEARTBEAT_URL }}
5. What you get back
The email
Subject [Fresh Jots] heartbeat_log_3 silent for 2 minutes,
sent to the slot's address (or your account email), with the last beat time and links to the record and the
slot page. Repeated every 9 minutes while the source stays
silent; the first ping afterwards clears it with no action on your side. Team slots go to the team's alert
destination instead.
The webhook
Set a URL on the slot page and every report is also POSTed as JSON, with
X-FreshJots-Event: heartbeat.silent and a unique
X-FreshJots-Delivery id you can use to de-duplicate:
{
"event": "heartbeat.silent",
"delivered_at": "2026-09-30T03:19:00Z",
"delivery_id": "0f3a6c2e-…",
"slot": 3,
"filename": "heartbeat_log_3",
"last_beat_at": "2026-09-30T03:16:41Z",
"silent_since": "2026-09-30T03:19:00Z",
"silent_for_minutes": 2,
"note_url": "https://freshjots.com/notes/…"
}
A minimal receiver that pages an on-call channel (Express shown; any framework works the same way):
app.post("/hooks/freshjots", express.json(), async (req, res) => {
if (req.get("X-FreshJots-Event") === "heartbeat.silent") {
const { filename, silent_for_minutes, note_url } = req.body;
await notifyOnCall(`${filename} silent for ${silent_for_minutes} min — ${note_url}`);
}
res.sendStatus(204);
});
The record in the note
Only the checker writes into a heartbeat note, and it writes three kinds of line, all UTC:
DAY|260930 # a day this slot was on (once per UTC day)
ON|260930-03-00-00 # the minute the source was first heard after switching on
OFF|260930-03-19-00 # the minute it was found silent
ON|260930-03-41-00 # the minute it was heard again
DAY|261001
Newest first by default (the note's own append direction). Because every line is an append to an append-only note, each one is hashed into the note's chain, timestamped, and folded into the nightly Bitcoin anchor like any other Fresh Jots log; the notes and their folder cannot be renamed, moved, unlocked or deleted. A year of a quiet server is a few hundred bytes, and the gaps are evidence.
Reading it back from a script is the same as any plain note (Dev and Team tiers, bearer token):
curl https://freshjots.com/api/v1/notes/by-filename/heartbeat_log_3 \
-H "Authorization: Bearer $FRESHJOTS_TOKEN"
6. Patterns and gotchas
- One slot per source. Two crons pinging the same slot hide each other's death. Personal has 1 slot, Dev 25, Team 25 per seat.
- Ping after success, not before.
work && curl …turns a crashing job into silence;curl … ; workhides it. - Faster than once a minute buys nothing. The check runs every minute and only the last beat matters; every second is fine, every ten seconds is fine, but the report cannot come sooner than two silent minutes plus up to one minute of check lag.
- Expect the first report within three minutes of turning a slot on if nothing pings it yet. Put the cron line in place first, or accept one email.
- A 404 means the slot is off, rotated, or the note is gone. With
curl -fthat is a non-zero exit in your own logs; nothing is recorded on the Fresh Jots side. - Rate limits. Per token 120 hits a minute, per IP 60 a minute; above that
the answer is
429. A one-per-second loop is inside the limit; a retry storm is not. - Keep the URL out of git and out of process lists. Environment files,
secrets managers, and
EnvironmentFile=all work; a leaked URL is fixed by Rotate URL on the slot page. - Continuous vs periodic. Heartbeat is for sources that should be alive all the time. A nightly backup or an hourly sync belongs on the dead-man's switch, which watches an append-only log with a deadline measured in hours.
- Trial expiry switches slots off. The notes stay; turn them back on once you subscribe. A paid plan that lapses keeps running slots until you switch them off.