<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Simon Raynaud</title>
    <description>The latest articles on DEV Community by Simon Raynaud (@keyral).</description>
    <link>https://dev.to/keyral</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1959875%2F52119a33-049f-41bc-940b-a131862af1e8.png</url>
      <title>DEV Community: Simon Raynaud</title>
      <link>https://dev.to/keyral</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/keyral"/>
    <language>en</language>
    <item>
      <title>Test a login flow without writing Playwright</title>
      <dc:creator>Simon Raynaud</dc:creator>
      <pubDate>Wed, 14 Oct 2026 18:08:07 +0000</pubDate>
      <link>https://dev.to/keyral/test-a-login-flow-without-writing-playwright-12g9</link>
      <guid>https://dev.to/keyral/test-a-login-flow-without-writing-playwright-12g9</guid>
      <description>&lt;p&gt;Pathly watches the path. Ping if it's up. Flow if a user can walk it. Chain if the services behind it hold. Login is a Flow. A Ping on &lt;code&gt;/login&lt;/code&gt; only proves the URL answered. This article is the steps a real Chrome run already understands, including the login template that ships in the product. You can try a journey without writing a script from &lt;a href="https://pathlyhq.com/en/try-flow" rel="noopener noreferrer"&gt;try Flow&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A Flow opens Chromium, follows a list of at most 50 steps, and stops. The default run cap is two minutes. A step timeout you do not set falls back to two seconds for a selector wait. Two clicks in a row wait two seconds between them unless you set &lt;code&gt;clickDelayMs&lt;/code&gt; to something else, including 0. The viewport you do not set is desktop 1280 by 720. Locale defaults to &lt;code&gt;fr-FR&lt;/code&gt; and the timezone to &lt;code&gt;Europe/Paris&lt;/code&gt;. Set those when the page changes language or the price depends on the clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a green Ping is not a login
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/login&lt;/code&gt; returns 200 when the button does nothing. A third-party script fails to load. An identity provider changes its redirect. A release renames the field. The document is fine. The sign-in is not. The check that matches a visitor opens the page, types an email and a password, clicks, and asserts a text that exists only after sign-in. The account name, or the words "Log out", are the usual asserts. An assert on the word "Login" passes on the form you failed to leave.&lt;/p&gt;

&lt;p&gt;Record that in the product by signing in as usual. Each click and each fill becomes a step. If a selector changes later, edit that step. You do not have to record the whole journey again. A failed run keeps a screenshot of the failing step. Open the run and read the step name before you change a selector from memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The login template, step by step
&lt;/h2&gt;

&lt;p&gt;The built-in template id is &lt;code&gt;login&lt;/code&gt;. Its steps are the ones below. The host &lt;code&gt;example.com&lt;/code&gt; is a placeholder. &lt;code&gt;applyTemplateStartUrl&lt;/code&gt; rewrites each &lt;code&gt;goto&lt;/code&gt; onto the origin you pass, and keeps the template path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"goto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/login"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wait_for"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input[type='email'], input[name='email']"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"timeoutMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fill"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input[type='email'], input[name='email']"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user@example.com"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fill"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input[type='password']"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ChangeMe123!"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"click"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"button[type='submit'], button:has-text('Connexion'), button:has-text('Sign in')"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assert_text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mon compte"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;goto&lt;/code&gt; navigates with &lt;code&gt;domcontentloaded&lt;/code&gt;, then waits up to five seconds for network idle and continues if that wait misses. After the navigation the runner tries known cookie-accept buttons for up to five seconds. That pass is a convenience. It is not a guarantee that your banner uses one of those buttons. Add your own step when it does not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;wait_for&lt;/code&gt; requires the selector to show. &lt;code&gt;timeoutMs&lt;/code&gt; here is 2000, which is also the default when you omit it. The maximum you can set on this op is 60000. A login form that appears after a slow identity widget needs a higher timeout. A timeout of two seconds on a widget that takes four will fail on every run, and you will blame the password.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fill&lt;/code&gt; types into the selector. The value is part of the scenario. Replace &lt;code&gt;user@example.com&lt;/code&gt; and &lt;code&gt;ChangeMe123!&lt;/code&gt; before you save this against a real site. The template password is a placeholder in source control, not an account.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;click&lt;/code&gt; tries the selector, then can fall back to visible text or an href when you recorded those. The template uses a CSS list: a submit button, or a button whose text is "Connexion" or "Sign in". A redesign that leaves the text and changes the element still matches the text fallback if you recorded &lt;code&gt;text&lt;/code&gt;. Add &lt;code&gt;retryTimes&lt;/code&gt; between 0 and 5 when the control mounts late. Retries are not a substitute for &lt;code&gt;wait_for&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;assert_text&lt;/code&gt; requires the string to be visible. If you also pass &lt;code&gt;selector&lt;/code&gt;, the text must sit in that element, not in a hidden node of the document. &lt;code&gt;ignoreCase&lt;/code&gt; and &lt;code&gt;regex&lt;/code&gt; are optional. "Mon compte" is French because the template was written that way. Change it to the string your signed-in page actually shows. An assert that matches the login page is a green failure.&lt;/p&gt;

&lt;p&gt;Creating this as a browser scenario is &lt;code&gt;POST /v1/scenarios&lt;/code&gt; with &lt;code&gt;type: "browser"&lt;/code&gt; and the steps under &lt;code&gt;scenario&lt;/code&gt;. &lt;code&gt;scenarios:write&lt;/code&gt; is the scope. &lt;code&gt;enabled: false&lt;/code&gt; saves it without alerting until you turn it on. The same call takes &lt;code&gt;Idempotency-Key&lt;/code&gt;, 8 to 128 characters from &lt;code&gt;[A-Za-z0-9._:-]&lt;/code&gt;, remembered for 24 hours.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"browser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sign in"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"intervalSec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"critical"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"prod"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"login"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scenario"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"goto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://shop.example.com/login"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wait_for"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input[type='email']"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"timeoutMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fill"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input[type='email']"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"monitor@shop.example.com"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fill"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input[type='password']"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"replace-with-the-test-password"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"click"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"button[type='submit']"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assert_text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Log out"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A read of that scenario does not return the steps. The list leaves the steps out because they carry login credentials. You get &lt;code&gt;scenarioFingerprint&lt;/code&gt; instead. It changes when the steps change. That is enough to notice drift. It is not enough to recover the password. Keep the steps in your own private source if you create them through the API. Do not commit the test password next to production passwords.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test account, never a real customer
&lt;/h2&gt;

&lt;p&gt;The typed password is stored with the scenario. Create an account that exists only for this run. No admin rights. No customer data. A password used nowhere else. If the scenario leaks, that account opens nothing that matters. Do not record your own staff login because it was convenient on a Thursday.&lt;/p&gt;

&lt;p&gt;Rotate that password on the site and in the scenario together. A fingerprint change on the next read tells you the steps moved. It does not tell you whether the site still accepts them. Run once after the rotation and read the screenshot if it fails.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;basicAuth&lt;/code&gt; on the scenario is a different field. It is HTTP basic credentials for the browser context, not the form fill. Use it only when the site gates the whole host with basic auth. The form fill remains the login you are testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cookie banner
&lt;/h2&gt;

&lt;p&gt;Three mechanisms exist. Use the one that matches the banner. Do not stack all three and then wonder which click won.&lt;/p&gt;

&lt;p&gt;After every &lt;code&gt;goto&lt;/code&gt;, the runner calls a dismiss helper. It walks a list of known accept selectors for at most five seconds, clicks the first that is visible, and waits 200 milliseconds. If your banner is OneTrust-shaped, this often clears it before your steps run. If your banner is custom, it does nothing, and your next step must handle it.&lt;/p&gt;

