REST API

Read and write your monitors, read and update incidents, and upload source maps. Everything here uses the same permission checks and the same tenancy filter as the dashboard — there is no separate API-side implementation that could drift from it.

Included on every plan, the free one included. There is no add-on to buy and no tier that unlocks it.

Base URL

https://vitrinaengine.com/api/v1

Machine-readable description

/openapi.json is an OpenAPI 3.1 document covering every route on this page. Point a client generator at it, or hand it to an agent that has to call this API without being talked through it.

It is generated from the service rather than written beside it: the routes come from the surface the API publishes at GET /, the shapes and their prose from the same .proto files at /api/proto, and the rate limits from the code that enforces them. A build fails when the committed document and the API disagree, so it describes what is served rather than what was true when somebody last edited it.

It describes the shapes. The reasons are on this page — that a cross-tenant id answers 404, that config is never returned — and no generator can write those.

For agents: MCP

https://vitrinaengine.com/mcp is a Model Context Protocol server. Add it to an MCP client with an API key as the bearer token, and an agent can read your monitors, incidents, errors and analytics — and create, change and delete monitors — without anybody explaining how this API works.

{
  "mcpServers": {
    "vitrina-engine": {
      "type": "http",
      "url": "https://vitrinaengine.com/mcp",
      "headers": { "Authorization": "Bearer vte_your_key_here" }
    }
  }
}

It reads everything and writes three things. Every GET operation on this page is a tool, and so are POST /monitors, PATCH /monitors/{id} and DELETE /monitors/{id}. Nothing else that writes is reachable — not a workspace, a member, an API key, a status page or your account — because no tool for any of them exists, and the list of writes that do is three lines somebody has to add to on purpose.

A key that cannot write is still refused. A tool call is the same request the key would make directly, through the same permission check and the same tenancy filter, so an agent holding a read-only key reads. Give it one unless you mean it to change your monitoring — and expect a client to ask you before it deletes, because deleting a monitor takes its history with it and there is no undo. To silence a monitor without losing it, pause it with PATCH.

Because it is the same request, it sees exactly what the key sees, writes the same audit entry as the dashboard would, and counts against the same rate limit — twice, once for the call and once for the request inside it. A write charges the write limit.

One thing worth knowing before an agent edits a monitor: config is replaced whole, not merged. Read the monitor first, change the one field, and send the whole object back. A partial config silently drops the headers and credentials the check depends on, and leaves it running.

Authentication

Create a key under Settings → API keys, then send it as a bearer token. The key is shown once, when it is created, and stored only as a hash — if you lose it, make another.

curl https://vitrinaengine.com/api/v1/summary \
  -H "Authorization: Bearer vte_your_key_here"

A key never grants more than the person who created it had. It is bound to their membership, so it carries their role: a key made by somebody with a read-only role cannot write, whatever you send it. A key can also be confined to a single workspace, in which case every response is filtered to that workspace and anything outside it does not exist as far as the key is concerned.

Removing somebody from your organisation revokes the keys they created, in the same operation. Revoking access has to revoke the credentials with it, or the person keeps a working one.

Conventions

Every successful response is a JSON object with a data property. Every failure is a JSON object with an error property holding a sentence meant for a human.

{ "data": { "id": "8f14e45f-…", "name": "Marketing site" } }

{ "error": "This key cannot create monitors." }

Responses are sent Cache-Control: private, no-store. This is per-key data on a shared origin and must never be held by a proxy.

Protobuf and gzip

Every endpoint under /api/v1 also speaks Protocol Buffers. Send Accept: application/x-protobuf and the answer comes back as protobuf; send a protobuf body with Content-Type: application/x-protobuf and the answer comes back in the same format. Send neither and it is JSON, exactly as before.

curl https://vitrinaengine.com/api/v1/monitors \
  -H "Authorization: Bearer vte_your_key_here" \
  -H "Accept: application/x-protobuf" \
  --compressed -o monitors.pb

The messages are published: /api/proto lists every file. Download them keeping their paths and point protoc -I at the directory. Three things differ from what a protobuf user might expect: a field that can be empty is null in JSON and absent in protobuf; a few fields whose shape varies — a monitor’s or a channel’s config, for instance — are JSON documents carried in a string; and StringList wraps a list that can itself be null.

Gzip works in both directions: Accept-Encoding: gzip for responses, Content-Encoding: gzip for requests. Because two callers can now get different bytes from one URL, every response carries Vary: Accept, Content-Type.

Status codes

CodeMeans
200Fine. 201 when something was created.
400The body was not JSON, a field is missing, or nothing was asked for.
401No key, or not a valid one. Deliberately never says which — a message distinguishing them is a way to test keys.
403A valid key without the permission for this action. The message names it.
404No such record for you. See below.
413A source map over the size cap.
429Over a rate limit. Carries Retry-After. See below.

Refusal codes

Every non-2xx body carries a code beside the sentence — not_found, limit_reached, rate_limited — and that is the field to branch on. The error string is prose for a person reading a log and may be reworded at any time; the code is stable. Where the sentence names a value, vars carries those values as strings, so you can put them where your own grammar wants them rather than parsing them back out of the English.

{
  "error": "The Pro plan includes 50 monitors, and you have 50.",
  "code": "limit_reached",
  "vars": { "plan": "pro", "limit": "50", "current": "50", "resource": "monitors" }
}

This is what our own apps render a refusal from, in the reader’s language. Treat a code you do not recognise the way they do — fall back to error — because codes are added without a version bump.

Rate limits

Per organisation, not per key — minting another key does not raise them.

BucketLimit
Reads (GET)120 a minute
Writes (POST, PATCH, DELETE)30 a minute
Source map uploads300 an hour

