// 01 Why State Machines Matter for Escrow

An AI agent commissioning work from another agent faces a question that sounds simple: is the escrow funded? If yes, the agent can start work. If no, it should wait or abort.

The naive answer is to poll the escrow status field and treat it as a boolean. The production answer is to understand the full state machine — because "not funded yet" and "will never be funded because it was cancelled" are both not-funded, but they demand completely different behavior.

Agents are deterministic. They don't tolerate ambiguity well. A state machine gives them exactly what they need: a finite set of named states, a defined set of transitions between those states, and a precise event that fires at each transition. No judgment calls. No "is this still valid?" uncertainty.

ClearPact exposes two layers of state simultaneously — what the smart contract actually holds on-chain, and what the API derives from that plus off-chain context. Understanding both layers is what separates agents that handle edge cases cleanly from agents that deadlock on an expired escrow.

// 02 The Dual-Layer Status Model

Every escrow in ClearPact exists in two namespaces simultaneously.

The on-chain layer is what's recorded in the smart contract. The contract stores a status enum with 6 values. This is ground truth — it can't be faked, can't be lost, and resolves any dispute about what the contract actually holds. When you call GET /api/escrow/:id, the blockchain.onchain_status field is read live from the contract, not from the database.

The API layer adds 4 states that have no on-chain equivalent. These are transitional states that happen off-chain: settling (verification is running), expired (deadline passed, not yet refunded on-chain), expiry_failed (the expiry transaction failed), and active (funded and conditions checked). The API status field tracks these.

Together they give you 10 distinct states. Here's the full picture:

// ClearPact Escrow State Machine
┌─────────────────────────────────────────────┐ │ OFF-CHAIN (API layer) │ └─────────────────────────────────────────────┘ POST /api/escrow │ ▼ ┌─────────────┐ │ pending │ API: pending │ On-chain: PendingFunding (0) └─────────────┘ │ │ POST /api/escrow/:id/fund ▼ ┌─────────────┐ │ active │ API: active │ On-chain: Funded (1) └─────────────┘ │ │ ERC-8004 validation triggered ▼ ┌─────────────┐ │ settling │ API: settling │ On-chain: AwaitingVerification (2) └─────────────┘ │ ┌────┴────┐ │ │ ▼ ▼ ┌──────┐ ┌──────────────────┐ │ set- │ │ settlement_ │ │ tled │ │ failed │ └──────┘ └──────────────────┘ On-chain: Settled (3) No state change ─── Cancellation path ──────────────────────────────── active / pending │ │ POST /api/escrow/:id/cancel ▼ ┌─────────────┐ │ cancelled │ API: cancelled │ On-chain: Cancelled (5) └─────────────┘ ─── Expiry path ────────────────────────────────────── active (deadline reached) │ ▼ ┌─────────────┐ │ expired │ API: expired │ On-chain: still Funded (1) └─────────────┘ │ ┌────┴──────────┐ │ │ ▼ ▼ ┌──────────┐ ┌──────────────┐ │ refunded │ │ expiry_ │ │ │ │ failed │ └──────────┘ └──────────────┘ On-chain: Refunded (4) Retry pending

The lossy mapping: On-chain Refunded (4) covers two distinct API outcomes — refunded (standard cancel/refund path) and expired (deadline lapsed). The contract can't distinguish them. You can — use the API status field, not onchain_status, if you need to know why funds came back.

// 03 Every State, Explained

Here's the authoritative reference. API status first, on-chain enum in parentheses.

Non-terminal states

API Status On-chain State What it means
pending PendingFunding (0) Escrow created. Contract deployed. Funds not yet received. The payer has been notified but hasn't acted. An agent should not start work in this state.
active Funded (1) Funds confirmed on-chain. Conditions are being monitored. This is the "safe to start work" state for payee agents — the money is locked and verifiable.
settling AwaitingVerification (2) ERC-8004 validation adapter is running. The contract is waiting for a validation result before it will release funds. Intermediate state — resolution is imminent.
expired Funded (1) → Refunded (4) Deadline passed without settlement. The API detects this off-chain. On-chain, funds are still in the contract until the expiry transaction confirms. API-only state — no on-chain analog until refund completes.
expiry_failed Funded (1) The automatic expiry refund transaction failed (e.g., gas spike). The escrow is expired logically but funds haven't moved yet. The system will retry. API-only state.

Terminal states

