# Verid Documentation (combined)

Generated from https://docs.verid.dev. Source MDX for each page is also reachable individually at the page URL + `.md`.

---

<!-- source: https://docs.verid.dev/ -->

# Verid Documentation

Verid is a developer-first web change-detection API. Monitor any URL and receive structured webhook notifications when content changes.

## What is Verid?

Verid watches web pages, JSON APIs, and RSS feeds for you — extracting exactly the fields you care about and delivering notifications when they change.

**Key features:**

- **Six extraction methods**: CSS selectors, XPath, JSONPath, regex, full-page hashing, AI/LLM
- **Nine diff predicates**: Fire only when specific conditions are met (price drops by 5%, field equals a value, regex matches, AND/OR composites, etc.)
- **Reliable delivery**: Webhooks, Slack, Discord, and email with 6 automatic retry attempts on failure
- **Three-layer fetching**: Static fetch → stealth headless browser → residential proxy network for bot-protected sites
- **REST API + SDK**: Full REST API with OpenAPI 3.1 spec and official Node.js SDK (`@verid/sdk`)
- **15 ready-made templates**: Get started in seconds for GitHub releases, npm/PyPI versions, CoinGecko prices, stock prices, sitemaps, RSS feeds, and more

## Quick links

- [Quickstart](/quickstart) — Create your first monitor in 2 minutes
- [Extraction methods](/extraction) — CSS, XPath, JSONPath, regex, full-page, and LLM
- [Predicates](/predicates) — All nine diff conditions and how to combine them
- [API Reference](/api-reference) — Full REST API documentation
- [Webhooks](/webhooks) — How to receive and verify webhook deliveries
- [Recipes](/recipes) — Common use-case examples

---

<!-- source: https://docs.verid.dev/api-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 param | Type | Default | Constraints |
|-------------|------|---------|-------------|
| `page` | integer | `1` | `>= 1` |
| `limit` | integer | `20` | `1`–`100` |

### 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 | — | Positive integer; tier-gated minimum (`free`: 86400, `starter`: 3600, `pro`: 900, `scale`: 300) |
| `extract_config` | object | yes | — | See [Extract config](#extract-config) |
| `diff_predicate` | object | yes | — | See [Diff predicate](#diff-predicate) |
| `deliveries` | array | no | `[]` | 0–25 items; tier-gated max per monitor (`free`: 1, `starter`: 3, `pro`: 10, `scale`: 25). See [Delivery config](#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-headers) |

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](/extraction) 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](/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](#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`.

### 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": { "...": "..." },
  "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 because the user is over their plan's monitor cap).

Both take an empty JSON body (`{}`).

### Trigger a manual run

`POST /v1/monitors/:id/run`

Empty body. Counts against the tier's `runNowPerDay` quota (`free`: 5, `starter`: 50, `pro`: 500, `scale`: 5000).

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).

## 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"
}
```

### 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> }`.

## 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, `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 param | Type | Allowed values |
|-------------|------|----------------|
| `category` | string | Filter 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`

| 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](#delivery-config) |

The template's `schedule_interval_seconds` is clamped up to the caller's tier 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`:

```json
{
  "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`

```json
{
  "monitorsActive": 3,
  "llmCallsThisMonth": 12,
  "runNowToday": 1,
  "tier": "free | starter | pro | scale",
  "limits": {
    "maxMonitors": 5,
    "minIntervalSecs": 86400,
    "retentionDays": 14,
    "maxDeliveriesPerMonitor": 1,
    "llmCallsPerMonth": 50,
    "runNowPerDay": 5,
    "proxyBytesPerMonth": 0
  }
}
```

### Tier limits (reference)

| Limit | free | starter | pro | scale |
|-------|------|---------|-----|-------|
| `maxMonitors` | 5 | 50 | 250 | 1,500 |
| `minIntervalSecs` | 86,400 | 3,600 | 900 | 300 |
| `retentionDays` | 14 | 180 | 365 | 730 |
| `maxDeliveriesPerMonitor` | 1 | 3 | 10 | 25 |
| `llmCallsPerMonth` | 50 | 500 | 5,000 | 25,000 |
| `runNowPerDay` | 5 | 50 | 500 | 5,000 |
| `proxyBytesPerMonth` | 0 | 52,428,800 | 524,288,000 | 5,368,709,120 |