Over the limit answers 429 with a Retry-After header giving whole seconds until the window resets. Honour it rather than retrying immediately.

These are set where a normal integration never reaches them: a wallboard polling every ten seconds spends six of the 120 reads. They are the same on every plan, because the collection endpoints return everything in one response — an account with five hundred monitors does not need more requests than one with twenty. Source maps get an hourly bucket because they arrive in bulk at deploy time, where a front end can easily have a hundred chunks.

404 rather than 403, on purpose

An id belonging to another customer answers “Not found.”, not “Forbidden.” A 403 would confirm the record exists, which turns the endpoint into a way to test whether an id is real. Do not read a 404 as proof that nothing exists anywhere — only that nothing exists that this key may see.

Endpoints

Everything in the account matching ?q=, whatever kind of thing it is — monitors, incidents and their postmortems, hosts, status pages, workspaces, notification channels, maintenance windows, error projects and issues, analytics sites, members and invitations, API keys, agents and private probes. One request rather than one per kind, because this is what a search box calls on a keystroke.

?type= narrows it to a comma-separated list of kinds: monitor, incident, postmortem, host, status_page, workspace, channel, maintenance, error_project, error_issue, analytics_site, member, invitation, api_key, agent, probe. An unknown one is refused rather than ignored, so a request that narrows cannot quietly get a wider answer. ?limit= caps how many of each kind come back, between 1 and 20, default 5. There is no cursor: search finds a thing, and each kind’s own collection endpoint pages through it.

Each kind is gated on the same permission its own endpoint needs, so search is never a way round one. A kind your key may not read is absent rather than refused, and absent in exactly the way a kind with no matches is. A term shorter than two characters answers with an empty list rather than an error — it means “keep typing”.

Nothing sealed, hashed or secret is matched against or returned: not a monitor’s configuration, not a channel’s, not an API key’s hash, not a heartbeat token. An API key is matched on its name and on the displayable prefix that exists to be shown.

{
  "data": [
    {
      "type": "monitor",
      "id": "3f1c…",
      "title": "Checkout API",
      "subtitle": "Acme",
      "status": "up",
      "workspace": { "id": "9b2e…", "name": "Acme" }
    },
    {
      "type": "incident",
      "id": "77a0…",
      "title": "Checkout API timed out",
      "subtitle": "Checkout API",
      "status": "resolved",
      "workspace": { "id": "9b2e…", "name": "Acme" }
    }
  ]
}
Parameters and responses
NameInType
limitqueryinteger
qquerystring
typequerystring
ResponseSearchResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /summary

Counts, for a wallboard or a daily digest. Needs monitor:read.

{
  "data": {
    "total": 42, "up": 39, "degraded": 1, "down": 1,
    "paused": 1, "pending": 0,
    "openIncidents": 2, "suppressedIncidents": 1
  }
}

suppressedIncidents counts incidents held back because they are blast radius of another one. They are real, and they are not separate outages.

Parameters and responses

No declared parameters or body fields.

ResponseGetSummaryResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /setup

What a new account still has to do — the same four questions the dashboard’s setup checklist asks, derived on every read. Needs monitor:read.

{
  "data": {
    "hasMonitor": true,
    "hasConfirmedChannel": false,
    "hasStatusPage": false,
    "hasColleague": false,
    "dismissedAt": null
  }
}

hasConfirmedChannel asks whether an alert could actually arrive, not whether a channel exists. Those came apart badly once, and a checklist that ticked “alerts are set up” for a channel the notifier refuses would repeat that bug as reassurance.

hasColleague counts memberships and never invitations: an invitation sent and not accepted has added nobody.

Not folded into /summary, because that endpoint is counts by status and nothing else — a wallboard polling it every few seconds does not want five extra lookups to render a nudge aimed at somebody who set the account up months ago.

Parameters and responses

No declared parameters or body fields.

ResponseGetSetupResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

DELETE /setup

Skips the checklist. Needs org:update, because the dismissal is one organisation-wide row rather than a per-person preference — a colleague who arrives after it was skipped is not shown it again.

Nothing else about the account changes, and no audit entry is written: hiding a nudge changes nothing about how the product behaves, alerts or bills.

Parameters and responses

No declared parameters or body fields.

ResponseDismissedResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /monitors

Every monitor the key can see. Needs monitor:read. Each carries id, name, kind, status, statusSince, enabled, intervalSeconds, tags, workspace, lastCheckedAt, lastResponseTimeMs, lastMessage, uptime24h, uptime30d and openIncidentId.

?tag=prod,eu narrows the list to monitors carrying every tag named — AND, not OR, so naming a second tag always returns fewer monitors rather than more. The dashboard’s own filter reads the same way. Tags are stored folded to lower case, and the value here is folded the same way before it is compared, so ?tag=Prod finds them. An entry that could not be a tag — blank, or longer than a tag may be — is dropped and the rest of the filter still applies, rather than the call being refused: a link should not stop working because one of its tags was renamed.

config is never returned. For some kinds it holds request headers and credentials, and a read-scoped key should not be a way to read back the secrets somebody typed into a form.

Parameters and responses
NameInType
tagquerystring
ResponseListMonitorsResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

POST /monitors

Needs monitor:create. Requires name, kind, workspaceId and config. Optional: intervalSeconds (default 300), confirmations, dependsOn.

curl -X POST https://vitrinaengine.com/api/v1/monitors \
  -H "Authorization: Bearer vte_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Marketing site",
    "kind": "http",
    "workspaceId": "…",
    "intervalSeconds": 60,
    "config": {
      "url": "https://example.com",
      "assertions": [{ "type": "status_code", "operator": "lt", "value": "400" }]
    }
  }'

