Know when an x402 endpoint changes its terms

Settlens Monitor reruns the passive scan on your schedule and compares it with the last run. When a check that passed starts failing, such as a price above your ceiling or a recipient outside your allowlist, it exits non-zero and can post an alert to your webhook.

Real output: the demo /drift route alternates its price between requests.

settlens monitor (second run)
settlens monitor --config settlens.config.json \
    --state settlens.state.json --webhook http://127.0.0.1:4517/settlens
 
priced-api (GET http://127.0.0.1:4402/drift)
  ALERT [high] policy.maximum_amount: pass -> fail — Amounts stay within the approved ceiling
 
1 blocking alert(s) at or above high.

In short

Settlens Monitor reruns the passive x402 scan on your schedule and compares every check with a stored baseline per method and URL. Regressions, new failing or warning checks and removed checks become alerts; blocking alerts exit non-zero and can POST a JSON webhook. It never sends a payment.

What Monitor does

  • A passive scan every run

    Each run requests your endpoints exactly like Assess in passive mode. It never sends a payment signature, even if the active flags are given.

  • A baseline per endpoint

    The state file stores the last observed status of every check, keyed by method and URL. The first run records it and alerts on nothing.

  • Regressions, not noise

    A check that gets worse, a new check that fails or warns, or a check that disappears (lost coverage) is an alert. A check that stays failing is not repeated on every run.

  • Your severity threshold

    Alerts at or above fail-on (high by default) are blocking: they set exit code 1 and trigger the webhook. Lower ones are printed as notes.

  • A webhook alert

    With --webhook, a blocking alert sends one JSON POST with the threshold and every run's method, URL and alerts, which a relay such as n8n or an internal service can forward to chat or incident tools.

  • Bring your scheduler

    Monitor is one command that exits. Cron, a systemd timer or a scheduled GitHub workflow decides how often it runs.

How a run works

  1. Load the configuration and the baseline

    Targets and policy come from the same configuration file as settlens check. The baseline comes from --state; a missing file means a first run; unreadable or corrupt files fail closed. Without --state, every run is a first run and nothing is compared.

  2. Scan every target passively

    Handshake, schema, binding, drift and policy checks run for each URL, plus facilitator checks for targets that name a facilitator.

  3. Compare with the baseline

    Each check is compared by id with its previous status. Regressions, new failures and removed checks become alerts; improvements are listed as resolved.

  4. Save, report, alert

    The new results replace the baseline in the state file. If any alert reaches the threshold, the webhook is called and the command exits with 1.

Each run compares with the state the previous run left behind, then overwrites it.

What counts as a regression

Status changes between the baseline and the current run
BaselineCurrentReported as
pass or skipwarn or failAlert: regression
warnfailAlert: regression
(not present)warn or failAlert: new check
fail or warna better statusResolved
anythe same statusNothing
present(not present)Alert: removed (coverage lost)
(no baseline)anyBaseline recorded

An alert is blocking when the check's severity is at or above --fail-on, or failOn in the configuration. Blocking alerts print as ALERT, count toward the exit code and trigger the webhook. The others print as note.

A skip ranks like a pass, so a check moving between pass and skip, for example drift with a single sample, never raises an alert. A removed check keeps the severity it had in the baseline, so removing a high-severity check, such as the price ceiling, blocks at the default threshold.

Example: a price that changes between runs

The demo /drift route alternates its price between 10000 and 25000 atomic units on each request. With repeat set to 1, each run sees one price, so the change shows up between runs.

JSONsettlens.config.json
{
  "version": 1,
  "failOn": "high",
  "targets": [
    {
      "name": "priced-api",
      "url": "http://127.0.0.1:4402/drift",
      "repeat": 1,
      "expected": {
        "networks": ["eip155:84532"],
        "payTo": ["0x209693Bc6afc0C5328bA36FaF03C514EF312287C"],
        "maxAmount": "10000"
      }
    }
  ]
}
Run 1: baseline
settlens monitor --config settlens.config.json \
    --state settlens.state.json --webhook http://127.0.0.1:4517/settlens
 
priced-api (GET http://127.0.0.1:4402/drift)
  baseline recorded (first run)
 
0 blocking alert(s) at or above high.
Run 3: back to the approved price
settlens monitor --config settlens.config.json \
    --state settlens.state.json --webhook http://127.0.0.1:4517/settlens
 
priced-api (GET http://127.0.0.1:4402/drift)
  no regressions
  resolved policy.maximum_amount: fail -> pass
 
0 blocking alert(s) at or above high.

The webhook body

Run 2 had one blocking alert, so Settlens sent this JSON as a POST with content-type: application/json to the local test receiver. The request times out after 5 seconds and refuses redirects. A webhook failure is printed on stderr and does not change the exit code.

JSONPOST body (run 2)
{
  "product": "Settlens Monitor",
  "alerting": 1,
  "failOn": "high",
  "runs": [
    {
      "method": "GET",
      "url": "http://127.0.0.1:4402/drift",
      "alerts": [
        {
          "kind": "regression",
          "id": "policy.maximum_amount",
          "from": "pass",
          "to": "fail",
          "severity": "high",
          "title": "Amounts stay within the approved ceiling"
        }
      ]
    }
  ]
}

Schedule it

Run Monitor wherever your endpoints are reachable from. Keep the state file somewhere that survives between runs.

Shellcron on a host with a Settlens checkout
# crontab: every 15 minutes, Monday to Friday
SETTLENS_WEBHOOK_URL=https://hooks.example.com/settlens
*/15 * * * 1-5 cd /opt/settlens && node src/cli.js monitor --config /etc/settlens/settlens.config.json --state /var/lib/settlens/settlens.state.json --webhook "$SETTLENS_WEBHOOK_URL" >> /var/log/settlens-monitor.log 2>&1
YAML.github/workflows/settlens-monitor.yml
name: Settlens monitor

on:
  schedule:
    - cron: "*/30 * * * *"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  monitor:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
      - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
        with:
          node-version: "24"
          package-manager-cache: false
      - run: npm ci --omit=dev --ignore-scripts

      - name: Restore the previous baseline
        uses: actions/cache/restore@v4 # pin to a reviewed commit SHA
        with:
          path: settlens.state.json
          key: settlens-state-${{ github.run_id }}
          restore-keys: settlens-state-

      - name: Compare with the baseline
        env:
          SETTLENS_WEBHOOK_URL: ${{ secrets.SETTLENS_WEBHOOK_URL }}
        run: >-
          node src/cli.js monitor
          --config settlens.config.json
          --state settlens.state.json
          --webhook "$SETTLENS_WEBHOOK_URL"

      - name: Save the new baseline
        if: always()
        uses: actions/cache/save@v4 # pin to a reviewed commit SHA
        with:
          path: settlens.state.json
          key: settlens-state-${{ github.run_id }}

On GitHub Actions

This workflow runs in the Settlens repository, like the CI gate. The checkout and setup-node pins are the ones the repository already uses.

  • The baseline lives in the Actions cache: each run restores the most recent settlens-state- entry and saves a new one, even when the comparison fails.
  • The webhook URL is a secret passed through an environment variable, not interpolated into the command.
  • GitHub runs schedules in UTC and can delay them under load; use a host scheduler when timing matters.
  • Pin actions/cache to a commit SHA you have reviewed before using this in production.

The state file

The state file is plain JSON keyed by each target's method and URL, such as GET https://api.example.com/paid. For each target it keeps the id, status, severity and title of every check, the summary counts and the time of the run. It holds no header values, response bodies or keys.

The private workspace supports explicitly approved references, bounded price ranges, persistent incidents, acknowledgement, comments, daily reminders and durable notification delivery. Endpoint name and policy editing retains history. Solana profile checks remain offline and cannot establish settlement.

Because the key is the method and URL, changing an endpoint URL starts a new baseline; renaming it retains its method/URL identity, while a GET and a POST target on one URL keep separate ones. State files from Settlens 0.4.0, keyed by URL only, are migrated on the next run. Deleting the file resets every baseline; the next run records fresh ones without alerting.

Monitor prints its comparison but does not write report files. For a report you can archive or sign, run settlens check with the same configuration and attest it as evidence.

JSONsettlens.state.json after run 1 (excerpt)
{
  "GET http://127.0.0.1:4402/drift": {
    "checks": [
      {
        "id": "transport.reachable",
        "status": "pass",
        "severity": "high",
        "title": "Endpoint is reachable"
      },
      …
      {
        "id": "policy.maximum_amount",
        "status": "pass",
        "severity": "high",
        "title": "Amounts stay within the approved ceiling"
      }
    ],
    "summary": { "pass": 15, "fail": 0, "warn": 0, "skip": 1 },
    "observedAt": "2026-10-13T11:16:44.404Z"
  }
}

What each run re-checks

Every passive, content and facilitator check from the catalog. Policy checks run when their field is set, facilitator checks when the target names a facilitator. Details in the check reference.

29 checks by category
CategoryChecks
transporttransport.reachable, transport.status_402, transport.payment_required_header, transport.payment_required_encoding, transport.response_body_limit, transport.https, transport.redirect
schemaschema.official_x402_sdk, schema.payment_required, schema.payment_requirements, schema.duplicate_requirements
bindingbinding.resource_url
freshnessfreshness.timeout_window
policypolicy.scheme, policy.network, policy.recipient, policy.asset, policy.maximum_amount
stabilitystability.payment_requirements
facilitatorfacilitator.reachable, facilitator.response_body_limit, facilitator.supported_status, facilitator.content_type, facilitator.https, facilitator.supported_schema, facilitator.v2_capability, facilitator.duplicate_kinds, facilitator.requirement_coverage, facilitator.signer_coverage

What Monitor does not do

  • Legacy state mode alerts on changes. The baseline moves forward on every run, so a check that keeps failing alerts once, on the run where it changed. Journal mode retains active incidents and gates persistent findings; settlens check also provides an absolute pass or fail.
  • No active probe. Monitor is passive only. The fail-closed probe stays a deliberate, authorized step.
  • No scheduler and no history. It runs once and keeps only the latest state. Retention and dashboards are up to the system that runs it.
  • A simple webhook. One attempt, a 5-second timeout, no retries and no signature or authentication header. Point it at an endpoint that can accept unauthenticated JSON, such as an internal relay.
  • Not uptime monitoring. Reachability is one check among many, sampled when the run happens.
  • Repeat comes from the configuration. The number of samples per run is set by repeat in the configuration file (two by default).

Questions about monitoring

Does Monitor ever send a payment?

No. Every run is a passive scan with ordinary unauthenticated requests. It never sends a payment signature and never calls /settle.

How often should it run?

As often as a change would matter to you and your endpoints can tolerate: every 15 or 30 minutes is common. Each run makes a few requests per target (two by default, plus one per facilitator).

Why did a failing check stop alerting?

Monitor alerts when a status changes. After the run that reported it, the failure is part of the new baseline. Use settlens check in CI for an absolute gate.

What does the first run do?

It records the baseline for each target and exits 0, whatever it finds. Read its output or run settlens check first if you want to start from a known-good state.

What happens if I remove a check from the configuration?

The next run reports each check that disappeared as an alert of kind removed, printed as coverage lost, with the severity it had before. That is deliberate: deleting a price ceiling or a recipient allowlist should not go unnoticed. The run after that uses the new configuration as its baseline.

Can I monitor a facilitator?

Yes, through a target that names a facilitator: each run also reads its GET /supported and compares those checks with the baseline.

Watch one production endpoint

In a pilot we write the policy with you, set up the schedule and route alerts to the tool your team already reads.