DocsReferenceAPI Reference

API Reference

Base URL: https://api.verid.dev

Version: 2026-05-01

All requests must include Authorization: Bearer YOUR_API_KEY where YOUR_API_KEY starts with vrd_. All request and response bodies are JSON.

Pagination is uniform across list endpoints: pass page (default 1, min 1) and limit (default 20, min 1, max 100) as query parameters. List responses always return:

json
{
  "data": [...],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 42,
    "totalPages": 3,
    "hasMore": true
  }
}

Monitors

List monitors

GET /v1/monitors

Query paramTypeDefaultConstraints
pageinteger1>= 1
limitinteger201–100
visualboolean-true = only monitors with the visual layer enabled; false = only without

Create a monitor

POST /v1/monitors

FieldTypeRequiredDefaultConstraints / Allowed values
namestringyes-1–200 characters
urlstringyes-Must be a valid URL
schedule_interval_secondsintegeryes-Integer, minimum 60 on every plan. Each check uses one run from the plan's monthly runs
extract_configobjectyes-See Extract config
diff_predicateobjectyes-See Diff predicate
deliveriesarrayno[]0–25 items; tier-gated max per monitor (free: 1, lite: 2, starter: 3, pro: 10, scale: 25). See Delivery config
template_slugstringnonullMarks the monitor as created from a template
fetch_modestringno"auto""auto" | "browser"
request_headersobjectnonullSee Request headers
request_configobjectnonullSee Request config
visual_configobjectnonullSee Visual config. Each check of a visual monitor uses one run (on the free trial, from the separate 100 visual runs)

Returns 201 Created with the full monitor object.

Extract config

Discriminated by method. One of:

methodExtra fields
"css"fields: { [name]: cssSelector }
"xpath"fields: { [name]: xpathExpression }
"json_path"fields: { [name]: jsonPathExpression }
"regex"fields: { [name]: regexPattern }
"full_page"(no extra fields - hashes the rendered document)
"prompt"prompt: string (10–2000 chars), optional schema: object (JSON-Schema-shaped hints)

See Extraction methods for worked examples.

Diff predicate

Discriminated by type. One of:

typeRequired fields
"any_field_changes"-
"field_changes"field: string
"field_increases_by_percent"field: string, threshold: number (positive)
"field_decreases_by_percent"field: string, threshold: number (positive)
"field_increases_by_absolute"field: string, threshold: number
"field_decreases_by_absolute"field: string, threshold: number
"field_matches_regex"field: string, pattern: string
"field_equals"field: string, value: string | number | boolean
"composite"operator: "AND" | "OR", conditions: DiffPredicate[] (min 1)

See Predicates for examples and semantics.

Delivery config

Discriminated by type. Each item in deliveries must be one of:

typeRequired fields
"webhook"url: string (must be URL), optional headers: { [name]: value } (subject to Request headers rules)
"slack"webhookUrl: string (Slack incoming webhook URL)
"discord"webhookUrl: string (Discord webhook URL)
"email"to: string (email address)

Request headers