&lt;p&gt;The conditional step is &lt;code&gt;if_visible&lt;/code&gt;. The runner looks for the selector for up to 2.5 seconds. If it is visible, the next step runs. If it is not visible, the next step is marked ok with the detail "skip (si…)" and is not executed. A missing banner must not fail the login.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"goto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://shop.example.com/login"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"if_visible"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"button:has-text('Accept all')"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"click"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"button:has-text('Accept all')"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wait_for"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"input[type='email']"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timeoutMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The click immediately after &lt;code&gt;if_visible&lt;/code&gt; is the step that gets skipped. Do not put the email fill in that position. The fill would be skipped on a day the banner is absent, and the click on "Sign in" would land on an empty form.&lt;/p&gt;

&lt;p&gt;There is also a template id &lt;code&gt;cmp&lt;/code&gt; that waits 1500 milliseconds, clicks a list of accept buttons, and runs &lt;code&gt;assert_no_cmp&lt;/code&gt;. That assert fails while a known overlay is still visible. Use it when the journey is "the banner goes away", not when the journey is "sign in". A login Flow can end with &lt;code&gt;assert_no_cmp&lt;/code&gt; if a leftover overlay would hide the account menu. It should not replace &lt;code&gt;assert_text&lt;/code&gt; of the signed-in page. An overlay can be gone on an error page.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;if_text&lt;/code&gt; works the same way as &lt;code&gt;if_visible&lt;/code&gt;, matching text in a selector or in the body, and skips the next step when the text is absent. Use it when the banner has no stable button selector and does have a stable sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Captcha and one-time codes
&lt;/h2&gt;

&lt;p&gt;A captcha or a one-time code exists to stop robots. This runner is a robot. Do not solve the captcha, do not read the mailbox, and do not replay a stolen cookie from a real user. Exempt the test account on your side, or recognise the monitoring traffic with a header you choose.&lt;/p&gt;

&lt;p&gt;Headers live on the scenario, not on a single step. At most ten. Each name matches &lt;code&gt;^[A-Za-z0-9-]+$&lt;/code&gt;, is at most 64 characters, and must not be &lt;code&gt;host&lt;/code&gt;, &lt;code&gt;content-length&lt;/code&gt;, &lt;code&gt;connection&lt;/code&gt; or &lt;code&gt;transfer-encoding&lt;/code&gt;. The value is at most 500 characters. The runner copies them to Chromium's extra HTTP headers for every request of that run. You pick the name. There is no reserved Pathly header you can assume your site already trusts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"goto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://shop.example.com/login"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"X-Shop-Monitor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"login-flow"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the site, skip the captcha when that header has the value you configured, and only for the test account. A header anyone can send is not an admin backdoor. Pair it with the test account, or with a network rule you already use for other monitors. If you cannot exempt the account, this Flow cannot pass the captcha, and it should not. Monitor the page up to the captcha with an assert that the widget rendered, and test the API behind it with a Chain from a network that your API allows.&lt;/p&gt;

&lt;p&gt;Two-factor on the test account has the same rule. Disable it for that account, or use a method your policy allows a monitor to complete without a human. Do not store a colleague's phone number in a step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asserts that mean you are in
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;assert_text&lt;/code&gt; was the template's last step. Add &lt;code&gt;assert_url&lt;/code&gt; when the signed-in page has a stable path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assert_url"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/account"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ignoreQuery"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ignoreHash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;includes&lt;/code&gt; matches a substring. &lt;code&gt;regex&lt;/code&gt; is a pattern of at most 200 characters. Ignoring the query and the hash avoids a failure on a tracking parameter. Do not ignore the path. A redirect to &lt;code&gt;/login?error=1&lt;/code&gt; must fail.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;assert_visible&lt;/code&gt; checks a selector and can retry. &lt;code&gt;assert_amount&lt;/code&gt; checks a number in a selector against &lt;code&gt;min&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt; or &lt;code&gt;equals&lt;/code&gt;, with an optional currency. Use it on a cart, not on a login. &lt;code&gt;wait_for_response&lt;/code&gt; waits for a URL substring and an optional status, which is how you tie the click to &lt;code&gt;POST /session&lt;/code&gt; returning 200. &lt;code&gt;wait_for_networkidle&lt;/code&gt; waits for the network to settle, up to the timeout you set, capped at 60 seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"op"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wait_for_response"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"urlIncludes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/session"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timeoutMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put that after the submit click, before the text assert. A 200 from &lt;code&gt;/session&lt;/code&gt; plus the text "Log out" is the pair. The status alone can be a 200 error payload. The text alone can be left over in a cached layout. Together they are the sign-in.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;press&lt;/code&gt; sends Enter, Escape, Tab, ArrowDown, ArrowUp or Space, optionally on a selector. Use Enter when the form submits from the password field and the button selector is unstable. You still want &lt;code&gt;assert_text&lt;/code&gt; after it.&lt;/p&gt;

&lt;p&gt;A click can set &lt;code&gt;newTab&lt;/code&gt; when the control opens a popup. The runner switches to that page. &lt;code&gt;switch_tab&lt;/code&gt; with &lt;code&gt;urlIncludes&lt;/code&gt; comes back to a tab whose URL contains the string. Identity providers that bounce through a second window need this. A Ping never will.&lt;/p&gt;

&lt;p&gt;Viewport, locale and timezone belong on the scenario next to &lt;code&gt;steps&lt;/code&gt; when the mobile menu or the translated button is what you are proving.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"viewport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mobile"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"locale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en-US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timezone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Europe/Paris"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mobile&lt;/code&gt; is a real viewport id. Desktop is the default, 1280 by 720. Record the size your customers use for that form. A banner that covers the button on a phone and not on a desktop will stay green if you only record the desktop.&lt;/p&gt;

&lt;h2&gt;
  
  
  When login is an API, use Chain
&lt;/h2&gt;

&lt;p&gt;Flow sees the button. Chain sees the token. A front end that calls an auth API can break in either place. Chain does not open Chrome. One hop extracts a JSON field into a name. The next call uses it only where you write &lt;code&gt;{{token}}&lt;/code&gt;, in the URL, the body, or an Authorization header. It is cheaper and it does not prove the button.&lt;/p&gt;

&lt;p&gt;Hops are one to ten. Methods are the usual HTTP set. &lt;code&gt;extractJson.path&lt;/code&gt; is at most 200 characters. &lt;code&gt;extractJson.as&lt;/code&gt; is a name of at most 64 characters, starting with a letter or underscore.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PathlyClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@pathly/sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pathly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PathlyClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PATHLY_API_TOKEN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pathly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createChain&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Login then /me&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;intervalSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;critical&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;hops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;API_USER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;API_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="na"&gt;assertStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;extractJson&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Me&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com/me&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer {{token}}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;assertStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;extractCookie&lt;/code&gt; on a hop stores a cookie name instead of a JSON path, when the session is a cookie rather than a bearer token. &lt;code&gt;waitMs&lt;/code&gt; pauses up to 30 seconds before the next hop. &lt;code&gt;expectText&lt;/code&gt; fails the hop when the body does not contain the string. Put the test password in the CI secret, as &lt;code&gt;API_PASSWORD&lt;/code&gt; above, not in a screenshot of this article.&lt;/p&gt;

&lt;p&gt;Keep the Flow for the button and the Chain for the token when both are on the critical path. A green Chain with a broken submit button is the outage this article started from. A green Flow with a token your mobile app cannot refresh is the outage the Chain is for.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /v1/scenarios/{id}/run&lt;/code&gt; with scope &lt;code&gt;runs:trigger&lt;/code&gt; queues a run and returns &lt;code&gt;jobId&lt;/code&gt;. Poll &lt;code&gt;GET /v1/runs?scenarioId=&lt;/code&gt; with &lt;code&gt;runs:read&lt;/code&gt;. Finished statuses are &lt;code&gt;ok&lt;/code&gt;, &lt;code&gt;fail&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt;. The browser cap is two minutes unless that account was given a higher one, up to ten minutes. A poll that waits longer than the cap will only see the timeout.&lt;/p&gt;