## 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)"
  }
}
```

| Code | HTTP Status | Meaning |
|------|-------------|---------|
| `UNAUTHORIZED` | 401 | Missing or invalid credentials |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `NOT_FOUND` | 404 | Resource not found |
| `VALIDATION_ERROR` | 422 | Invalid request body |
| `TIER_LIMIT_EXCEEDED` | 429 | Plan limit reached |
| `RATE_LIMIT_EXCEEDED` | 429 | Too many requests |
| `INTERNAL_ERROR` | 500 | Server error |

---

<!-- source: https://docs.verid.dev/extraction -->

# Extraction methods

Every monitor needs to know **what** to pull out of the response. Verid supports six extraction methods — pick the one that matches the shape of your source.

| Method | Best for | Anchor |
|--------|----------|--------|
| CSS selector | Rendered HTML pages | [#css](#css-selector) |
| XPath | Complex HTML / XML | [#xpath](#xpath) |
| JSONPath | JSON APIs | [#json_path](#jsonpath) |
| Regex | Plain text, sitemaps, raw bodies | [#regex](#regex) |
| Full-page hash | "Anything changed?" mode | [#full_page](#full-page-hash) |
| AI / LLM prompt | Hard-to-scrape or unstructured pages | [#prompt](#ai--llm-prompt) |

## Named fields

For `css`, `xpath`, `json_path`, and `regex` you provide a map of **field name → expression**. Each field is tracked independently in the diff — predicates reference fields by name, e.g. `{ "type": "field_changes", "field": "price" }`.

```json
"extract_config": {
  "method": "css",
  "fields": {
    "price": "[data-test=product-price]",
    "title": "h1"
  }
}
```

Field names can be anything (`price`, `tag_name`, `incidents`). Keep them short and descriptive — they show up in diffs and webhook payloads.

## CSS selector

`method: "css"` — standard CSS selectors run against the rendered DOM.

```json
{
  "method": "css",
  "fields": {
    "headline": "h1.article-title",
    "price": "[data-test=price]"
  }
}
```

Selectors match the **first** element. Use attribute selectors or `:nth-child` for precision. The page is fully rendered before selectors run when `fetch_mode: "browser"` is set or auto-detected.

## XPath

`method: "xpath"` — XPath 1.0 expressions, useful when CSS can't express the structure you need (parent/ancestor traversal, text-content predicates, etc.).

```json
{
  "method": "xpath",
  "fields": {
    "status": "//div[@id='status']/text()",
    "count":  "//table[@id='users']//tr"
  }
}
```

## JSONPath

`method: "json_path"` — for JSON APIs.

```json
{
  "method": "json_path",
  "fields": {
    "tag_name":     "$.tag_name",
    "published_at": "$.published_at"
  }
}
```

The response body is parsed as JSON. Use `$` for the root, `.` for child, `[*]` for array iteration. If your API returns an array at the root, start with `$[0]`.

## Regex

`method: "regex"` — for plain text and quick counts.

```json
{
  "method": "regex",
  "fields": {
    "url_count": "<loc>"
  }
}
```

Each pattern is run against the **raw response body**. The match count is what gets stored — predicates like `field_increases_by_absolute` are perfect for "more results appeared". If you need a captured value, use the first capture group: `price: "\\$([0-9.]+)"`.

## Full-page hash

`method: "full_page"` — no selectors. Verid hashes the entire response and triggers when the hash changes.

```json
{ "method": "full_page" }
```

Use this when you don't care what changed, only that **something** did. Pair with `any_field_changes` for the simplest possible monitor. The downside: false positives from rotating ads, timestamps, or session tokens.

## AI / LLM prompt

`method: "prompt"` — describe in plain English what to extract.

```json
{
  "method": "prompt",
  "prompt": "Extract the product name, current price as a number, and availability status from this page.",
  "schema": {
    "name": "string",
    "price": "number",
    "available": "boolean"
  }
}
```

Best for unstructured pages, marketing sites that re-layout often, or anywhere selectors would be brittle. `schema` is optional — when provided, the model is forced to return JSON matching that shape, and the keys become field names you can reference in predicates.

LLM extractions count against your plan's monthly LLM-call quota — check `/usage` on the API or the **Billing** page in the dashboard.

## Fetch mode

Independent of extraction method, every monitor has a `fetch_mode`:

- **`auto`** (default) — try a static HTTP fetch first; fall back to a headless browser if the response looks empty or JS-heavy.
- **`browser`** — always render in a stealth headless browser. Slower, but required for SPAs and pages that block bots.

Most monitors should leave this on `auto`. Switch to `browser` if your selectors return empty values despite being correct in DevTools.

## Related

- [Predicates](/predicates) — Once you've extracted fields, choose when to fire
- [Webhooks](/webhooks) — Receive and verify the resulting deliveries
- [Recipes](/recipes/price-drop-alert) — Worked examples per method

---

<!-- source: https://docs.verid.dev/predicates -->

# Diff predicates

A **predicate** decides when a monitor actually fires. After Verid extracts your fields and compares them to the previous run, the predicate is evaluated against the diff — if it returns true, deliveries go out. If it returns false, the run is recorded but nothing is delivered.

This is the difference between "the page changed" and "the price dropped by more than 5%".

| Type | Fires when | Anchor |
|------|------------|--------|
| `any_field_changes` | Any tracked field changed | [#any_field_changes](#any_field_changes) |
| `field_changes` | A specific field changed | [#field_changes](#field_changes) |
| `field_increases_by_percent` | Numeric field went up by ≥ N% | [#field_increases_by_percent](#field_increases_by_percent) |
| `field_decreases_by_percent` | Numeric field dropped by ≥ N% | [#field_decreases_by_percent](#field_decreases_by_percent) |
| `field_increases_by_absolute` | Numeric field went up by ≥ N | [#field_increases_by_absolute](#field_increases_by_absolute) |
| `field_decreases_by_absolute` | Numeric field dropped by ≥ N | [#field_decreases_by_absolute](#field_decreases_by_absolute) |
| `field_matches_regex` | New value matches a regex | [#field_matches_regex](#field_matches_regex) |
| `field_equals` | Field equals a literal value | [#field_equals](#field_equals) |
| `composite` | AND / OR of other predicates | [#composite](#composite-and--or) |

All predicates reference fields by the **name** you gave them in `extract_config.fields`. See [Extraction methods](/extraction) for how fields are defined.

## any_field_changes

```json
{ "type": "any_field_changes" }
```

Fires if any tracked field differs from the previous run. The simplest predicate — pair it with `method: "full_page"` for a "tell me when anything changes" monitor.

## field_changes

```json
{ "type": "field_changes", "field": "tag_name" }
```

Fires only when `tag_name` differs. Other fields can shift without firing. Best for "new release", "status text changed", and similar exact-match-irrelevant cases.

## field_increases_by_percent

```json
{ "type": "field_increases_by_percent", "field": "price", "threshold": 5 }
```

Fires when `price` is at least 5% higher than the previous run. The field value must be numeric (or a numeric string Verid can parse). Threshold is a positive number — `5` means 5%, not 0.05.

## field_decreases_by_percent

```json
{ "type": "field_decreases_by_percent", "field": "price", "threshold": 10 }
```

Mirror of the above — fires on a ≥ 10% drop. Common pattern for price-drop alerts and stock-dip notifications.

## field_increases_by_absolute

```json
{ "type": "field_increases_by_absolute", "field": "url_count", "threshold": 1 }
```

Fires when the field increases by at least the absolute amount. With `threshold: 1` you get a "one or more new entries" trigger — useful for sitemap, RSS, or comment-count monitors.

## field_decreases_by_absolute

```json
{ "type": "field_decreases_by_absolute", "field": "in_stock", "threshold": 5 }
```

Same idea, downward. Useful for inventory drops, follower-count loss, etc.

## field_matches_regex

```json
{ "type": "field_matches_regex", "field": "status", "pattern": "^(error|failed|degraded)" }
```

Fires when the **new** value of `status` matches the regex. JavaScript regex syntax. Escape backslashes in JSON (`\\d+`, not `\d+`).

## field_equals

```json
{ "type": "field_equals", "field": "availability", "value": "In Stock" }
```

Fires when the new value equals the literal `value` exactly. `value` can be a string, number, or boolean. Case-sensitive for strings.

## composite (AND / OR)

```json
{
  "type": "composite",
  "operator": "AND",
  "conditions": [
    { "type": "field_changes", "field": "tag_name" },
    { "type": "field_equals",  "field": "prerelease", "value": false }
  ]
}
```

Combine multiple predicates with `AND` or `OR`. Conditions can themselves be composite — nest as deep as you need. Common pattern: "tag_name changed **AND** prerelease is false" to skip beta releases, or "price dropped by 10% **OR** went out of stock" to capture both signals in one monitor.

## Tips

- **Numeric parsing**: For `*_by_percent` and `*_by_absolute` predicates, Verid attempts to parse strings like `"$1,299.00"` or `"+5.4%"` as numbers. Strip currency symbols in your extractor when possible — it's less surprising.
- **First-run behaviour**: The first run of a monitor never fires (there's nothing to compare against). The baseline is established silently.
- **Boolean fields**: With `field_equals`, JSON booleans (`true` / `false`) match exactly. If your extractor returns the string `"true"`, that's not equal to the boolean `true`.

## Related

- [Extraction methods](/extraction) — Define the fields predicates reference
- [Webhooks](/webhooks) — What gets delivered when a predicate fires
- [Recipes](/recipes/crypto-price-alert) — Predicates in worked examples

---

<!-- source: https://docs.verid.dev/quickstart -->

# Quickstart

Get your first monitor running in under 2 minutes.

## 1. Get an API key

Sign up at [verid.dev](https://verid.dev) and create an API key from the **API Keys** page in the dashboard.

```bash
export VERID_API_KEY="vrd_your_key_here"
```

API keys start with the prefix `vrd_`. Treat them like passwords — they grant full access to your account.

## 2. Create a monitor

Let's monitor the GitHub React repository for new releases:

```bash
curl -X POST https://api.verid.dev/v1/monitors \
  -H "Authorization: Bearer $VERID_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "React New Releases",
    "url": "https://api.github.com/repos/facebook/react/releases/latest",
    "schedule_interval_seconds": 3600,
    "extract_config": {
      "method": "json_path",
      "fields": {
        "tag_name": "$.tag_name",
        "name": "$.name",
        "published_at": "$.published_at"
      }
    },
    "diff_predicate": {
      "type": "field_changes",
      "field": "tag_name"
    },
    "deliveries": [
      {
        "type": "webhook",
        "url": "https://your-app.com/webhooks/verid"
      }
    ]
  }'
