Flow Monitors

Check a sequence of requests instead of one. Sign in, carry the token, call the endpoint it authorises — and assert on what comes back at every step.

Last updated: 2026-09-11

A flow monitor runs a multi-step API check. An HTTP monitor asks one question — did this URL answer the way it should? A flow monitor asks whether a sequence still works: that signing in returns a token, that the token is accepted by the endpoint it authorises, and that the endpoint answers with the data it is supposed to.

The whole configuration is a block of source you write. There is no URL field, no host field and no region selector — every address the flow requests is written in the source itself, which is what makes it possible to check where a flow is allowed to go before it goes there.

The source is written in an executable-spec language: test blocks that arrange, act and assert. The monitor is up when every test in the source passes, and down when any test fails. The first failure is the detail you get.

What a Flow Can and Cannot Do

These limits are stated first because a flow monitor is only as useful as your understanding of what it covers. None of them are oversights — they are the shape of the feature.

  • There is no browser. Nothing clicks, fills in a form field, runs JavaScript or lays a page out. A flow drives HTTP requests; html reads the markup a request answered with, so a flow can assert on the page your server rendered — the heading it carries, the button it offers — but not on what that page becomes once a browser has run it.

  • A flow can bring new data on every run. sample gives it a fresh name, address or identifier per check, which is what a sign-up flow needs. A flow that signs in still needs a fixed account you keep for the purpose, since the credential has to exist before the run.

  • There is no branching and no arithmetic. No conditionals, no loops, no sums. str.format fills a template's holes, which is the whole of composition; a flow is otherwise a straight line of requests and assertions.

  • expect is equality and truthiness only. There is no regular expression, no comparison and no numeric range. The html readers take a containing clause of their own, which is where substring matching lives.

  • The only retry vocabulary is eventually, and it cannot retry a request. See Waiting for something to become true.

  • There is no file access, no shell and no database access. The seven namespaces below are the whole vocabulary.

  • The fastest interval is 15 minutes. A flow makes several requests and costs accordingly. If you need to know within a minute that something broke, put an HTTP monitor on the endpoint the flow depends on — that is what 1-minute resolution is for — and let the flow cover the sequence.

Verified Domains

A flow may only reach hosts covered by your team's verified domains. This is checked before the flow runs, on every run, and it is the rule to understand first.

The reason is worth stating plainly. An HTTP monitor sends a single request that any stranger could have sent anyway. A flow drives a sequence — it signs in, it carries a token, it calls the endpoint that token authorises. Without a gate on where it can point, the feature would be a way to automate somebody else's site.

Verifying a domain

Verification is a DNS TXT record. For the hostname you are verifying, publish:

_ping-verification.example.com.  TXT  "ping_<your team domain id>"

The record is re-checked every 10 minutes. See Domains.

What a verified domain covers

Ownership extends one label boundary deep. Verifying example.com covers:

  • example.com itself

  • app.example.com, api.example.com

  • api.staging.example.com

It does not cover notexample.com, and it does not cover example.com.evil.test. A name that merely ends in your domain's text is not your domain.

The grant is resolved against the exact hosts your source names. A flow written to request app.example.com is granted app.example.com — not everything your team has verified. If the same flow later reaches internal.example.com, it is refused, even though the team owns that name too.

The allowance is resolved fresh on every run and is never stored on the monitor. Un-verifying a domain stops its flows at the very next check, with no cache to wait out.

When a flow is refused

Two refusals happen before any request is sent:

This flow reaches {hosts}, which no verified domain on this team covers. A flow monitor can only drive a domain the team has verified.

This flow names no host to reach. Every URL it requests has to be written in the spec, so it can be checked against the team's verified domains.

Both record a result with status Error, not Down — nothing was found out about your service, so nothing about your service is being reported.

A team with no verified domains cannot create a flow monitor at all. Verify a domain first.

Writing a Flow

Tests and phases

A source holds one or more test blocks. Each test has up to three phases, in this order:

PhasePurpose
givenArrange — set up what the test needs
whenAct — make the requests
thenAssert — check what came back

Every phase is optional, but the order is enforced. A given after a when, or a second when after a then, is a parse error rather than a quietly reordered run.

test "the health endpoint answers" {
	when {
		let response = http.get "https://api.example.com/health"
	}
	then {
		expect response.status 200
		expect response.json.ok true
	}
}

