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:
{
"data": [...],
"pagination": {
"page": 1,
"limit": 20,
"total": 42,
"totalPages": 3,
"hasMore": true
}
}Monitors
List monitors
GET /v1/monitors
| Query param | Type | Default | Constraints |
|---|---|---|---|
page | integer | 1 | >= 1 |
limit | integer | 20 | 1–100 |
visual | boolean | - | true = only monitors with the visual layer enabled; false = only without |
Create a monitor
POST /v1/monitors
| Field | Type | Required | Default | Constraints / Allowed values |
|---|---|---|---|---|
name | string | yes | - | 1–200 characters |
url | string | yes | - | Must be a valid URL |
schedule_interval_seconds | integer | yes | - | Integer, minimum 60 on every plan. Each check uses one run from the plan's monthly runs |
extract_config | object | yes | - | See Extract config |
diff_predicate | object | yes | - | See Diff predicate |
deliveries | array | no | [] | 0–25 items; tier-gated max per monitor (free: 1, lite: 2, starter: 3, pro: 10, scale: 25). See Delivery config |
template_slug | string | no | null | Marks the monitor as created from a template |
fetch_mode | string | no | "auto" | "auto" | "browser" |
request_headers | object | no | null | See Request headers |
request_config | object | no | null | See Request config |
visual_config | object | no | null | See 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:
method | Extra 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:
type | Required 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:
type | Required 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.
| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
method | string | no | "GET" | "GET" | "POST" | "PUT" | "PATCH" |
body | string | no | - | 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.type | string | yes* | - | "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.in | string | page, cursor | - | "query" | "body". "body" needs the body to be a JSON object. |
pagination.param | string | page, cursor | - | Query parameter name, or a dot path into the body (e.g. variables.page) |
pagination.start | integer | no | 1 | page only; >= 0 |
pagination.step | integer | no | 1 | page only; >= 1. For offset APIs use start: 0 and the page size as the step. |
pagination.cursor_path | string | cursor | - | Dot path to the next cursor (e.g. data.pageInfo.endCursor). The first request is sent as-is. |
pagination.next_url_path | string | next_url | - | Dot path to the next page URL (e.g. next, _links.next.href); relative URLs are resolved |
pagination.items_path | string | no | - | Dot path to the items array in each response (e.g. data.items). Omit when the response is itself an array. |
pagination.total_path | string | no | - | Dot path to the total item count; the loop stops once it is reached |
pagination.has_more_path | string | no | - | Dot path to a boolean (e.g. has_more); the loop stops when it is false |
pagination.max_pages | integer | no | 20 | 1 - 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.
| Field | Type | Required | Default | Constraints |
|---|---|---|---|---|
enabled | boolean | yes | - | - |
thresholdPct | number | no | 1 | 0–100; deliveries fire when the changed-pixel area reaches this percent |
fullPage | boolean | no | true | Full scroll height vs viewport only |
regions | array | no | [] | 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 |
ignoreSelectors | array | no | - | Max 20 CSS selectors hidden before the shot (cookie banners, ads, tickers) |
viewport | object | no | 1280×720 | {width: 320–3840, height: 240–2160} |
Get a monitor
GET /v1/monitors/:id
Returns the monitor object:
{
"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.
{
"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
{
"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).
{
"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 param | Type | Default | Allowed values |
|---|---|---|---|
page | integer | 1 | >= 1 |
limit | integer | 20 | 1–100 |
status | string | all | "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:
{
"data": [...],
"pagination": { "...": "..." },
"retention": {
"tier": "free",
"retention_days": 14,
"cutoff_at": "ISO 8601"
}
}Get a delivery
GET /v1/deliveries/:id
{
"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 param | Type | Allowed values |
|---|---|---|
category | string | Filter to one category. Common values: developer, crypto, ecommerce, news, status, generic |
Each item:
{
"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
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | no | Overrides the template's default name |
url | string | no | Overrides the template's example_url |
deliveries | array | no | Same 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
| Field | Type | Required | Default | Constraints / Allowed values |
|---|---|---|---|---|
name | string | yes | - | 1–100 characters |
scopes | string[] | no | ["read"] | Each scope is a string. Recognized: "read", "write" |
expires_at | string | no | (no expiry) | ISO 8601 datetime |
Returns 201 Created:
{
"key": "vrd_...", // shown exactly once
"apiKey": { "id": "...", "name": "...", "scopes": [...], "expires_at": "..." }
}Rotate API key
POST /v1/keys/:id/rotate
| Field | Type | Required | Constraints |
|---|---|---|---|
grace_period_hours | integer | yes | 0–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
{
"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)
| Limit | free | lite | starter | pro | scale |
|---|---|---|---|---|---|
runsPerMonth | 200 (whole 14-day trial) | 1,000 | 5,000 | 15,000 | 100,000 |
visualRunsPerMonth | 100 (whole 14-day trial) | null (shared) | null (shared) | null (shared) | null (shared) |
serpRunsPerMonth | 100 (whole 14-day trial) | null (shared) | null (shared) | null (shared) | null (shared) |
retentionDays | 14 | 90 | 180 | 365 | 730 |
maxDeliveriesPerMonitor | 1 | 2 | 3 | 10 | 25 |
llmCallsPerMonth | 50 | 250 | 500 | 5,000 | 25,000 |
proxyBytesPerMonth | 0 | 0 | 52,428,800 | 524,288,000 | 5,368,709,120 |
maxTeamMembers | 2 | 2 | 3 | 10 | 25 |
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.
{
"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
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | yes | 1 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.
{
"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
| Field | Type | Required | Constraints |
|---|---|---|---|
team_id | string | yes | UUID 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.
{
"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
| Field | Type | Required | Default | Constraints / Allowed values |
|---|---|---|---|---|
email | string | yes | - | Valid email address, max 254 characters, lower-cased |
role | string | no | "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:
{
"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
| Field | Type | Required | Allowed values |
|---|---|---|---|
role | string | yes | "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.
{
"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
| Field | Type | Required | Constraints |
|---|---|---|---|
token | string | yes | The 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:
| Parameter | Type | Default | Constraints |
|---|---|---|---|
page | integer | 1 | min 1 |
limit | integer | 50 | 1 to 100 |
action | string | - | One action name, for example monitor.deleted |
actor_user_id | string | - | UUID; only events by this user |
target_type | string | - | One target type, for example api_key |
from | string | - | ISO 8601 datetime; lower bound on created_at |
to | string | - | ISO 8601 datetime; upper bound on created_at |
{
"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:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Human-readable message",
"details": [],
"requestId": "string (optional)"
}
}| Code | HTTP Status | Meaning |
|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid credentials |
FORBIDDEN | 403 | Insufficient permissions: the API key scope or your team role does not allow this |
NOT_FOUND | 404 | Resource not found |
VALIDATION_ERROR | 422 | Invalid request body |
TIER_LIMIT_EXCEEDED | 429 | Plan limit reached |
RUN_LIMIT_REACHED | 429 | Monthly 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_EXCEEDED | 429 | Too many requests |
INTERNAL_ERROR | 500 | Server error |