Answers 201 with the new id. The interval is clamped to your plan’s floor rather than refused, so asking for 10 seconds on a plan whose floor is 60 gives you 60 — read it back if it matters.

Parameters and responses
NameInType
namebodystring
kindbodystring
workspaceIdbodystring
configbodystring
intervalSecondsbodyinteger
confirmationsbodyinteger
dependsOnbodystring[]
probeIdbodystring
tagsbodystring[]
ResponseCreatedResponse
Status codes201 400 401 403 404 429
Rate limit30 a minute

GET /monitors/{id}

One monitor, with everything the list gives plus paused. Needs monitor:read.

For mail_posture, tls_audit and snmp it also carries lastCheckDetail, the last check’s own result: the findings behind the status, each a stable code and a severity, plus a TLS grade or the SNMP readings. It is null until the first check runs, and absent for every other kind.

Parameters and responses
NameInType
id requiredpathstring
ResponseGetMonitorResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

PATCH /monitors/{id}

Send only what you want changed: name, intervalSeconds, confirmations, enabled, config, or paused.

paused is checked against monitor:pause and everything else against monitor:update, separately — a key allowed to pause but not to edit can still pause. Sending an empty change is a 400.

The monitor is re-read and returned rather than the input echoed back, so what you see is what was stored.

# Silence a monitor for the length of a deploy.
curl -X PATCH https://vitrinaengine.com/api/v1/monitors/$ID \
  -H "Authorization: Bearer vte_…" \
  -H "Content-Type: application/json" \
  -d '{"paused": true}'
Parameters and responses
NameInType
id requiredpathstring
namebodystring
intervalSecondsbodyinteger
confirmationsbodyinteger
enabledbodyboolean
pausedbodyboolean
configbodystring
tagsbodyStringList
ResponseUpdatedMonitorResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

DELETE /monitors/{id}

Needs monitor:delete. Answers { "data": { "id": "…", "deleted": true } }.

Parameters and responses
NameInType
id requiredpathstring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /hosts · POST /hosts

Hosts are the second way monitors group: a workspace says whose a monitor is, a host says what it runs on. Entirely optional — an account that never creates one loses nothing, and most monitors name no host at all.

Each host carries status, pinned and spanning. status is derived only from monitors pinned to that host alone. A monitor that runs on several machines counts towards spanning and colours none of them, because a load-balanced endpoint failing says the service is broken and not which machine broke it. A host with nothing pinned to it answers "status": null rather than up.

POST needs monitor:create and a name. The optional provider, address and notes are yours to read and nothing in the product acts on them. A name that folds to one you already have — case and a trailing dot are ignored — answers 409.

Parameters and responses

GET /hosts

No declared parameters or body fields.

ResponseListHostsResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

POST /hosts

NameInType
namebodystring
providerbodystring
addressbodystring
notesbodystring
ResponseCreatedResponse
Status codes201 400 401 403 404 429
Rate limit30 a minute

GET /hosts/{id} · PATCH /hosts/{id} · DELETE /hosts/{id}

The detail carries the host and the monitors on it, each flagged pinned and, when it is not, alsoOn: the other machines it answers from, which is what you read before rebooting one. DELETE retires the host and every monitor on it keeps running — a host owns no schedule and no history.

Parameters and responses

GET /hosts/{id}

NameInType
id requiredpathstring
ResponseGetHostResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

PATCH /hosts/{id}

NameInType
id requiredpathstring
namebodystring
providerbodystring
addressbodystring
notesbodystring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

DELETE /hosts/{id}

NameInType
id requiredpathstring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

POST /hosts/{id}/monitors · DELETE /hosts/{id}/monitors

One monitor on or off one host, by monitorId in the body or ?monitorId=. Deliberately not a whole list: on an agency account a host carries monitors from several workspaces, and a caller replacing “the list” would be replacing rows it was never shown. Both directions are idempotent.

Parameters and responses

POST /hosts/{id}/monitors

NameInType
id requiredpathstring
monitorIdbodystring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

DELETE /hosts/{id}/monitors

NameInType
id requiredpathstring
monitorIdquerystring
monitorIdbodystring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /monitors/{id}/hosts · PUT /monitors/{id}/hosts

The other side of the same relationship, and this one is a whole list — these links belong to one monitor, so there is nothing on the far end that another workspace can see. Send hostIds; an empty array is a real request and means the monitor runs on no host in particular. Ids that are not yours are dropped rather than refused. Every monitor response also carries hosts, empty when there are none.

Parameters and responses

GET /monitors/{id}/hosts

NameInType
id requiredpathstring
ResponseListMonitorHostsResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

PUT /monitors/{id}/hosts

NameInType
id requiredpathstring
hostIdsbodystring[]
ResponseListMonitorHostsResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /incidents

Needs incident:read. Takes ?status= — one of open, acknowledged, resolved or suppressed — and ?open=true for the three that are not resolved. An unrecognised status is ignored rather than refused, the same way ?limit= (default 50) is clamped to 200 rather than rejected.

Each carries id, monitorId, monitorName, title, cause, status, severity, startedAt, resolvedAt, durationSeconds, acknowledgedAt and rootIncidentId.

rootIncidentId is the field to look at when building an alert feed. When it is set, this incident is the blast radius of another one — the database host went down and this is one of the twelve services behind it. Skip those and you get one alert instead of thirteen.

Parameters and responses
NameInType
limitqueryinteger
openqueryboolean
statusquerystring
ResponseListIncidentsResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /incidents/{id}

One incident. Needs incident:read. The same fields as the list, plus workspaceId.

It does not return the incident’s timeline — the comments and status changes shown in the dashboard are not on this endpoint. If you need them, say so and they can be added; documenting them here while they do not exist would be worse than the gap.

