Pathly watches the path. Ping if it's up. Flow if a user can walk it. Chain if the services behind it hold. This article describes the HTTP contract those three share, and why a Terraform provider can sit on it without a second source of truth. The developer pages, including the contract they render, are on the Pathly developer docs.
A Ping is one HTTP check on a timer. A Flow is a Chrome session with steps. A Chain is several HTTP calls in order, on the same scenario resource, in the httpChain field. The provider declares the Ping, the availability target, the maintenance window and the webhook. It does not invent a parallel API.
The published contract is the one that runs
The API serves its OpenAPI document at a public address, with no authentication. The document is not written beside the code. It is produced from the table that declares the routes. That same table decides, on every call, which scope is required. A route that is missing from the table does not start. The developer page reads the contract when it renders, so a removed endpoint disappears from the documentation. A hand-copied page drifts from the route table.
Each operation carries x-pathly-scope. A generator, or a human reading the JSON, can see the scope without opening the handler. The public document is also tagged with x-pathly-language for the locale of the text. The routes do not change with the language. The sentences do.
GET /v1/scenarios requires scenarios:read. POST /v1/scenarios requires scenarios:write. POST /v1/scenarios/{id}/run requires runs:trigger. Those three are the split you feel first. Listing is not creating. Creating is not executing. An execution spends quota. A read does not.
The create call says what each type needs. type: http requires url or httpChain. type: browser requires scenario. enabled: false prepares a scenario without raising an alert. The same call accepts an Idempotency-Key header so a retry does not create a second row.
A minimal Ping body, using a host reserved for examples:
{
"type": "http",
"name": "Home",
"url": "https://www.example.com",
"intervalSec": 300,
"expectedStatus": 200,
"maxLatencyMs": 2000,
"severity": "major",
"tags": ["prod"]
}
intervalSec is seconds between checks. maxLatencyMs fails the run when the response is slower than that, even if the status matches. severity is critical, major or minor. Tags are the labels a Terraform data source can filter on later. If you omit expectedStatus, the server stores 200. If you omit maxLatencyMs, it stores 5000. Send the values you mean.
Narrow scopes, and four refusals
A key carries scopes shaped as resource then action. scenarios:read lists and reads. scenarios:write creates, updates, deletes, mutes and sets a baseline, and it includes read, because a tool always re-reads what it just wrote. runs:read lists runs. runs:trigger queues a run and includes runs:read. alerting:write includes alerting:read. maintenance:write includes read. sla:write includes read. org:read is only read. Writing does not include triggering. A provisioner that creates checks should not hold runs:trigger unless that same job also launches them.
Four actions stay out of every key, by decision.
A key cannot issue or revoke a key. A key that could would cancel the scopes of every other key.
A key cannot invite a member. The new account would issue whatever keys it wants. That is the same escalation one hop later.
A key cannot change plan or touch billing. A continuous integration token must not start a recurring charge.
A key cannot export the organisation. The export contains members, so it contains personal data, and it does not belong in a job log.
The official TypeScript client does not widen any of this. A missing scope is a 403 whose message names the scope. Create the key with the minimum you need. A leaked provisioner token still cannot become an organisation takeover, because the four actions above are not scopes you can grant.
Keys are minted in the product settings, by a person. Copy the value once. It starts with sp_. The client rejects anything else before the first request, so a truncated paste fails in your process instead of as a 401 that looks like a revoked key.
An id from another organisation is a 404
If the identifier belongs to another organisation, the API answers 404, never 403. A 403 would confirm that the id exists. A 404 is the same answer as a deleted id or a typo. Log the status. Do not branch on "forbidden" to decide that a scenario is real and hidden. Your code should treat 404 as "not in this org." Recreate only when you are sure the id was yours and was deleted.
The SDK exposes that as PathlyApiError with notFound.
import { PathlyClient, PathlyApiError } from "@pathly/sdk";
const pathly = new PathlyClient({ token: process.env.PATHLY_API_TOKEN! });
try {
await pathly.getScenario("mon-unknown");
} catch (e) {
if (e instanceof PathlyApiError && e.notFound) {
// Gone from this org. Create it again, or fix the id.
}
throw e;
}
status, path, the API message, retryAfter and validation details are on the same error. A 400 puts the field errors in details. Do not retry a 400. The body will be refused again, and you will spend rate limit on a typo.
Idempotency for twenty-four hours
Writes accept Idempotency-Key. The key must match ^[A-Za-z0-9._:-]{8,128}$. Shorter than 8 characters is ignored, which means the call is not replayable. A missing header is the same. A key with a space or a slash in it is ignored, so that call is not replayable. Spaces around an otherwise valid key are trimmed. Use a job id or a UUID.
The server stores the path, a hash of the body, and the response. The row counts for 24 hours. The same key and the same body return the original response, including the original id. The same key and a different body return 409 with the message that the key was already used with another body. A pipeline that retries after a dropped response must send the same bytes, not a "fixed" name.
The SDK attaches Idempotency-Key on writes. runScenario opts out (idempotent: false), because two triggers are two runs, and replaying the previous verdict would hide the second one. The default key function builds a new id each time it is called, and the client calls it on each attempt. A transport retry inside one process can therefore send a new key. When the retry is your pipeline starting over, pass idempotencyKey so it returns the same job id for that deploy. A fresh random key creates a second scenario. The option exists for that stable id. It does not invent one for you.
curl -sS -X POST "https://api.pathlyhq.com/v1/scenarios" \
-H "Authorization: Bearer $PATHLY_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: deploy-2026-10-14-home" \
-d '{"type":"http","name":"Home","url":"https://www.example.com","intervalSec":300,"expectedStatus":200,"severity":"major"}'
Run that twice with the same body. You get one scenario. Change name and keep the key. You get 409. Wait, or use a new key, when the body is intentionally new.
POST /v1/scenarios/{id}/run is not a create of a configuration object. It queues a run and returns jobId. It spends quota. Give it runs:trigger and do not confuse it with idempotent create. Triggering twice is two runs.
Cursor pages, not page numbers
List endpoints take limit and cursor. The default page size is in the schema, not only in a sentence, so a generator can read it. The next page is the nextCursor value from the previous response. The last page omits it.
Page numbers break when someone creates a row while you are walking. Later pages shift, and you either read a row twice or skip one. A cursor does not. An integration that stops after the first page believes it knows the estate, and then deletes what it never saw.
for await (const scenario of pathly.paginate((cursor) =>
pathly.listScenarios({ cursor }),
)) {
console.log(scenario.id, scenario.name);
}
Pass the cursor through. Do not request limit so high that you think one page is the world. When you compare this list to a Terraform configuration, you are doing the same job as the pathly_scenarios data source. Filter by tag in your own code if you only care about one environment. The data source's filter_tag is that filter for HCL.
Retry-After, and what is never retried
When the rate limit is hit, the API sends Retry-After. Without that header an automated client retries on its own clock and stays rate-limited. The SDK honours the header, caps the wait at 90 seconds, and stops after four attempts. If the header is absent on a 429 or a 5xx, the wait is attempt * 500 milliseconds. A 400 or a 403 is thrown immediately. Both would fail the same way on the next try.
The client also refuses a base URL that is not https, except http://localhost or http://127.0.0.1. The key is in the Authorization header. Clear text to a remote host is readable by every intermediary. baseUrl defaults to https://api.pathlyhq.com. Override it only for a local server.
userAgent defaults to pathly-sdk-ts. Set it to the name of your integration so API logs can tell your job from someone else's.
What a read never returns
An authentication password, a webhook address, and the steps of a Flow are written and not returned. In their place the API returns a fingerprint. For a webhook it is twelve characters, and it changes when the address changes. For a browser scenario the field is scenarioFingerprint, and it changes when the steps change. The list description says the Flow steps are not returned, because they carry login credentials. The fingerprint is enough to see drift. It is not enough to rebuild the secret.
Terraform uses that for the webhook. The destination edited by hand in the console shows on the next plan, and the address is not stored as a read-back attribute you could print. You still keep the URL in your own file, because the API will not remind you of it. The fingerprint only tells you it moved.
Do not log a create body that contains a Flow password or a Chain body with a test password. The request you sent is the copy. The response is the safe one.
A Chain on the same route
Chain is not a second product hostname. createChain posts type: http to /v1/scenarios and sets httpChain to the hops. The first hop's URL is also the scenario URL. At least one hop is required. The client rejects an empty list before the request, with a 400-shaped error, so you do not spend a round trip on it.
const chain = await pathly.createChain({
name: "Login then /me",
intervalSec: 300,
hops: [
{
name: "Login",
method: "POST",
url: "https://api.example.com/login",
body: JSON.stringify({
email: process.env.API_USER,
password: process.env.API_PASSWORD,
}),
assertStatus: 200,
extractJson: { path: "token", as: "token" },
},
{
name: "Me",
method: "GET",
url: "https://api.example.com/me",
assertStatus: 200,
},
],
});
Each hop has a method in GET, POST, PUT, PATCH, HEAD, DELETE, a URL, an optional name, optional headers, an optional body up to 8000 characters, an optional waitMs up to 30000, an optional assertStatus, an optional expectText, an optional extractJson and an optional extractCookie. extractJson.as must match ^[A-Za-z_][A-Za-z0-9_]*$ and is at most 64 characters. The path is at most 200 characters. That extracted name is how the next hop can use the token. A Chain sees a broken token. It does not see a broken button. Flow is the Chrome run for the button. Use both when both can fail.
Ten hops is the maximum on the schema. A chain longer than that is two scenarios, not one hop array you hope the server will truncate.
Trigger, then read the run
runs:trigger queues. It does not wait. listRuns with scenarioId and limit: 1 is how you see the latest. Status ok passes. fail and error are finished and not ok. Anything else is still running.
await pathly.runScenario(chain.id);
const started = Date.now();
for (;;) {
const { items } = await pathly.listRuns({ scenarioId: chain.id, limit: 1 });
const run = items[0];
if (run && (run.status === "ok" || run.status === "fail" || run.status === "error")) {
if (run.status !== "ok") throw new Error(`Pathly ${run.status}`);
break;
}
if (Date.now() - started > 120_000) throw new Error("Pathly run timeout");
await new Promise((r) => setTimeout(r, 3000));
}
A browser run's default cap is two minutes, which is why this loop stops at 120 seconds. A Ping is usually faster. Do not poll tighter than a few seconds. You will hit the same rate limit the SDK is trying to back off from.
For a pipeline, the published GitHub Action pathlyhq/pathly-action at v1 already implements assert and run-and-wait. Prefer it to a hand-rolled poll when the job is GitHub Actions. The snippet above is the same idea when the caller is the SDK. Scopes for the action's assert path are scenarios:read and runs:read. Add runs:trigger only for run-scenario. Add scenarios:write only for ensure-scenario.
Mute and baseline are writes, not runs
POST /v1/scenarios/{id}/mute takes mutedUntil as a date-time, or null to wake the scenario. The deadline is capped at 30 days. Mute is scenarios:write. It does not spend a run. Use it for a known bad deploy, and wake it. A mute that nobody wakes leaves a failing scenario silent.
POST /v1/scenarios/{id}/baseline sets the comparison baseline to the last successful run. Same scope. It does not change the URL or the interval. A patch does that.
{ "intervalSec": 600, "maxLatencyMs": 3000, "severity": "critical" }
That patch body is the in-place update. The id stays. History stays. A client that implements "change the URL" as delete plus create will drop incidents. The provider's rule is the same rule. After apply, the next plan is empty, and a changed interval must not force a new id.
How the provider sits on this
Four resources, and data sources that re-read the inventory. pathly_scenario is this create and patch. pathly_sla_target is the SLA routes under sla:write. pathly_maintenance_window is maintenance:write. pathly_webhook is alerting:write, with events run.failed and run.recovered only. The data source pathly_scenarios is GET /v1/scenarios plus a tag filter. API keys and members are absent because the API refuses those actions.
The provider block stays empty. PATHLY_API_TOKEN is the same sp_ key. Version ~> 0.1 while the schema is 0.x. Import by id when the scenario already exists. The fingerprint fields are how plan sees a webhook URL or a Flow's steps move without the API echoing the secret.
If you are writing a language the SDK does not ship, you do not wait for a provider either. One key, https://api.pathlyhq.com, and the OpenAPI document are enough. Generate the client from that document, or call the routes in this article. Keep the idempotency key, follow nextCursor, honour Retry-After, and treat 404 as "not in this org."
Page size, order, and a client that stops too soon
The default page size is 100. The maximum is 500. Both numbers are in the pagination schema the contract publishes, so a generated client can read them instead of hard-coding a guess. Asking for more than 500 is clamped to 500. Asking for 500 on every call because you can will make each response heavy, and you will hit Retry-After sooner. 100 is enough for a small org. A large org still needs the cursor.
Order is created_at DESC, id DESC. The timestamp alone is not a cursor. Two rows created in the same millisecond are tied, and the id breaks the tie. If you sort again on the client by name, do it after you have walked every page. Sorting one page and then fetching the next mixes two orders and hides gaps.
A safe walk stores the last nextCursor you successfully finished. If the process dies, resume from that cursor. Starting over from the first page is correct only when you are building a full set in memory and you discard the partial one. Starting over and merging by name will double-count a scenario that was renamed between the two walks.
paginate on the SDK is that walk. It stops when nextCursor is absent. Do not add a counter "just in case" that breaks after three pages. The day the estate passes 300 scenarios, the counter is the bug.
A provisioner key and a pipeline key are not the same key
Split them.
The Terraform key needs scenarios:write, sla:write, maintenance:write and alerting:write. Those include the matching reads. It does not need runs:trigger. Apply should not launch Chrome because a plan was saved.
The deploy key needs runs:trigger if the job starts a run, and runs:read if it only asserts the last status. runs:trigger already includes runs:read. It does not need scenarios:write unless the job also creates checks. ensure-scenario on the GitHub Action is the operation that needs scenarios:write. assert-scenario does not.
org:read is for usage and org surfaces. The smoke configuration leaves it off so a data source that reaches outside the managed resources fails closed. Copy that. A key that can read every member list is not required to declare a Ping.
Neither key can invite a teammate or change the plan. If a runbook says "the CI token can also add an admin," the runbook is wrong about this API.
What a 409 is, and what a 429 is
409 on create means the Idempotency-Key was already stored with a different body hash, inside the last 24 hours, for this organisation. Fix the body or change the key. Retrying the same wrong body keeps the 409. The SDK will not retry it, because it is not a 429 and not a 5xx.
429 means slow down. Read Retry-After. The SDK waits that many seconds, and never more than 90. Your own curl loop should do the same. A loop with no sleep will stay at 429 until the job times out, and the scenario will not exist.
5xx is the other retry. Four attempts, then the error. If the first attempt actually created the row and the response was lost, a stable idempotency key is what returns the original id on the next attempt. A new key on each attempt cannot do that. That is the whole reason to pass a job id.
400 is a body the schema refused. details names the fields. Common causes on a scenario are a URL that is not a URL, an intervalSec of the wrong type, a hop list that is empty, an extractJson.as that starts with a digit, or more than ten headers. Fix the JSON. Do not rotate the key.
401 is a missing or revoked token, or a token that does not start with sp_ and never reached the API because the client refused it locally. 403 names the scope. Those two are not interchangeable. Adding scopes does not fix a revoked token. Minting a new token does not fix a 403 if you copy the same scopes.
Patch versus replace, in one call
await pathly.updateScenario(id, {
intervalSec: 600,
maxLatencyMs: 3000,
severity: "critical",
});
updateScenario is PATCH /v1/scenarios/{id}. The id in the path is the id you keep in state. Fields you omit stay as they are. A client that sends the full object on every sync must still send the same URL and the same name, or it will change them. Terraform's empty-plan rule is this patch behaving as a no-op when the file matches the read-back.
Delete is DELETE /v1/scenarios/{id}, also scenarios:write. The SDK returns an empty object when the body is empty, so the caller does not dereference null. Deleting a scenario deletes the history you were keeping. Import and patch instead, unless the check is actually gone.
Mute, when you need silence without a delete:
await pathly.muteScenario(id, "2026-10-15T06:00:00.000Z");
await pathly.muteScenario(id, null);
The first call silences until that instant, and the API caps the horizon at 30 days. The second call wakes it. Leave a comment in the job with the incident id. A mute left in place keeps a failing scenario silent.
Top comments (0)