Lines beginning with # are comments.

Statements

Inside a phase you can write:

  • let name = … — run something and bind its result under a name

  • expect … — assert

  • eventually { … } — retry an assertion until it holds

  • a bare tool or command call — run it for its effect, binding nothing

  • return … — inside a command or fixture body, to give back its value

Reusable steps and data

command defines a reusable step, fixture reusable data. Both are named once and usable from any test in the source.

fixture monitor_account {
	return { email: "uptime-monitor@example.com", password: "the monitor account's password" }
}

A fixture's value is read with let creds = fixture monitor_account. A command is called by name with its arguments, and what a test asserts about it is its effect, not a returned value.

Chaining steps

There is no string interpolation and there are no operators. A step uses an earlier step's result by reaching into the bound value by path:

let session = http.post "https://app.example.com/api/sessions" form {
	email: "uptime-monitor@example.com"
	password: "the monitor account's password"
}
let account = http.get "https://api.example.com/v1/account" bearer session.json.token

One rule catches everyone once: a bare word in tool-argument position is a symbol, not a variable. bearer token passes the word token; bearer session.json.token passes the value. If you are holding a value under a plain name and need to pass it, box it into an object and read it back through a dotted reference:

let at = { url: landing }
let code = url.query at.url "code"

Tools

Seven namespaces are available. There is nothing else.

http

http.get, http.post, http.put, http.patch and http.delete. The URL is a quoted absolute http or https address.

Options are word-tagged, order-independent, and combinable:

OptionEffect
headers { … }Request headers
form { … }Body encoded as application/x-www-form-urlencoded
json <value>Body encoded as application/json
text "<string>"Body sent as a plain string
bearer <token>Authorization: Bearer …
basic <user> <pass>HTTP Basic credentials

At most one body, one headers block and one auth option per request. A body on a GET is an error.

A response is a value with status, ok, headers, text and json, read by path:

expect response.status 201
expect response.ok true
expect response.json.id
expect response.headers.location "/v1/orders/1"

An HTTP error status is a normal value, not a failure. A 500 does not abort the flow; it lands in response.status where you can assert on it, which is what lets a flow check that a protected endpoint refuses an unauthenticated caller. Only a network-level failure — the connection never completing — is an error.

Redirects are followed, one hop at a time, and each target is re-checked against your verified domains before the request is sent. A redirect off your domain is refused rather than followed.

url

url.query <url> <name>, url.fragment <url> <name>, url.path <url> and url.host <url>. Pure parsing, no network. These exist for the shape that comes up constantly in authentication: pulling the code or the state out of a redirect address.

let code = url.query granted.json.redirect_to "code"
let landed = url.host granted.json.redirect_to

jwt

jwt.decode <token> returns { header, payload } and checks no signature. Use it to look at a token you already trust.

jwt.verify <token> <jwks_url> fetches the issuer's JWKS, selects the key by kid, verifies an ES256 signature and the token's expiry, and returns the verified payload. Claims read out of it are issuer-attested rather than merely well-formed.

jwt.verify reaches the network, so the JWKS host has to be covered by a verified domain too.

sample

Input a flow generates for itself, for the steps that have to send something new every time — a person, a place, an identifier, a file name, a date. Generating is free: only the requests your flow makes count against its limit.

Each kind of data is one tool that returns a record. Bind it once, then read the fields you need:

let who = sample.person
let where = sample.location
ToolFields on the record
sample.personfirst_name, last_name, full_name, prefix, suffix, sex, gender, zodiac_sign, job_title, bio, email, username, phone
sample.internetemail, username, url, domain_name, password, ip, ipv4, ipv6, mac, port, protocol, http_method, http_status_code, jwt, user_agent, emoji
sample.locationcountry, city, country_code, state, county, street, street_address, zip_code, postal_address, latitude, longitude, time_zone
sample.companyname, catch_phrase, buzz_phrase, and the words they are built from
sample.loremword, words, sentence, paragraph, lines, slug, text
sample.datepast, future, recent, soon, anytime, birthdate, month, weekday, time_zone — dates as ISO timestamps
sample.stringuuid, ulid, nanoid, alpha, alphanumeric, numeric, hexadecimal, binary, octal, symbol
sample.numberint, float, hex, binary, octal, roman_numeral, big_int
sample.colorhuman, hex, rgb, hsl, space, css_function
sample.datatypeboolean
sample.gitbranch, commit_sha, short_sha, commit_message, commit_date, commit_entry
sample.hackerabbreviation, adjective, noun, verb, ingverb, phrase
sample.phonenumber, national, international, imei
sample.systemfile_name, file_ext, file_type, mime_type, directory_path, file_path, network_interface, semver, cron