&lt;p&gt;The GitHub Action &lt;code&gt;pathlyhq/pathly-action@v1&lt;/code&gt; can &lt;code&gt;run-scenario&lt;/code&gt; or &lt;code&gt;assert-scenario&lt;/code&gt; on the id you saved. Assert reads the last status and does not start a browser. Use assert in a pull request when you only need to know the scheduled Flow is currently green. Use run after a deploy when you need a fresh sign-in. The token scopes are &lt;code&gt;scenarios:read&lt;/code&gt; and &lt;code&gt;runs:read&lt;/code&gt;, plus &lt;code&gt;runs:trigger&lt;/code&gt; for the run operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What not to put in the steps
&lt;/h2&gt;

&lt;p&gt;Do not put a production admin password in &lt;code&gt;fill&lt;/code&gt;. Do not put a card number in a checkout Flow. Stop the journey before payment, on a text that proves the payment page rendered, unless you have a payment provider test card that cannot move money. Do not add a step that clicks "delete account". The runner will click it on schedule.&lt;/p&gt;

&lt;p&gt;Fifty steps is the maximum. A journey that long is two journeys. The default click delay of two seconds times a long click list will also hit the two-minute cap. Shorten the path to the assert that matters.&lt;/p&gt;

&lt;p&gt;Selectors are at most 500 characters. Prefer an id or a name. A selector that is a copy of the entire form will break on the next class hash. &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;href&lt;/code&gt; on a click are the fallbacks for pages that have neither.&lt;/p&gt;

&lt;p&gt;When the run fails, the step result carries the op, the duration, the page URL, and the screenshot of that step. Fix the selector in the list. Run again. A Ping on the same host can stay green the whole time. That is not a contradiction. They are different instruments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Viewports you can name
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;viewport&lt;/code&gt; is one of &lt;code&gt;desktop&lt;/code&gt;, &lt;code&gt;tablet&lt;/code&gt;, &lt;code&gt;mobile&lt;/code&gt;, &lt;code&gt;iphone_se&lt;/code&gt;, &lt;code&gt;iphone_14&lt;/code&gt;, &lt;code&gt;pixel_7&lt;/code&gt;, &lt;code&gt;galaxy_s21&lt;/code&gt;, &lt;code&gt;ipad_mini&lt;/code&gt;. &lt;code&gt;desktop&lt;/code&gt; is 1280 by 720 and it is the default when the field is absent. &lt;code&gt;tablet&lt;/code&gt; is 768 by 1024. Pick &lt;code&gt;mobile&lt;/code&gt; or a named phone when the login form collapses into a menu. Record and replay the same id. A journey recorded on &lt;code&gt;desktop&lt;/code&gt; and replayed on &lt;code&gt;iphone_14&lt;/code&gt; will miss buttons that moved. Set the id in the scenario, do not rely on the recorder window being maximised.&lt;/p&gt;

&lt;p&gt;Locale and timezone are strings on the same object. &lt;code&gt;locale&lt;/code&gt; is at most 16 characters. &lt;code&gt;timezone&lt;/code&gt; is at most 64. &lt;code&gt;fr-FR&lt;/code&gt; and &lt;code&gt;Europe/Paris&lt;/code&gt; are what you get if you omit them. An English submit button will not match &lt;code&gt;button:has-text('Connexion')&lt;/code&gt; on an &lt;code&gt;en-US&lt;/code&gt; locale. Align the assert text, the click text, and the locale. A mismatch here looks like a product outage and is a configuration error.&lt;/p&gt;

&lt;h2&gt;
  
  
  A scheduled run versus a run you start
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;intervalSec: 300&lt;/code&gt; checks the login every five minutes and spends browser time on each pass. A login that only needs to be proven after a deploy can stay on a long interval, and the pipeline calls &lt;code&gt;POST /v1/scenarios/{id}/run&lt;/code&gt; when the deploy finishes. Plans do not share the same Flow quota. Do not copy a five-minute interval from this example onto every journey because the JSON looked tidy. Browser minutes are the meter. A Ping on the same URL is the cheap signal that the host answers. The Flow is the expensive signal that a person can sign in.&lt;/p&gt;

&lt;p&gt;The GitHub Action below starts one fresh run and fails the job if it does not finish &lt;code&gt;ok&lt;/code&gt;. The id is a variable, not a secret. The token is a secret.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pathly sign-in&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pathlyhq/pathly-action@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;api_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.PATHLY_API_TOKEN }}&lt;/span&gt;
    &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;run-scenario&lt;/span&gt;
    &lt;span class="na"&gt;scenario_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.PATHLY_LOGIN_SCENARIO_ID }}&lt;/span&gt;
    &lt;span class="na"&gt;timeout_sec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;120"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;timeout_sec&lt;/code&gt; of 120 matches the default browser cap. Raising the action timeout above the scenario cap does not give the browser more time. &lt;code&gt;assert-scenario&lt;/code&gt; is the other operation. It fails the job when the last status is not &lt;code&gt;ok&lt;/code&gt;, and it does not start a browser. Use it on pull requests. Use &lt;code&gt;run-scenario&lt;/code&gt; after the deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a failure without guessing
&lt;/h2&gt;

&lt;p&gt;The step list in the run is the order the runner executed, including a skipped step after &lt;code&gt;if_visible&lt;/code&gt;. A skipped step is &lt;code&gt;ok&lt;/code&gt; and its detail says it was skipped. Do not "fix" a skip by deleting &lt;code&gt;if_visible&lt;/code&gt;. The skip is the banner being absent, which is the success case for that pair.&lt;/p&gt;

&lt;p&gt;A failed &lt;code&gt;fill&lt;/code&gt; usually means the selector matched nothing, or matched an element that cannot be typed into. A failed &lt;code&gt;click&lt;/code&gt; after a successful &lt;code&gt;fill&lt;/code&gt; often means a banner appeared late and covered the button. Put &lt;code&gt;if_visible&lt;/code&gt; and the accept click after &lt;code&gt;goto&lt;/code&gt; and before &lt;code&gt;wait_for&lt;/code&gt; on the email field, not after the fill. A failed &lt;code&gt;assert_text&lt;/code&gt; with a screenshot of the login form means the click did not navigate. Look at &lt;code&gt;wait_for_response&lt;/code&gt; for &lt;code&gt;/session&lt;/code&gt;. A 401 or a timeout there is the password or the exempt rule, not the CSS.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;assert_no_cmp&lt;/code&gt; fails while a known overlay is still visible. That is a banner the dismiss list did not click. Add an explicit click. Do not lengthen the goto and hope.&lt;/p&gt;

&lt;p&gt;Console messages and a short network list can be attached to the step when the runner captured them. They explain a script that did not load. They are not a second monitoring product. The screenshot is still the first thing to open.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the API will not echo back
&lt;/h2&gt;

&lt;p&gt;Create the scenario once, store the id, and stop sending the password on every deploy. A later &lt;code&gt;GET&lt;/code&gt; does not include the steps. &lt;code&gt;scenarioFingerprint&lt;/code&gt; changes when they change. If you need to rotate the test password, patch the scenario from a secret store, then run once. Do not print the patch body in the job log. The fingerprint moving, plus a green run, is the confirmation.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;enabled: false&lt;/code&gt; on create lets you save the steps and read a fingerprint before any alert goes out. Patch &lt;code&gt;enabled&lt;/code&gt; to true after the first manual run is green. An enabled scenario with a wrong assert will page you every interval. That is the correct behaviour, and it is a bad first hour.&lt;/p&gt;

&lt;p&gt;Headers you set for the captcha exemption are part of the scenario object too. Rotate the header value the same way you rotate the password, on the site and in the scenario together. A header that has been in a ticket and a screenshot is not a secret. Use a fresh value per environment.&lt;/p&gt;

