---
title: "Detecting x402 payment drift | Settlens"
description: "How to catch x402 payment terms that change between identical requests or over time, with the Settlens stability check, policy pins and Monitor baselines."
url: https://settlens.com/blog/detecting-x402-payment-drift
last_updated: 2026-10-13
published: 2026-10-13
author: "Alessandro Gagliardi"
---

# Detecting x402 payment drift: price, recipient, network and asset changes

> **In short:** x402 payment drift is an unapproved change to payTo, amount, asset or network. Settlens catches it within one scan by comparing identical requests, fails at high severity when the terms leave the values pinned in your policy, and alerts on regressions over time by comparing each Monitor run with a stored baseline.

An x402 endpoint that charges a different price, pays a different address or switches chains without anyone deciding it should is drifting. Here is how to see it within one scan and across weeks.

An x402 challenge is a promise written by the party that gets paid. The `PAYMENT-REQUIRED` header tells the client which network to pay on, in which token, to which address and how much. A paying agent has no other source for those values: it signs what the header says.

Payment drift is when that promise changes without anyone deciding it should. The price doubles after a configuration change. A recipient address rotates between two backends. A testnet network ships to production. The asset contract changes during a migration. Each request still returns a perfectly valid 402, so conformance tests stay green, and the problem only shows up in the ledger.

This article explains how to catch drift at two timescales: between identical requests made seconds apart, and between scans made days or weeks apart. Every output below is real, captured from the Settlens CLI against the `/drift` route of its demo target, which alternates its price on every request.

## What counts as drift

Four fields carry the money: `payTo` (who receives it), `amount` (how much, in atomic units), `asset` (which token contract or mint) and `network` (which chain, as a CAIP-2 identifier such as `eip155:8453`). The `resource.url` that the payment is bound to and the `maxTimeoutSeconds` window matter too. Drift is any change to these for the same request, whether it happens:

- **between identical requests**: two backends with different configurations behind one load balancer, a random recipient rotation, a pricing function with a bug, or someone tampering with responses on the path; or
- **over time**: a deploy that changes the price, a new treasury address, a token migration, an environment variable pointing production at a testnet.

Some changes are legitimate. A merchant may rotate addresses or adjust prices on purpose. The goal is not to forbid change but to make sure every change to the money fields is visible and was intended.

_Diagram: Two timescales of drift detection. Left: within one scan, Settlens sends identical requests seconds apart and compares the payment terms; three samples with amounts 10000, 25000 and 10000 produce a stability warning. Right: across scheduled runs, Monitor compares each scan with a stored baseline; a check that goes from pass to fail is a regression that raises an alert, exits with code 1 and can post a webhook._

_Drift within one scan is caught by comparing samples; drift over time is caught by comparing each run with a stored baseline._

## Within one scan: the stability check

By default, every passive scan samples the challenge twice. The check behind it is `stability.payment_requirements`, and the catalog describes it precisely:

> Repeated identical unauthenticated requests (2 by default) return the same resource, accepts and extensions, ignoring the known ephemeral fields recentBlockhash and lastValidBlockHeight. Skipped when only one sample is available.

In practice Settlens builds a fingerprint for each sample: the `resource`, `accepts` and `extensions` objects serialized as canonical JSON with sorted keys. Fields that are expected to change on every request, a Solana `recentBlockhash` or `lastValidBlockHeight`, are removed first, so a correct Solana challenge does not look unstable. If all fingerprints match, the check passes. If any differs, it warns at medium severity. With a single sample, for example when the second request fails, it is skipped rather than passed.

Use `--repeat` to take more samples (a configuration file accepts up to 10 per target). Here are three samples against the demo's drifting route, with a price ceiling of 10000:

**Three samples of a drifting challenge**

```text
$ settlens scan http://127.0.0.1:4402/drift --max-amount 10000 --repeat 3
Settlens x402 assessment 8d1c0f0c
Targets 1 · Active 0 · Settlement calls 0
 
127.0.0.1  GET http://127.0.0.1:4402/drift
13 pass · 0 fail · 1 warn · 0 skip
  WARN medium   Payment requirements remain stable
       Payment terms changed between identical unauthenticated requests.
 
Total: 13 pass · 0 fail · 1 warn · 0 skip
```

The report keeps the evidence. Each fingerprint is truncated to its first 160 characters, enough to show a changed amount at a glance:

**Stability check in the JSON report (excerpt)**

```json
{
  "id": "stability.payment_requirements",
  "title": "Payment requirements remain stable",
  "status": "warn",
  "severity": "medium",
  "message": "Payment terms changed between identical unauthenticated requests.",
  "evidence": {
    "sampleCount": 3,
    "fingerprints": [
      "{\"accepts\":[{\"amount\":\"10000\",\"asset\":\"0x036CbD53842c5426634e7929541eC2318f3dCF7e\",\"extra\":{\"name\":\"USDC\",\"version\":\"2\"},\"maxTimeoutSeconds\":60,\"network\":\"eip15",
      "{\"accepts\":[{\"amount\":\"25000\",\"asset\":\"0x036CbD53842c5426634e7929541eC2318f3dCF7e\",\"extra\":{\"name\":\"USDC\",\"version\":\"2\"},\"maxTimeoutSeconds\":60,\"network\":\"eip15",
      "{\"accepts\":[{\"amount\":\"10000\",\"asset\":\"0x036CbD53842c5426634e7929541eC2318f3dCF7e\",\"extra\":{\"name\":\"USDC\",\"version\":\"2\"},\"maxTimeoutSeconds\":60,\"network\":\"eip15"
    ]
  },
  "recommendation": "Confirm that dynamic pricing or recipient selection is intentional and cryptographically bound to the requested resource."
}
```