A record's fields agree with each other: a person's address matches their name, and a location's postal address names its own city and country.

Five more tools take an argument, or stand alone:

ToolReturns
sample.int <min> <max>A whole number between the two, both included
sample.float <min> <max>A number between the two, to two decimals
sample.words <count>That many words of filler text
sample.pick <list>One item of a list a step returned, such as sample.pick catalog.json.plans
sample.email, sample.uuidAn address, and a UUID
use http
use sample

test "a visitor can sign up" {
	given {
		let person = sample.person
	}
	when {
		let created = http.post "https://app.example.com/signup" form {
			email: person.email
			name: person.full_name
		}
	}
	then {
		expect created.status 201
	}
}

The values change on every run, which is what makes a sign-up flow possible: the same address posted every five minutes would pass once and fail on every check after it.

Two things follow for how you write the flow.

Assert on what the step proved, not on the value you generated. expect created.status 201 holds for any address. expect created.json.email "ana.moreau35@example.com" pins a value your monitor invented for one run, and fails on the next.

Remember the flow really runs. A sign-up flow creates a real account on every check, at whatever interval you set. Point it at something that tolerates that — a staging environment, a tenant you prune, or a sign-up that expires unconfirmed registrations by itself.

Generated addresses are always on example.com, example.org or example.net, the domains reserved for documentation. Treat them as write-only: a sign-up whose last step is a confirmation link wants an endpoint that can confirm the account directly.

A generated value is a step, like a request: it belongs in given or when.

html

Reads a page the way a person reads it, from the markup a request answered with. Every tool takes the HTML as its first argument — in practice response.text — and answers from that string alone. Nothing is fetched and nothing is rendered.

let page = http.get "https://app.example.com/status"
expect html.title page.text "Status"
expect html.heading page.text "All systems operational"

Four tools read the document as a whole:

ToolReads
html.title <source>The <title>
html.text <source>The text a reader would see, markup stripped
html.meta <source> <name>A meta tag's content, by name or property
html.rel <source> <rel>A <link>'s href, by one token of its rel

The rest address one element by its role and accessible name — what a screen reader would call it — rather than by a CSS selector, so a flow keeps passing when the markup around the thing changes:

ToolAddresses
html.element <source> <role> <name>Any ARIA role, or the word field by name attribute
html.heading <source> <name> [level <n>]A heading, optionally at a level
html.link <source> <name>A link
html.button <source> <name>A button
html.checkbox <source> <name> [checked]A checkbox, and whether it is ticked
html.cell <source> row <n> column <n>A table cell, counting from 1 over body rows
html.definition <source> <term>The definition paired with a term

Called bare, a lookup returns the element's text, which you bind and assert on. A trailing word narrows or asserts instead:

WordEffect
containing "<text>"Matches part of a name, or asserts part of a value
exactly "<text>"Asserts the whole value
existsReports presence as true or false instead of failing
count <n>Asserts how many elements match
first, last, nth <n>Picks one of several matches
value "<text>"Asserts a control's value
attribute <name> "<text>"Asserts a raw markup attribute
enabled, disabledAsserts the element's state
use http
use html

test "the pricing page still offers the Pro plan" {
	when {
		let page = http.get "https://app.example.com/pricing"
	}
	then {
		expect page.status 200
		expect html.heading page.text "Pricing" level 1
		expect html.link page.text "Start a Pro trial" exists
		expect html.text page.text containing "per month"
	}
}

A lookup that matches nothing fails with what the document did hold under the same lookup, and a lookup that matches several asks which one you meant — so a failing flow tells you what changed rather than that a selector missed.

str

str.format <template> <values…> fills a template's ${…} holes. Holes are ${0}, ${1} by argument position, or ${name} by the keys of a single object; {{ writes a literal ${. Every hole has to be filled and every argument has to be used, so a template and its arguments cannot drift apart quietly.