Parameters and responses
NameInType
id requiredpathstring
ResponseGetIncidentResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

PATCH /incidents/{id}

Send status of "acknowledged" or "resolved", or a comment, or both. Each is checked against its own permission: incident:acknowledge, incident:resolve, incident:comment. Adding "publish": true to a comment puts it on the status page and additionally needs status_page:manage.

# A runbook that fixed the thing itself can close its own incident.
curl -X PATCH https://vitrinaengine.com/api/v1/incidents/$ID \
  -H "Authorization: Bearer vte_…" \
  -H "Content-Type: application/json" \
  -d '{"status": "resolved", "comment": "Restarted by runbook.", "publish": true}'
Parameters and responses
NameInType
id requiredpathstring
statusbodystring
commentbodystring
publishbodyboolean
ResponseGetIncidentResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

PUT /incidents/{id}/postmortem

Write or replace the incident’s postmortem. Needs incident:resolve, and the incident must already be resolved — otherwise 409 with code incident_not_resolved. Send body as Markdown, 1 to 50,000 characters after trimming (postmortem_required, postmortem_too_long). There is one per incident, so each call replaces the last. The response is the incident, as GET /incidents/{id} returns it.

GET /incidents/{id} carries it as postmortem: { body, authorName, updatedAt }, or null, and the list carries hasPostmortem. Render body as Markdown and never as HTML: it is text a person typed. A postmortem is internal and never appears on a status page.

curl -X PUT https://vitrinaengine.com/api/v1/incidents/$ID/postmortem \
  -H "Authorization: Bearer vte_…" \
  -H "Content-Type: application/json" \
  -d '{"body": "## What happened\n\nThe connection pool ran dry after a deploy."}'
Parameters and responses
NameInType
id requiredpathstring
bodybodystring
ResponseGetIncidentResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /probes

Your organisation’s own private probes: id, name, lastSeenAt, version, hostname, monitorCount, revoked and createdAt. Needs agent:read. This is what a monitor’s probe picker offers.

There is no region field, and there will not be one. You choose a probe by name; where a check runs behind it is ours to change. Revoked probes are listed with revoked: true and cannot be given work, so do not offer them.

Parameters and responses

No declared parameters or body fields.

ResponseListProbesResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /monitors/{id}/push-url

A heartbeat monitor’s ping URL, shown again. Needs monitor:read. url is null for a kind that is not pinged, and for a token that can no longer be decrypted — the monitor keeps working, and rotating the token in the dashboard issues a URL that can be shown.

Parameters and responses
NameInType
id requiredpathstring
ResponseGetPushUrlResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /organizations · PUT /organizations/active

The organisations the signed-in person belongs to, each with their role there and active on the one in use, and switching between them. Send organizationId; the response is the membership now in use.

A signed-in session only. A key belongs to one membership and answers 403 with session_required. Switching moves every session that person holds, the dashboard included, and an organisation they are not in answers 404.

Parameters and responses

GET /organizations

No declared parameters or body fields.

ResponseListOrganizationsResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

PUT /organizations/active

NameInType
organizationIdbodystring
ResponseActiveOrganizationResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

POST /sms/enrolment · POST /sms/enrolment/confirm

Enrol your own mobile number for SMS alerts: send phone (and optionally name) and we text a six-digit code to it; send that code to the second route to confirm. Needs notification_channel:manage and a signed-in session — a number is added by the person holding it, never by a key.

The number is stored unconfirmed and receives nothing until the code comes back. Enrolling again replaces your previous number. Refusals: sms_not_included (402, the plan has no SMS allowance), not_a_phone_number, sms_country_unsupported, sms_route_not_open, verification_code_not_sent (502; call again for a new code), verification_code_malformed and verification_code_incorrect. A successful start carries country and caveat — sender-replaced, registration-pending, unverified or null — which is worth showing before somebody relies on the number.

Parameters and responses

POST /sms/enrolment

NameInType
phonebodystring
namebodystring
ResponseSmsEnrolmentResponse
Status codes201 400 401 403 404 429
Rate limit30 a minute

POST /sms/enrolment/confirm

NameInType
codebodystring
ResponseSmsConfirmedResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

POST /channels/{id}/confirmation

Send an email channel’s confirmation message again. Needs notification_channel:manage. Each call issues a new link and the previous one stops working. sent: false means there was nothing to send — the address has already confirmed — and a confirmed address is never mailed again this way. A send the mail provider refused is 502 with confirmation_not_sent.

Parameters and responses
NameInType
id requiredpathstring
ResponseChannelConfirmationResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

PATCH /workspaces/{id}

Rename a workspace. Needs workspace:update. name is required (1 to 80 characters); clientReference is optional, and null clears it. The slug never changes, because it is in status-page URLs. The response is the workspace as it now reads.

Parameters and responses
NameInType
id requiredpathstring
namebodystring
clientReferencebodyrequests.NullableString
ResponseUpdatedWorkspaceResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

POST /sourcemaps

Upload a source map so minified stack traces resolve. Needs monitor:create. Requires projectRef (a number) and filename, plus the map itself as either map (JSON) or mapGzipBase64. Optional debugId and release.

Send a debug id or a release. A map with neither cannot be matched to a stack trace and will sit there doing nothing. Matching is by debugId first, then release plus filename.

Maps are resolved when an issue is read, not at upload, so one uploaded after the errors arrived still helps — which is the usual order.

Parameters and responses
NameInType
projectRefbodyinteger
filenamebodystring
debugIdbodystring
releasebodystring
mapbodystring
mapGzipBase64bodystring
ResponseUploadedSourceMapResponse
Status codes201 400 401 403 404 429
Rate limit300 an hour

