// 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:
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.
-
— → pendingTrigger:
POST /api/escrow. Contract is deployed on Base. Payer address recorded. No funds on-chain yet. -
pending → activeTrigger:
POST /api/escrow/:id/fund. USDC transferred to contract. On-chain state moves toFunded (1). This is the signal payee agents wait for. -
active → settlingTrigger: Payee submits proof via ERC-8004 validation endpoint. Contract transitions to
AwaitingVerification (2)while the adapter evaluates conditions. -
settling → settledTrigger: ERC-8004 adapter returns
APPROVED. Contract executes fund release to payee. On-chain state:Settled (3). -
settling → settlement_failedTrigger: ERC-8004 adapter returns
REJECTED. On-chain state staysAwaitingVerification (2). API records failure. Requires manual resolution. -
active → expiredTrigger: Deadline timestamp passes without settlement. API detects off-chain. On-chain contract is still
Funded (1)— funds haven't moved yet. -
expired → refundedTrigger: Automatic expiry transaction confirms on-chain. Funds returned to payer. On-chain:
Refunded (4). -
active → cancelledTrigger:
POST /api/escrow/:id/cancel. Valid frompendingoractive. On-chain:Cancelled (5).
// 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:
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:
{
"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.
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 →