&lt;p&gt;This is the whole login check. A test account, a &lt;code&gt;goto&lt;/code&gt;, a banner step that is allowed to skip, two fills, a click, a response wait, and a text that only exists once signed in. Ping stays on the URL. Chain stays on the token. Flow is the walk between them.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Pathly as an API, and a Terraform provider</title>
      <dc:creator>Simon Raynaud</dc:creator>
      <pubDate>Wed, 14 Oct 2026 18:07:15 +0000</pubDate>
      <link>https://dev.to/keyral/pathly-as-an-api-and-a-terraform-provider-oi</link>
      <guid>https://dev.to/keyral/pathly-as-an-api-and-a-terraform-provider-oi</guid>
      <description>&lt;p&gt;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 &lt;a href="https://pathlyhq.com/en/developers" rel="noopener noreferrer"&gt;the Pathly developer docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;httpChain&lt;/code&gt; field. The provider declares the Ping, the availability target, the maintenance window and the webhook. It does not invent a parallel API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The published contract is the one that runs
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Each operation carries &lt;code&gt;x-pathly-scope&lt;/code&gt;. A generator, or a human reading the JSON, can see the scope without opening the handler. The public document is also tagged with &lt;code&gt;x-pathly-language&lt;/code&gt; for the locale of the text. The routes do not change with the language. The sentences do.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GET /v1/scenarios&lt;/code&gt; requires &lt;code&gt;scenarios:read&lt;/code&gt;. &lt;code&gt;POST /v1/scenarios&lt;/code&gt; requires &lt;code&gt;scenarios:write&lt;/code&gt;. &lt;code&gt;POST /v1/scenarios/{id}/run&lt;/code&gt; requires &lt;code&gt;runs:trigger&lt;/code&gt;. Those three are the split you feel first. Listing is not creating. Creating is not executing. An execution spends quota. A read does not.&lt;/p&gt;

&lt;p&gt;The create call says what each type needs. &lt;code&gt;type: http&lt;/code&gt; requires &lt;code&gt;url&lt;/code&gt; or &lt;code&gt;httpChain&lt;/code&gt;. &lt;code&gt;type: browser&lt;/code&gt; requires &lt;code&gt;scenario&lt;/code&gt;. &lt;code&gt;enabled: false&lt;/code&gt; prepares a scenario without raising an alert. The same call accepts an &lt;code&gt;Idempotency-Key&lt;/code&gt; header so a retry does not create a second row.&lt;/p&gt;

&lt;p&gt;A minimal Ping body, using a host reserved for examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Home"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://www.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"intervalSec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expectedStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxLatencyMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"major"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"prod"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;intervalSec&lt;/code&gt; is seconds between checks. &lt;code&gt;maxLatencyMs&lt;/code&gt; fails the run when the response is slower than that, even if the status matches. &lt;code&gt;severity&lt;/code&gt; is &lt;code&gt;critical&lt;/code&gt;, &lt;code&gt;major&lt;/code&gt; or &lt;code&gt;minor&lt;/code&gt;. Tags are the labels a Terraform data source can filter on later. If you omit &lt;code&gt;expectedStatus&lt;/code&gt;, the server stores 200. If you omit &lt;code&gt;maxLatencyMs&lt;/code&gt;, it stores 5000. Send the values you mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Narrow scopes, and four refusals
&lt;/h2&gt;

&lt;p&gt;A key carries scopes shaped as resource then action. &lt;code&gt;scenarios:read&lt;/code&gt; lists and reads. &lt;code&gt;scenarios:write&lt;/code&gt; creates, updates, deletes, mutes and sets a baseline, and it includes read, because a tool always re-reads what it just wrote. &lt;code&gt;runs:read&lt;/code&gt; lists runs. &lt;code&gt;runs:trigger&lt;/code&gt; queues a run and includes &lt;code&gt;runs:read&lt;/code&gt;. &lt;code&gt;alerting:write&lt;/code&gt; includes &lt;code&gt;alerting:read&lt;/code&gt;. &lt;code&gt;maintenance:write&lt;/code&gt; includes read. &lt;code&gt;sla:write&lt;/code&gt; includes read. &lt;code&gt;org:read&lt;/code&gt; is only read. Writing does not include triggering. A provisioner that creates checks should not hold &lt;code&gt;runs:trigger&lt;/code&gt; unless that same job also launches them.&lt;/p&gt;

&lt;p&gt;Four actions stay out of every key, by decision.&lt;/p&gt;

&lt;p&gt;A key cannot issue or revoke a key. A key that could would cancel the scopes of every other key.&lt;/p&gt;

&lt;p&gt;A key cannot invite a member. The new account would issue whatever keys it wants. That is the same escalation one hop later.&lt;/p&gt;

&lt;p&gt;A key cannot change plan or touch billing. A continuous integration token must not start a recurring charge.&lt;/p&gt;

&lt;p&gt;A key cannot export the organisation. The export contains members, so it contains personal data, and it does not belong in a job log.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Keys are minted in the product settings, by a person. Copy the value once. It starts with &lt;code&gt;sp_&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  An id from another organisation is a 404
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The SDK exposes that as &lt;code&gt;PathlyApiError&lt;/code&gt; with &lt;code&gt;notFound&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PathlyClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PathlyApiError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@pathly/sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pathly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PathlyClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PATHLY_API_TOKEN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pathly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getScenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mon-unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;PathlyApiError&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Gone from this org. Create it again, or fix the id.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;status&lt;/code&gt;, &lt;code&gt;path&lt;/code&gt;, the API message, &lt;code&gt;retryAfter&lt;/code&gt; and validation &lt;code&gt;details&lt;/code&gt; are on the same error. A 400 puts the field errors in &lt;code&gt;details&lt;/code&gt;. Do not retry a 400. The body will be refused again, and you will spend rate limit on a typo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency for twenty-four hours
&lt;/h2&gt;

&lt;p&gt;Writes accept &lt;code&gt;Idempotency-Key&lt;/code&gt;. The key must match &lt;code&gt;^[A-Za-z0-9._:-]{8,128}$&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The SDK attaches &lt;code&gt;Idempotency-Key&lt;/code&gt; on writes. &lt;code&gt;runScenario&lt;/code&gt; opts out (&lt;code&gt;idempotent: false&lt;/code&gt;), 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 &lt;code&gt;idempotencyKey&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.pathlyhq.com/v1/scenarios"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$PATHLY_API_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Idempotency-Key: deploy-2026-10-14-home"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"type":"http","name":"Home","url":"https://www.example.com","intervalSec":300,"expectedStatus":200,"severity":"major"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that twice with the same body. You get one scenario. Change &lt;code&gt;name&lt;/code&gt; and keep the key. You get 409. Wait, or use a new key, when the body is intentionally new.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /v1/scenarios/{id}/run&lt;/code&gt; is not a create of a configuration object. It queues a run and returns &lt;code&gt;jobId&lt;/code&gt;. It spends quota. Give it &lt;code&gt;runs:trigger&lt;/code&gt; and do not confuse it with idempotent create. Triggering twice is two runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cursor pages, not page numbers
&lt;/h2&gt;

&lt;p&gt;List endpoints take &lt;code&gt;limit&lt;/code&gt; and &lt;code&gt;cursor&lt;/code&gt;. The default page size is in the schema, not only in a sentence, so a generator can read it. The next page is the &lt;code&gt;nextCursor&lt;/code&gt; value from the previous response. The last page omits it.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scenario&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pathly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;paginate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;pathly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listScenarios&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass the cursor through. Do not request &lt;code&gt;limit&lt;/code&gt; 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 &lt;code&gt;pathly_scenarios&lt;/code&gt; data source. Filter by tag in your own code if you only care about one environment. The data source's &lt;code&gt;filter_tag&lt;/code&gt; is that filter for HCL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry-After, and what is never retried
&lt;/h2&gt;

&lt;p&gt;When the rate limit is hit, the API sends &lt;code&gt;Retry-After&lt;/code&gt;. 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 &lt;code&gt;attempt * 500&lt;/code&gt; milliseconds. A 400 or a 403 is thrown immediately. Both would fail the same way on the next try.&lt;/p&gt;