POST /symbols

The same idea for a native app: upload an iOS .dSYM or an Android R8 mapping.txt so crash stacks resolve. Needs monitor:create. Twenty uploads an hour, and twenty builds’ worth kept per project.

Unlike /sourcemaps, the body is the gzipped file itself and the metadata is in headers: X-Vitrina-Project-Ref, X-Vitrina-Platform (ios or android), X-Vitrina-Symbol-Name, and X-Vitrina-Release. A dSYM is tens of megabytes, and base64 in a JSON field costs a third again in transfer.

The two platforms are matched differently. An iOS frame names the Mach-O UUID of the image it belongs to, so an iOS upload must carry X-Vitrina-Debug-Ids and is matched on it exactly. R8 emits no such id, so an Android mapping is matched on the release alone — which must be exactly what the app reports, <applicationId>@<versionName>+<versionCode>. One digit out and frames resolve to lines that look plausible and are wrong.

Re-uploading the same build replaces its symbols rather than failing, so a retried release job is not an error. As with source maps, resolution happens when an issue is read.

Parameters and responses

No declared parameters or body fields.

ResponseUploadedSymbolsResponse
Status codes200 400 401 403 404 429
Rate limit20 an hour

GET /errors/projects

The error-tracking projects this account holds. Needs monitor:read. Each carries ref — the number in the DSN — plus publicKey, unresolved and eventsLast24h. Read-only: a project is created with a key that still has to be pasted into an application’s config, so there is nothing here an endpoint would finish.

Parameters and responses

No declared parameters or body fields.

ResponseListErrorProjectsResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /errors/issues

Grouped errors, newest activity first. Needs monitor:read. An issue is a fingerprint rather than an event, so a thousand occurrences of one throw are a single row with timesSeen at a thousand. ?status= defaults to unresolved and also takes resolved, ignored or all; ?project= filters by project id, ?q= searches the type, value and culprit, and ?limit= is capped at 200. spark is twenty-four hourly counts, oldest first.

Parameters and responses
NameInType
limitqueryinteger
projectquerystring
qquerystring
statusquerystring
ResponseListIssuesResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /errors/issues/{id}

One issue with its stack. Needs monitor:read. exceptions is the SDK’s own chain, thrown error last and its causes before it, and its frames are resolved against any source map you have uploaded — the same resolution the dashboard does, so a script and the person you are talking to are never looking at different stacks. A map that cannot be applied falls back to the original frames rather than failing the request.

lastEvent carries two timestamps on purpose. occurredAt is what the SDK reported and receivedAt is when ingest wrote it; a device that was offline, or a queue that backed up, is exactly the gap between them.

Parameters and responses
NameInType
id requiredpathstring
ResponseGetIssueResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

PATCH /errors/issues/{id}

Resolve, ignore or reopen: { "status": "resolved" }. Needs monitor:update. ignoreHours may accompany ignored to un-ignore it again later, and is capped at 90 days.

Resolving records the release it was resolved in, so a straggler from an older deployment does not reopen the issue. When the SDK sent no release there is nothing to compare against and any later event does reopen it — with no way to tell a straggler from a regression, the safe assumption is that the bug is back.

Parameters and responses
NameInType
id requiredpathstring
statusbodystring
ignoreHoursbodyinteger
ResponseIssueStatusResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /analytics

The websites this account measures. Needs monitor:read. Carries publicId — the value that goes in the script tag, which is public by design — plus viewsLast24h and trafficAlerting, which is true while a site’s traffic is below what that hour normally sees.

Parameters and responses

No declared parameters or body fields.

ResponseListAnalyticsSitesResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /analytics/{id}

One site’s numbers, for a spreadsheet or a report. Needs monitor:read. ?days= defaults to 7 and is capped at 365. ?dimensions= takes a comma-separated list from path, referrer, country, browser, os, device, utm_source, utm_medium, utm_campaign and event; an unknown one is a 400 rather than an empty answer, because a typo that returns nothing reads as “no traffic”.

The visitor figure is called dailyUniqueVisitors, and that is what it is. It is the sum of daily uniques and cannot be anything else: the secret behind a visitor hash is destroyed at the end of its UTC day, so somebody who visited on two days counts twice and there is no key that would join them. That is the privacy design working rather than an approximation — but a field called visitors beside a 30-day range would invite you to report a number that means something else.

curl "https://vitrinaengine.com/api/v1/analytics/$ID?days=30&dimensions=path,referrer" \
  -H "Authorization: Bearer vte_…"
Parameters and responses
NameInType
id requiredpathstring
daysqueryinteger
ResponseGetAnalyticsSiteResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /billing

The plan, and where it is billed. Needs billing:read. Carries plan, status, cadence, currentPeriodEnd, cancelAt, addOnPacks and the last dozen receipts.

source is paddle, apple, manual or none. It matters: a subscription bought in the iOS app is Apple’s to change, and the plan, the card and the cancellation all live in the customer’s App Store settings rather than here. canCheckoutOnWeb and canPurchaseInApp say which offers are safe to show, so a client does not have to re-derive that rule and get it wrong in the direction that bills somebody twice.

Parameters and responses

No declared parameters or body fields.

ResponseGetBillingResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /monitors/{id}/history

Daily uptime for one monitor. Needs monitor:read. Takes days, up to your plan’s history retention.

Computed from daily rollups rather than from individual check results, which is why it keeps answering for periods whose raw results retention has already deleted.

Parameters and responses
NameInType
id requiredpathstring
daysqueryinteger
ResponseGetMonitorHistoryResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /monitors/{id}/graph · GET /monitors/graphs

The last hours of one monitor, hour by hour, or of every monitor this key can see in one response — what the dashboard draws on a monitor row and page. Needs monitor:read. Takes hours, 24 by default and at most 168.

