Skip to content
DevPenh

How to Monitor an API Endpoint for Free

What to check beyond uptime, tested curl, jq and Node.js check scripts, and the free tiers of UptimeRobot, Better Stack, Checkly and Uptime Kuma.

By Yorn Chanpenh · Last reviewed

An endpoint can return 200 OK and still be broken: an empty list, an error message inside a JSON body, a field that changed type, or a response that takes eight seconds. This guide covers what to check, how to script those checks yourself with tested curl and Node.js examples, and what the free tiers of common hosted monitors include.

What to check

CheckCatches
Status code is 2xxCrashes, failed deploys, auth and routing errors
Content type is JSONA proxy, CDN or login page answering with HTML instead of your API
A known value in the body"Successful" responses that carry an error or empty data
Response time under a limitSlow queries and overloaded servers before they time out
TLS certificate expiryRenewals that failed silently
Heartbeat from scheduled jobsCron jobs and workers that stopped running. These do not serve requests, so they have to report in instead of being probed.

Pick a lightweight endpoint that exercises the real dependencies, such as a /health route that queries the database, or a cheap read-only call. Use a dedicated, read-only credential for it, never a personal or admin token.

A check script with curl and jq

This script fails (exit status 1) when the status is not 2xx, the body does not match a jq filter, or the response is slower than a limit. It was tested with curl 8.7.1, jq 1.7.1 and bash 3.2 on macOS.

#!/usr/bin/env bash
# Usage: ./check-api.sh URL JQ_FILTER [MAX_SECONDS]
set -euo pipefail

url=$1
filter=$2
max=${3:-2}

body=$(mktemp)
trap 'rm -f "$body"' EXIT

read -r code time < <(curl -sS -o "$body" -w '%{http_code} %{time_total}\n' \
  --max-time 10 "$url")

if [[ $code != 2* ]]; then
  echo "FAIL $url: HTTP $code" >&2; exit 1
fi
if ! jq -e "$filter" "$body" > /dev/null; then
  echo "FAIL $url: body does not match $filter" >&2; exit 1
fi
if awk -v t="$time" -v m="$max" 'BEGIN { exit !(t > m) }'; then
  echo "FAIL $url: slow response (${time}s > ${max}s)" >&2; exit 1
fi
echo "OK $url: HTTP $code in ${time}s"

Against the public test service httpbin.org it printed (timings vary):

$ ./check-api.sh https://httpbin.org/json '.slideshow.title == "Sample Slide Show"'
OK https://httpbin.org/json: HTTP 200 in 1.152885s

$ ./check-api.sh https://httpbin.org/status/500 '.'
FAIL https://httpbin.org/status/500: HTTP 500

$ ./check-api.sh https://httpbin.org/delay/3 '.url' 2
FAIL https://httpbin.org/delay/3: slow response (4.047483s > 2s)

$ ./check-api.sh https://nonexistent.invalid/ '.'
curl: (6) Could not resolve host: nonexistent.invalid
FAIL https://nonexistent.invalid/: HTTP 000
  • jq -e sets its exit status from the result, so a filter that evaluates to false or null fails the check. HTML responses fail too, because jq cannot parse them.
  • set -o pipefail matters if you pipe curl | jq: without it, the pipeline's exit status is jq's alone and a failed request can go unnoticed.
  • Don't rely on a specific curl --fail exit code for HTTP errors. With curl 8.7.1, a 500 response gave exit code 22 over HTTP/1.1 but 56 over HTTP/2. Checking %{http_code} as above avoids the difference.

The same check in Node.js

Node.js 18 and later include fetch. This version was tested on Node.js 24.21.0:

// Usage: node check-api.mjs URL
const url = process.argv[2];
const maxMs = 2000;

const started = performance.now();
let res;
try {
  res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
} catch (err) {
  console.error(`FAIL ${url}: ${err.message}`);
  process.exit(1);
}
const ms = Math.round(performance.now() - started);