```

## 3. Receive webhook notifications

When React publishes a new release, you'll receive a POST request:

```json
{
  "id": "del_01H...",
  "version": "2026-05-01",
  "monitor_id": "uuid",
  "run_id": "uuid",
  "fired_at": "2026-05-08T12:00:00Z",
  "diff": {
    "fields_changed": ["tag_name"],
    "before": { "tag_name": "v18.2.0" },
    "after":  { "tag_name": "v18.3.0" }
  },
  "monitor": {
    "url":  "https://api.github.com/repos/facebook/react/releases/latest",
    "name": "React New Releases"
  }
}
```

## 4. Verify the signature

All webhooks are signed. Verify the `Verid-Signature` header before processing:

```typescript
import { createHmac, timingSafeEqual } from 'crypto';

function verifySignature(header: string, rawBody: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const ts = parseInt(parts['t'] ?? '0', 10);
  const sig = parts['v1'];
  if (!ts || !sig) return false;
  if (Math.abs(Date.now() / 1000 - ts) > 300) return false; // 5 min drift

  const expected = createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
}

app.post('/webhooks/verid', (req, res) => {
  const header = req.headers['verid-signature'] as string;
  const body = req.rawBody; // raw string body

  if (!verifySignature(header, body, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).send('Invalid signature');
  }

  console.log('Change detected:', req.body.diff.fields_changed);
  res.sendStatus(200);
});
```

See the [Webhooks](/webhooks) page for verification snippets in Python, Ruby, Go, and PHP.

## Using the Node.js SDK

```typescript
import { VeridClient } from '@verid/sdk';