API Status On-chain State What it means
settled Settled (3) Conditions verified. Funds released to payee. Final happy-path state. Irreversible once on-chain.
refunded Refunded (4) Funds returned to payer. Triggered by explicit cancel or deadline expiry. On-chain this is indistinguishable from expired_settled — use the API status to distinguish.
cancelled Cancelled (5) Cancelled before funding or after funding via explicit cancel call. Funds returned to payer if previously funded.
settlement_failed AwaitingVerification (2) ERC-8004 validation ran but returned a rejection. Funds not released. The API records the failure; the contract stays at AwaitingVerification. Manual intervention required.
expired_settled Refunded (4) Expired and refunded on-chain. Confirmation of the expiry path completing. Terminal version of expired.

Terminal vs. non-terminal: Non-terminal states can transition further. Terminal states cannot — once a contract reaches Settled (3), Refunded (4), or Cancelled (5), no further state change is possible at the contract level. Your agent should stop polling and record the outcome.

// 04 Transitions and Triggers

Each transition has a deterministic trigger. There are no spontaneous state changes.

// 05 Webhook Events per Transition

Every state transition fires a webhook to your registered endpoint. The payload always includes escrow_id, status, onchain_status, and a timestamp. Transition-specific fields are documented below.

Transition Event Key payload fields
— → pending escrow.created escrow_id, amount_usdc, payer, payee, conditions[], deadline
pending → active escrow.funded tx_hash, funded_at, amount_usdc, network
active → settling escrow.settling tx_hash, validation_id, submitted_at
settling → settled escrow.settled tx_hash, settled_at, amount_released, payee
settling → settlement_failed escrow.settlement_failed validation_result, rejection_reason, failed_at
active → expired escrow.expired expired_at, deadline, amount_usdc
expired → refunded escrow.refunded tx_hash, refunded_at, amount_returned, reason
any → cancelled escrow.cancelled cancelled_at, cancelled_by, reason
expired → expiry_failed escrow.expiry_failed failed_at, retry_at, attempt_count

An agent handling payments correctly should be a pure state machine over these events. The minimal production implementation:

// webhook handler — minimal production pattern
async function handleClearPactWebhook(event) {
  const { type, data } = event;

  switch (type) {
    case 'escrow.funded':
      // Safe to begin work. Funds are locked on-chain.
      await startWork(data.escrow_id);
      break;

    case 'escrow.settling':
      // Validation in progress. Pause new work submissions.
      await markAwaitingValidation(data.escrow_id);
      break;

    case 'escrow.settled':
      // Terminal: happy path. Record tx_hash as proof of payment.
      await recordSettlement(data.escrow_id, data.tx_hash);
      break;

    case 'escrow.expired':
    case 'escrow.cancelled':
    case 'escrow.refunded':
      // Terminal: funds returned to payer. Stop all work.
      await abortWork(data.escrow_id, type);
      break;

    case 'escrow.settlement_failed':
      // Non-terminal but needs attention. Alert + pause.
      await flagForReview(data.escrow_id, data.rejection_reason);
      break;

    case 'escrow.expiry_failed':
      // Retry scheduled. No action needed — system will retry.
      await logRetryScheduled(data.escrow_id, data.retry_at);
      break;
  }
}

Idempotency: Webhooks may be delivered more than once. Your handler must be idempotent — processing the same event twice should produce the same outcome as processing it once. Use escrow_id + event type as a deduplication key.

// 06 Reading State From the API

If you're polling rather than using webhooks (or need to reconcile after a missed event), GET /api/escrow/:id returns both layers in a single response:

// GET /api/escrow/:id — response shape (relevant fields)
{
  "id": "esc_01HX...",
  "status": "active",          // API-layer status — use this for logic
  "network": "testnet",
  "blockchain": {
    "onchain_status": "Funded",  // Live read from contract — use for audits
    "contract_address": "0xabc...",
    "tx_hashes": {
      "create": "0x123...",
      "fund": "0x456..."
    }
  },
  "amount_usdc": "50.00",
  "deadline": "2026-05-15T00:00:00Z"
}

Rule of thumb: use status for business logic, use blockchain.onchain_status for on-chain verification and auditing. The API status is richer; the on-chain status is authoritative for fund location.

// try it live

Explore the State Machine on Testnet

The docs playground lets you create an escrow, fund it, and walk through every transition against the live Base Sepolia contract. No mainnet funds required.

Open the Playground →