request_headers (on the monitor, applied to outgoing scrape requests) and headers (on a webhook delivery, applied to outbound webhook calls) share the same validation:

  • At most 20 headers.
  • Header name: 1–256 chars, RFC 7230 token chars only ([!#$%&'*+\-.^_`|~0-9a-zA-Z]+).
  • Header value: max 2048 chars, printable ASCII + tab (no CR/LF).
  • Reserved names rejected: host, content-length, connection, transfer-encoding, cookie.

Request config

Send something other than a plain GET, and follow paginated APIs. A monitor with a request_config is always fetched directly (never through the browser or proxy), because only a direct request can carry a method and body.

FieldTypeRequiredDefaultConstraints
methodstringno"GET""GET" | "POST" | "PUT" | "PATCH"
bodystringno-Max 100,000 chars; not allowed with GET. Sent as application/json when it parses as JSON, unless a Content-Type request header is set.
pagination.typestringyes*-"page" (page number or offset) | "cursor" (next cursor read from the response) | "next_url" (next page URL in the response) | "link_header" (Link: <...>; rel="next")
pagination.instringpage, cursor-"query" | "body". "body" needs the body to be a JSON object.
pagination.paramstringpage, cursor-Query parameter name, or a dot path into the body (e.g. variables.page)
pagination.startintegerno1page only; >= 0
pagination.stepintegerno1page only; >= 1. For offset APIs use start: 0 and the page size as the step.
pagination.cursor_pathstringcursor-Dot path to the next cursor (e.g. data.pageInfo.endCursor). The first request is sent as-is.
pagination.next_url_pathstringnext_url-Dot path to the next page URL (e.g. next, _links.next.href); relative URLs are resolved
pagination.items_pathstringno-Dot path to the items array in each response (e.g. data.items). Omit when the response is itself an array.
pagination.total_pathstringno-Dot path to the total item count; the loop stops once it is reached
pagination.has_more_pathstringno-Dot path to a boolean (e.g. has_more); the loop stops when it is false
pagination.max_pagesintegerno201 - 50

* Required when pagination is present; the Required column names the types that need a field. Dot paths accept an optional leading $. and numeric array indexes (links.0.href). Pages are fetched until one comes back empty, the has-more flag is false, the total is reached, the next cursor, URL or Link header is missing or repeats, or (page type) a page is shorter than the first. Every item is merged into items_path of the first response (or into one array), so json_path fields see the whole list. A non-2xx page, a page without an items array, or a list that max_pages cuts short fails the run instead of saving partial data.

Visual config

Additive screenshot layer, independent of the extraction method: when enabled, every check also screenshots the page (or the picked regions) and pixel-diffs it against the previous run. See Visual monitoring for the full guide.

FieldTypeRequiredDefaultConstraints
enabledbooleanyes--
thresholdPctnumberno10–100; deliveries fire when the changed-pixel area reaches this percent
fullPagebooleannotrueFull scroll height vs viewport only
regionsarrayno[]Max 20. Each region has exactly one of selector (CSS, re-resolved every run) or rect ({x, y, width, height} in full-page CSS pixels), plus an optional label
ignoreSelectorsarrayno-Max 20 CSS selectors hidden before the shot (cookie banners, ads, tickers)
viewportobjectno1280×720{width: 320–3840, height: 240–2160}

Get a monitor

GET /v1/monitors/:id

Returns the monitor object:

json
{
  "id": "uuid",
  "user_id": "uuid",
  "name": "string",
  "url": "string",
  "schedule_interval_seconds": 3600,
  "extract_config": { "...": "..." },
  "diff_predicate": { "...": "..." },
  "deliveries": [],
  "status": "active | paused | billing_paused | error | deleted",
  "template_slug": "string | null",
  "consecutive_failures": 0,
  "fetch_mode": "auto | browser",
  "request_headers": { "...": "..." },
  "request_config": { "...": "..." },
  "visual_config": { "...": "..." },
  "last_run_at": "ISO 8601 | null",
  "next_run_at": "ISO 8601 | null",
  "created_at": "ISO 8601"
}

Update a monitor

PATCH /v1/monitors/:id

All create fields are patchable except template_slug (set once on creation). Same constraints and tier gates apply.

Delete a monitor

DELETE /v1/monitors/:id

Returns 204 No Content.

Pause / Resume a monitor

POST /v1/monitors/:id/pause - sets status: "paused".

POST /v1/monitors/:id/resume - sets status: "active" and reschedules an immediate run. Returns 403 FORBIDDEN if the monitor is billing_paused (locked by an earlier plan change; syncing the plan from Billing unlocks it).

Both take an empty JSON body ({}).

Trigger a manual run

POST /v1/monitors/:id/run

Empty body. There is no daily cap on manual runs. The run needs a run left in the month or in your run credits: when both are used up it returns 429 RUN_LIMIT_REACHED.

Returns { "run_id": "uuid", "queued": true }.

List runs for a monitor

GET /v1/monitors/:id/runs

Supports page and limit (same defaults as the global list).

Visual screenshot history

GET /v1/monitors/:id/visual-history?limit=20

Newest-first screenshot history for a visual monitor (limit: 1–20, default 20). Thumbnail URLs are signed and short-lived - request them when needed rather than storing.

json
{
  "data": [
    {
      "run_id": "uuid",
      "started_at": "ISO 8601",
      "change_pct": 6.1,
      "has_changes": true,
      "thumb_url": "https://...signed..."
    }
  ]
}

Runs

Get a run

GET /v1/runs/:id

json
{
  "id": "uuid",
  "monitor_id": "uuid",
  "user_id": "uuid",
  "started_at": "ISO 8601",
  "completed_at": "ISO 8601 | null",
  "status": "running | success | error | skipped",
  "extracted": { "...": "..." },
  "diff": { "...": "..." },
  "delivery_triggered": true,
  "fetch_method": "static | browser | proxy | null",
  "duration_ms": 1234,
  "error_message": "string | null",
  "visual_change_pct": 6.1
}

visual_change_pct is only set on runs of monitors with the visual layer enabled.

List deliveries for a run

GET /v1/runs/:id/deliveries

Returns { "data": [Delivery, ...] } (not paginated - one run produces at most maxDeliveriesPerMonitor rows).

Replay all failed deliveries for a run

POST /v1/runs/:id/replay

Re-queues every failed or dead delivery on the run. Returns { "queued": <number> }.

Visual run images

GET /v1/run-images/:id

Signed before/after/diff-overlay image URLs for a visual run, per region. URLs are short-lived - request them when needed rather than storing. Returns 404 if the run has no visual capture. beforeUrl comes from the previous screenshot-bearing run (null on the first capture); diffUrl is the red-highlight pixel-diff overlay (null when nothing changed).

json
{
  "kind": "visual",
  "aggregateChangePct": 6.1,
  "hasChanges": true,
  "regions": [
    {
      "regionIndex": 0,
      "label": "Pricing table",
      "changedPct": 6.1,
      "hasChanges": true,
      "beforeUrl": "https://...signed... | null",
      "afterUrl": "https://...signed...",
      "diffUrl": "https://...signed... | null"
    }
  ]
}

Deliveries

List deliveries

GET /v1/deliveries

Query paramTypeDefaultAllowed values
pageinteger1>= 1
limitinteger201–100
statusstringall"pending" | "success" | "failed" | "dead"

Results are restricted to the tier's retention window (free: 14 days, lite: 90, starter: 180, pro: 365, scale: 730). The response also returns the retention envelope:

json
{
  "data": [...],
  "pagination": { "...": "..." },
  "retention": {
    "tier": "free",
    "retention_days": 14,
    "cutoff_at": "ISO 8601"
  }
}

Get a delivery

GET /v1/deliveries/:id

json
{
  "id": "uuid",
  "monitor_id": "uuid",
  "user_id": "uuid",
  "run_id": "uuid",
  "delivery_type": "webhook | slack | discord | email",
  "destination": "string",
  "payload": { "...": "..." },
  "status": "pending | success | failed | dead",
  "attempts": 0,
  "last_attempt_at": "ISO 8601 | null",
  "next_attempt_at": "ISO 8601 | null",
  "response_status": 200,
  "response_body": "string | null",
  "created_at": "ISO 8601"
}

Replay a delivery

POST /v1/deliveries/:id/replay

Empty body. Resets the delivery to pending and re-queues it immediately. Returns { "queued": true }.

Templates

List templates

GET /v1/templates

Query paramTypeAllowed values
categorystringFilter to one category. Common values: developer, crypto, ecommerce, news, status, generic

Each item:

json
{
  "slug": "string",
  "name": "string",
  "description": "string",
  "category": "string",
  "audience": "developer | operator | both",
  "example_url": "string | null",
  "config": {
    "extract": { "...": "..." },
    "diff_predicate": { "...": "..." },
    "schedule_interval_seconds": 3600
  },
  "created_at": "ISO 8601"
}

Create monitor from template

POST /v1/monitors/from-template/:slug

FieldTypeRequiredNotes
namestringnoOverrides the template's default name
urlstringnoOverrides the template's example_url
deliveriesarraynoSame shape as Delivery config

The template's schedule_interval_seconds is clamped up to the 60-second minimum if it would otherwise violate it.

Available template slugs: aws-status-page, crypto-price-coingecko, generic-css-selector, generic-full-page, generic-json-field, generic-llm-prompt, github-new-release, hacker-news-front-page, job-listings-css, npm-new-version, pypi-new-version, rss-new-item, shopify-product-price, sitemap-new-url, stock-price-yahoo.

API Keys

List API keys

GET /v1/keys

Returns { "data": [ApiKey, ...] }. The key_hash column is never returned - only key_prefix (the first 8 chars, safe to display).

Create API key

POST /v1/keys

FieldTypeRequiredDefaultConstraints / Allowed values
namestringyes-1–100 characters
scopesstring[]no["read"]Each scope is a string. Recognized: "read", "write"
expires_atstringno(no expiry)ISO 8601 datetime

Returns 201 Created:

json
{
  "key": "vrd_...",         // shown exactly once
  "apiKey": { "id": "...", "name": "...", "scopes": [...], "expires_at": "..." }
}

Rotate API key

POST /v1/keys/:id/rotate

FieldTypeRequiredConstraints
grace_period_hoursintegeryes0–168 (0 = revoke old immediately; otherwise old key keeps working for N hours)

Returns the same shape as create. The new key inherits the old key's remaining expiry window.

Delete API key

DELETE /v1/keys/:id

Returns 204 No Content.

Usage

Get usage summary

GET /v1/usage

json
{
  "monitorsActive": 3,
  "monitorsLocked": 0,
  "serpMonitorsLocked": 0,
  "llmCallsThisMonth": 12,
  "runs": { "used": 84, "limit": 200 },
  "visualRuns": { "used": 10, "limit": 100 },
  "serpRuns": { "used": 5, "limit": 100 },
  "serpAllowed": true,
  "serpViaCredits": false,
  "runCredits": 1500,
  "serpTrialEndsAt": "2026-10-17T09:12:00.000Z",
  "trialEndsAt": "2026-10-17T09:12:00.000Z",
  "trialActive": true,
  "runsPeriodStart": "2026-10-03T09:12:00.000Z",
  "runsResetAt": "2026-11-03T09:12:00.000Z",
  "runsPeriodIsBilling": false,
  "tier": "free",
  "limits": {
    "runsPerMonth": 200,
    "visualRunsPerMonth": 100,
    "serpRunsPerMonth": 100,
    "serp": "trial",
    "retentionDays": 14,
    "maxDeliveriesPerMonitor": 1,
    "llmCallsPerMonth": 50,
    "proxyBytesPerMonth": 0,
    "screenshotBytesPerMonth": 209715200
  }
}

Every check is a run, and a SERP check uses one run per result page. Monthly runs are counted from runsPeriodStart to runsResetAt: your billing period when you have a subscription (runsPeriodIsBilling: true). On paid plans normal, visual and SERP checks share runs, and visualRuns and serpRuns are null. New accounts start on a 14-day free trial (tier free, no card required): the trial's runs, visual runs and SERP runs are separate pools that cover the whole 14 days, not a month. trialEndsAt says when the trial ends (null on paid plans) and trialActive whether it is still running. When the trial ends without a plan, monitors pause; they keep their settings and history, and choosing a plan restarts them right away. Monitors are unlimited on every plan. When a pool is used up, checks use your run credits (runCredits), which you buy once in the dashboard and which never expire. When the credits are gone too, scheduled checks stop until runsResetAt (the start of the next billing period) and manual runs return 429 RUN_LIMIT_REACHED. Upgrading or buying credits starts them again right away.

Run credits work on every plan, the free trial included, and on every kind of check, and they keep checks running after the trial ends until they are used up. On a plan without SERP (a base plan, or after the trial) SERP monitors can still run, but only on run credits, and serpViaCredits is true. When those credits run out the SERP monitors are locked until you buy more or move to a + SERP plan.

Tier limits (reference)

Limitfreelitestarterproscale
runsPerMonth200 (whole 14-day trial)1,0005,00015,000100,000
visualRunsPerMonth100 (whole 14-day trial)null (shared)null (shared)null (shared)null (shared)
serpRunsPerMonth100 (whole 14-day trial)null (shared)null (shared)null (shared)null (shared)
retentionDays1490180365730
maxDeliveriesPerMonitor1231025
llmCallsPerMonth502505005,00025,000
proxyBytesPerMonth0052,428,800524,288,0005,368,709,120
maxTeamMembers2231025

Team

A team is one shared account owned by one user; the owner's plan applies to everyone in it. Roles are owner, admin, member and viewer. Team endpoints use the same authentication, pagination and error format as the rest of the API. Unless noted they require a signed-in user: an API key may call only GET /v1/team, GET /v1/team/members and GET /v1/team/audit, and every other team route answers a key with 403 FORBIDDEN. The Teams guide covers roles, seats and the audit log in full.

Get the current team

GET /v1/team

Any role, and API keys.

json
{
  "id": "uuid",
  "name": "Acme",
  "owner_user_id": "uuid",
  "owner_email": "owner@acme.com",
  "role": "admin",
  "tier": "pro",
  "member_count": 4,
  "pending_invites": 1,
  "max_members": 10,
  "created_at": "2026-10-10T09:00:00.000Z"
}

role is the caller's role in this team (api_key when called with a key). tier is the owner's plan and max_members the seats it includes; member_count plus pending_invites counts against it.

Rename the team

PATCH /v1/team

FieldTypeRequiredConstraints
namestringyes1 to 80 characters, trimmed

Admin or owner. Returns the same shape as GET /v1/team.

List your teams

GET /v1/team/list

Every team the signed-in user belongs to. Not paginated.

json
{
  "data": [
    {
      "id": "uuid",
      "name": "Acme",
      "role": "admin",
      "owner_email": "owner@acme.com",
      "is_personal": false,
      "active": true
    }
  ]
}

Switch the active team

POST /v1/team/switch

FieldTypeRequiredConstraints
team_idstringyesUUID of a team you belong to

Makes that team the active one for the session. Returns { "team_id": "uuid" }; 404 if you are not a member.

List members and invitations

GET /v1/team/members

Admin or owner, and API keys.

json
{
  "members": [
    {
      "user_id": "uuid",
      "email": "owner@acme.com",
      "name": "Sam Owner",
      "role": "owner",
      "joined_at": "2026-10-01T09:00:00.000Z"
    }
  ],
  "invitations": [
    {
      "id": "uuid",
      "email": "new@acme.com",
      "role": "member",
      "invited_by_email": "owner@acme.com",
      "expires_at": "2026-10-17T09:00:00.000Z",
      "created_at": "2026-10-10T09:00:00.000Z"
    }
  ]
}

Invite a member

POST /v1/team/members/invite

FieldTypeRequiredDefaultConstraints / Allowed values
emailstringyes-Valid email address, max 254 characters, lower-cased
rolestringno"member""admin", "member" or "viewer"

Admin or owner. Sends an email with a link that expires after 7 days and must be accepted with the invited address. Returns 201 Created:

json
{
  "id": "uuid",
  "email": "new@acme.com",
  "role": "member",
  "invited_by_email": "owner@acme.com",
  "expires_at": "2026-10-17T09:00:00.000Z",
  "created_at": "2026-10-10T09:00:00.000Z",
  "email_sent": true
}

email_sent is false if the email could not be delivered; the invitation still exists and can be resent. 409 if the address is already a member or has a pending invitation, 429 TIER_LIMIT_EXCEEDED when the team has no seat left (pending invitations count).

Change a member's role

PATCH /v1/team/members/:userId

FieldTypeRequiredAllowed values
rolestringyes"admin", "member" or "viewer"

Admin or owner. The owner's role and your own role cannot be changed. Returns the member (user_id, email, name, role, joined_at).

Remove a member

DELETE /v1/team/members/:userId

Admin or owner; with your own user id, leaves the team (any role except owner). The owner cannot be removed. Returns { "left": true | false }.

Revoke an invitation

DELETE /v1/team/invitations/:id

Admin or owner. Returns 204 No Content; 409 if the invitation was already accepted or revoked.

Resend an invitation

POST /v1/team/invitations/:id/resend

Admin or owner. Issues a fresh link and emails it again; the previous link stops working. Returns the invitation with email_sent, like invite. Limited to 3 resends per invitation per hour.

Preview an invitation

GET /v1/team/invitation?token=...

Public, no authentication, rate limited per IP address. Returns what the accept page shows; state is open, expired, revoked or accepted.

json
{
  "state": "open",
  "team_name": "Acme",
  "email": "new@acme.com",
  "role": "member",
  "invited_by": "Sam Owner",
  "expires_at": "2026-10-17T09:00:00.000Z"
}

Accept an invitation

POST /v1/team/accept

FieldTypeRequiredConstraints
tokenstringyesThe token from the invitation link

Signed-in user only; the account email must match the invited address. Returns { "team_id", "team_name", "role" } and switches the session to that team. 409 INVITATION_INVALID when the link is expired, revoked, already used, or was sent to a different address.

List audit events

GET /v1/team/audit

Admin or owner, and API keys. Newest first. Events are kept for the plan's retentionDays, the same window as run history. Query parameters, all optional:

ParameterTypeDefaultConstraints
pageinteger1min 1
limitinteger501 to 100
actionstring-One action name, for example monitor.deleted
actor_user_idstring-UUID; only events by this user
target_typestring-One target type, for example api_key
fromstring-ISO 8601 datetime; lower bound on created_at
tostring-ISO 8601 datetime; upper bound on created_at
json
{
  "data": [
    {
      "id": "uuid",
      "action": "team.member_role_changed",
      "target_type": "member",
      "target_id": "uuid",
      "target_label": "dev@acme.com",
      "actor": { "type": "user", "id": "uuid", "label": "owner@acme.com" },
      "metadata": { "from": "viewer", "to": "member" },
      "ip_address": "203.0.113.4",
      "created_at": "2026-10-10T09:12:00.000Z"
    }
  ],
  "pagination": { "page": 1, "limit": 50, "total": 1, "totalPages": 1, "hasMore": false },
  "actions": ["team.member_role_changed", "monitor.created"]
}

actor.type is user, api_key or system. actions lists every distinct action recorded for the team, for building a filter.

Export the audit log

GET /v1/team/audit/export

Admin or owner, signed-in user only. Takes the same filters as the list (without page and limit) and returns text/csv with up to 50,000 rows and the columns time, actor, actor_type, action, target_type, target_id, target_label, ip_address, metadata.

OpenAPI

The full OpenAPI 3.1 spec is served by the API itself:

GET /api/openapi.json

You can also browse it interactively at /docs on the dashboard host.

Error responses

All errors follow this format:

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Human-readable message",
    "details": [],
    "requestId": "string (optional)"
  }
}
CodeHTTP StatusMeaning
UNAUTHORIZED401Missing or invalid credentials
FORBIDDEN403Insufficient permissions: the API key scope or your team role does not allow this
NOT_FOUND404Resource not found
VALIDATION_ERROR422Invalid request body
TIER_LIMIT_EXCEEDED429Plan limit reached
RUN_LIMIT_REACHED429Monthly runs and run credits used up. error.details has used, limit, resets_at (null when only credits can run it) and credits_remaining
RATE_LIMIT_EXCEEDED429Too many requests
INTERNAL_ERROR500Server error