const client = new VeridClient({
  apiKey: process.env.VERID_API_KEY!,
});

// Create a monitor
const monitor = await client.monitors.create({
  name: 'React New Releases',
  url: 'https://api.github.com/repos/facebook/react/releases/latest',
  schedule_interval_seconds: 3600,
  extract_config: {
    method: 'json_path',
    fields: { tag_name: '$.tag_name' },
  },
  diff_predicate: { type: 'field_changes', field: 'tag_name' },
  deliveries: [
    { type: 'webhook', url: 'https://your-app.com/webhooks/verid' },
  ],
});

// List monitors
const { data } = await client.monitors.list();

// Trigger a manual run
await client.monitors.runNow(monitor.id);
```

## Next steps

- [API Reference](/api-reference) — Full endpoint documentation
- [Webhooks](/webhooks) — Signature verification in multiple languages
- [Recipes](/recipes) — Common patterns and examples

---

<!-- source: https://docs.verid.dev/recipes/crypto-price-alert -->

# Recipe: Crypto Price Alert

Monitor Bitcoin price and fire when it drops more than 10%.

```bash
curl -X POST https://api.verid.dev/v1/monitors \
  -H "Authorization: Bearer $VERID_API_KEY" \
  -d '{
    "name": "Bitcoin 10% Drop Alert",
    "url": "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd&include_24hr_change=true",
    "schedule_interval_seconds": 3600,
    "extract_config": {
      "method": "json_path",
      "fields": {
        "price_usd": "$.bitcoin.usd",
        "change_24h": "$.bitcoin.usd_24h_change"
      }
    },
    "diff_predicate": {
      "type": "field_decreases_by_percent",
      "field": "price_usd",
      "threshold": 10
    },
    "deliveries": [
      { "type": "webhook", "url":        "https://your-app.com/hooks/crypto-alert" },
      { "type": "discord", "webhookUrl": "https://discord.com/api/webhooks/..." }
    ]
  }'