let path = str.format "https://api.example.com/v1/orders/${0}" created.json.id
let greeting = str.format "Hi ${name}, you are on ${plan}" { name: who.first_name, plan: "pro" }

This is the only string building there is. A URL built this way is still checked against your verified domains when the request is sent, so composing an address does not widen where a flow can go.

spec

What this run is called. spec.run_id is the identifier every test in the run shares, spec.attempt is which attempt of the test is running, and spec.nonce is the two joined — the value to compose into something that has to be new on every check.

let nonce = spec.nonce
let email = str.format "signup-${0}@example.com" nonce

sample gives you plausible data that repeats for a given seed; spec.nonce gives you a value no earlier run produced. Reach for it when a flow needs an identifier that has to be unique rather than merely realistic.

Assertions

expect has three forms.

Two values — deep structural equality:

expect response.status 201
expect claims.aud "uptime-flow"

One value — it must be true (a present, truthy value):

expect response.ok
expect session.json.token

An observable call — a read-only tool as the head. The tool is called with the remaining arguments and its result has to hold:

expect url.host "https://app.example.com/callback"

The url tools, jwt.decode, and every html and spec tool are read-only and can be used this way. http.get and its siblings, jwt.verify, and str.format are actions and cannot. In practice you will bind a read-only result with let and use the two-value form, which says more:

let landed = url.host granted.json.redirect_to
expect landed "app.example.com"

Equality is structural, so objects and arrays compare by their contents. There is no contains, no regex, no greater than and no range. When you need to assert on part of a response, reach into the part: expect response.json.user.plan "pro".

Waiting for Something to Become True

Some things are true a moment after the request that caused them. eventually retries an assertion until it holds:

then {
	eventually within 2s {
		expect status.json.state "ready"
	}
}

The default window is 5 seconds, polled every 100 milliseconds. eventually is valid only inside then, and only assertions and observable calls may appear inside it.

An HTTP request cannot be retried by eventually. http.get and its siblings are actions, not observations, and putting an action inside eventually is an error. There is no polling-until-the-endpoint-answers in a flow: the request happens once, in when, and eventually can only retry an assertion over a result that is already in hand. If your flow needs an asynchronous process to finish before the next step, a flow monitor is not the tool for it.

Examples

Signing in and calling a protected endpoint

The flow every team writes first: prove that credentials still mint a token, and that the token is still accepted by the endpoint it authorises.

# The account this flow drives. Created for monitoring, read-only, and shared by
# every test in this source.
fixture monitor_account {
	return { email: "uptime-monitor@example.com", password: "the monitor account's password" }
}

test "signing in returns a token the account endpoint accepts" {
	given {
		let creds = fixture monitor_account
	}
	when {
		let session = http.post "https://app.example.com/api/sessions" form {
			email: creds.email
			password: creds.password
		}
		# The token is passed by dotted reference; `bearer token` would send the
		# word `token`, not the value.
		let account = http.get "https://api.example.com/v1/account" bearer session.json.token
		# The same endpoint without the token, to prove the door is actually locked.
		let refused = http.get "https://api.example.com/v1/account"
	}
	then {
		expect session.status 201
		expect session.json.token
		expect account.status 200
		expect account.ok
		# Not merely a 200: the token resolved to the right subject.
		expect account.json.email "uptime-monitor@example.com"
		expect account.json.plan "pro"
		# A 401 is a value, not an error, so it can be asserted on directly.
		expect refused.status 401
	}
}

Both hosts, app.example.com and api.example.com, are covered by a verified example.com.

Signing up as a new visitor every run

The journey a fixed account cannot cover: registration itself. sample.person gives each run its own visitor, so the flow is not fighting the account the last run created.

test "a visitor can register and land signed in" {
	given {
		# One person, consistent across every field: the address matches the name,
		# and both are new on every run.
		let visitor = sample.person
	}
	when {
		let created = http.post "https://app.example.com/api/registrations" form {
			email: visitor.email
			name: visitor.full_name
			password: "a fixed password is fine; the address is what has to be new"
		}
		# The session the registration handed back is what proves the journey
		# finished, rather than a row having been written somewhere.
		let profile = http.get "https://app.example.com/api/me" bearer created.json.token
	}
	then {
		expect created.status 201
		expect created.json.token
		expect profile.status 200
		# Assert on what the step proved — that the profile belongs to whoever just
		# registered — never on the generated address itself, which changes each run.
		expect profile.json.name visitor.full_name
	}
}

