Getting access
The API is in public beta and keys are issued by hand. Email support@latencyradar.com with a sentence about what you want to measure and you will get a key back, usually the same day.
Beta keys are free and come with a daily test quota. Pricing for higher volumes will arrive after the beta; the request and response contracts documented here are the ones that will carry forward.
Quick start
Tests are asynchronous: creating one returns an id immediately, and you poll for results. A preview test finishes in a few seconds; a full test takes roughly half a minute.
1. Create a test
curl -X POST https://api.latencyradar.com/api/v1/tests \
-H "Authorization: Bearer $LATENCY_RADAR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/health",
"method": "GET",
"regions": ["ams", "sfo", "sin"],
"mode": "full"
}'
2. The API accepts it with 202
{
"id": "tst_2f6c1a8e",
"status": "queued",
"created_at": "2026-08-19T14:02:11.000Z",
"queue": { "position": 0 }
}
3. Poll until status is terminal
curl "https://api.latencyradar.com/api/v1/tests/tst_2f6c1a8e?wait_seconds=15" \
-H "Authorization: Bearer $LATENCY_RADAR_API_KEY"
wait_seconds long-polls: the server holds the request up to 15 seconds and returns as soon as there is news, so a completed full test usually needs two or three polls instead of thirty. Terminal statuses are completed, partial, and failed.
Authentication
Every request carries your API key as a bearer token:
Authorization: Bearer lr_beta_...
A missing or unknown key returns 401 UNAUTHORIZED; a revoked key returns 403 API_KEY_REVOKED. Keys are stored hashed on our side, so a lost key is replaced, not recovered.
POST/api/v1/tests
Creates one latency test for a single public HTTP or HTTPS URL and queues it. Returns 202 with the test id.
| Field | Type | Description |
|---|
url | string, required | Public HTTP or HTTPS URL to measure. Targets resolving to private or internal addresses are rejected. |
method | string, required | One of GET, POST, PUT, DELETE, PATCH. |
regions | string[], required | 1–5 region codes from the table below, e.g. ["ams", "sfo"]. |
mode | string, optional | preview (default, one sample per region) or full (20 samples per region with percentiles). |
headers | object, optional | Headers sent to the target. Only Accept and User-Agent are allowed on the public API — anything else is rejected with INVALID_HEADERS. |
body | string, optional | Request body for non-GET methods. |
An Idempotency-Key header is accepted but not enforced during beta, so a retried create may start a duplicate test.
GET/api/v1/tests/{id}
Returns the current state of a test: its status, per-region results as they land, a cross-region summary, and a routing verdict. Pass wait_seconds (1–15) to long-poll.
Completed full-mode response (one region shown)
{
"id": "tst_2f6c1a8e",
"status": "completed",
"mode": "full",
"target": { "url": "https://api.example.com/health", "method": "GET" },
"created_at": "2026-08-19T14:02:11.000Z",
"started_at": "2026-08-19T14:02:12.000Z",
"completed_at": "2026-08-19T14:02:31.000Z",
"queue": { "position": null },
"regions": [
{
"region": "ams",
"region_name": "Amsterdam, Netherlands",
"status": "completed",
"sample_count": 20,
"success_count": 20,
"failure_count": 0,
"success": true,
"http_status": 200,
"resolved_ip": "203.0.113.10",
"dns_resolve_ms": 12.4,
"p50_total_ms": 84.1,
"p95_total_ms": 112.7,
"p99_total_ms": 168.3,
"breakdown": {
"from": "median_sample",
"connect_ms": 9.2,
"tls_ms": 21.5,
"wait_ms": 48.6,
"download_ms": 4.8,
"total_ms": 84.1,
"ttfb_ms": 79.3
},
"tls": {
"version": "TLSv1.3",
"cipher": "TLS_AES_256_GCM_SHA384",
"cert_expires_at": "2026-11-02T09:00:00.000Z",
"cert_days_remaining": 75,
"cert_issuer": "Let's Encrypt",
"cert_subject": "api.example.com",
"cert_valid": true
},
"error_code": null,
"error_message": null,
"error_sample_count": 0
}
],
"summary": {
"regions_requested": 3,
"regions_ok": 3,
"regions_degraded": 0,
"regions_failed": 0,
"regions_pending": 0,
"fastest_region": "ams",
"fastest_total_ms": 84.1,
"slowest_region": "sin",
"slowest_total_ms": 241.9,
"worst_p95_region": "sin",
"worst_p95_total_ms": 289.4
},
"routing": {
"distinct_ips": 1,
"ips_by_region": {
"ams": "203.0.113.10",
"sfo": "203.0.113.10",
"sin": "203.0.113.10"
},
"latency_spread_ms": 157.8,
"delivery": "single_origin",
"note": "All 3 regions reached one address with a 157.8ms spread, consistent with a single origin server that distant regions pay to reach."
}
}
Region status values
completed — every sample succeeded.degraded — some samples failed; percentiles are computed from the successful ones and failure_count / error_code say what went wrong.failed — zero samples succeeded; the target could not be measured from this region.unavailable — the measurement worker itself was unreachable; nothing about the target was measured.
Regions
| Code | Location |
|---|
ams | Amsterdam, Netherlands |
sfo | San Francisco, United States |
yul | Montreal, Canada |
sin | Singapore |
tyo | Tokyo, Japan |
Preview vs full mode
preview
One sample per region. Fast, and enough to answer "does this endpoint work from Singapore, and roughly how slow is it". Percentile fields are null — one sample cannot produce a percentile — and the region's authoritative total is breakdown.total_ms.
full
20 samples per region. Produces p50_total_ms, p95_total_ms, and p99_total_ms; the p50 is the region's authoritative total. Every sample opens its own connection and performs a full TLS handshake, so no sample reports a time another sample paid for.
Reading the numbers
- Percentiles are real observations. They use nearest-rank selection with no interpolation, so every percentile is a value that was actually measured. With 20 samples,
p99_total_ms is the maximum — a single slow sample becomes the p99. - Percentiles exist for the total only. "Where does the time go" is answered by
breakdown instead: the four phases of one real request (connect_ms, tls_ms, wait_ms, download_ms), which sum to that request's total_ms exactly. breakdown.from names which request it was — the median sample in full mode, so breakdown.total_ms equals p50_total_ms by construction. ttfb_ms is comparable with browser tooling. It is the cumulative connect + tls + wait, the sense Chrome DevTools and WebPageTest use, and sits outside the additive set — adding it to the phases would double-count.- DNS is reported separately. Workers pre-resolve the hostname (for SSRF safety) and pin the request to the resolved addresses, so no total includes resolution.
dns_resolve_ms reports the cold-cache resolution itself; a first-time visitor pays roughly dns_resolve_ms + p50_total_ms. - Failed samples poison no statistic. They are excluded from every percentile; a region with 19 fast samples and one timeout reports clean percentiles plus
status: degraded and failure_count: 1. Read the counts before the percentiles. summary never averages. Every value in it is copied verbatim from a specific region, so it can always be traced back to the regions array. Under partial failure it names the broken region instead of reporting a healthy figure.routing is the one conclusion. It pairs the number of distinct addresses the regions reached with the latency spread between them to give a hedged delivery verdict — single origin, anycast/edge, or geo-distributed. Its note is one sentence written to be quoted verbatim.
Errors
Errors are stable and machine-readable: { "error": { "code", "message", "request_id", "retry_after_seconds" } }.
| Code | Status | Meaning |
|---|
UNAUTHORIZED | 401 | Missing or unknown API key. |
API_KEY_REVOKED | 403 | The key exists but has been revoked. |
QUOTA_EXCEEDED | 429 | The key's daily test quota is used up. retry_after_seconds says when it resets. |
RATE_LIMITED | 429 | Too many requests in a short window. Slow down and retry. |
TARGET_NOT_ALLOWED | 400 | The target resolves to a private or internal address, or is otherwise blocked. |
INVALID_REQUEST | 400 | The request body failed validation. The message says which field. |
INVALID_HEADERS | 400 | A target header other than Accept or User-Agent was supplied. |
CAPACITY_EXCEEDED | 429 | Too many tests are running concurrently. Wait for them to finish. |
TEST_NOT_FOUND | 404 | No test with that id is visible to this key. |
WORKER_UNAVAILABLE | 502 | A measurement region could not be reached. |
INTERNAL_ERROR | 500 | Something failed on our side. Include request_id when reporting it. |
Limits
- Beta keys include a daily test quota (default 25 tests/day).
- 1–5 regions per test; one URL per test.
- Target headers are limited to
Accept and User-Agent, so the public API cannot replay credentials against third-party endpoints. - Targets resolving to private, internal, or link-local addresses are rejected.
- Concurrent tests are capped, and per-target rate limits protect measured hosts. This is a diagnostic tool, not a load-testing platform.
wait_seconds long-polling is capped at 15 seconds per request.
MCP server
Latency Radar ships an MCP server so agents like Claude can run measurements as a tool. It wraps this API with two tools — run a test and wait for results, or fetch an existing test by id — and authenticates with your own API key. During the beta it is distributed on request: mention MCP in your API key email and you will get the package alongside the key.
{
"mcpServers": {
"latency-radar": {
"command": "node",
"args": ["/path/to/latency-radar-mcp/src/server.js"],
"env": {
"LATENCY_RADAR_API_KEY": "lr_beta_..."
}
}
}
}
The server needs LATENCY_RADAR_API_KEY set; it embeds no shared credentials.