```

CoinGecko's free API returns numeric values directly, so the `field_decreases_by_percent` predicate works perfectly here. The predicate only fires on numeric fields — string values like `"$3,241.88"` won't trigger.

## Or: use the template

The `crypto-price-coingecko` template is pre-configured for this use case:

```bash
curl -X POST https://api.verid.dev/v1/monitors/from-template/crypto-price-coingecko \
  -H "Authorization: Bearer $VERID_API_KEY" \
  -d '{
    "name": "My Bitcoin Alert",
    "url": "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd"
  }'
```

---

<!-- source: https://docs.verid.dev/recipes/github-release-tracker -->

# Recipe: GitHub Release Tracker

Monitor a GitHub repository for new, non-prerelease releases and post to Slack.

```bash
curl -X POST https://api.verid.dev/v1/monitors \
  -H "Authorization: Bearer $VERID_API_KEY" \
  -d '{
    "name": "Next.js Releases",
    "url": "https://api.github.com/repos/vercel/next.js/releases/latest",
    "schedule_interval_seconds": 3600,
    "extract_config": {
      "method": "json_path",
      "fields": {
        "tag_name": "$.tag_name",
        "name": "$.name",
        "html_url": "$.html_url",
        "prerelease": "$.prerelease"
      }
    },
    "diff_predicate": {
      "type": "composite",
      "operator": "AND",
      "conditions": [
        { "type": "field_changes", "field": "tag_name" },
        { "type": "field_equals", "field": "prerelease", "value": false }
      ]
    },
    "deliveries": [
      { "type": "slack", "webhookUrl": "https://hooks.slack.com/services/..." }
    ]
  }'
