Skip to content

← 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.

Nothing to install, nothing to authenticate. The URL is the credential: 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 every plan, Free included, and with the 14-day API trial (Free 3 / Personal 10 / Dev 25 / Team 25 per seat / trial 10).

1. Get the URL

  1. Open Settings → Heartbeats. Your account already holds a Heartbeats folder with heartbeat_log_1 … heartbeat_log_N; each is one slot for one source.
  2. 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).
  3. Copy the ping URL. It looks like https://freshjots.com/hb/<token>.
  4. 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

For a home server, a Raspberry Pi, a laptop or a client's box with no public IP, see the full guide, machines nothing can reach from outside →.

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.

Full guides: your project's code → and background workers and job queues →.

Ruby / Rails

A module, a one-line job, and a Solid Queue recurring entry that runs it every minute. The job form is the one that also proves your workers are alive.

# app/lib/heartbeat.rb
require "net/http"

module Heartbeat
  def self.ping
    url = ENV["HEARTBEAT_URL"]
    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
class HeartbeatJob < ApplicationJob
  def perform = Heartbeat.ping
end

# config/recurring.yml
production:
  heartbeat:
    class: HeartbeatJob
    schedule: "* * * * *"

Python

Standard library only. Shown at the end of a worker loop; the same function goes at the end of a Celery task or a cron-driven script.

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)

while True:
    process_one_batch()
    heartbeat()

Node.js

Built-in fetch (Node 18+) on an interval inside a server process. The ping stops the moment the process dies or the event loop stalls on something synchronous.

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

A ticker in its own goroutine, or the call at the bottom of your worker's for loop.

package main

import (
	"io"
	"log"
	"net/http"
	"os"
	"time"
)

var heartbeatClient = &http.Client{Timeout: 10 * time.Second}

func heartbeat() {
	url := os.Getenv("HEARTBEAT_URL")
	if url == "" {
		return
	}
	resp, err := heartbeatClient.Get(url)
	if err != nil {
		log.Printf("heartbeat ping failed: %v", err)
		return
	}
	io.Copy(io.Discard, resp.Body) // read to EOF so the connection can be reused
	resp.Body.Close()
}

func main() {
	go func() {
		for range time.Tick(time.Minute) {
			heartbeat()
		}
	}()
	serve() // your server or worker loop
}

PHP / Laravel

For a plain site, call it from a cron-driven script. In Laravel the scheduler already runs every minute, so the ping proves schedule:run is wired. file_get_contents on a URL needs allow_url_fopen enabled (the default) and the openssl extension for https; with either missing it returns false and the @ hides the warning, so check the record once after wiring it up.

<?php
function heartbeat(): void {
    $url = getenv("HEARTBEAT_URL");
    if (!$url) return;
    $ctx = stream_context_create(["http" => ["timeout" => 10]]);
    @file_get_contents($url, false, $ctx);
}

// routes/console.php (Laravel 11+)
use Illuminate\Support\Facades\Schedule;

Schedule::call(fn () => heartbeat())->everyMinute();

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.

# compose.yaml
services:
  app:
    image: ghcr.io/you/app:latest

  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'

Full guide: Heartbeat from a Docker container →, with the HEALTHCHECK-coupled ping and a single docker run for the host.

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 10 slot, Dev 25, Team 25 per seat.
  • Ping after success, not before. work && curl … turns a crashing job into silence; curl … ; work hides 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 -f that 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.