An uptime graph carries the share of each hour’s checks that passed, with null for an hour with none; a value graph, for the quantitative kinds, carries its readings as series with a unit. Every hour in the window is present whether or not anything was recorded, so a gap is visibly a gap. Readings are sent as measured: a raw series has no known scale, so draw it against its own highest reading and never print that share as the reading.

Parameters and responses

GET /monitors/{id}/graph

NameInType
id requiredpathstring
hoursqueryinteger
ResponseGetMonitorGraphResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /monitors/graphs

NameInType
hoursqueryinteger
ResponseListMonitorGraphsResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /monitors/{id}/channels · PUT /monitors/{id}/channels

Which channels this monitor alerts, and setting them. Reading needs notification_channel:read; writing needs monitor:update.

PUT replaces the whole set — send every channel id you want, not the ones you are adding. An empty array means the monitor alerts nobody, which is a valid thing to want and a bad thing to do by accident.

Parameters and responses

GET /monitors/{id}/channels

NameInType
id requiredpathstring
ResponseGetMonitorChannelsResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

PUT /monitors/{id}/channels

NameInType
id requiredpathstring
channelIdsbodyStringList
ResponseGetMonitorChannelsResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /incidents/{id}/events

The timeline for one incident: state changes, acknowledgements, comments, escalation steps. Needs incident:read. This is the timeline that GET /incidents/{id} does not include.

Parameters and responses
NameInType
id requiredpathstring
ResponseListIncidentEventsResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /channels · POST /channels

Reading needs notification_channel:read; creating needs notification_channel:manage. Each carries id, kind, name, target and workspaceId.

secret is returned only when a channel is created, and never again. It is the signing key for a webhook; storing it somewhere you can read it back would make a read-scoped key a way to forge signed requests.

You can create the kinds a person can create in the dashboard: email, webhook, telegram, slack, teams, discord, pagerduty and jsm. SMS, WhatsApp, satellite and push are enrolled by the person who receives them and cannot be created through the API — that is what makes them consent rather than a field somebody typed.

An email channel is created unconfirmed and receives one message asking whether it wants alerts. Nothing else is sent until it agrees, so creating one through the API does not put an address on your paging rota by itself.

Parameters and responses

GET /channels

No declared parameters or body fields.

ResponseListChannelsResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

POST /channels

NameInType
kindbodystring
namebodystring
targetbodystring
workspaceIdbodyrequests.NullableString
ResponseCreatedChannelResponse
Status codes201 400 401 403 404 429
Rate limit30 a minute

PATCH /channels/{id} · DELETE /channels/{id} · POST /channels/{id}/test

All need notification_channel:manage. The test send is refused for an address that has not confirmed — otherwise it would be an unlimited way to mail an unconsenting address, wearing a helpful label.

Parameters and responses

PATCH /channels/{id}

NameInType
id requiredpathstring
enabledbodyboolean
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

DELETE /channels/{id}

NameInType
id requiredpathstring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

POST /channels/{id}/test

NameInType
id requiredpathstring
ResponseTestDeliveryResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /status-pages · POST /status-pages

Reading needs status_page:read; writing needs status_page:manage. Each carries id, name, slug, visibility, customDomain, domainVerifiedAt, subscribersEnabled, componentCount, workspace and createdAt.

Parameters and responses

GET /status-pages

No declared parameters or body fields.

ResponseListStatusPagesResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

POST /status-pages

NameInType
namebodystring
workspaceIdbodystring
ResponseCreatedResponse
Status codes201 400 401 403 404 429
Rate limit30 a minute

GET /status-pages/{id} · PATCH /status-pages/{id} · DELETE /status-pages/{id}

Reading needs status_page:read; changing or deleting needs status_page:manage.

Parameters and responses

GET /status-pages/{id}

NameInType
id requiredpathstring
ResponseGetStatusPageResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

PATCH /status-pages/{id}

NameInType
id requiredpathstring
namebodystring
headlinebodyrequests.NullableString
descriptionbodyrequests.NullableString
logoUrlbodyrequests.NullableString
themeAccentbodystring
languagebodystring
historyDaysbodyinteger
showResponseTimesbodyboolean
showIncidentHistorybodyboolean
subscribersEnabledbodyboolean
visibilitybodystring
passwordbodyrequests.NullableString
customCssbodyrequests.NullableString
hideVitrinaBrandingbodyboolean
groupByHostbodyboolean
ResponseUpdatedStatusPageResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

DELETE /status-pages/{id}

NameInType
id requiredpathstring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

POST /status-pages/{id}/components · DELETE /status-pages/{id}/components

Add or remove a monitor from a page. Needs status_page:manage.

The monitor must be in the same workspace as the page, not merely in the same organisation. In an agency account that is what stops one client’s monitor being published on another client’s page.

Parameters and responses

POST /status-pages/{id}/components

NameInType
id requiredpathstring
monitorIdbodystring
displayNamebodystring
ResponseComponentAckResponse
Status codes201 400 401 403 404 429
Rate limit30 a minute

DELETE /status-pages/{id}/components

NameInType
id requiredpathstring
componentquerystring
componentIdbodystring
ResponseComponentAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /workspaces · POST /workspaces · DELETE /workspaces/{id}

Reading needs workspace:read, and the rest their own permissions. Each carries id, name, slug, clientReference and isDefault.

A key belonging to a confined member sees only the workspaces that member is confined to. The default workspace cannot be deleted.

Parameters and responses

GET /workspaces

No declared parameters or body fields.

ResponseListWorkspacesResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

POST /workspaces