&lt;p&gt;The client also refuses a base URL that is not &lt;code&gt;https&lt;/code&gt;, except &lt;code&gt;http://localhost&lt;/code&gt; or &lt;code&gt;http://127.0.0.1&lt;/code&gt;. The key is in the &lt;code&gt;Authorization&lt;/code&gt; header. Clear text to a remote host is readable by every intermediary. &lt;code&gt;baseUrl&lt;/code&gt; defaults to &lt;code&gt;https://api.pathlyhq.com&lt;/code&gt;. Override it only for a local server.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;userAgent&lt;/code&gt; defaults to &lt;code&gt;pathly-sdk-ts&lt;/code&gt;. Set it to the name of your integration so API logs can tell your job from someone else's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a read never returns
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;scenarioFingerprint&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Chain on the same route
&lt;/h2&gt;

&lt;p&gt;Chain is not a second product hostname. &lt;code&gt;createChain&lt;/code&gt; posts &lt;code&gt;type: http&lt;/code&gt; to &lt;code&gt;/v1/scenarios&lt;/code&gt; and sets &lt;code&gt;httpChain&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pathly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createChain&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Login then /me&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;intervalSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;hops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;API_USER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;API_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="na"&gt;assertStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;extractJson&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;as&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Me&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com/me&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;assertStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each hop has a method in &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, &lt;code&gt;PATCH&lt;/code&gt;, &lt;code&gt;HEAD&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, a URL, an optional name, optional headers, an optional body up to 8000 characters, an optional &lt;code&gt;waitMs&lt;/code&gt; up to 30000, an optional &lt;code&gt;assertStatus&lt;/code&gt;, an optional &lt;code&gt;expectText&lt;/code&gt;, an optional &lt;code&gt;extractJson&lt;/code&gt; and an optional &lt;code&gt;extractCookie&lt;/code&gt;. &lt;code&gt;extractJson.as&lt;/code&gt; must match &lt;code&gt;^[A-Za-z_][A-Za-z0-9_]*$&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trigger, then read the run
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;runs:trigger&lt;/code&gt; queues. It does not wait. &lt;code&gt;listRuns&lt;/code&gt; with &lt;code&gt;scenarioId&lt;/code&gt; and &lt;code&gt;limit: 1&lt;/code&gt; is how you see the latest. Status &lt;code&gt;ok&lt;/code&gt; passes. &lt;code&gt;fail&lt;/code&gt; and &lt;code&gt;error&lt;/code&gt; are finished and not ok. Anything else is still running.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pathly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runScenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(;;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pathly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listRuns&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;scenarioId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Pathly &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Pathly run timeout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For a pipeline, the published GitHub Action &lt;code&gt;pathlyhq/pathly-action&lt;/code&gt; at &lt;code&gt;v1&lt;/code&gt; 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 &lt;code&gt;scenarios:read&lt;/code&gt; and &lt;code&gt;runs:read&lt;/code&gt;. Add &lt;code&gt;runs:trigger&lt;/code&gt; only for &lt;code&gt;run-scenario&lt;/code&gt;. Add &lt;code&gt;scenarios:write&lt;/code&gt; only for &lt;code&gt;ensure-scenario&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mute and baseline are writes, not runs
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;POST /v1/scenarios/{id}/mute&lt;/code&gt; takes &lt;code&gt;mutedUntil&lt;/code&gt; as a date-time, or null to wake the scenario. The deadline is capped at 30 days. Mute is &lt;code&gt;scenarios:write&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /v1/scenarios/{id}/baseline&lt;/code&gt; sets the comparison baseline to the last successful run. Same scope. It does not change the URL or the interval. A patch does that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"intervalSec"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxLatencyMs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"critical"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the provider sits on this
&lt;/h2&gt;

&lt;p&gt;Four resources, and data sources that re-read the inventory. &lt;code&gt;pathly_scenario&lt;/code&gt; is this create and patch. &lt;code&gt;pathly_sla_target&lt;/code&gt; is the SLA routes under &lt;code&gt;sla:write&lt;/code&gt;. &lt;code&gt;pathly_maintenance_window&lt;/code&gt; is &lt;code&gt;maintenance:write&lt;/code&gt;. &lt;code&gt;pathly_webhook&lt;/code&gt; is &lt;code&gt;alerting:write&lt;/code&gt;, with events &lt;code&gt;run.failed&lt;/code&gt; and &lt;code&gt;run.recovered&lt;/code&gt; only. The data source &lt;code&gt;pathly_scenarios&lt;/code&gt; is &lt;code&gt;GET /v1/scenarios&lt;/code&gt; plus a tag filter. API keys and members are absent because the API refuses those actions.&lt;/p&gt;

&lt;p&gt;The provider block stays empty. &lt;code&gt;PATHLY_API_TOKEN&lt;/code&gt; is the same &lt;code&gt;sp_&lt;/code&gt; key. Version &lt;code&gt;~&amp;gt; 0.1&lt;/code&gt; while the schema is &lt;code&gt;0.x&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;If you are writing a language the SDK does not ship, you do not wait for a provider either. One key, &lt;code&gt;https://api.pathlyhq.com&lt;/code&gt;, and the OpenAPI document are enough. Generate the client from that document, or call the routes in this article. Keep the idempotency key, follow &lt;code&gt;nextCursor&lt;/code&gt;, honour &lt;code&gt;Retry-After&lt;/code&gt;, and treat 404 as "not in this org."&lt;/p&gt;

&lt;h2&gt;
  
  
  Page size, order, and a client that stops too soon
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;Retry-After&lt;/code&gt; sooner. 100 is enough for a small org. A large org still needs the cursor.&lt;/p&gt;

&lt;p&gt;Order is &lt;code&gt;created_at DESC, id DESC&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;A safe walk stores the last &lt;code&gt;nextCursor&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;paginate&lt;/code&gt; on the SDK is that walk. It stops when &lt;code&gt;nextCursor&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A provisioner key and a pipeline key are not the same key
&lt;/h2&gt;

&lt;p&gt;Split them.&lt;/p&gt;

&lt;p&gt;The Terraform key needs &lt;code&gt;scenarios:write&lt;/code&gt;, &lt;code&gt;sla:write&lt;/code&gt;, &lt;code&gt;maintenance:write&lt;/code&gt; and &lt;code&gt;alerting:write&lt;/code&gt;. Those include the matching reads. It does not need &lt;code&gt;runs:trigger&lt;/code&gt;. Apply should not launch Chrome because a plan was saved.&lt;/p&gt;

&lt;p&gt;The deploy key needs &lt;code&gt;runs:trigger&lt;/code&gt; if the job starts a run, and &lt;code&gt;runs:read&lt;/code&gt; if it only asserts the last status. &lt;code&gt;runs:trigger&lt;/code&gt; already includes &lt;code&gt;runs:read&lt;/code&gt;. It does not need &lt;code&gt;scenarios:write&lt;/code&gt; unless the job also creates checks. &lt;code&gt;ensure-scenario&lt;/code&gt; on the GitHub Action is the operation that needs &lt;code&gt;scenarios:write&lt;/code&gt;. &lt;code&gt;assert-scenario&lt;/code&gt; does not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;org:read&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a 409 is, and what a 429 is
&lt;/h2&gt;

&lt;p&gt;409 on create means the &lt;code&gt;Idempotency-Key&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;429 means slow down. Read &lt;code&gt;Retry-After&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;400 is a body the schema refused. &lt;code&gt;details&lt;/code&gt; names the fields. Common causes on a scenario are a URL that is not a URL, an &lt;code&gt;intervalSec&lt;/code&gt; of the wrong type, a hop list that is empty, an &lt;code&gt;extractJson.as&lt;/code&gt; that starts with a digit, or more than ten headers. Fix the JSON. Do not rotate the key.&lt;/p&gt;

&lt;p&gt;401 is a missing or revoked token, or a token that does not start with &lt;code&gt;sp_&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Patch versus replace, in one call
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pathly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateScenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;intervalSec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxLatencyMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;critical&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;updateScenario&lt;/code&gt; is &lt;code&gt;PATCH /v1/scenarios/{id}&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;Delete is &lt;code&gt;DELETE /v1/scenarios/{id}&lt;/code&gt;, also &lt;code&gt;scenarios:write&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;Mute, when you need silence without a delete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pathly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;muteScenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-10-15T06:00:00.000Z&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pathly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;muteScenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>api</category>
      <category>terraform</category>
      <category>devops</category>
      <category>typescript</category>
    </item>
    <item>
      <title>The Pathly Terraform provider, in practice</title>
      <dc:creator>Simon Raynaud</dc:creator>
      <pubDate>Wed, 14 Oct 2026 18:07:11 +0000</pubDate>
      <link>https://dev.to/keyral/the-pathly-terraform-provider-in-practice-2l74</link>
      <guid>https://dev.to/keyral/the-pathly-terraform-provider-in-practice-2l74</guid>
      <description>&lt;p&gt;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 is about the Terraform provider that declares the HTTP check, the availability target, the maintenance window and the webhook. The browser journey and the HTTP chain are real products, and they are not what this file creates. If you want the contract those resources call, read the API article. The pages a developer actually uses live on &lt;a href="https://pathlyhq.com/en/developers" rel="noopener noreferrer"&gt;the Pathly developer docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The provider is published as &lt;code&gt;pathlyhq/pathly&lt;/code&gt; on the public Terraform Registry. You do not compile it and you do not host a mirror. A version constraint plus &lt;code&gt;terraform init&lt;/code&gt; is the install. The schema is still &lt;code&gt;0.x&lt;/code&gt;. That is a precise statement, not a shrug. Existing resources will not move without a reason, and a schema field can still change before &lt;code&gt;1.0&lt;/code&gt;. Pin &lt;code&gt;~&amp;gt; 0.1&lt;/code&gt; so a later &lt;code&gt;0.2&lt;/code&gt; does not land in a pipeline that never asked for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smallest file that does something
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.6"&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pathly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pathlyhq/pathly"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 0.1"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"pathly"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"pathly_scenario"&lt;/span&gt; &lt;span class="s2"&gt;"site"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Home page"&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://shop.example.com"&lt;/span&gt;
  &lt;span class="nx"&gt;interval_sec&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
  &lt;span class="nx"&gt;expect_text&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Our products"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"prod"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider block is empty on purpose. The API key is read from the environment variable &lt;code&gt;PATHLY_API_TOKEN&lt;/code&gt;. If you pass that key through a Terraform variable, the value is written in clear into the state file. State is copied, backed up and sometimes committed by mistake. The environment variable stays out of the plan. The provider also refuses plain &lt;code&gt;http&lt;/code&gt; toward anything except localhost, because the key travels in an &lt;code&gt;Authorization&lt;/code&gt; header and every proxy on a clear-text hop can read it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pathly_scenario&lt;/code&gt; with a &lt;code&gt;url&lt;/code&gt; is a Ping. It requests that address on a timer. It does not open Chrome, it does not click, and it does not call a second URL. &lt;code&gt;interval_sec = 300&lt;/code&gt; means one check every five minutes. &lt;code&gt;expect_text&lt;/code&gt; is the line that separates a live server from a live site. An application error page is very happy to answer HTTP 200. The expected text is what exposes it. &lt;code&gt;tags&lt;/code&gt; are free labels. Later a data source filters on one of them, which is how you find checks that exist in the console and not in this file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;shop.example.com&lt;/code&gt; is a documentation host. Use your real origin when you apply this. Do not point a production check at a host you do not control.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the CI smoke actually applies
&lt;/h2&gt;

&lt;p&gt;The repository ships a smoke configuration that creates every managed resource, reads it back, and destroys it. It is the file to copy when you want the shapes, not a file to apply against a customer site. The identifier of the run is a variable so two applies do not collide.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"smoke_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Unique id for this CI pass, so two applies do not collide."&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pathly-tf-smoke"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"pathly_scenario"&lt;/span&gt; &lt;span class="s2"&gt;"api_health"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CI smoke API health ${var.smoke_id}"&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://api.pathlyhq.com/health"&lt;/span&gt;
  &lt;span class="nx"&gt;interval_sec&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;
  &lt;span class="nx"&gt;expect_text&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"pathly-api"&lt;/span&gt;
  &lt;span class="nx"&gt;expected_status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
  &lt;span class="nx"&gt;severity&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"critical"&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"ci"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;runbook&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://pathlyhq.com/en/developers"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;interval_sec = 86400&lt;/code&gt; is one check a day. That is a long interval so a CI smoke does not poll all day, and it is the wrong interval for a checkout. A Ping does not spend the browser budget. That budget is Flow. A public home page usually wants 300 seconds. A health URL that only proves the process is up can wait longer. &lt;code&gt;expected_status = 200&lt;/code&gt; fails the check when the origin answers anything else. &lt;code&gt;expect_text = "pathly-api"&lt;/code&gt; fails it when the status is 200 and the body is the wrong service. Both arguments belong together. Status alone is how a parking page stays green.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;severity&lt;/code&gt; is &lt;code&gt;critical&lt;/code&gt;, &lt;code&gt;major&lt;/code&gt; or &lt;code&gt;minor&lt;/code&gt;. It does not change the HTTP request. It changes how loud the failure is, and it is the predicate the SLA target below uses to decide which journeys get an objective. &lt;code&gt;runbook&lt;/code&gt; is a URL shown with the alert. Put the page your on-call already trusts. Do not put a secret, a ticket id that contains a customer name, or a link that only works on a VPN you will have forgotten in a year.&lt;/p&gt;

&lt;p&gt;The name includes &lt;code&gt;smoke_id&lt;/code&gt; because Terraform names are labels for humans, not primary keys. Two smokes with the same name and different ids are two resources. The API id, not the name, is what &lt;code&gt;terraform import&lt;/code&gt; uses later.&lt;/p&gt;

&lt;h2&gt;
  
  
  An availability target on that Ping
&lt;/h2&gt;

&lt;p&gt;A Ping that fails is an incident. An SLA target is the budget you are willing to spend before the month is considered broken. The smoke attaches one target to the scenario it just created.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"pathly_sla_target"&lt;/span&gt; &lt;span class="s2"&gt;"api_health"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;scenario_id&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathly_scenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api_health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CI smoke SLA ${var.smoke_id}"&lt;/span&gt;
  &lt;span class="nx"&gt;objective_pct&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;99.0&lt;/span&gt;
  &lt;span class="nx"&gt;window_days&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
  &lt;span class="nx"&gt;exclude_maintenance&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;warn_at_budget_ratio&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scenario_id&lt;/code&gt; is the id Terraform just received. Do not paste a UUID from the console into this block unless you are importing. A pasted id from another workspace is how a plan updates the wrong check.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;objective_pct = 99.0&lt;/code&gt; over &lt;code&gt;window_days = 7&lt;/code&gt; is a loose objective for a smoke. A payment path in production is usually tighter and longer, for example 99.9 over 30 days. The percentage is not a marketing claim about Pathly. It is the number you asked the product to measure on your own runs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;exclude_maintenance = true&lt;/code&gt; only helps if the maintenance window is also in this configuration. A window declared in the console, and a target that ignores it, spends the error budget on an outage you announced. The alert then punishes a planned job. &lt;code&gt;warn_at_budget_ratio = 0.8&lt;/code&gt; fires when eight tenths of that budget is already gone, while there is still time to stop shipping risky changes. Waiting for 100 percent means the warning and the breach arrive together.&lt;/p&gt;

&lt;p&gt;On a real set of Pings, attach the target only to the critical ones. A marketing page that is allowed to be briefly red should not share the payment objective.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"pathly_sla_target"&lt;/span&gt; &lt;span class="s2"&gt;"objective"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;journeys&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="nx"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"critical"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;scenario_id&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathly_scenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;journey&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;objective_pct&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;99.9&lt;/span&gt;
  &lt;span class="nx"&gt;window_days&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
  &lt;span class="nx"&gt;exclude_maintenance&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;warn_at_budget_ratio&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;for_each&lt;/code&gt; filter is ordinary Terraform. Rows whose severity is not &lt;code&gt;critical&lt;/code&gt; get a Ping and no objective. That is a product decision expressed as data, which is the reason to leave the console.&lt;/p&gt;

&lt;h2&gt;
  
  
  A maintenance window that does not mute the whole organisation
&lt;/h2&gt;

&lt;p&gt;The smoke window is scoped to one scenario. Sunday, starting at minute 180 of the day, for 30 minutes. Minute 180 is 03:00. Weekday 7 is Sunday in this schema.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"pathly_maintenance_window"&lt;/span&gt; &lt;span class="s2"&gt;"weekly"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;scenario_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathly_scenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api_health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;weekday&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
  &lt;span class="nx"&gt;start_minute&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;
  &lt;span class="nx"&gt;duration_min&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
  &lt;span class="nx"&gt;reason&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CI smoke weekly ${var.smoke_id}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scenario_id&lt;/code&gt; matters. Without it, a window can cover more than the check you meant to silence. The smoke sets the id so a CI pass does not mute every Ping in the organisation for half an hour. &lt;code&gt;reason&lt;/code&gt; is the sentence a human reads on Monday. Write the job name, not "test".&lt;/p&gt;

&lt;p&gt;A weekly backup that should not spend the error budget looks like this. It is the shape from the practice article, and it is not scoped in that example, so copy the &lt;code&gt;scenario_id&lt;/code&gt; line from the smoke when the backup belongs to one journey.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"pathly_maintenance_window"&lt;/span&gt; &lt;span class="s2"&gt;"backup"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;weekday&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
  &lt;span class="nx"&gt;start_minute&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
  &lt;span class="nx"&gt;duration_min&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;
  &lt;span class="nx"&gt;reason&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Weekly backup"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;start_minute&lt;/code&gt; is minutes from midnight in the window's clock, not a cron expression. &lt;code&gt;3 * 60&lt;/code&gt; is the same integer as &lt;code&gt;180&lt;/code&gt;. Prefer the multiplication when the hour is the thing you want a reviewer to see. &lt;code&gt;duration_min = 120&lt;/code&gt; is two hours. A duration that covers the whole night will also cover incidents you wanted to know about. Keep it as long as the job and no longer.&lt;/p&gt;

&lt;p&gt;Failures inside the window do not spend the error budget when the target has &lt;code&gt;exclude_maintenance = true&lt;/code&gt;. They can still be visible in the run history. Maintenance is not a delete. When the window ends, the next failed run alerts again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The webhook, and the URL that never comes back
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"pathly_webhook"&lt;/span&gt; &lt;span class="s2"&gt;"alerts"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/hooks/pathly-smoke/${var.smoke_id}"&lt;/span&gt;
  &lt;span class="nx"&gt;events&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"run.failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"run.recovered"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two event names are the set the API accepts on this resource: a run failed, and a run recovered. There is no third event to subscribe to. If your receiver only handles failure, pass a one-element list. An empty list is not a useful webhook.&lt;/p&gt;

&lt;p&gt;The URL is written on create and on update. A later read does not return it. The API returns a twelve-character fingerprint instead. The fingerprint changes when the address changes. It is not enough to reconstruct the address, and it is enough for the next plan to show that someone edited the destination in the console.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"webhook_fingerprint"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathly_webhook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url_fingerprint&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat a fingerprint change as drift. Do not try to reverse it. Put the real receiver URL in the configuration, in a private module or a private variable file, and let Terraform set it. The smoke uses &lt;code&gt;example.com&lt;/code&gt; because that name is reserved for documentation. A real hook must be HTTPS on a host you operate.&lt;/p&gt;

&lt;p&gt;Deliveries are signed. The signature header is &lt;code&gt;X-Pathly-Signature&lt;/code&gt; and the value starts with &lt;code&gt;sha256=&lt;/code&gt;. Verify that header with the webhook secret before you trust the body. Do not log the secret next to the payload. The secret stays in state on the machine that applied the configuration. That is another reason state belongs in an encrypted backend, not in git.&lt;/p&gt;

&lt;p&gt;A Ping going red is &lt;code&gt;run.failed&lt;/code&gt;. The same Ping going green again is &lt;code&gt;run.recovered&lt;/code&gt;. Your receiver should close or resolve on the second event, otherwise the incident stays open after the site is back.&lt;/p&gt;

&lt;h2&gt;
  
  
  An estate as data
&lt;/h2&gt;

&lt;p&gt;Past three or four checks, one block per resource becomes a list you re-read on every addition. A variable plus &lt;code&gt;for_each&lt;/code&gt; makes a new journey one row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"pathly_scenario"&lt;/span&gt; &lt;span class="s2"&gt;"journey"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;journeys&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;url&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;
  &lt;span class="nx"&gt;interval_sec&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interval_sec&lt;/span&gt;
  &lt;span class="nx"&gt;expect_text&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expect_text&lt;/span&gt;
  &lt;span class="nx"&gt;max_latency_ms&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max_latency_ms&lt;/span&gt;
  &lt;span class="nx"&gt;expected_status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
  &lt;span class="nx"&gt;severity&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"terraform"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;max_latency_ms&lt;/code&gt; is per journey because a cached home page and a payment page do not share a budget. A threshold copied from the home page will page you for a checkout that was always slower, and you will mute it. &lt;code&gt;expected_status = 200&lt;/code&gt; is fixed in the loop on purpose. If one URL is allowed to answer 204, give it its own resource instead of a nullable field half the team will forget.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;concat&lt;/code&gt; puts a common tag, the environment, and the row's own tags on every check. The common tag is what the data source filters on. If each row invents its own tag and nothing is shared, the drift check in the next section cannot see them as one set.&lt;/p&gt;

&lt;p&gt;Keep URLs, names and thresholds in the variable. Keep the rules in the resource. A reviewer then diffs a row, not a copy of the whole block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spotting what was created by hand
&lt;/h2&gt;

&lt;p&gt;The useful data source is the one that lists scenarios the API can see, then subtracts the ids this configuration owns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"pathly_scenarios"&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;filter_tag&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;
  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pathly_scenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;journey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"scenarios_outside_terraform"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathly_scenarios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scenarios&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
    &lt;span class="nx"&gt;if&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;pathly_scenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;journey&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;filter_tag&lt;/code&gt; limits the list to one environment. Without it you compare production code to every sandbox check in the org and the output is noise. &lt;code&gt;depends_on&lt;/code&gt; matters on the first apply, because the data source can be read before the resource it should see has been created. After that, Terraform usually orders them by the reference in the output. Leave the dependency in. A green first apply is the one people trust.&lt;/p&gt;

&lt;p&gt;A name in that output is a Ping somebody created in the console. Nobody reviews it in pull requests, and nobody deletes it when the environment is destroyed. Import it, or delete it on purpose. Do not ignore the list until it is long.&lt;/p&gt;

&lt;p&gt;The smoke checks the same idea with four booleans. Each created id must appear in the matching data source: scenarios filtered by tag, all SLA targets, maintenance windows filtered by scenario id, and all webhooks. &lt;code&gt;pathly_sla&lt;/code&gt; reads the measured budget for that scenario. On the first run the measure can be empty. The smoke only requires the read to succeed.&lt;/p&gt;

&lt;p&gt;Org-wide reads are absent from that smoke on purpose. The CI key is scoped to the resources it manages. It does not have &lt;code&gt;org:read&lt;/code&gt;. A data source that calls a route the key cannot read fails the apply. That failure is the scope working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Import, then an empty plan
&lt;/h2&gt;

&lt;p&gt;You do not rebuild monitoring to move it into code. Each resource imports by the id you already have. For a scenario, that id is the one in the console address.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform import pathly_scenario.checkout mon_01H8ZK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;mon_01H8ZK&lt;/code&gt; with the real id. After the import, &lt;code&gt;terraform plan&lt;/code&gt; shows the gap between the live object and the file. While the plan is not empty, apply changes the existing scenario in place. It does not create a second one beside it. A recreate would drop history and incidents, which is the record you were keeping.&lt;/p&gt;

&lt;p&gt;The same import exists for the SLA target, the maintenance window and the webhook. Import the scenario first, because the others store its id.&lt;/p&gt;

&lt;p&gt;The usability test is one sentence. After a successful apply, the next plan is empty. A provider that proposes a change on every pass teaches people to stop reading plans. The day the plan is true, nobody sees it. In-place update of the URL, the interval and the latency threshold is what makes that empty plan possible. Those fields are readable back as the same values you wrote. The webhook URL is the exception, and it is represented by the fingerprint so a real change still shows up.&lt;/p&gt;

&lt;p&gt;If a plan wants to replace a scenario after you only changed &lt;code&gt;interval_sec&lt;/code&gt;, stop. Do not apply a replacement to accept the diff. That is the bug the empty-plan rule is there to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this provider will never declare
&lt;/h2&gt;

&lt;p&gt;Four actions stay out of a key, and therefore out of HCL.&lt;/p&gt;

&lt;p&gt;An API key cannot issue or revoke API keys. A key that could do that would cancel the scopes of every other key. A configuration file must not be able to widen itself.&lt;/p&gt;

&lt;p&gt;An API key cannot invite a member. The new account would then issue whatever keys it wants. That is the same escalation one step later.&lt;/p&gt;

&lt;p&gt;An API key cannot change plan or touch billing. A continuous integration token must not start a recurring charge.&lt;/p&gt;

&lt;p&gt;An API key cannot export the organisation. The export contains members, which is personal data, and it does not belong in a plan log.&lt;/p&gt;

&lt;p&gt;The provider follows those refusals. You will not find a &lt;code&gt;pathly_api_key&lt;/code&gt; or a &lt;code&gt;pathly_member&lt;/code&gt; resource. If a module offers one, it is not this provider.&lt;/p&gt;

&lt;p&gt;The token you export as &lt;code&gt;PATHLY_API_TOKEN&lt;/code&gt; still needs the scopes of the resources you declare. Writing scenarios needs &lt;code&gt;scenarios:write&lt;/code&gt;, which includes read. An SLA target needs &lt;code&gt;sla:write&lt;/code&gt;. A maintenance window needs &lt;code&gt;maintenance:write&lt;/code&gt;. A webhook needs &lt;code&gt;alerting:write&lt;/code&gt;. Triggering a run is a different scope, &lt;code&gt;runs:trigger&lt;/code&gt;, and this file does not trigger runs. Do not add it because it was on a checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flow and Chain stay out of this file
&lt;/h2&gt;

&lt;p&gt;A Ping answers "did this URL respond the way the file says." A Flow is a real Chrome session: click, type, assert a text that only exists once a person is signed in. A Chain is several HTTP calls in order, where one hop can extract a token for the next. Do not invent a &lt;code&gt;pathly_flow&lt;/code&gt; resource. Flow is &lt;code&gt;steps&lt;/code&gt; on &lt;code&gt;pathly_scenario&lt;/code&gt;, with &lt;code&gt;type = "browser"&lt;/code&gt;. Chain is &lt;code&gt;http_chain&lt;/code&gt; on the same resource. This file only declares the Ping.&lt;/p&gt;

&lt;p&gt;Changing a Ping's address in this file updates that Ping. It does not migrate a Flow's recorded steps. Those steps are not returned on read, because a &lt;code&gt;fill&lt;/code&gt; or an &lt;code&gt;http_auth&lt;/code&gt; step can contain a password. State can still hold them, marked sensitive, and &lt;code&gt;scenario_fingerprint&lt;/code&gt; shows a console edit. This file stays on the Ping fields so the first example has nothing write-only in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apply order on a new workspace
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;terraform init&lt;/code&gt; downloads &lt;code&gt;pathlyhq/pathly&lt;/code&gt; at the &lt;code&gt;0.1&lt;/code&gt; line you pinned. &lt;code&gt;terraform plan&lt;/code&gt; should show four adds the first time: scenario, SLA target, maintenance window, webhook. &lt;code&gt;terraform apply&lt;/code&gt; creates them in dependency order because the target, the window and any output reference the scenario id. &lt;code&gt;terraform plan&lt;/code&gt; a second time should show no changes. Then read &lt;code&gt;scenarios_outside_terraform&lt;/code&gt;. An empty list means the tag you filtered on has no console-only checks. A non-empty list is the work queue.&lt;/p&gt;

&lt;p&gt;Store state in a backend you already encrypt. Do not commit &lt;code&gt;terraform.tfstate&lt;/code&gt;. The webhook secret and the fingerprint live there. The API key should not, as long as it came from &lt;code&gt;PATHLY_API_TOKEN&lt;/code&gt; and not from a variable.&lt;/p&gt;

&lt;p&gt;When you rotate the key, export the new value and re-run the plan. An empty plan means the rotation did not touch the resources. A 401 means the new key is missing a scope the old one had. Add the scope. Do not fall back to a key that can also invite members, because no such key exists, and a personal session token does not belong in CI either.&lt;/p&gt;

&lt;p&gt;This is the whole loop. One provider block, one Ping that checks text, one objective that excludes maintenance you also declared, one window, one webhook whose URL you keep in the file because the API will not remind you of it, and a data source that lists everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The outputs the smoke keeps
&lt;/h2&gt;

&lt;p&gt;The smoke publishes the four ids it created, the webhook fingerprint, and one boolean that is true only when every created id was read back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"created_ids"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;scenario&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathly_scenario&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api_health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
    &lt;span class="nx"&gt;sla_target&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathly_sla_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api_health&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
    &lt;span class="nx"&gt;maintenance_window&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathly_maintenance_window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weekly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
    &lt;span class="nx"&gt;webhook&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathly_webhook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"all_resources_visible"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scenario_visible&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sla_visible&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;window_visible&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhook_visible&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sla_measure_ok&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;all_resources_visible&lt;/code&gt; is the assertion a scheduled job can fail on. A create that returns 200 and a later list that omits the id is a broken read, and an empty plan will not catch it, because the plan compares state to config, not state to a filtered list. The data source is what compares state to the API. Wire that boolean into the job. Do not only archive the ids.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;visible_in_inventory&lt;/code&gt; remains in the smoke as the older scenario-only check, so a job that already reads that output does not break. New jobs should read &lt;code&gt;all_resources_visible&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When you copy the smoke into a real workspace, delete &lt;code&gt;smoke_id&lt;/code&gt; from the names, set &lt;code&gt;interval_sec&lt;/code&gt; to the interval you mean, replace the health URL, and keep the read-back outputs. The destroy at the end of CI is correct for a smoke and wrong for production. Remove the destroy step before the first real apply.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>sre</category>
    </item>
    <item>
      <title>Fail your GitHub Actions deploy if Pathly Ping or Chain is red</title>
      <dc:creator>Simon Raynaud</dc:creator>
      <pubDate>Wed, 07 Oct 2026 11:04:15 +0000</pubDate>
      <link>https://dev.to/keyral/fail-your-github-actions-deploy-if-pathly-ping-or-chain-is-red-3hf9</link>
      <guid>https://dev.to/keyral/fail-your-github-actions-deploy-if-pathly-ping-or-chain-is-red-3hf9</guid>
      <description>&lt;p&gt;Unit tests pass. Prod still breaks on the path users actually hit.&lt;/p&gt;

&lt;p&gt;Pathly: Ping if HTTP is up, Flow if a user can walk the journey, Chain if the API hops behind it hold.&lt;/p&gt;

&lt;p&gt;Wire a CI gate so the job fails when that path is red:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- uses: pathlyhq/pathly-action@v1
  with:
    api_token: ${{ secrets.PATHLY_API_TOKEN }}
    operation: run-scenario
    scenario_id: ${{ vars.PATHLY_CHECKOUT_SCENARIO_ID }}
    timeout_sec: "120"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Use run-scenario for a fresh check. Use assert-scenario for the last known status (faster).&lt;/p&gt;

&lt;p&gt;Live: &lt;a href="https://pathlyhq.com/try-flow" rel="noopener noreferrer"&gt;https://pathlyhq.com/try-flow&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>github</category>
      <category>monitoring</category>
      <category>cicd</category>
    </item>
  </channel>
</rss>