```

This uses a **composite AND predicate**: only fire when the tag changes AND it's not a prerelease.

## Or: use the template

```bash
curl -X POST https://api.verid.dev/v1/monitors/from-template/github-new-release \
  -H "Authorization: Bearer $VERID_API_KEY" \
  -d '{
    "name": "Next.js Releases",
    "url": "https://api.github.com/repos/vercel/next.js/releases/latest",
    "deliveries": [
      { "type": "slack", "webhookUrl": "https://hooks.slack.com/services/..." }
    ]
  }'
```

---

<!-- source: https://docs.verid.dev/recipes/llm-extraction -->

# Recipe: AI-Powered Extraction

Use LLM extraction for pages that are hard to scrape with CSS or XPath.

```bash
curl -X POST https://api.verid.dev/v1/monitors \
  -H "Authorization: Bearer $VERID_API_KEY" \
  -d '{
    "name": "Product Page AI Monitor",
    "url": "https://example.com/product/42",
    "schedule_interval_seconds": 86400,
    "extract_config": {
      "method": "prompt",
      "prompt": "Extract the product name, current price (as a number without currency symbol), availability status, and any active discount percentage from this page.",
      "schema": {
        "name": "string",
        "price": "number",
        "available": "boolean",
        "discount_pct": "number or null"
      }
    },
    "diff_predicate": {
      "type": "composite",
      "operator": "OR",
      "conditions": [
        { "type": "field_changes", "field": "available" },
        { "type": "field_decreases_by_percent", "field": "price", "threshold": 5 }
      ]
    },
    "deliveries": [
      { "type": "webhook", "url": "https://your-app.com/hooks/product-change" }
    ]
  }'
```

## LLM extraction caching

LLM results are cached for 30 days using a content hash. If the page content doesn't change between runs, the cached result is returned at no LLM cost.

LLM calls are only counted against your monthly quota on cache misses.

## Models used

Verid runs your prompt against a fast, JSON-capable LLM. If the primary model returns malformed JSON or is unreachable, Verid automatically falls back to a secondary model so extraction stays resilient. You don't pick the model — Verid manages this for you.

The page content sent to the model is truncated at 50,000 characters.

## Prompt rules

- Minimum 10 characters, maximum 2,000 characters
- Be specific about the keys you want returned
- Mention how to handle missing fields (e.g. "use null if not listed")
- Provide a `schema` when you need consistent output shape

---

<!-- source: https://docs.verid.dev/recipes/price-drop-alert -->

# Recipe: Price Drop Alert

Get notified when an e-commerce product drops in price by more than 5%.

```bash
curl -X POST https://api.verid.dev/v1/monitors \
  -H "Authorization: Bearer $VERID_API_KEY" \
  -d '{
    "name": "Nike Air Max Price Alert",
    "url": "https://www.nike.com/t/air-max-90-shoes",
    "schedule_interval_seconds": 3600,
    "extract_config": {
      "method": "css",
      "fields": {
        "price": "[data-test=product-price]",
        "title": "h1"
      }
    },
    "diff_predicate": {
      "type": "field_decreases_by_percent",
      "field": "price",
      "threshold": 5
    },
    "deliveries": [
      { "type": "webhook", "url": "https://your-app.com/hooks/price-drop" }
    ]
  }'
```

> **Note**: The `field_decreases_by_percent` predicate only works with numeric values.
> CSS-extracted prices that include a currency symbol like `"$49.99"` parse as `NaN` and will not trigger.
> Use a site that returns a raw number, switch to JSONPath against the site's product API, or pair with LLM extraction to normalize prices to numbers.

---

<!-- source: https://docs.verid.dev/recipes/serp-monitoring -->

# Recipe: SERP Monitoring

Track how a keyword's Google search result page changes — top organic listing, AI Overview, featured snippet — and get notified the moment any of them shift.

```bash
curl -X POST https://api.verid.dev/v1/monitors \
  -H "Authorization: Bearer $VERID_API_KEY" \
  -d '{
    "name": "SERP — best invoicing software",
    "url": "https://www.google.com/search?q=best+invoicing+software&hl=en&gl=us&pws=0",
    "fetch_mode": "browser",
    "schedule_interval_seconds": 1800,
    "extract_config": {
      "method": "xpath",
      "fields": {
        "top_result_title":  "(//div[@id=\"search\"]//h3)[1]",
        "top_result_url":    "(//div[@id=\"search\"]//a[h3])[1]/@href",
        "ai_overview":       "//div[contains(@aria-label,\"AI Overview\")]",
        "featured_snippet":  "//div[@data-attrid=\"wa:/description\"]"
      }
    },
    "diff_predicate": { "type": "any_field_changes" },
    "deliveries": [
      { "type": "webhook", "url": "https://your-app.com/hooks/serp-shift" }
    ]
  }'
