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. 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 |
bom | Mumbai, India |
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, because 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, so 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, because 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–6 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.