### Two details that matter

**A warning never fails the build.** The exit code and the CI gate count failing checks only, at or above the `--fail-on` threshold. Unstable terms are a warning because dynamic pricing can be intentional, so they show up in the JSON, HTML and SARIF reports, but they do not stop a pipeline on their own. If drift should block you, pin the values with a policy (next section): a challenge outside the policy fails at high severity. In Monitor, a stability check that goes from pass to warn is a medium-severity regression, which becomes blocking with `--fail-on medium`.

**Policy checks evaluate the first sample.** Look at the transcript again: the price ceiling was 10000 and the second sample asked for 25000, yet the policy check passed. Policy and content checks run on the first challenge; the later samples feed the stability comparison only. Drift that alternates is therefore reported as instability, not as a policy breach, which is another reason to read warnings rather than just the exit code.

### How many samples are enough?

Alternating drift like the demo's is caught by any two consecutive samples, as long as no other traffic interleaves. Random drift is probabilistic. If a server picks uniformly at random among _N_ configurations, the chance that _k_ samples all land on the same one is _N_^(1−_k_). With two configurations, two samples miss the drift half the time, five samples about 6% of the time and ten samples about 0.2% of the time. On an endpoint that matters, raise `repeat`.

## Pin the values you care about

The stability check answers “did the terms change between requests?”. A policy answers “are these the terms we approved?”. Settlens evaluates a local policy against every advertised payment option, and each constraint fails at high severity when an option falls outside it:

_Policy constraints and the checks they drive_

| Constraint | CLI flag | Configuration field | Check |
| --- | --- | --- | --- |
| Allowed recipients | `--expect-pay-to` | `expected.payTo` | `policy.recipient` |
| Allowed networks (CAIP-2) | `--expect-network` | `expected.networks` | `policy.network` |
| Allowed schemes | `--expect-scheme` | `expected.schemes` | `policy.scheme` |
| Allowed assets | `--expect-asset` | `expected.assets` | `policy.asset` |
| Maximum atomic amount | `--max-amount` | `expected.maxAmount` | `policy.maximum_amount` |

Recipients and assets on EVM chains are compared case-insensitively, because EIP-55 capitalization is only a checksum; other formats, such as Solana base58 addresses, are compared exactly. Amounts are compared in atomic units, so a ceiling only means something next to an asset allowlist: `10000` is 0.01 USDC with 6 decimals, but a different quantity entirely in a token with other decimals. Pin the recipient, the asset, the network and the amount for any endpoint where money is at stake.

## Over time: Monitor baselines

Most drift does not alternate between requests. It arrives with a deploy and then stays perfectly stable, so every individual scan looks consistent. Catching it takes memory. `settlens monitor` runs the passive scan for each target in a configuration, compares the result with the previous run stored in a state file and reports what changed.

