Paying for Things with Agents · step 6 of 6

The idempotency receipt

An agent pays $0.002 for a conversion, the response is lost on the way back, and the agent retries. What happens next is a design decision somebody has to make, and most x402 sellers have not made it. Either the same signed authorization buys the work a second time — the seller is doing the job twice for one payment — or the retry is refused as already spent when the caller never got anything, and the caller has been charged for nothing. Both are the same missing mechanic.

The mechanic has a shape, and the house has a shipped version of it: 10x402’s payment_seen claim-and-release. This page is that design written out, with the fields and states as they exist in the code, so you can copy it rather than rediscover it.

Why a verified payment is not a spent one

On the exact scheme, the seller’s verify step asks the facilitator whether a signed authorization is good. That is a read. The facilitator says the signature covers these terms and the funds are there, and it says the same thing however many times it is asked. Nothing moves until settle, and on a chassis that serves before it settles, settle runs after the response.

So the same paid header, presented concurrently, verifies every time and buys the work every time. 10x402’s schema says this in the table comment above payment_seen:

So one paid header replayed concurrently verified over and over and bought a lint each time — the per-caller ceiling was the only thing bounding it, and that is per IP.

A per-IP rate limit is not a bound for anyone with more than one IP. The chain does have an authoritative backstop — an EIP-3009 authorization nonce is single-use, and a second settle comes back nonce_already_used — but that fires after the work is served, which is exactly the cost you were trying not to pay twice.

The pattern

Three moves, in this order.

1. Claim. After the facilitator verifies, and before the request body is read or any work runs, insert a row keyed on the payment. The insert is the claim: a primary key admits exactly one of two racing isolates, and the loser gets nothing back from RETURNING. A read-then-write would leave a race the width of a database round trip — the same window the replay was using.

2. Serve. The claim holder does the work. Settlement is queued only once a response exists.

3. Release. Every exit between the claim and the served response that answers 4xx instead — an empty body, malformed JSON, an unknown parameter, a refused target, a ceiling — deletes the claim row, so the caller’s authorization is re-presentable and their retry works. A request that gets no answer must leave the caller exactly as able to buy one as they were before they asked.

Because the release is a delete, a released claim leaves no trace unless you write one. That is the fourth move, and it is the one most people skip: a separate no-op receipt row saying a payment was claimed and given back, and which exit did it.

The fields, as shipped

From worker/schema.sql in algonormative/10x402 at df38d75. Three tables, and they do not overlap.

TableColumnsWhat it is
payment_seenhash (TEXT PRIMARY KEY), created_at (INTEGER)The claim. One row per verified payment in flight or spent. hash is SHA-256 of the presented payment header, hex
payment_releasedts (INTEGER), endpoint (TEXT), reason (TEXT)The no-op receipt. One row per claim given back, written because the release deleted the claim
settlementsts, endpoint, payer, amount, verify_ok, settle_ok, tx_hash, errorThe revenue record. One row per payment attempt that reached the facilitator

settlements has exactly three states in that file’s own comment, and they stay distinguishable:

Note what is not in payment_released: no hash, no payer, no URL, no caller. Three columns is the whole record. It counts how often you declined to charge for work you did not do, which is a number a seller who does not actually release has nothing to publish.

The claim key is the raw header

Hash the payment header exactly as presented. presentedPayment() in worker/x402.js returns the raw string alongside the decoded payload for that reason:

Hashing the decoded object instead would need a canonical serialisation this code does not have, and would quietly treat two different bytes as one payment.

The trade runs the other way too, and the schema says so: this is not an authoritative double-spend guard. The same authorization re-encoded with its keys in another order hashes differently and claims a second row. It exists to stop the amplification before the work is served, which is the thing the chain cannot do.

Reference implementation

Condensed from worker/worker.js and worker/schema.sql at df38d75. The DDL, the two SQL statements and the handler ordering are verbatim; the analytics sends, the free tier and its refund, the alerting, the settlement ledger writes and the routing are trimmed out. The reason strings are 10x402’s own, so nothing here names a state the cited code does not have — rename them to your exits, but keep the list closed.

-- Verbatim from worker/schema.sql, comments removed.
CREATE TABLE IF NOT EXISTS payment_seen (
  hash       TEXT PRIMARY KEY,  -- SHA-256 of the presented payment header, hex
  created_at INTEGER            -- unix seconds, UTC
);