```

> **Notes:**
> - `fetch_mode: "browser"` is required — Google's results are JavaScript-rendered.
> - Pin `hl`, `gl`, and `pws=0` in the URL so personalization can't introduce noise.
> - Selectors evolve when Google ships layout changes; expect to update them occasionally.
> - For high-frequency or geo-specific tracking, configure the residential proxy layer so layer-3 fallback can route requests from the right region.

---

<!-- source: https://docs.verid.dev/recipes/sitemap-monitor -->

# Recipe: Sitemap New URL Monitor

Get notified when a website adds new pages to its sitemap.

```bash
curl -X POST https://api.verid.dev/v1/monitors \
  -H "Authorization: Bearer $VERID_API_KEY" \
  -d '{
    "name": "Competitor Blog New Posts",
    "url": "https://competitor.com/sitemap.xml",
    "schedule_interval_seconds": 86400,
    "extract_config": {
      "method": "regex",
      "fields": {
        "url_count": "<loc>"
      }
    },
    "diff_predicate": {
      "type": "field_increases_by_absolute",
      "field": "url_count",
      "threshold": 1
    },
    "deliveries": [
      { "type": "webhook", "url": "https://your-app.com/hooks/sitemap" }
    ]
  }'
```

The regex extractor counts `<loc>` occurrences (no capture group → returns a count). When the count increases by 1 or more, the webhook fires.

## Or: use the template

```bash
curl -X POST https://api.verid.dev/v1/monitors/from-template/sitemap-new-url \
  -H "Authorization: Bearer $VERID_API_KEY" \
  -d '{
    "name": "My Sitemap",
    "url": "https://competitor.com/sitemap.xml",
    "deliveries": [
      { "type": "webhook", "url": "https://your-app.com/hooks/sitemap" }
    ]
  }'
```

---

<!-- source: https://docs.verid.dev/recipes/status-page-monitor -->

# Recipe: Status Page Monitor

Monitor AWS or any status page for service incidents.

```bash
curl -X POST https://api.verid.dev/v1/monitors \
  -H "Authorization: Bearer $VERID_API_KEY" \
  -d '{
    "name": "AWS Status Monitor",
    "url": "https://health.aws.amazon.com/health/status",
    "schedule_interval_seconds": 300,
    "extract_config": {
      "method": "css",
      "fields": {
        "incidents": ".current-incidents li, [data-test=incident-title]",
        "overall_status": ".overall-status"
      }
    },
    "diff_predicate": {
      "type": "any_field_changes"
    },
    "deliveries": [
      { "type": "slack", "webhookUrl": "https://hooks.slack.com/services/..." },
      { "type": "email", "to":         "oncall@yourcompany.com" }
    ]
  }'
```

> **Tip**: On the Scale plan (5-minute minimum interval), you can catch incidents within minutes. The free plan is limited to daily checks.

## Or: use the template

```bash
curl -X POST https://api.verid.dev/v1/monitors/from-template/aws-status-page \
  -H "Authorization: Bearer $VERID_API_KEY" \
  -d '{
    "name": "AWS Status",
    "deliveries": [
      { "type": "slack", "webhookUrl": "https://hooks.slack.com/services/..." }
    ]
  }'
```

---

<!-- source: https://docs.verid.dev/webhooks -->

# Webhooks

Verid signs all webhook deliveries using HMAC-SHA256. The signature is included in the `Verid-Signature` header.

## Signature format

```
Verid-Signature: t={timestamp},v1={signature}
```

Where:
- `timestamp` is a Unix timestamp (seconds)
- `signature` = HMAC-SHA256(`{timestamp}.{raw_body}`, `webhook_secret`)

**You must verify the signature before processing any webhook payload.**

---

## Verification examples

### Node.js

```typescript
import { createHmac, timingSafeEqual } from 'crypto';