Monitor compares _check results_, not raw values. A check that moves from pass or skip to warn or fail is a regression; a warning that becomes a failure is a regression; a check that appears for the first time already failing or warning is reported as new; a check that disappears, for example because someone deleted the price ceiling from the configuration, is reported as removed (coverage lost). Each alert is blocking when its severity is at or above `--fail-on` (the configuration's `failOn`, high by default) and a note otherwise. Monitoring is always passive: it never runs the active probe and never sends a payment.

Here is a configuration for the demo's drifting route, with recipient, network and price pinned:

**settlens.config.json**

```json
{
  "version": 1,
  "failOn": "high",
  "targets": [
    {
      "name": "demo-drift",
      "url": "http://127.0.0.1:4402/drift",
      "repeat": 3,
      "expected": {
        "networks": ["eip155:84532"],
        "payTo": ["0x209693Bc6afc0C5328bA36FaF03C514EF312287C"],
        "maxAmount": "10000"
      }
    }
  ]
}
```

Three consecutive runs. Because the demo alternates its price on every request, the first sample of each run alternates too, which makes it a convenient way to watch Monitor in both directions:

**Three Monitor runs against the drifting route**

```text
$ settlens monitor --config settlens.config.json --state settlens.state.json
demo-drift (GET http://127.0.0.1:4402/drift)
  baseline recorded (first run)
0 blocking alert(s) at or above high.
 
$ settlens monitor --config settlens.config.json --state settlens.state.json
demo-drift (GET http://127.0.0.1:4402/drift)
  no regressions
  resolved policy.maximum_amount: fail -> pass
0 blocking alert(s) at or above high.
 
$ settlens monitor --config settlens.config.json --state settlens.state.json
demo-drift (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.
```

The first run records the baseline. The second sees the price back under the ceiling and reports the failure as resolved. The third sees it above the ceiling again: `policy.maximum_amount` regressed from pass to fail, at high severity, so the process exits with code `1`. The stability warning was present in every run, so it never regressed and raised nothing new. The state file is plain JSON, keyed by each target's method and URL:

**settlens.state.json (excerpt)**

```json
{
  "GET http://127.0.0.1:4402/drift": {
    "checks": [
      …,
      { "id": "policy.maximum_amount", "status": "fail", "severity": "high", "title": "Amounts stay within the approved ceiling" },
      { "id": "stability.payment_requirements", "status": "warn", "severity": "medium", "title": "Payment requirements remain stable" }
    ],
    "summary": { "pass": 14, "fail": 1, "warn": 1, "skip": 0 },
    "observedAt": "2026-10-13T09:43:13.255Z"
  }
}
```

### Alerting

Add `--webhook <url>` and Monitor posts a JSON summary whenever at least one blocking alert fires. This payload was captured from the third run above with a local listener:

**Webhook payload**

```json
{
  "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"
        }
      ]
    }
  ]
}
```

The request times out after five seconds and does not follow redirects; a failed webhook is reported on stderr and does not change the exit code. Run Monitor from cron or a scheduled CI job, keep the state file between runs, and route the exit code or the webhook to whoever owns the endpoint.

> **Monitor compares statuses, not values**
>
> A deploy that moves the price from 9000 to 10000 under a 10000 ceiling, or that switches the recipient to another allowed address, produces no status change and therefore no alert. That is by design: those changes are inside what you approved. Tighten the policy if you want to hear about them.

## What each kind of drift looks like

_Drift scenarios and where they surface_

| Change | Within one scan | Across Monitor runs |
| --- | --- | --- |
| Price alternates between requests | Stability warning | Stability regression if the previous run was stable; policy alerts when the first sample crosses the ceiling |
| Price raised above the ceiling at a deploy | Policy failure (high), if a ceiling is set | Alert: maximum amount pass to fail |
| Price changed within the ceiling | Nothing | Nothing (inside the approved range) |
| Recipient replaced by an unreviewed address | Policy failure (high), if recipients are pinned | Alert: recipient pass to fail |
| Recipient rotates among several addresses | Stability warning (probabilistic) | Regression if the previous run was stable |
| Network switched, for example testnet to mainnet | Policy failure (high), if networks are pinned | Alert: network pass to fail |
| Asset contract changed | Policy failure (high), if assets are pinned | Alert: asset pass to fail |
| Challenge no longer bound to the URL | `binding.resource_url` failure | Alert: binding pass to fail |
| Header disappears after a migration | `transport.payment_required_header` failure | Alert: header pass to fail |

## When prices are dynamic on purpose

Dynamic pricing and x402 can coexist, but identical requests should still get identical terms. If the price depends on the request, put the variable in the URL (a path segment or a query parameter) so `resource.url` binds the payment to exactly what was priced, and each URL is stable on its own. If the price varies with time, expect a stability warning when a change lands between two samples, keep a ceiling that bounds the worst case, and document the behavior so whoever reviews the report knows the warning is intended. The check's own recommendation says it well: confirm that dynamic pricing or recipient selection is intentional and bound to the requested resource.

## Drift from the agent's side

An agent paying an endpoint sees one challenge at a time, so it cannot compare samples the way a scan does. Its defense is the policy: allowed recipients, assets and networks plus a maximum amount, checked on every 402 before it signs (see our guide to [agent spending policies](https://settlens.com/learn/agent-spending-policy)). The Settlens [MCP server](https://settlens.com/mcp) exposes that check to AI agents as `evaluate_payment_requirements`, which answers pay, review or do not pay, and its passive `scan_endpoint` tool runs the same two-sample stability check described above. The article on [verifying before paying with MCP](https://settlens.com/blog/mcp-verify-before-paying) walks through it.

## Putting it together

1. Write the reviewed terms into a configuration file: recipients, assets, networks, schemes and a ceiling.
2. Scan with enough samples to make random drift unlikely to slip through, and read warnings, not just the exit code.
3. Gate releases in CI so a deploy that breaks the policy never ships (see the [testing checklist](https://settlens.com/blog/how-to-test-x402-endpoint)).
4. Schedule `settlens monitor` with a persistent state file and a webhook so drift after release reaches a person.

The [Monitor documentation](https://settlens.com/docs/monitor) covers scheduling and state files, and the [Monitor product page](https://settlens.com/product/monitor) explains how it fits with the rest of Settlens. To set this up on your own endpoints, [request a pilot](https://settlens.com/contact).

## Sources

1. [x402 protocol specification, version 2 (§5.1 PaymentRequired schema and PaymentRequirements fields)](https://github.com/x402-foundation/x402/blob/main/specs/x402-specification-v2.md) — x402 Foundation
2. [CAIP-2: Blockchain ID specification](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) — Chain Agnostic Standards Alliance
3. [EIP-55: Mixed-case checksum address encoding](https://eips.ethereum.org/EIPS/eip-55) — Ethereum Improvement Proposals