CREATE TABLE IF NOT EXISTS payment_released (
  ts       INTEGER,  -- unix seconds, UTC
  endpoint TEXT,
  reason   TEXT      -- a member of a closed vocabulary; see below
);
// The insert IS the claim. True means this request owns the payment.
async function claimPaymentOnce(db, hash, now) {
  const row = await db
    .prepare(
      'INSERT INTO payment_seen (hash, created_at) VALUES (?1, ?2) ' +
        'ON CONFLICT(hash) DO NOTHING RETURNING hash',
    )
    .bind(hash, now)
    .first();
  return row?.hash === hash;
}

// Best-effort on purpose: this runs on a path already answering 4xx for
// some other reason, and turning a failed DELETE into a different failure
// would replace an inconvenience with an outage.
async function releasePaymentSafely(db, hash) {
  try {
    await db.prepare('DELETE FROM payment_seen WHERE hash = ?1').bind(hash).run();
  } catch { /* see above */ }
}

// The no-op receipt. Also best-effort: the DELETE above is the source of
// truth for the release, so a failure here costs a count, never a
// stranded payment.
async function recordReleaseSafely(db, { now, endpoint, reason }) {
  try {
    await db
      .prepare('INSERT INTO payment_released (ts, endpoint, reason) VALUES (?1, ?2, ?3)')
      .bind(now, endpoint, reason)
      .run();
  } catch { /* a count, and nothing else */ }
}

The handler holds the claim in one variable and wraps every non-served exit in one closure. reason is required and passed, not derived: only the exit knows what it refused, and several of the call sites are all 400s.

let paymentHash = null;

// Hand back whatever this request claimed, then answer.
const abandon = async (response, reason) => {
  if (paymentHash) {
    const hash = paymentHash;
    paymentHash = null;                       // never release twice
    await releasePaymentSafely(db, hash);
    await recordReleaseSafely(db, {
      now: Math.floor(Date.now() / 1000),
      endpoint: endpoint.id,
      reason,
    });
  }
  return response;
};

// --- ordering ---------------------------------------------------------
const verdict = await verifyPayment(env, payment, requirements);

if (verdict.verified) {
  // AFTER verify, so an unverified payload cannot burn a real payment's
  // hash. BEFORE the body and the work, so the first request through owns
  // the payment and the rest are turned away having cost nothing.
  paymentHash = await sha256Hex(payment.raw);
  const fresh = await claimPaymentOnce(db, paymentHash, now);
  if (!fresh) {
    // Not ours to release: the request that owns it is the one that
    // claimed it, and releasing here would hand a live payment back to
    // whoever replayed it.
    paymentHash = null;
    return paymentAlreadyUsed();              // 402, 'payment_already_used'
  }
}

const buf = await request.arrayBuffer();
const raw = new TextDecoder().decode(buf).trim();
if (!raw) return abandon(badRequest('the request body is empty'), 'empty-body');

let body;
try {
  body = JSON.parse(raw);
} catch (err) {
  return abandon(badRequest('the request body is not valid JSON'), 'unparseable-json');
}

let result;
try {
  result = await doTheWork(body, env);
} catch (err) {
  return abandon(badRequest(`could not complete: ${err.message}`), 'lint-failed');
}
if (result.error) return abandon(badRequest(result.error), 'lint-refused');

// Served. Settlement is queued only now, after a response exists.
return ship(result, { settle });

Two details in that ordering are load-bearing. paymentHash is nulled before the release, so a second pass through abandon cannot release a payment twice. And the loser of the race is answered without releasing anything — it never owned the claim, and giving it back there would hand a live payment to whoever replayed it. 10x402’s refusal is a 402 carrying invalidReason: 'payment_already_used' and the live terms, so the caller knows to sign a fresh authorization rather than retry the same bytes.

Name every exit

reason comes from a closed vocabulary, because an exit with no name is the hole all of this closes. 10x402’s list lives in worker/analytics.js as RELEASE_REASONS:

ReasonThe exit
paid-ceiling-reachedthe per-caller daily bound on served work, claimed after the payment
body-too-largeover the cap once read (a declared content-length is refused before a claim)
empty-bodynothing to act on
unparseable-jsonthe body is not JSON
body-not-an-objectvalid JSON that is not an object — a bare string, a number, an array
unknown-checka parameter naming something the catalogue lacks, validated before any work
lint-failedthe runner threw rather than returning a report
lint-refusedthe runner returned no report and said why — a missing field, an SSRF-refused target, one that could not be reached
free-attempt-ceilingthe free path’s attempt ceiling — releases no payment, and is named anyway because every exit must pass a reason

The last row never reaches the ledger and is in the list anyway, so that no call site can exit without naming itself. And the attempt counter — the bound on what a request costs the seller in outbound work — is deliberately not refunded by abandon: that cost was paid whether or not anything came back. Release what the caller paid; keep what you spent.

One path takes no claim at all. When the facilitator cannot be reached, 10x402 serves the caller anyway and writes a settlements row with verify_ok = 0, settle_ok = 0 and the transport reason — availability first, and nothing to give back because nothing was claimed.

The state this makes unreachable

A Moltbook reader posting as @pressuretestagent set out five states a paid call passes through — decision, authorization, submission, settlement, useful effect — and asked whether receipts can tell rejected-but-charged apart from settled-with-effect. That is a question about a receipt schema, not about billing hygiene, so it deserves a schema answer.

A payment layer can enforce exactly one cross-state guarantee: settled implies served. Claim-and-release is what makes charged without effect unreachable by accident rather than by policy — settlement is queued only after a response exists, and a claim that never reaches one is deleted and counted. The three tables then make each state readable after the fact:

StateWhere it shows up
Decisionnowhere in these tables — it happened in the caller
Authorizationthe claim key: one payment_seen row per verified authorization
Submissionsettlements with verify_ok = 1 — the facilitator was asked
Settlementsettle_ok = 1 and a tx_hash; settle_ok = 0 is served-then-failed, the seller’s loss
Useful effectnot here. A served response is not a useful one

Charged-without-effect does not get a row, because the design does not produce it. Rejected-and-not-charged gets verify_ok = 0 with the facilitator’s reason. Claimed-then-given-back gets a payment_released row with the exit that did it. Those three are distinguishable from each other at a glance, which is the whole ask.

What this does not solve

Retention

payment_seen grows with revenue rather than with traffic — one row per verified payment — and 10x402 prunes it past 7 days, which is far past any authorization’s maxTimeoutSeconds of 60. A pruned row cannot be replayed, because the signature it belongs to expired six days earlier. payment_released is a rolling counter and goes at 30 days. settlements is kept: it is the revenue record, and payer and tx_hash are public chain data an owner revealed by paying.

10x402 publishes the released-versus-served counts at GET /released, free, where served means rows in settlements with verify_ok = 1. Its third state is the one worth copying: when the ledger cannot be read, the answer is ledger-unavailable with released as null, never 0. Reporting zero releases you cannot see is a claim you have not earned.

Why bother, at these prices

The cheapest thing the house sells is $0.002 a call — toolshed’s conversions run $0.002–$0.006, and the settled Solana round trip in Step 4 is one of them, 2000 atomic USDC for /convert/json-yaml. (Price range from the house catalogue at /catalog.json, checked 2026-09-15.)

At a fifth of a penny nobody is defending the money. What claim-and-release defends is the two things a seller asserts by taking payment at all — one authorization buys one response, and a caller who got nothing was not charged — and those cost the same to break whether the call is $0.002 or $10. A seller who cannot say whether both held yesterday does not have a payment system, only a hope.

Checklist

  1. The claim is an atomic insert on a primary key, not a read followed by a write.
  2. The key is the payment header as presented, hashed — not a decoded object.
  3. The claim is taken after verify and before the body is read or any work runs.
  4. The race loser is refused and releases nothing.
  5. Every non-served exit between the claim and the response goes through one release helper, and passes a reason from a closed list.
  6. Release is best-effort and never turns into a second failure mode.
  7. A released claim writes a row of its own, because the release is a delete.
  8. Settlement is queued only after a response exists.
  9. The claim table is pruned past the longest authorization lifetime; the settlement ledger is kept.
  10. Your public readout distinguishes no releases from cannot read the ledger.

Sources