function verifyWebhook(
  header: string,
  rawBody: string,
  secret: string,
  toleranceSecs = 300,
): boolean {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=')),
  );
  const timestamp = parseInt(parts['t'] ?? '0', 10);
  const signature = parts['v1'];

  if (!timestamp || !signature) return false;

  // Check timestamp drift
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSecs) return false;

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  // Constant-time comparison
  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'));
}
```

### Python

```python
import hmac
import hashlib
import time

def verify_webhook(header: str, raw_body: str, secret: str, tolerance_secs: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = int(parts.get("t", 0))
    signature = parts.get("v1", "")

    if not timestamp or not signature:
        return False

    if abs(time.time() - timestamp) > tolerance_secs:
        return False

    signed_payload = f"{timestamp}.{raw_body}"
    expected = hmac.new(
        secret.encode(),
        signed_payload.encode(),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)
```

### Ruby

```ruby
require 'openssl'
require 'time'

def verify_webhook(header, raw_body, secret, tolerance_secs: 300)
  parts = Hash[header.split(',').map { |p| p.split('=', 2) }]
  timestamp = parts['t']&.to_i
  signature = parts['v1']

  return false unless timestamp && signature
  return false if (Time.now.to_i - timestamp).abs > tolerance_secs

  signed_payload = "#{timestamp}.#{raw_body}"
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)

  ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
```

### Go

```go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "math"
    "strconv"
    "strings"
    "time"
)

func verifyWebhook(header, rawBody, secret string, toleranceSecs float64) bool {
    parts := make(map[string]string)
    for _, p := range strings.Split(header, ",") {
        kv := strings.SplitN(p, "=", 2)
        if len(kv) == 2 {
            parts[kv[0]] = kv[1]
        }
    }

    tsStr, ok := parts["t"]
    if !ok { return false }
    ts, err := strconv.ParseInt(tsStr, 10, 64)
    if err != nil { return false }

    sig, ok := parts["v1"]
    if !ok { return false }

    if math.Abs(float64(time.Now().Unix()-ts)) > toleranceSecs { return false }

    signedPayload := fmt.Sprintf("%d.%s", ts, rawBody)
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedPayload))
    expected := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(expected), []byte(sig))
}
```

### PHP

```php
<?php
function verifyWebhook(string $header, string $rawBody, string $secret, int $toleranceSecs = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = explode('=', $part, 2);
        $parts[$k] = $v;
    }

    $timestamp = (int)($parts['t'] ?? 0);
    $signature = $parts['v1'] ?? '';

    if (!$timestamp || !$signature) return false;
    if (abs(time() - $timestamp) > $toleranceSecs) return false;

    $signedPayload = "{$timestamp}.{$rawBody}";
    $expected = hash_hmac('sha256', $signedPayload, $secret);

    return hash_equals($expected, $signature);
}
?>
```

## Webhook payload structure

```json
{
  "id": "del_01H...",
  "version": "2026-05-01",
  "monitor_id": "uuid",
  "run_id": "uuid",
  "fired_at": "2026-05-08T12:00:00Z",
  "diff": {
    "fields_changed": ["price", "stock"],
    "before": { "price": "49.99", "stock": "In stock" },
    "after":  { "price": "44.99", "stock": "Low stock" }
  },
  "monitor": {
    "url":  "https://example.com/product",
    "name": "My Product Monitor"
  }
}
```

The request also includes the header `User-Agent: Verid/1.0`.

## Retries

If your endpoint returns a non-2xx response or times out, Verid retries with exponential backoff:

| Attempt | Delay |
|---------|-------|
| 1 | Immediate |
| 2 | 5 minutes |
| 3 | 15 minutes |
| 4 | 30 minutes |
| 5 | 1 hour |
| 6 | 2 hours |

After 6 failed attempts, the delivery is marked as **dead**. You can replay dead deliveries from the dashboard or via `POST /v1/deliveries/:id/replay`.

---