const fail = (why) => {
  console.error(`FAIL ${url}: ${why}`);
  process.exit(1);
};
if (!res.ok) fail(`HTTP ${res.status}`);
if (!res.headers.get("content-type")?.includes("application/json"))
  fail(`expected JSON, got ${res.headers.get("content-type")}`);
const body = await res.json();
if (typeof body.slideshow?.title !== "string") fail("slideshow.title missing");
if (ms > maxMs) fail(`slow response (${ms} ms)`);
console.log(`OK ${url}: HTTP ${res.status} in ${ms} ms`);

Pointed at https://example.com, it reports FAIL https://example.com: expected JSON, got text/html; charset=utf-8. Replace the slideshow.title line with an assertion about your own response.

Running checks on a schedule

A script only helps if something runs it and tells you when it fails. Common options are cron on a server, a scheduled CI job, or a serverless function on a timer. Keep two limits in mind:

  • Location. A check that runs next to the API can pass while users elsewhere cannot reach it because of DNS, a CDN or a firewall. Run at least one check from outside your network.
  • Who watches the watcher. If the machine running your checks goes down, you get silence, not an alert. Hosted monitors, or a heartbeat service that alerts when a check stops reporting, cover this gap.

Hosted monitors: free tiers compared

These figures come from each vendor's own pricing and documentation pages, checked on 2026-10-28. Plans change, so confirm on the linked page before you rely on a limit.

ServiceFree tier (as published)Notes
UptimeRobot50 monitors, 5-minute intervalKeyword monitors work on the free plan. In a free account (checked 2026-10-28), SSL and domain expiry details, heartbeat monitors and API monitors with JSON assertions are marked as paid features. Described as "good for hobby and non-profit projects".
Better Stack10 monitors and heartbeats, 1 status pageFree for personal projects, with Slack and e-mail alerts. Its API monitor supports request headers and HTTP authentication. The free-tier check interval is not stated on the pricing page.
ChecklyHobby: 10 uptime monitors, 10,000 API and 1,000 browser check runs per monthAssertions cover status code, JSON body, text body, headers and response time. Checks stop when the monthly runs are used up. Alerts by e-mail, Slack and webhooks.
Uptime KumaSelf-hosted, no plan limitsOpen source (MIT). Supports HTTP keyword and JSON query monitors and many notification services. You run and maintain the server, which should not be the same machine as the API.
Healthchecks.ioHobbyist: 20 jobsA heartbeat monitor for cron jobs and workers, not an endpoint prober: your job pings it, and it alerts when the pings stop. The code is open source (BSD-3-Clause) and can be self-hosted.

This site itself is monitored with UptimeRobot (affiliate link, see the disclosure below) on the free plan: two HTTP monitors, plus a keyword monitor that alerts when the JSON formatter page stops containing the text "JSON Formatter". That catches a broken deploy that still returns 200 OK. The results are on a public status page.

Disclosure: the UptimeRobot link in the paragraph above is an affiliate link. If you buy a paid plan after following it, this site earns a commission, at no extra cost to you. It did not change the comparison: every figure in the table comes from the vendors' own pages, and this site has no affiliate or sponsorship relationship with the other services listed.

Choosing

  • One public endpoint, status and keyword checks: any hosted free tier above is enough.
  • Assertions on JSON values or response time: look for explicit JSON assertions (Checkly's assertions, Uptime Kuma's JSON query monitor), or run the script above on a schedule.
  • Data must stay in your infrastructure: self-host Uptime Kuma, on a different machine or provider from the API.
  • Scheduled jobs: use a heartbeat (Healthchecks.io, or the heartbeat monitors some uptime services include).

When a check fails

Save the failing response body. Paste it into the JSON formatter to see its structure, and compare it with a known-good response in the JSON diff tool, ignoring volatile keys such as timestamps and request IDs. For the full troubleshooting workflow, see how to debug API responses.

Part of the API debugging series. Start with How to Debug API Responses: A Practical Workflow.