How is monitoring an API different from checking a web page?
API monitoring checks that a program can call your API and get a correct answer in good time. It differs from checking a web page because nobody is looking at the result. The caller is code, and code cannot tell you that something "looks wrong".
| Web page | API | |
|---|---|---|
| Who calls it | A person in a browser | Another program: your app, a mobile client, a partner's server |
| How a failure shows | The person sees an error and may tell you | The caller retries, fails quietly or shows its own vague error |
| What "correct" means | The page renders | The status code and the data are both right |
| A 200 response | Nearly always means it worked | Can still carry an error in the body |
Three things follow from this.
- Status codes carry the meaning. Your API has to return honest ones.
- You need a URL built for checking. There is no home page to load. You write a small endpoint, usually
/health, that answers one question: can this API do its job right now? - The monitor has to deal with authentication. It either carries a key of its own, or it checks an endpoint where "not authorised" is the expected answer.
What should you monitor on an API?
Watch four things: availability, correctness, latency, and the certificate and DNS that clients need before they can reach you at all.
| What | The question | The simplest check |
|---|---|---|
| Availability | Does it answer? | An HTTP request every minute that expects a status from 200 to 299 |
| Correctness | Is the answer right? | A /health endpoint that tests its dependencies and returns 503 when one fails |
| Latency | Is it fast enough? | The response time of each check, compared with a limit |
| Certificate and DNS | Can clients find it and trust it? | A certificate expiry check and a DNS record check |
Why the fourth one is on the list
An expired certificate takes an API down while every server is healthy. A browser shows a warning a person can click past. An API client refuses the connection, and every call fails.
DNS is the same kind of failure. If the name stops resolving, or points at an old address, no request arrives and your own logs show nothing wrong.
You can check both by hand now with the SSL checker and the DNS lookup tool.
What a plain uptime monitor covers
A PULSX HTTP monitor covers availability and records the response time of every check. Certificate expiry is tracked on every HTTPS monitor, and DNS records have their own monitor type. It does not read the response body, so correctness has to come from the status code. The /health endpoint later in this guide is how you do that.
Which status codes mean an API is healthy?
Healthy is the code the endpoint should return when it works. For most checks that is anything from 200 to 299. For a protected endpoint checked without credentials, it is 401.
| Code | What it means to a monitor | Healthy? |
|---|---|---|
| 200, 204 | The request worked | Yes, as long as the body is right too |
| 401 | No credentials, or wrong ones | Yes, if the monitor sends none on purpose. No, if it sends a key. |
| 403 | Credentials understood, action not allowed | No. The key lost a permission, or a firewall is blocking the monitor. |
| 404 | No such route | No. A deploy removed or renamed it. |
| 429 | Too many requests | No. The monitor is hitting your rate limit. |
| 500, 502, 503, 504 | The code hit an error it did not handle, or a proxy could not get an answer from the app in time | No |
The guide to HTTP status codes explains each of these in full. The definitions are in RFC 9110.
Why a 401 can be good news
To return 401, the name must resolve, the certificate must be valid, the server must be running and the code that checks credentials must have answered. It needs no secret in your monitoring tool.
In PULSX you set the healthy codes as a list or as ranges (the default is 200 to 299), so one check can expect exactly 401. If that endpoint ever returns 200 without credentials, the check fails. That is correct: an endpoint that should be closed is open.
Why a 200 can be bad news
Some APIs return 200 for everything and put the failure in the body:
HTTP/1.1 200 OK
Content-Type: application/json
{"error": "database unavailable"}
GraphQL APIs commonly do this by design, with an errors field in a 200 response. A monitor that reads only the status code sees a healthy API.
There are two fixes. Use a tool that reads the response body, or give the monitor an endpoint whose status code tells the truth.
How do you design a /health endpoint properly?
Build two endpoints. A shallow one says the process is running. A deep one tests each dependency and returns 200 when all pass and 503 when any fails. Point your uptime monitor at the deep one.
| Shallow check | Deep check | |
|---|---|---|
| Typical path | /health/live | /health |
| What it tests | The process can answer HTTP | The things a real request needs |
| Who calls it | A load balancer or container orchestrator | Your uptime monitor |
Keep them apart. If an orchestrator restarts your app whenever the deep check fails, a database outage becomes a database outage plus a restart loop.
What the deep check should test
- Database: run
SELECT 1through the same connection pool the app uses. - Cache: send a
PING. - Queue: reach the broker. Better still, fail when the oldest waiting job is more than, say, 10 minutes old.
- A critical third party: only one your API cannot work without. Cache the result for 30 to 60 seconds, so one check a minute does not become one paid call a minute.
Rules for the endpoint
- Return 503 when a dependency is down. The status code carries the answer. The body is detail for people.
- Keep it fast. Give each dependency a limit of 1 second and run them at the same time. A dependency that is too slow counts as failed.
- Keep it cheap. No table scans, no writes. One check a minute is 1,440 calls a day.
- Do not leak internals. No hostnames, versions, connection strings or error messages. Return "ok" or "fail" for each check and write the real error to your logs.
- Do not cache the response. Send
Cache-Control: no-store, or a CDN may serve an old 200.
Worked example: Node (Express)
const express = require('express');
const { Pool } = require('pg');
const { createClient } = require('redis');
const app = express();
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const cache = createClient({ url: process.env.REDIS_URL });
cache.on('error', () => {});
cache.connect().catch(() => {});
// Run one check with a 1 second limit. Never throws.
async function check(fn) {
const limit = new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 1000)
);
try {
await Promise.race([fn(), limit]);
return 'ok';
} catch {
return 'fail';
}
}
// Shallow: the process is running.
app.get('/health/live', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Deep: the things a real request needs are reachable.
app.get('/health', async (req, res) => {
const [database, cacheState] = await Promise.all([
check(() => db.query('SELECT 1')),
check(() => cache.ping()),
]);
const healthy = database === 'ok' && cacheState === 'ok';
res.set('Cache-Control', 'no-store');
res.status(healthy ? 200 : 503).json({
status: healthy ? 'ok' : 'fail',
checks: { database, cache: cacheState },
});
});
app.listen(3000);
Worked example: Python (FastAPI)
import asyncio
import os
from contextlib import asynccontextmanager
import asyncpg
import redis.asyncio as redis
from fastapi import FastAPI, Response
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.db = await asyncpg.create_pool(os.environ["DATABASE_URL"])
app.state.cache = redis.from_url(os.environ["REDIS_URL"])
yield
await app.state.db.close()
await app.state.cache.aclose()
app = FastAPI(lifespan=lifespan)
async def check(call) -> str:
"""Run one check with a 1 second limit. Never raises."""
try:
await asyncio.wait_for(call(), timeout=1.0)
return "ok"
except Exception:
return "fail"
# Shallow: the process is running.
@app.api_route("/health/live", methods=["GET", "HEAD"], include_in_schema=False)
async def live():
return {"status": "ok"}
# Deep: the things a real request needs are reachable.
@app.api_route("/health", methods=["GET", "HEAD"], include_in_schema=False)
async def health(response: Response):
database, cache = await asyncio.gather(
check(lambda: app.state.db.execute("SELECT 1")),
check(lambda: app.state.cache.ping()),
)
healthy = database == "ok" and cache == "ok"
response.status_code = 200 if healthy else 503
response.headers["Cache-Control"] = "no-store"
return {
"status": "ok" if healthy else "fail",
"checks": {"database": database, "cache": cache},
}
Both return the same thing: 200 with every check "ok", or 503 with the failed check marked "fail". Add one check line for each extra dependency.
How do you monitor an endpoint that needs authentication?
Create one API key that exists only for monitoring, give it read-only access to as little as possible, and have the monitor send it in a request header. Never use a person's key or your production admin key.
Setting up the key
- Make a separate account or client named for its purpose, for example
uptime-monitor. It should own no real customer data. - Give it read-only scope on the one or two endpoints you check.
- Send it in a header, for example
Authorization: Bearer YOUR_MONITOR_KEY. Do not put it in the URL as a query parameter, because URLs are written to access logs.
Rotating it
Rotate the key on a schedule (every 90 days is a common choice) and whenever someone with access to it leaves. Do it in this order so the check never fails:
- Create the new key. Both keys now work.
- Put the new key in the monitor's header and wait for two or three passing checks.
- Revoke the old key.
When a header is not enough
Some APIs need a step before the real call, such as posting credentials to get a short-lived token. A monitor that sends one request cannot do that.
PULSX HTTP monitors can send custom request headers, including Authorization. They do not send a request body and do not run multi-step or scripted checks. If your API needs a token exchange, either use a tool built for scripted checks, or let your deep /health endpoint do the work on the server and report 200 or 503.
Should the check use HEAD or GET?
Use GET for a health endpoint. Use HEAD when the response is large and you only need the status code. HEAD asks for the same response as GET with the body left out.
| HEAD | GET | |
|---|---|---|
| What comes back | Status line and headers only | Status line, headers and body |
| Data sent over the network | A few hundred bytes | The whole response |
| Risk | Some frameworks and proxies do not answer HEAD on a route written for GET, and return 405 | None, apart from size |
| Best for | Large pages, file downloads, big list endpoints | Health endpoints and anything small |
A health response is tiny. The body in the examples above is under 100 bytes, so HEAD saves nothing. GET is also the method your real callers send.
If you do choose HEAD, test it by hand first with curl -sS -I. A 405 means the route does not answer HEAD. Section 9.3.2 of RFC 9110 says a server should send the same headers for HEAD as for GET, but not every framework wires that up for you. The FastAPI example above lists HEAD in methods for that reason.
How should you measure latency?
Measure latency with percentiles, not averages. A percentile tells you how long the slowest share of requests took, and the slowest share is where trouble shows first.
The 95th percentile, written p95, is the time that 95 of every 100 requests finished within. To find it, sort the times from fastest to slowest and take the 95th. For p99, take the 99th.
A worked example
Take 100 requests. 90 of them took 80 ms, 8 took 400 ms and 2 took 5,000 ms.
| Measure | Value | What it tells you |
|---|---|---|
| Average | 204 ms | Looks fine. It is a time that no request actually took. |
| p50 (the median) | 80 ms | What a typical request gets |
| p95 | 400 ms | 1 request in 20 is this slow or slower |
| p99 | 5,000 ms | 1 request in 100 waits 5 seconds or more |
The average is (90 x 80 + 8 x 400 + 2 x 5,000) divided by 100, which is 204 ms.
The 5 second requests matter more than 2% suggests. If one screen of your app makes 10 API calls, the chance that at least one is a 5 second call is about 18%.
The time an outside check records includes the DNS lookup, the connection and the TLS handshake, and it is measured from the monitor's location. Treat it as a steady reference line, not as what every user sees.
Picking a threshold
- Collect a week of response times. At one check a minute that is 10,080 samples.
- Find the p99. Say it is 320 ms.
- Set "too slow" at about three times that, rounded: 1,000 ms. This is a rule of thumb to start from.
- Keep it below your callers' own timeout. If clients give up after 5 seconds, a 10 second threshold tells you nothing.
- Alert only when it is breached several checks in a row.
If your monitor alerts on status codes only, put the threshold inside the health endpoint. Both examples above already do this: a dependency that takes longer than 1 second counts as failed, and the endpoint returns 503.
How often should you check, and how do you avoid your own rate limit?
Check every 60 seconds for anything customers depend on. The interval sets how late you find out about an outage, and how short an outage can be before you never see it.
The numbers below assume a failed check is retried 3 times, 30 seconds apart, before an alert goes out.
| Every 1 minute | Every 5 minutes | |
|---|---|---|
| Checks per day | 1,440 | 288 |
| Alert after the outage starts, at best | 1 min 30 s | 1 min 30 s |
| Alert after the outage starts, on average | 2 min | 4 min |
| Alert after the outage starts, at worst | 2 min 30 s | 6 min 30 s |
| A 3 minute outage is seen by a check | Always | 60% of the time |
| A 3 minute outage raises an alert | Always | 30% of the time |
The last two rows work like this. A 3 minute outage lasts 180 seconds. The chance that a check every 300 seconds lands inside it is 180 in 300, which is 60%.
To raise an alert, the first failed check and all three retries must fall inside the outage, so the first failure has to land in its first 90 seconds. That is 90 in 300, which is 30%.
PULSX checks as often as every 60 seconds, and 3 retries 30 seconds apart is its default.
Not tripping your own rate limit
A monitor is a client like any other, and your rate limiter will treat it as one. When it is limited, it receives 429 (Too Many Requests, defined in RFC 6585) and you get an outage alert for an API that is working.
One monitor at one check a minute sends 1,440 requests a day, which is small. The trouble starts when the monitor shares a key, and so an allowance, with a real integration.
The fixes: leave /health out of the rate limiter and keep it cheap, give the monitor key its own limit, and never add 429 to the list of healthy codes. A 429 on a check means the setup is wrong, and hiding it hides real outages too.
Synthetic checks, real user metrics or logs: which do you need?
Start with synthetic checks, add logs and server metrics next, and add real user metrics last.
- A synthetic check is a request a monitor sends on a timer from outside your network. Nobody real is waiting for the answer.
- Real user metrics are timings and error counts recorded for the requests your real callers make, measured in the client or at the edge of your network.
- Logs and server metrics are what your own servers record about each request and about themselves.
| Synthetic checks | Real user metrics | Logs and server metrics | |
|---|---|---|---|
| Answers | Is it up right now? | What are callers actually getting? | Why did it fail? |
| Works at 03:00 with no traffic | Yes | No. No requests, no data. | Partly. Errors need requests to appear. |
| Sees DNS and certificate failures | Yes | Often not. The request never completes, so nothing is reported. | No. The request never arrives. |
| Explains the cause | No | No | Yes |
A synthetic check is the only one of the three that reports an outage when nobody is using the API, and it takes minutes to set up. Logs come second, because the question after "it is down" is "why". Real user metrics find the slow endpoint in one country that no synthetic check was pointed at.
How do you monitor third-party APIs you depend on?
Watch them from two sides: an outside check on the provider's own endpoint, and a count of failed calls inside your code. The first tells you they are down. The second tells you whether it is hurting you.
Start with a list of the services your API cannot work without. Payments, email sending, sign-in and storage are the usual ones.
Checking from outside
- Use an endpoint that needs no key if the provider has one, such as a public status or ping URL.
- Or expect a 401. Many APIs answer 401 to a request with no credentials. Check what theirs returns, then set that as the healthy code. It proves their API is reachable without you storing their key in a monitor.
- Check every 5 minutes, not every minute. It is their service and their rate limit. Read their terms first.
- Do not put a live production key for a payment or email provider into a monitor. If you must authenticate, use a restricted or test key.
For a quick look by hand, the live status page for well-known services shows whether a provider is up right now.
Checking from inside
The outside check does not prove your own calls work. Your key may have expired, or your account may be over its quota. So log every call to the provider with its status and duration, and alert when failures pass a level you choose, for example 5 in 100 calls over 5 minutes.
Which check catches which failure?
No single check catches everything. Use this table to see what each kind of failure needs, and where a status-only monitor stops.
| Failure | What detects it |
|---|---|
| Timeout (the request hangs) | Any HTTP check. No answer within the check's time limit counts as a failure. |
| 5xx error | An HTTP check with healthy codes set to 200 to 299 |
| Wrong body (a 200 with an error or bad data inside) | A tool that reads the response body, or a /health endpoint that runs the test itself and returns 503 |
| Slow (correct answers that arrive late) | A response time threshold, or a time limit inside /health that turns "slow" into 503. Real user metrics for the full picture. |
| Expired certificate | A certificate expiry check that warns days ahead. After expiry, the HTTPS check itself fails. |
| DNS failure ("could not resolve host") | A DNS record check. The HTTP check also fails, because it cannot find the server. |
| Failure in one region only | Checks from several locations, or real user metrics |
Where PULSX stops
PULSX catches the timeout, 5xx, certificate and DNS rows directly. It does not read response bodies, has no keyword or JSON checks and no multi-step checks, and it checks from one location.
So the "wrong body" and "slow" rows need the health endpoint approach: put the test on your server and let the status code carry the answer. The "one region only" row needs a different tool or real user metrics.
What is the set-up checklist, with commands to test each check?
Run each command by hand before you create the monitor. Replace api.example.com with your own host.
1. The health endpoint answers 200
curl -sS -o /dev/null -w '%{http_code}\n' https://api.example.com/health
Expect 200.
2. It answers 503 when a dependency is down
In staging, stop the database and run the same command against the staging host. Expect 503. If you get 200, the check is shallow.
3. A protected endpoint refuses a request with no key
curl -sS -o /dev/null -w '%{http_code}\n' https://api.example.com/v1/orders
Expect 401.
4. The monitor key works, and only for reading
curl -sS -o /dev/null -w '%{http_code}\n' \
-H 'Authorization: Bearer YOUR_MONITOR_KEY' \
'https://api.example.com/v1/orders?limit=1'
Expect 200. Then, in staging only, try a write with the same key and expect 403.
5. Response time is where you think it is
curl -sS -o /dev/null \
-w 'dns %{time_namelookup} connect %{time_connect} tls %{time_appconnect} first byte %{time_starttransfer} total %{time_total}\n' \
https://api.example.com/health
Each value is in seconds, counted from the start of the request. A large gap between tls and first byte is time spent in your server.
6. The certificate is valid and not close to expiry
curl -sS -vI https://api.example.com/health 2>&1 | grep -i 'expire date'
If your build of curl prints nothing here, use openssl:
openssl s_client -connect api.example.com:443 -servername api.example.com \
</dev/null 2>/dev/null | openssl x509 -noout -enddate
7. DNS returns the address you expect
dig +short api.example.com
8. Create the monitors
- One HTTP monitor on
/health: GET, every 60 seconds, healthy codes 200 to 299. - One HTTP monitor on a protected endpoint, with either no key and healthy code 401, or the monitor key in a header and healthy codes 200 to 299.
- One DNS monitor on the API's hostname.
9. Test the alert
Break the staging health check on purpose and wait for the alert to arrive. Then make sure it goes to a named person. The on-call guide covers who that should be and what happens when they do not answer.
All of step 8 fits in a free PULSX account: 20 monitors, checks every 60 seconds, and certificate expiry tracked on every HTTPS monitor. Sign up, no card needed.