Note what is asserted and what is not. expect profile.json.name visitor.full_name compares the response against the value the flow sent, which holds on every run. Writing expect profile.json.email "ana.moreau35@example.com" would pin a value the monitor invented for one run, and fail on the next.

This flow leaves an account behind on every check. At the 15-minute floor that is 96 accounts a day, so point it at a staging environment, a tenant you prune, or a registration that expires unconfirmed sign-ups on its own.

An OAuth exchange, with the token verified against the JWKS

Reading a code out of a redirect address and spending it, then checking that the id_token it bought was really signed by your issuer. This test sits in the same source as the one above, so the monitor_account fixture is already in scope — definitions are shared across every test in the flow.

fixture oauth_client {
	return { id: "uptime-flow", secret: "the monitoring client's secret" }
}

test "an authorization code exchanges for an id_token this issuer signed" {
	given {
		let client = fixture oauth_client
		let creds = fixture monitor_account
	}
	when {
		let session = http.post "https://app.example.com/api/sessions" form {
			email: creds.email
			password: creds.password
		}
		# Authorizing as that signed-in user. The endpoint answers with the redirect
		# target in its body, so the code can be read out of it.
		let granted = http.post "https://app.example.com/oauth/authorize" json {
			client_id: client.id
			redirect_uri: "https://app.example.com/callback"
			response_type: "code"
			scope: "openid email"
			state: "flow-check"
		} bearer session.json.token
		let code = url.query granted.json.redirect_to "code"
		let state = url.query granted.json.redirect_to "state"
		let landing = url.host granted.json.redirect_to
		let tokens = http.post "https://api.example.com/oauth/token" form {
			grant_type: "authorization_code"
			code: code
			redirect_uri: "https://app.example.com/callback"
		} basic client.id client.secret
		# Verified against the live JWKS: the payload comes back only if the token
		# is genuinely signed by this issuer's key and has not expired.
		let claims = jwt.verify tokens.json.id_token "https://api.example.com/.well-known/jwks.json"
	}
	then {
		expect session.status 201
		expect granted.status 200
		# The state that went out came back: the round trip is this request's.
		expect state "flow-check"
		expect landing "app.example.com"
		expect tokens.status 200
		expect tokens.json.token_type "Bearer"
		expect tokens.json.access_token
		# Read out of the JWKS-verified payload, so these are issuer-attested.
		expect claims.iss "https://api.example.com"
		expect claims.aud "uptime-flow"
		expect claims.email "uptime-monitor@example.com"
	}
}

jwt.verify fetches the JWKS over the network, so api.example.com has to be covered by a verified domain for that step as well as for the token request.

Configuration Options

Source

The flow itself, up to 20,000 characters. Every test in the source runs on every check, and every test has to pass for the monitor to be up.

Check Interval

Seven fixed values: 15 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 1 day. The default is 1 hour. Nothing else is accepted.

It is a fixed list rather than a number you type because each step down roughly doubles what a run costs. The interval is a spending decision as much as a detection one, so it is presented as a short list of choices rather than a box that will accept 30 and quietly mean thirty seconds.

There are no regions — a flow runs once, from one place. There are also no monitor-level retries: a failed run is down immediately, with no confirmation re-check and no flap suppression. A flow asserts on a sequence you control, so a failure is usually a real one.

Limits on a Run

Four caps apply to each run:

CapLimit
Wall clock30 seconds
HTTP requests20, counted across every test in the source
Response size1,048,576 bytes per response
Source length20,000 characters

Only HTTP requests count against the request cap. url, jwt.decode, sample, html, str and spec calls are free — they reach nothing, answering from the values they are handed or from the run's own identity. (jwt.verify fetches the issuer's JWKS, so it does make a request.)

The time and request caps are checked before each request the flow makes, so a run that has spent its budget stops at the next request it tries to send, and reports why:

This flow ran out of time: a run may take at most 30000ms.

This flow made too many requests: a run may make at most 20.

The response cap applies to each response on its own — twenty responses just under it are fine — and is enforced while the body arrives, so an endpoint that answers with a megabyte and a half is refused rather than read:

http.get response from https://app.example.com/report is 1572864 bytes; this run reads at most 1048576 bytes of a response body

Each of the three produces Error, not Down. A flow that outgrew its budget is a monitor problem, not an outage, and it should not page anyone as though your service were down. Point a flow at the endpoint or page you want to assert on rather than at a bulk export, and the cap stays out of the way: a megabyte covers a server-rendered page and a JSON payload with room to spare.

Monitor Statuses

StatusMeaning
UpEvery test passed.
DownA test failed — an assertion did not hold. Your service answered, and it answered wrongly.
ErrorThe flow could not run: the source will not parse, a host is not covered by a verified domain, the run ran out of time, it made too many requests, or a response was too large to read.

Error is deliberately not an outage. It is the app failing to find out, which is a different fact from your service being down. Keeping the two apart is what stops a mistyped source from paging your on-call, and it is why the pass rate on the monitor's page is computed over passing and failing runs only, excluding errors. A run that never happened is not evidence either way.

Results

Each run records one row: its status, how many tests ran, passed and failed, how many HTTP requests it made, the name of the failing test, that test's line number in your source, the failure detail, the run's duration, and any error message.

Results are kept for 90 days — longer than the 7 days HTTP monitor results are kept. A flow runs at most four times an hour and each run says something specific, so the history stays small enough to be worth keeping and useful enough to read months later.

No per-step timings and no screenshots are stored. During an incident the readable thing is which assertion broke and where, not a waterfall.

The monitor's page shows:

  • Pass rate, average duration and total runs

  • A Last failure block naming the failing test and its line, with the failure detail formatted

  • Your source rendered as a numbered listing, with the failing line marked

  • A table of recent runs: Time, Status, Tests, Requests, Duration

Run now runs the flow inline and reports the outcome straight back, which is how you check a source you just edited without waiting out the interval.

Alerting

A flow alerts when it goes down, and again when it recovers. It uses the same alerts, the same maintenance windows and the same cooldowns as every other monitor type, so an alert scoped to your whole team already covers your flows. See Alerts.

Error never alerts. A flow that cannot run has not detected anything.

Billing

A flow run is metered as one ping per HTTP request it made, drawn from the same monthly ping allowance as every other check — because that is what a flow is: several pings with assertions between them. A five-request flow on an hourly interval spends 3,360 pings a month.

A run refused before it starts — an unverified host, a source that will not parse — makes no requests and costs nothing, while still recording a visible result so you can see it happened.

A Note on Credentials

A password in a flow is stored as part of the source text. There is no separate secret store today, so a login flow's credentials are written in the source and visible to anyone on your team who can read the monitor.

Use a dedicated account created for monitoring, with the least privilege that still exercises the flow. Never point a flow at a real user's credentials, and never at an account that can change anything you would mind being changed.

Best Practices

Assert on the thing the step proved

expect response.status 200 says the request completed. expect account.json.email "uptime-monitor@example.com" says the token was accepted and resolved to the right subject. The second assertion is why you wrote a flow instead of an HTTP monitor.

Keep the flow short

Twenty requests is the cap, but a flow you can read in one screen is the one you will actually fix at 3am. Cover one journey per monitor — sign-in in one, checkout in another — so a failure names itself.

Sign in as a fixed account, sign up as a generated one

A flow that signs in needs an account that exists before the run and still exists after it — the credential has to be written in the source, so it cannot be invented. Prefer those flows to read rather than write; where one must write, have it write something it can overwrite next time.

A flow that signs up is the opposite: give it sample.person and let every run bring its own address. What needs thinking about there is not the data but the accounts it leaves behind — see sample.

Let HTTP monitors do the fast detection

A flow at 15 minutes is not your outage detector. Put HTTP monitors on the endpoints the flow depends on for minute-resolution detection, and let the flow tell you the thing they cannot: that the sequence still holds together.

Verify the domain before you write the flow

The source has to name the hosts it reaches, and those hosts have to be covered when the monitor is created. Verifying first — including any separate API or JWKS host the flow touches — turns a refusal into a non-event.

Treat Error as your problem

Down means your service broke. Error means the flow did. An error streak is a source to fix, a domain to re-verify, or a budget to raise — and nothing about it is a reason to distrust the up and down results either side of it.