NameInType
namebodystring
clientReferencebodystring
ResponseCreatedResponse
Status codes201 400 401 403 404 429
Rate limit30 a minute

DELETE /workspaces/{id}

NameInType
id requiredpathstring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /members · POST /members

Reading needs member:read; inviting needs member:invite. The response has members and invitations separately — an invitation that nobody has accepted has not added anybody, and merging the two would make a seat count that disagrees with what you are billed for.

POST sends an invitation rather than creating an account. It takes an email, a role, and optionally workspaceIds to confine them.

Parameters and responses

GET /members

No declared parameters or body fields.

ResponseListMembersResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

POST /members

NameInType
emailbodystring
rolebodystring
workspaceIdsbodystring[]
ResponseAckResponse
Status codes201 400 401 403 404 429
Rate limit30 a minute

PATCH /members/{id} · DELETE /members/{id} · DELETE /invitations/{id}

Changing a role needs member:update_role; removing needs member:remove; revoking an invitation needs member:invite.

Removing a member deletes their personal alert channels — their handset, their private chat, their mobile number — along with their API keys. Shared channels are untouched. The last owner cannot be removed or demoted.

Parameters and responses

PATCH /members/{id}

NameInType
id requiredpathstring
rolebodystring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

DELETE /members/{id}

NameInType
id requiredpathstring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

DELETE /invitations/{id}

NameInType
id requiredpathstring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /maintenance · POST /maintenance · DELETE /maintenance

Windows during which nothing pages anybody. By default checks still run and still record, so those minutes count towards uptime like any others; with keepChecking false nothing is checked while the window is open, which leaves a gap in the history rather than a dip. Reading needs incident:read; writing needs maintenance:manage.

Carries title, startsAt, endsAt, timezone, monitorIds, recurrenceRule, keepChecking, showOnStatusPage and notifySubscribers. A recurring window is one row rather than many.

A rule repeats the window at the same wall-clock time in its own timezone, so a window through a clock change stays at the hour it was scheduled for. FREQ=DAILY, FREQ=WEEKLY and FREQ=MONTHLY are expanded, optionally with INTERVAL, COUNT, UNTIL, and a BYDAY or BYMONTHDAY naming the day the window itself starts on. Any other rule is stored and returned to you unchanged and holds alerts back for its first occurrence only — it is never guessed at.

Parameters and responses

GET /maintenance

NameInType
pastqueryboolean
ResponseListMaintenanceResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

POST /maintenance

NameInType
titlebodystring
descriptionbodystring
workspaceIdbodystring
monitorIdsbodystring[]
startsAtbodystring
endsAtbodystring
recurrenceRulebodystring
timezonebodystring
keepCheckingbodyboolean
showOnStatusPagebodyboolean
notifySubscribersbodyboolean
ResponseCreatedResponse
Status codes201 400 401 403 404 429
Rate limit30 a minute

DELETE /maintenance

NameInType
idquerystring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /on-call

Schedules, escalation policies, overrides, and who is on call right now. Needs oncall:read.

onCallNow is resolved at the moment you ask, with viaOverride saying whether it came from the rotation or from somebody covering. unreachable names participants with no channel that could actually reach them, which is the failure worth catching before an incident rather than during one.

Parameters and responses

No declared parameters or body fields.

ResponseGetOnCallResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /agents · POST /agents · DELETE /agents · GET /agents/{id}/metrics

Server agents report CPU, memory and disk from your own machines. Enrolling needs agent:enroll, reading needs agent:read, removing needs agent:delete.

Enrolling returns a token, once. It is what the agent authenticates with, so it is never readable afterwards.

Parameters and responses

GET /agents

No declared parameters or body fields.

ResponseListAgentsResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

POST /agents

NameInType
namebodystring
workspaceIdbodyrequests.NullableString
ResponseEnrolledAgentResponse
Status codes201 400 401 403 404 429
Rate limit30 a minute

DELETE /agents

NameInType
idquerystring
idbodystring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /agents/{id}/metrics

NameInType
id requiredpathstring
hoursqueryinteger
limitqueryinteger
ResponseGetAgentMetricsResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /api-keys · POST /api-keys · DELETE /api-keys

Reading needs api_key:read; creating and revoking need api_key:manage. Each carries id, name, prefix, scopes, workspaceIds, owner, createdAt, lastUsedAt, expiresAt and revokedAt.

token is returned only at creation. prefix is the displayable part, which is what lets you tell two keys apart in a list without either being readable.

A key inherits the role and workspace confinement of the membership that created it, so a key cannot be a way around a limit its author has.

Parameters and responses

GET /api-keys

No declared parameters or body fields.

ResponseListApiKeysResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

POST /api-keys

NameInType
namebodystring
scopesbodystring[]
workspaceIdsbodystring[]
expiresInDaysbodyrequests.NullableInt64
ResponseCreatedApiKeyResponse
Status codes201 400 401 403 404 429
Rate limit30 a minute

DELETE /api-keys

NameInType
idquerystring
idbodystring
ResponseAckResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /audit

Configuration changes, with who made them and what changed. Needs audit_log:read, which is Business and above.

Paginated by nextBefore rather than by page number, so a page boundary does not shift while you are reading it. Entries are recorded on every plan — the plan gates reading, so upgrading opens the whole history rather than starting one.

Credentials, hashes and secrets are never written into changes.

Parameters and responses
NameInType
limitqueryinteger
ResponseListAuditResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

GET /me

Who this key is, what it can reach, and what the plan allows. No permission needed — every key can describe itself.

Carries user, organization, membership(role and workspace confinement), entitlements (the resolved plan limits, with any add-on packs already folded in) and usage. Use it to check a limit before attempting a create rather than after being refused.

