Conventions
Pagination, time ranges, idempotency keys, path shapes, and the response rules that apply to every VitalSentinel API endpoint.
These rules hold across the whole surface. Learning them once saves reading 217 operation descriptions.
Path shape
Workspace and domain are always path segments, in the same position:
/api/public/v1/workspaces
/api/public/v1/workspaces/{workspace_id}
/api/public/v1/domains
/api/public/v1/domains/{domain_id}
/api/public/v1/domains/{domain_id}/uptime/timeline
/api/public/v1/domains/{domain_id}/gsc/performanceSearch Console hangs off the domain its property is linked to, rather than exposing an internal property identifier.
The dashboard's own API accumulated several path-shape inconsistencies over the years. None of them is inherited here: a frozen public contract is the wrong place to preserve drift.
Pagination
Two forms. Check which one an endpoint uses before you write the loop, because they are not interchangeable.
Cursor, on 26 endpoints
The default, and the one to expect on anything reading a continuously-inserted table. LIMIT 100 OFFSET 200 silently returns duplicate and skipped rows there as new data lands between page fetches, and both pages look well-formed, so a client has no way to notice.
{
"items": [ ... ],
"next_cursor": "eyJ0IjoxNzUw...",
"has_more": true,
"total": null
}| Field | Meaning |
|---|---|
items | The page |
next_cursor | Pass back verbatim as the cursor query parameter |
has_more | Keep paging while this is true |
total | Present only where counting is cheap. Absent rather than wrong. |
Cursors are opaque base64url. Never parse one, never construct one, never store one across a config change. A malformed cursor returns a 400 naming the parameter, not a silent restart from the beginning.
limit defaults to 50 and clamps at 1000. It clamps rather than rejects, so an agent asking for 5,000 rows gets 1,000 back with an accurate has_more, rather than a 400 in the middle of a paging loop.
A few endpoints clamp lower than 1000, because they delegate to an internal handler with a tighter ceiling of its own. /rum/pages and /rum/pageviews cap at 500. Others cap at 500 or 100. Check the OpenAPI document for the maximum on the endpoint you are calling before you hard-code a page size, rather than assuming 1000 everywhere. Both RUM endpoints above used to advertise 1000 and then answer 400 when you asked for it; the published maximum is now the one the endpoint actually honors.
A correct loop:
cursor = None
while True:
page = get("/domains/{id}/robots/changes", cursor=cursor, limit=200)
for row in page["items"]:
handle(row)
if not page["has_more"]:
break
cursor = page["next_cursor"]Offset, on 8 endpoints
Search Console and a few analytics reads page by limit and offset instead, because they sit on a stable stored result rather than a live insert stream. They return an exact total (total_pages, total_queries) rather than has_more, and there is no cursor to pass back:
GET /domains/{domain_id}/gsc/queries
GET /domains/{domain_id}/gsc/pages
GET /domains/{domain_id}/gsc/countries
GET /domains/{domain_id}/gsc/new-rankings
GET /domains/{domain_id}/gsc/reports/runs/{run_id}/rows
GET /domains/{domain_id}/analytics/pages
GET /domains/{domain_id}/analytics/ecommerce/products
GET /domains/{domain_id}/analytics/reports/runs/{run_id}/rowsA client that assumes a cursor everywhere will loop forever on these, because next_cursor never appears. Branch on which fields come back, or check the OpenAPI document per endpoint.
Time ranges
There is no from or to parameter anywhere in v1. If you have seen that shape in another API, it does not apply here.
Four forms, depending on the endpoint:
| Form | Used by | Shape |
|---|---|---|
start_date / end_date | ~52 endpoints | YYYY-MM-DD calendar dates, not timestamps |
period | 19 endpoints | A window named rather than calculated, resolved on the server. See Named periods |
period_days | ~22 endpoints | Integer window ending now. Default 30, range 1 to 365 |
days | A handful, in Indexing Monitoring | Integer. Default 7, range 1 to 30 |
Defaults differ per endpoint and are worth reading rather than assuming. Search Console endpoints default end_date to two days ago, not today, because Google's own data lags roughly that far. Ask for yesterday there and you will correctly get nothing.
The OpenAPI document is authoritative per endpoint, and this is the parameter set most worth checking there before writing a query.
A range that predates your plan's data retention is clamped, not rejected. The response reports meta.clamped_from when that happens, so a client charting "last 12 months" on a three-month plan sees why the chart is short, instead of reading nine months of flat zero as a traffic collapse. Check for that field before you draw a conclusion from a long window.
Named periods
Nineteen endpoints accept period, which names a reporting window instead of asking you to calculate its edges. Seven values are accepted:
| Value | Window |
|---|---|
last_7_days | The 7 days ending today |
last_30_days | The 30 days ending today |
last_90_days | The 90 days ending today |
this_week | Monday of the current week through today |
last_week | The last complete Monday to Sunday week |
this_month | The 1st of the current month through today, month to date |
last_month | The last complete calendar month |
last_month is the last complete calendar month, not the trailing 30 days. Those are two different questions with two different answers, and the gap is large enough to change a conversation: on one monitored domain the trailing 30 days read 96.74% uptime while the calendar month of July read 98.89%. A report headed "last month" that quietly sends a trailing 30-day window is wrong by that much.
Trailing windows remain the default on every endpoint, so a named period is opt-in. A client that never sends one behaves exactly as it did before.
period cannot be combined with start_date or end_date. Sending both is a 400, not a precedence rule, because silently picking one of two windows the caller asked for is how a report ends up covering days nobody chose.
The resolved boundaries are always echoed in the response, so you can label a chart with the days that were actually counted rather than with the name you sent.
Comparing against a previous window
Four endpoints pair compare_previous=true with compare_mode, which decides what "previous" means:
| Value | Baseline window |
|---|---|
previous_period | The window immediately preceding yours, of equal length |
calendar_month | The whole calendar month before the one your window ends in |
Omit compare_mode and it resolves to calendar_month when period named a calendar month (this_month or last_month), and to previous_period otherwise. That default is what people mean by "compared to last month": the baseline for a calendar month is the previous calendar month, not an equal-length run of days that is not a month at all.
compare_mode exists on /analytics/overview, /gsc/overview, /gsc/pages, and /uptime/availability, and nowhere else. Other endpoints that accept compare_previous, such as /gsc/queries, /rum/overview, and /rum/pages, always compare against the immediately preceding window of equal length.
Idempotency keys are mandatory on run: endpoints
Every run:-class endpoint requires an Idempotency-Key header. This is not optional polish:
curl -sS -X POST \
"https://app.vitalsentinel.com/api/public/v1/domains/$DOMAIN_ID/reports/$TEMPLATE_ID/generate" \
-H "Authorization: Bearer $VITALSENTINEL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: report-$(echo -n "$TEMPLATE_ID$BODY" | shasum -a 256 | cut -c1-32)" \
-d "$BODY"| Rule | Detail |
|---|---|
| Same key, same body | Replays the original response and spends nothing |
| Same key, different body | 409 conflict. This is a client bug, and returning the first response to a different request would corrupt your state without an error |
| Missing key | 400 invalid-request |
| Maximum length | 255 characters |
| Retention | 24 hours, long enough to cover re-running a failed CI job the next morning |
| Namespacing | Per credential, so two customers generating the same key never collide |
One write:-class endpoint has the same requirement: POST /domains/{domain_id}/robots/sitemap-conflicts/recompute is write:robots-scoped rather than run:, and still rejects a call that arrives without a key, because a retry would otherwise queue a second recompute of the same domain.
Derive the key from the request, not from a fresh UUID. A UUID regenerated on each attempt defeats the entire purpose: your retry becomes a second billed run. A hash of the operation and its arguments is the right shape.
This matters most for automated callers. Agents and job runners retry aggressively, on a timeout, on a transport hiccup, or on their own uncertainty about whether a call succeeded. Without an idempotency key, one retried report run is billed a second time and the customer sees a credit balance they cannot account for.
A POST does not always mean a mutation
A handful of endpoints are POSTs that take a body and change nothing. They exist because the input does not fit in a query string: a batch of URLs to test, a candidate robots.txt to simulate.
They are recognizable three ways: they live under a /query-style sub-path, they require only a read: scope, and their OpenAPI description says so explicitly.
POST /domains/{domain_id}/robots/test-urls/query
POST /domains/{domain_id}/robots/simulate/query
POST /domains/{domain_id}/robots/overlapping-rules/query
POST /domains/{domain_id}/reports/data/{template_id}None of these needs an Idempotency-Key, and none of them costs a dispatch price.
Response headers
Every response carries these:
| Header | Meaning |
|---|---|
X-Request-Id | Quote this in any support conversation |
X-RateLimit-Limit | The ceiling of whichever bucket will refuse you first |
X-RateLimit-Remaining | Your remaining budget in that bucket |
X-RateLimit-Reset | Absolute epoch second. Sleep until it, not for it |
X-Credits-Cost | What this request cost. See API credits |
X-Credits-Remaining | Balance after this request, for the workspace it was charged to |
X-Cache | HIT, MISS, or UNAVAILABLE. Explains why a heavy read cost zero |
Null is not zero
Reading a response means telling three states apart: measured and genuinely zero, not measured, and not measurable. The API keeps them separate rather than flattening them all to 0, because a fabricated zero charts identically to a real one.
- A metric an endpoint did not measure is
null, never0.GET /analytics/pages?type=entryreports entry data, soexitsand the bounce fields come back null there rather than as zeros you might plot. The same applies toentriesundertype=exitand tochain_validon a domain that was never scanned. - A percentage change with nothing to compare against is
null, not a fabricated100.0or0.0. That coversvisitors_change_pctand its siblings on/analytics/overview,revenue_changeandorders_changeon/analytics/ecommerce/overview, andactual_ctron/gsc/ctr-benchmark.bounce_rate_change_ppis the exception: a percentage-point difference is always defined, so it stays a plain number that is always present. - A zero the endpoint could not have counted is absent, not
0.GET /uptime/incidentsfetches at most the 2,000 most recent incidents, so it zero-fillsclass_countsonly whenwindow_truncatedisfalse. A missing key means unknown. A present0means genuinely none. - A date on which nothing was recorded is
null, not a placeholder like1970-01-01.
RUM is stricter here than the dashboard
Every RUM response on the public API is redacted unconditionally. There is no scope that widens this, and no plan that changes it.
| What | Treatment |
|---|---|
session_id | Omitted entirely from every RUM response, not nulled |
url, referrer, resource_url, sample_url | Query strings stripped |
Error message, stack, filename, element_id, element_class | Email-shaped and long token-shaped substrings replaced with [redacted] |
| Individual visitor session records | Not reachable at all |
If you are wondering why the API hides a query string your dashboard shows you, this is why. The query string is where personal data hides in a real-user error URL, and an API credential could extract it at a scale a human browsing the dashboard cannot. This is a deliberate difference, not an oversight to be reconciled later by loosening the API.
Aggregate and page-level RUM metrics are unaffected. Everything you need to rank pages by 75th-percentile LCP, break down INP subparts, or count error groups is still there.
An explicit end_date now ends the window
A public RUM route given an explicit end_date used to include the following day's events. It inherited a whole-day pad from the dashboard handler underneath it, so end_date=2026-07-31 genuinely counted events from August 1. That is fixed. The window ends where end_date says it ends, and window_end on the response states the exact window that was queried, so you can confirm it rather than infer it.
The dashboard still pads, deliberately and unchanged. A dashboard figure and an API figure quoting the same end_date can therefore differ by one day of traffic, and the API is the one reporting the window you asked for. If you have a stored RUM total from before this change, it covered one more day than its label claimed.
Kill switches are not deprecations
Two 503 responses are operational levers rather than contract changes:
| Type | Meaning |
|---|---|
api-disabled | The public API is temporarily off. Retry after Retry-After. |
read-only-mode | Reads are still being served; writes and runs are temporarily disabled. |
Neither means an endpoint is going away. See Versioning and stability for what an actual retirement looks like.
Five habits that keep a client working
The compatibility promise is written against clients that do these five things. A client that does not is not covered:
- Ignore unknown fields. New fields ship inside
v1without a version bump. - Tolerate unknown enum values. New enum members are additive.
- Branch on the error
typeURI, never on the prose indetail. - Treat cursors as opaque.
- Honor
Retry-After.