How a Verid Check Runs, End to End
Follow one scheduler check end-to-end: fetch, extract, compare, decide, record, and deliver. Knowing what each stage logs makes monitors debuggable.
Why this guide exists
You will spend more time asking "why did my monitor do that?" than you will spend creating monitors. Almost every answer to that question is "because of what happened at one specific stage of one specific check", and every stage leaves a record behind.
So this guide follows a single check from the moment it becomes due to the moment your Slack message arrives. Read it once and the dashboard stops being a list of green ticks and starts being a trace you can debug.
Here is the whole thing in one table. The rest of the guide is one section per row.
| Stage | What happens | What it writes down |
|---|---|---|
| 0. Schedule | The monitor becomes due and is claimed for work | last_run_at, next_run_at |
| 1. Fetch | Verid requests your URL, escalating if blocked | fetch_method on the run |
| 2. Extract | Your extractor turns the response into named fields | extracted on the run |
| 3. Compare | Those fields are diffed against the last successful check | diff on the run |
| 4. Decide | Your predicate is evaluated against that diff | delivery_triggered on the run |
| 5. Record | The whole check is saved | the run row itself |
| 6. Deliver | One delivery is created and queued per endpoint | a delivery row per endpoint |
Two things are worth noticing about that table before you read on.
The run row is written whatever happens. A check that fails at stage 1 still produces a run, with a status of error and the failure message on it. There is no such thing as a check that vanished.
Delivery is a separate stage with a separate life. Once a delivery is queued, it retries and eventually succeeds or dies on its own schedule, entirely independent of the check that created it. A red delivery does not mean a bad check.
Stage 0 - The scheduler decides it is time
Verid does not keep a timer per monitor. Instead a scheduler wakes up every 30 seconds and asks the database one question: which active monitors have a next_run_at in the past?
It takes up to 100 of them per wake-up, oldest due first, and hands each one to a worker.
Three consequences of that design, all of which you will notice at some point.
Checks start up to 30 seconds late, by design. An hourly monitor does not fire at exactly 60 minutes. It fires at the first scheduler wake-up after 60 minutes have passed. On a 5-minute interval that jitter is under 10 percent and nobody notices; it is simply not a stopwatch.
A new monitor runs almost immediately. When you create a monitor its next_run_at is set to right now, so it is already due and the next wake-up picks it up. You normally see your first run inside half a minute. The same is true after you resume a paused monitor.
Run now is a nudge, not a shortcut. Pressing the run button in the dashboard, or calling POST /v1/monitors/{id}/run, does not push a job straight onto the queue. It sets next_run_at to now and lets the ordinary scheduler pick it up, which is why a manual run can take a few seconds to appear. Manual runs are capped per day by plan: 5 on Free, 30 on Lite, 50 on Starter, 500 on Pro, and 5,000 on Scale.
The claim itself is atomic. In one transaction Verid locks the due rows, sets last_run_at to now, and pushes next_run_at forward by the monitor's interval, so two scheduler ticks overlapping can never pick up the same monitor twice and you never get a duplicated check. When the check finishes successfully, next_run_at is written again from that moment, so in practice your interval is measured from the end of one check to the start of the next.
Several checks run at the same time. The worker processes up to 10 scrape jobs concurrently by default, so a batch of due monitors is worked through in parallel rather than one at a time.
Stage 1 - Fetch: getting the page
The worker's first action is to load your URL. It has three ways to do that and it tries them in order, stopping at the first one that works:
- Static. A plain HTTP request. Fast and cheap, and enough for most pages and every JSON API.
- Browser. A real Chromium browser with stealth patches, which runs the page's JavaScript.
- Proxy. The same browser through a residential proxy network, so the request arrives from a home IP address rather than a data centre.
Whichever one produced the content is recorded on the run as fetch_method, which is the single most useful field on a run you did not expect. A monitor that has quietly been escalating to the browser on every check for a week is telling you something.
Two details of the ladder matter here.
What counts as "blocked". A 403 or a 429 response is treated as a block, and so is a request that never completes at all: a timeout, a dropped connection, a DNS failure. Anti-bot systems frequently drop data-centre traffic rather than answering it, so a silent failure is treated the same as a refusal and escalates to the next tier. A static request waits up to 30 seconds and retries twice before giving up on that tier. Note what is not a block: 401 and 407 mean credentials are required, and no amount of escalation invents credentials, so those surface as the real error. That is your cue to send the credential yourself with the monitor's custom request headers.
Setting fetch mode to Browser skips the static tier entirely. That is the right choice when you already know the content only exists after JavaScript runs, and the wrong choice everywhere else, because you pay a browser render on every single check.
If the top of the ladder is unavailable, or the last tier is reached and still blocked, the fetch throws and the check goes straight to the failure path at the end of this guide.
Stage 2 - Extract: turning a response into named fields
Verid now applies your extract configuration to whatever came back, producing a flat map of field names to values. That map is stored on the run as extracted, exactly as captured:
{
"pro_plan_price": "$59",
"stock_status": "In Stock"
}
This map is the input to everything downstream. If it is wrong, nothing after it can be right, which is why the create form's test button exists: it runs your extractor against the live page and shows you this exact map before you save.
The empty-fields retry
There is one piece of automatic recovery here that explains a lot of otherwise confusing runs.
If the monitor is on Auto fetch mode, the content came from the static tier, your config has named fields, and every one of those fields came back empty, Verid concludes that the page needs JavaScript and quietly re-runs the fetch with a real browser, then extracts again. The run's fetch_method ends up as browser, and unless you look for that you will never know the first attempt happened.
Two limits on that retry:
- It does not apply to JSONPath. A browser renders a JSON endpoint as an HTML page wrapping the JSON, which the JSONPath extractor cannot read, so an empty JSONPath result means the path did not match and a browser would not help.
- It only fires when all fields are empty. Two fields where one works and one does not is a broken selector, not an unrendered page, and Verid leaves the broken one empty so you can see it.
There is a second, narrower retry: on a browser fetch with CSS or XPath fields where extraction still came back completely empty, Verid re-evaluates the selectors natively inside the browser's own DOM rather than in its HTML parser. Some real-world markup parses differently in the two, and this catches that case.
If your extractor is the AI method, this is also where an LLM call happens and where it gets metered. The result is cached, so a repeated identical page does not spend a second credit, and only a cache miss is written to your usage log.
Stage 3 - Compare: the diff
Verid now compares the fields it just captured against the fields from the most recent successful run of this monitor.
That phrase is doing real work. The comparison baseline is the last run with a status of success, not the last run of any kind. A failed check does not become the new baseline, so a monitor that fails on Tuesday and succeeds on Wednesday compares Wednesday against Monday, and no change is lost in the gap.
The result is a small object holding only what moved:
{
"hasChanges": true,
"fieldsChanged": ["pro_plan_price"],
"before": { "pro_plan_price": "$49" },
"after": { "pro_plan_price": "$59" }
}
Fields that did not change are absent from before and after entirely. This is stored on the run as diff, and it is the thing your alert carries.
Three rules of the comparison:
Comparison is exact. Values are compared for equality with no normalisation. "$59.00" and "$59" are two different values, and so are "In Stock" and "in stock". Lists and objects are compared by their serialised form, so a list of three prices counts as changed if any one of them moved or if their order changed.
A field appearing or disappearing is a change. If your selector matched last week and matches nothing today, the field goes from a value to empty, and empty is a different value. Broken selectors announce themselves rather than going quiet.
The first check has no baseline, so everything is new. With no previous successful run, Verid treats every captured field as changed, with an empty before. A monitor set to alert on any field change therefore does normally alert on its very first check. That is expected and it is establishing the baseline, not a bug.
Stage 4 - Decide: the predicate
The diff says what moved. Your predicate says whether that is worth telling you about. It is evaluated against the diff and produces a single boolean, stored on the run as delivery_triggered.
This is the stage that produces the most support questions, because a run can succeed, find a real change, and still send nothing at all. When that happens the run shows delivery_triggered: false next to a populated diff, and that is your confirmation that the predicate is stricter than you meant it to be, not that the alert was lost.
The rule that catches everyone: every field-scoped predicate first requires that field to be in fieldsChanged. "Alert me when stock_status equals In Stock" does not fire on every check where the value happens to be In Stock. It fires on a check where that field changed and the new value is In Stock. Predicates describe changes, not states. There are nine predicate types, and the numeric ones have a second trap: they parse your captured value as a number, so a currency symbol or a thousands separator is enough to stop a threshold rule firing forever.
Deliveries are created only when the predicate says yes and the diff actually contains changes. Both conditions, every time.
Stage 5 - Record: what a run stores
Before anything is sent, the run row is finalised. This is the record you will read when debugging, so it is worth knowing every field on it.
| Field | Meaning |
|---|---|
status |
running while in flight, then success or error |
started_at, completed_at |
When the check began and finished |
duration_ms |
How long the whole check took |
fetch_method |
static, browser, or proxy - which tier produced the content |
extracted |
Every field captured on this check |
diff |
Changed fields with their before and after values |
delivery_triggered |
Whether the predicate said yes |
error_message |
The failure reason, on an errored run |
visual_change_pct, screenshot_key, diff_image_key, visual |
Visual monitoring only |
You can read all of it from the dashboard under Runs, or from the API with GET /v1/runs/{id}. GET /v1/monitors/{id}/runs lists a single monitor's history, and GET /v1/runs/{id}/deliveries gives you every delivery that run produced.
Three things happen alongside the run record on a successful check, and each surprises somebody eventually.
Full Page Hash monitors keep a content snapshot, but only the latest one. A hash tells you that a page changed and nothing about what changed, so for full page hash monitors Verid also keeps a readable text rendering of the page and rolls it forward on every check: the previous snapshot becomes the "before" side and the new one the "after", which is what the dashboard's side-by-side view reads. It is a two-deep buffer per monitor, not a history. You can always see the most recent change and never an older one. If you need the history of a specific value, extract it as a field instead.
The visual layer runs after the text pipeline and is strictly best effort. On a monitor with visual monitoring enabled, screenshots are captured and pixel-diffed after the fields are done, and a failure there is recorded on the run without failing the check. A visual change can set delivery_triggered on its own, independently of your predicate, so a monitor with both layers can alert from either one.
The failure counter resets. Any successful check sets consecutive_failures back to zero, which is what stops an intermittently flaky site from eventually being auto-paused.
Stage 6 - Deliver: one row per endpoint
If stage 4 said yes, Verid builds the alert payload once and then creates one delivery record per endpoint on the monitor, each queued separately. Four endpoints means four delivery rows, four independent attempt histories, and four things that can fail on their own.
A webhook delivery looks like this on the wire:
{
"id": "del_1755600004512",
"version": "2026-05-01",
"monitor_id": "3f9a1c72-8d4e-4b1a-9f2e-7c6d5b4a3e21",
"run_id": "b21c9f08-4a7d-4c3e-8b15-9e0f1a2b3c4d",
"fired_at": "2026-08-19T09:00:04.512Z",
"diff": {
"fields_changed": ["pro_plan_price"],
"before": { "pro_plan_price": "$49" },
"after": { "pro_plan_price": "$59" }
},
"monitor": {
"url": "https://example.com/pricing",
"name": "Competitor pricing page"
}
}
Notice that the alert carries the values, not just the news. Your receiver can act on the numbers.
If the monitor has a signing secret, the request also carries a Verid-Signature header so your endpoint can prove the request came from Verid. Verifying it correctly has one non-obvious requirement: check the signature against the raw request body, before your framework parses the JSON, because re-serialising the body changes the bytes that were signed.
The important boundary: the check is over at this point. Delivery attempts, their retries, and their eventual success or death all happen afterwards on the delivery's own timeline. The first attempt is immediate; if it fails, five more follow at roughly 5 minutes, 15 minutes, 30 minutes, 1 hour, and 2 hours after that, and then the delivery is parked in a dead-letter state you can replay from the Deliveries dashboard.
A monitor with no endpoints at all still runs, still diffs, and still records everything. It just has nobody to tell, which is a perfectly reasonable setup for something you review weekly.
When a check fails
Any stage throwing takes the check to the same failure path:
- The run is closed out with status
errorand the error message on it. - The monitor's
consecutive_failurescounter goes up by one. - If the counter has reached 10, the monitor's status is set to
error, it stops running, and Verid emails you to say so. Otherwisenext_run_atis pushed forward and the monitor carries on as normal. - The job is then retried.
That last step is worth understanding, because it makes the counter move faster than you would expect. A failed scrape job is retried up to 3 times in total, with exponential backoff starting at 5 seconds, so a genuinely broken monitor makes three attempts in about fifteen seconds. Each attempt is a full check: it writes its own run row and increments the failure counter again.
So a monitor whose site has started refusing Verid outright produces three error runs per scheduled check and adds three to the counter each time, which means it reaches the auto-pause threshold of 10 after roughly four scheduled checks rather than ten. If you are reading a monitor's history and see errors in bursts of three, that is why. A transient failure that clears on the second attempt writes one error run and one success run, and the success resets the counter to zero.
Auto-pause sets the status to error, not paused. That distinction is deliberate: paused means you stopped it, error means Verid stopped it. Fix the configuration, test it against the live page, then resume it from the dashboard or with POST /v1/monitors/{id}/resume, which clears the failure counter as well as the status so the next single failure cannot re-trip the pause.
One case produces no run at all: if the monitor is no longer active by the time a queued job reaches a worker, because you paused or deleted it in the meantime, the job is dropped without creating a run.
The whole thing, timed
For a monitor on an hourly interval whose price changed, with a Slack endpoint:
| Time | What happened |
|---|---|
| 09:00:00 | next_run_at passes. The monitor is now due. |
| 09:00:12 | The scheduler's next wake-up claims it, sets next_run_at to 10:00:12, and queues the job. |
| 09:00:12 | A worker picks it up and creates a run with status running. |
| 09:00:13 | Static fetch succeeds. fetch_method is static. |
| 09:00:13 | The CSS selector captures pro_plan_price: "$59". |
| 09:00:13 | Compared against the 08:00 run's "$49". One field changed. |
| 09:00:13 | The predicate field_changes on pro_plan_price returns true. |
| 09:00:13 | The run is saved: success, with the diff, delivery_triggered: true. next_run_at is written again as 10:00:13 and the failure counter is reset. |
| 09:00:14 | One Slack delivery row is created and queued. |
| 09:00:15 | Slack accepts it. The delivery is marked successful. |
Sub-second in the ordinary case. Almost all of the variance you will see in duration_ms comes from stage 1: a browser render is seconds rather than milliseconds, and a proxied browser render is slower still.
Debugging by stage
The value of knowing the pipeline is that every symptom points at one stage.
| Symptom | Stage | Where to look |
|---|---|---|
| The monitor has not run when you expected | 0 | Its status (only active runs) and next_run_at. Remember the 30-second poll. |
| The run errored with a block or a timeout | 1 | The run's error_message, and fetch_method on recent successful runs to see which tier it was relying on |
| Fields are empty | 2 | fetch_method on that run. If it says browser, the retry already fired and still found nothing, so the selector is wrong. |
| The run is slow | 1 | fetch_method. browser and proxy are the cost. |
| Values look right but no change was detected | 3 | diff on the run. Compare against the last successful run, not the last run. |
| Changes were detected but nothing was sent | 4 | delivery_triggered on the run. This is the predicate, and it is working as configured. |
| An alert arrived but your service never acted | 6 | The delivery row's response status. A 2xx is all Verid can see. |
| Errors arriving in groups of three | failure path | Expected. One scheduled check, three job attempts. |
What to read next
- What a Verid monitor is made of - the five decisions that make up the configuration this pipeline executes
- CSS selectors, XPath, JSONPath, regex, full page hash, and AI extraction - the six things stage 2 can be
- The docs reference at docs.verid.dev for the terse version of every field named here
Ready to try it? Point Verid at your URL and get a signed alert on every change. 5 monitors free, no credit card.