A session also gets organizations, listing every organisation the person belongs to. An API key does not: a key is issued against one membership and confined to it, so listing the others would advertise organisations that credential cannot reach.

Parameters and responses

No declared parameters or body fields.

ResponseGetMeResponse
Status codes200 400 401 403 404 429
Rate limit120 a minute

DELETE /account

Deletes the organisation and everything in it. No permission constant guards this one because ownership is the check: it is refused for anybody who is not an owner.

This is not reversible and is not a pause. Use it deliberately.

Parameters and responses

No declared parameters or body fields.

ResponseAccountDeletedResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

POST /billing/apple/verify

Confirms an in-app purchase made in our own mobile app. Needs billing:manage.

Documented for completeness rather than for use: it is only callable with a receipt Apple issued to our app, so there is nothing a third-party integration can do with it. Listed here because a route that exists and is described nowhere is indistinguishable from one somebody forgot.

Parameters and responses
NameInType
transactionIdbodystring
ResponseAppleVerifyResponse
Status codes200 400 401 403 404 429
Rate limit30 a minute

GET /

The index. Lists the routes this deployment serves, which is the machine-readable version of this page and always current — it is generated from what is actually mounted.

Incident feed (RSS and Atom)

Every incident in your account as a feed, for a reader rather than a client library. Create the URL in the dashboard under Settings → Notifications:

https://vitrinaengine.com/feeds/incidents/vtf_…/feed.xml
https://vitrinaengine.com/feeds/incidents/vtf_…/atom.xml

The URL is the credential. There is no header to send: anybody holding the link can read every incident in the account, across every workspace. It is shown in the dashboard whenever you need it again, because it lives in a feed reader somebody will one day reinstall — and it can be replaced or revoked, which takes effect on the next request rather than whenever a cache expires.

It carries incident-level facts only: the monitor’s name, the cause, severity, status, when it opened and closed, and the updates marked public. Never a postmortem, never monitor configuration, and never where a check ran from. Most readers sync through somebody else’s servers, so the document is written as though it leaves your building.

Polling once a minute is expected. Past sixty requests a minute for one URL it answers 429, and a URL that was never valid, has been replaced or has been revoked answers 404 — the same answer for all three, so it cannot be used to find out which tokens were once real.

What this API deliberately does not return

  • Monitor config. It can contain credentials.
  • Which region a check ran in. Where we check from is ours to decide and ours to change as probes are placed and moved. It would be a promise about our infrastructure that you could not act on, and it is not shown anywhere else in the product either.

Exporting your data

Two routes hand back a file rather than a message, so that taking your data out does not mean paging through a collection endpoint a thousand times. Both are on every plan, the free one included — what a plan changes is how much history there is to export, not whether you may.

GET /export/{entity}

One kind of row, as CSV or JSON. entity is one of monitors, hosts, status-pages, notification-channels, members, incidents, incident-events, postmortems, audit-log, error-issues, uptime-daily, uptime-hourly or analytics-hourly. Anything else answers 404.

format is csv (the default) or json. from and to are RFC 3339 timestamps bounding the entity’s own time column. bom prepends a UTF-8 byte-order mark to a CSV: Excel needs it to read a non-ASCII name correctly, and most scripting languages read it as part of the first column heading — so it is off unless you ask.

curl -H "Authorization: Bearer $KEY" \
"https://vitrinaengine.com/api/v1/export/incidents?format=json"
Parameters and responses
NameInType
entity requiredpathstring
bomqueryboolean
formatquerystring
fromquerystring
toquerystring
Status codes200 400 401 403 404 429
Rate limit6 an hour

GET /export

Everything the key may read, as a zip: one file per entity in the format you asked for, plus a manifest.json saying what the archive holds, how many rows each file has and which entities are absent. Entities your key’s role cannot read are left out rather than refused — a key scoped to one workspace should still be able to take that workspace’s data — and the manifest is what stops that being silent.

Parameters and responses
NameInType
bomqueryboolean
formatquerystring
fromquerystring
toquerystring
Status codes200 400 401 403 404 429
Rate limit6 an hour

What an export does not contain

  • Nothing that authenticates. No channel configuration — a webhook URL or chat credential — no heartbeat tokens, no password hashes, no API keys. A monitor ’s configuration is included with anything credential-shaped removed: a request body, request headers, an SNMP community string, a mailbox login and any field named like a password or a key. The URL, the host, the port and the assertions stay.
  • Raw check results and raw error events. Those are dropped whole by retention — three days on the free plan — so an export of them would answer differently depending on the hour it ran. Uptime comes from the rollups instead, which is what the product itself shows beyond that window.

Size, and the one refusal you may meet

The body is streamed, so it starts arriving before the whole result has been read out of the database, and no part of your account is ever held in memory in full. The hourly series — uptime-hourly and analytics-hourly — are one row per subject per hour, so they are capped at 92 days per request and a wider window is refused with export_window_too_wide rather than quietly narrowed: a file that is silently missing two years is worse than one you were told to ask for again. uptime-daily carries the same span at a twenty-fourth of the size and is what an archive or an SLA report wants. For the same reason the two hourly series are not in the whole-account zip; ask for them separately with a window.

Exports have their own rate bucket — 6 an hour, per organisation — because one request walks every table your account touches, which is not the shape the 120-a-minute read ceiling was sized for.

Versioning

The version is in the path. Fields will be added to responses — treat unknown ones as something to ignore rather than an error — but nothing will be removed from v1 or change meaning under it.

Anything missing?

The surface is deliberately small: it covers a wallboard, a chat digest and a deploy script that silences a monitor for the length of a release. If you are building something it does not reach, write to support@vitrinaengine.com — knowing what people actually want is how the next endpoint gets chosen.

Pricing · Terms