# Ezys Documentation — full text > A public permissioned solver network for institutional-grade FX transactions Every public page in the order of the index at https://docs.ezys.io/llms.txt, each exactly as published at its `.md` URL. Each page starts with the line `` followed by its Source URL and section; cite that URL for what you use from the page. --- > Source: [/](https://docs.ezys.io/) · Markdown: https://docs.ezys.io/index.md > Section: Introduction · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Ezys Exchange token and fiat assets through one partner API, with quotes, signed intents and settlement tracking. ## What is Ezys? In traditional FX trading, fintech services must integrate directly with individual banks and exchanges, and manually compare multiple liquidity sources to obtain the best rates. Ezys solves this problem. **Fintech Service Providers** simply submit their trading intent, and the **Ezys Solver Network** collects quotes from multiple **FX Trading Institutions** to determine the optimal execution path. Through **Hybrid Settlement**, which supports both on-chain and off-chain assets as settlement targets, transactions across diverse asset types are made possible. ![Ezys Overview](https://docs.ezys.io/images/ezys_overview_03.jpg) Choose from the asset pairs, chains and payout rails enabled for your integration. Availability depends on the environment and provider configuration. ## Who is Ezys for? ### Fintech Service Providers For companies operating travel/remittance apps, fintech wallets, and payment services. | Feature | Description | |---------|-------------| | **Send Intents** | Submit FX requests to Ezys | | **Price Discovery** | Request prices from configured liquidity sources and set execution bounds | | **Hybrid Settlement** | Automatic on-chain/off-chain settlement processing | > [For Fintech Service Providers](https://docs.ezys.io/for-fsps/overview.md) ### FX Trading Institutions For banks, exchanges, and market makers. | Feature | Description | |---------|-------------| | **Provide Quotes** | Provide pricing through an onboarded integration | | **Access New Demand** | Direct access to FX demand from fintech channels | | **Run Solver Node** | Propose batch solutions after registration and verification | > [For FX Trading Institutions](https://docs.ezys.io/for-institutions/overview.md) ## How It Works **Flow:** 1. **Request a firm quote** — receive locked amounts, rate, fees and a short-lived `quoteId`. 2. **Authorize and submit** — prepare the required wallet signature and submit an intent referencing the quote. 3. **Track the execution** — acceptance starts asynchronous execution; query status or receive webhooks. 4. **Reconcile the result** — check payout delivery, and retrieve a receipt or settlement anchor when available. ## Key Concepts | Concept | Description | |---------|-------------| | **Intent** | Trading intent declaration — specify assets, amounts, recipient and authorization; Ezys coordinates execution | | **Solver Network** | Network that receives intents, collects LP quotes, and determines optimal execution paths | | **Quote** | Exchange rate quote provided by LPs for a given intent | | **Execution** | Confirmed trade execution | | **Settlement** | Hybrid settlement combining on-chain (Crypto) and off-chain (Fiat) | ## Quick Links | Resource | Description | |----------|-------------| | [Architecture](https://docs.ezys.io/architecture.md) | Technical architecture details | | [API Reference](https://docs.ezys.io/api-reference/overview.md) | API documentation for FSPs | | [Solver Network](https://docs.ezys.io/core-concepts/solver-network.md) | Network structure and participation | ## Contact For integration inquiries, partnership discussions, or API key requests, please reach out to our business team. ## For AI agents Every page of this site is also available as plain Markdown at the same path with a `.md` extension (for example `/for-fsps/intent.md`). Use the **Copy for agent** button at the top of a page to copy that Markdown, or **View Markdown** to open it in a new tab. - [llms.txt](https://docs.ezys.io/llms.txt) — an index of every page with a one-line summary, for coding agents and LLM tools - [OpenAPI specification](https://docs.ezys.io/api-reference/openapi.md) — the Partner API as a machine-readable OpenAPI document (YAML) --- > Source: [/architecture](https://docs.ezys.io/architecture.html) · Markdown: https://docs.ezys.io/architecture.md > Section: Introduction · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Architecture Ezys turns exchange requests into executions across token transfers and fiat payout rails. The public integration consists of quote and intent submission, execution status, receipts and webhooks. _Legacy overview: some component names and the REST entry path differ from the current implementation; the text below is the source of truth._ ![Ezys architecture overview: clients, core services, orchestration, fiat abstraction layer, on-chain contracts and infrastructure](https://docs.ezys.io/images/architecture-overview.svg) [Open the full-size architecture diagram](https://docs.ezys.io/images/architecture-overview.svg) ## Entry and execution | Surface | Role | |---|---| | Partner REST API | Request indicative or firm quotes, submit signed intents, and read execution results | | FIX integration | Receive institutional orders and liquidity quotes through an onboarded FIX session | | Registered solver integration | Propose solutions for batches after verification and corridor configuration | REST requests are processed by the solver directly. FIX orders are normalized before entering the execution services. Their authentication and funding capabilities differ; a REST signature or token permit is not automatically available to a FIX order. The solver checks the request, selects a route or consumes a firm quote, and starts the applicable settlement path. `metadata.urgency: batch` adds batch collection and solution selection; otherwise the intent is processed immediately. See [Solver Network](https://docs.ezys.io/core-concepts/solver-network.md). There is no public ERC-7683 `open()` entry path or on-chain intent-cancellation contract in this version. ## Settlement paths | Path | Authorization and asset movement | Completion | |---|---|---| | Token input with fiat payout | A signed NfxPermit2 authorization funds an HTLC escrow. The payout provider delivers fiat, and matching confirmation permits the escrow claim | Payout state, confirmation and settlement anchoring are exposed through status and webhooks | | Same-chain Reactor swap | The owner signs the order's EIP-712 data. EzysReactor exchanges the input and output tokens atomically in the fill transaction | The fill is verified and settlement is anchored; there is no fiat receipt | The HTLC path uses a durable workflow to coordinate input locking, payout, confirmation and claim. It can extend the escrow deadline while a payout remains unresolved. The Reactor path does not use that HTLC workflow or its balance reservation. Acceptance is an off-chain acknowledgement. It does not itself create a public on-chain record of all intent terms. Asset movement and the later settlement anchor are distinct operations. ## Token-to-fiat flow _Internal service flow (for the partner API flow, see the text below)._ ![Ezys sequence flow: internal services from order entry to settlement](https://docs.ezys.io/images/sequence-flow.png) [Open the full-size sequence diagram](https://docs.ezys.io/images/sequence-flow.png) ```text Firm quote (optional) → Signed intent → Accepted execution → Input tokens locked in HTLC → Fiat payout instructed → Provider confirmation checked → Escrow claimed → Receipt and settlement anchor when eligible ``` The input stays in escrow until it is claimed or refunded. If confirmation is late, the workflow can extend the deadline and emit `offramp.disputed`. A later matching payout can still permit a claim, but an execution already marked with that dispute has no receipt or completion/finality webhook. See [When the payout is late or fails](https://docs.ezys.io/for-fsps/settlement.md#when-the-payout-is-late-or-fails). ## Same-chain swap flow ```text Firm quote with swapper → Sign returned reactorOrder.typedData → Submit intent with quoteId and reactorOrder → Reactor fill on the token chain → Settlement anchor → Execution status completed ``` The fill moves the tokens; the anchor records settlement evidence. The Status API's `finality.txHash` refers to the anchor transaction, not the fill. A completed Reactor execution has no fiat payout receipt and can remain in phase `intent`. ## Services | Component | Responsibility | |---|---| | Solver | Quote aggregation, amount and authorization validation, route selection, batch collection and Reactor submission | | Settlement | Input funding, transfers, escrow operations, fill verification and settlement anchoring | | Workflow engine | Durable coordination and recovery of the HTLC path, including delayed payout confirmation | | Fiat gateway | Provider-specific quotes and payouts, recipient validation, execution status, receipts and partner webhooks | | ALM | Institutional capacity and exposure checks on the paths that use them | | Oracle | Reference prices used by pricing integrations | | Event transport | Durable delivery of acceptance and settlement updates between services; consumers handle duplicate delivery | ## Contracts | Contract | Responsibility | |---|---| | **HTLCEscrow** | Holds the input against a hashlock and timelock. Claims pay the designated recipient; refunds return funds to the sender. The recipient or an authorized settlement operator can claim, and the sender or an authorized operator can refund after expiry. An active lock can be extended by three days at most twice, while more than 15 minutes remain | | **NfxPermit2** | Verifies token-spending authorizations, including contract-wallet signatures. The HTLC path uses `PermitSingle`; Reactor fills use a witness-bound transfer permit | | **EzysReactor** | Executes a signed same-chain swap in one transaction, including token movement and nonce consumption | | **SettlementFinalityRegistry** | Records a write-once settlement commitment on the settlement ledger | | **NfxVaultERC20** | Holds protocol inventory used by configured settlement paths and restricts withdrawals to authorized roles | | **Registries, oracle and hooks** | Supporting asset, solver, price and configured contract-policy controls | Token custody depends on the path: an HTLC holds locked input, a vault can hold protocol inventory, and a Reactor fill transfers tokens within its transaction. Fiat funds are handled by the configured payout provider. The API does not expose a separate on-chain intent state machine. ## Observing the result Use [execution status](https://docs.ezys.io/api-reference/status-api.md) as the current result and [webhooks](https://docs.ezys.io/api-reference/webhooks.md) for notifications. The `events[]` history, aggregate `status`, payout delivery, receipt issuance and settlement anchor answer different questions; do not use one as a substitute for all the others. - [Intent API](https://docs.ezys.io/api-reference/intent-api.md) - [Execution & Settlement](https://docs.ezys.io/core-concepts/execution-settlement.md) - [Receipts](https://docs.ezys.io/api-reference/receipts-api.md) --- > Source: [/for-fsps/overview](https://docs.ezys.io/for-fsps/overview.html) · Markdown: https://docs.ezys.io/for-fsps/overview.md > Section: For Fintech Service Providers · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Overview — For Fintech Service Providers Ezys enables Fintech Service Providers to access competitive FX execution without building or managing direct relationships with multiple liquidity providers. ## What Ezys Does for You As an FSP, you submit an **Intent** — a declaration of what you want to exchange and under what conditions. Ezys handles the rest: sourcing quotes from a diverse network of liquidity providers, selecting the best available rate, and coordinating settlement across on-chain and off-chain rails. You can lock a **firm quote** before committing to any transaction: request one, review the locked rate and fees, and execute it by submitting an Intent that references it. Nothing executes until you submit the Intent. ## How It Works ``` Request firm Quote → Receive locked rate + quoteId → Submit Intent (quoteId attached) → Settlement ``` 1. **Request a quote** — Declare the trade you want (e.g. 1,000 USDC → KRW) 2. **Receive the firm quote** — Ezys returns a locked rate, itemized fees, and a `quoteId` valid until `validUntil` 3. **Submit the Intent** — Attach the `quoteId`; submission is single-step and terminal (`accepted` or `rejected`), and acceptance starts execution at the locked terms 4. **Settlement** — Funds are settled via the optimal path (on-chain or off-chain) You can also skip the quote and submit an Intent directly — supported routes then attempt execution within the bounds you set. See the [Intent Guide](https://docs.ezys.io/for-fsps/intent.md). ## Why Ezys | Benefit | Description | |---------|-------------| | **Best available rate** | Ezys evaluates configured liquidity sources within your execution bounds | | **Rate certainty** | A firm quote locks the rate and itemized fees for its TTL — an Intent that references it settles at exactly the locked terms when it completes. The quote does not itself guarantee that the transfer succeeds | | **Single integration** | One API covers multiple currency pairs, liquidity sources, and settlement rails | | **Hybrid settlement** | Supports both crypto and fiat settlement paths seamlessly | | **Transparent execution** | Track status and webhooks, with receipts and settlement anchors when available | ## Getting Started 1. Obtain an API key — see [Authentication](https://docs.ezys.io/api-reference/authentication.md) 2. Request your first firm quote — `POST /v1/quotes/firm`, see the [Quote API](https://docs.ezys.io/api-reference/quote-api.md) 3. Submit your first Intent — see [Intent Guide](https://docs.ezys.io/for-fsps/intent.md) ## Next - [Intent](https://docs.ezys.io/for-fsps/intent.md) — Step-by-step guide to locking a firm quote and submitting an Intent - [Execution Strategy](https://docs.ezys.io/for-fsps/strategy.md) — Choosing the right strategy for your use case - [Settlement](https://docs.ezys.io/for-fsps/settlement.md) — How and when you receive funds --- > Source: [/for-fsps/intent](https://docs.ezys.io/for-fsps/intent.html) · Markdown: https://docs.ezys.io/for-fsps/intent.md > Section: For Fintech Service Providers · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Intent How to lock a rate with a firm quote and execute it by submitting an Intent. Submission is **single-step**: the submission response is `accepted` (asynchronous execution starts) or `rejected` (with a reason). There is no separate confirm step; rate certainty comes from attaching a **firm quote** to the Intent instead. ## Flow ``` ① POST /v1/quotes/firm → quoteId + locked rate, fees, validUntil ② POST /v1/intents (+ quoteId) → accepted → settlement begins → rejected (reason) ③ Track via webhooks / Status API ``` The firm quote is optional — an Intent submitted **without** a `quoteId` is priced against available sources and your bounds; `metadata.urgency` selects immediate or batch solving. Attach a `quoteId` when you need the rate and fees locked. See [Quote API → Firm quotes](https://docs.ezys.io/api-reference/quote-api.md#firm-quotes). ## Step 1: Lock a rate (firm quote) Request a firm quote for what you want to exchange. `amountType: "target"` fixes the **recipient amount** — the solver computes the required input including fees, to price the requested recipient amount; settlement must still confirm the actual delivered amount. ``` POST /v1/quotes/firm ``` ```json { "requestId": "RFQ-FIRM-001", "actorId": "your-partner-id", "side": "sell", "inputAsset": { "type": "erc20", "symbol": "USDC", "chain": "avalanche-fuji", "address": "0x5425890298aed601595a70AB815c96711a31Bc65" }, "outputAsset": { "type": "fiat", "symbol": "USD", "currency": "USD" }, "amountMinor": "1000000", "amountType": "target" } ``` The response is returned directly (no `{ success, data }` wrapper). The amounts and fees below are illustrative, not a fee schedule: ```jsonc { "requestId": "RFQ-FIRM-001", "quoteId": "fq_9ab32c1f", // ← attach this to the Intent to execute "pair": "USDC/USD", "allInRate": "1", "input": { "amountMinor": "10010250000", "currency": "USDC", "exponent": 6 }, "output": { "amountMinor": "1000000", "currency": "USD", "exponent": 2 }, "fees": [ { "type": "protocol_fee", "amountMinor": "1000", "currency": "USD", "exponent": 2, "collection": "deducted" }, { "type": "payout_fee", "amountMinor": "25", "currency": "USD", "exponent": 2, "collection": "deducted" }, { "type": "network_fee", "amountMinor": "0", "currency": "USDC", "exponent": 6, "collection": "absorbed" } ], "validUntil": "2026-07-15T12:00:30Z" } ``` At this point, **nothing has been executed**. The quote records the rate and itemized fees until `validUntil` — you can review the locked terms and decide whether to proceed. See [Quote API → Firm Quote Response](https://docs.ezys.io/api-reference/quote-api.md#firm-quote-response) for field details. ## Step 2: Submit the Intent Submitting the Intent **is** the acceptance of the quote — it must arrive before `validUntil`. ``` POST /v1/intents ``` Send an `X-Idempotency-Key` header (a UUID) so a lost response can be retried safely — see [Idempotency](https://docs.ezys.io/api-reference/idempotency.md). Use `clientReference` for your own correlation key; it is echoed verbatim on every read surface (status, webhooks, receipts). ```json { "intentId": "INT-9C1D2E3F", "clientReference": "your-payment-id-123", "user": "0x2521f0a5a2B78dE703510798342AbaE2F95872B5", "inputAsset": { "type": "erc20", "symbol": "USDC", "chain": "avalanche-fuji", "address": "0x5425890298aed601595a70AB815c96711a31Bc65" }, "outputAsset": { "type": "fiat", "symbol": "USD", "currency": "USD" }, "input": { "limitAmountMinor": "10100000000" }, "output": { "targetAmountMinor": "1000000", "limitAmountMinor": "1000000" }, "quoteId": "fq_9ab32c1f", "deadline": 1798761600000, "settlementType": "hybrid", "beneficiary": { "account": "4412789033", "name": "Jane Doe", "country": "US", "payoutMethod": "ach", "routingNumber": "021000021", "accountType": "checking", "bank": "JPMorgan Chase", "address": { "line1": "420 Montgomery St", "city": "San Francisco", "state": "CA", "postalCode": "94104" } }, "permit": { "token": "0x5425890298aed601595a70AB815c96711a31Bc65", "amountMinor": "10100000000", "spender": "0x1417c8D5Cd1BcC2bC4146Fe49F3fdFcbcEaD0d73", "nonce": "0", "deadline": 1798761600, "signature": "0x<65-byte sig>" } } ``` From `POST /v1/intents/technical-fields`, use `escrowAddress` as `permit.spender` and `permitNonce` as `permit.nonce`, and set `permit.deadline` a few hours ahead; the example values are illustrative. Two fields deserve attention: - **`permit`** — required for on-chain (`erc20`) inputs, except [reactor orders](https://docs.ezys.io/api-reference/intent-api.md#reactor-orders-same-chain-swaps). A signed NfxPermit2 authorization that lets settlement pull the input from `user`'s wallet; without it the Intent is rejected with `permit_required`. See [Intent API → Permit](https://docs.ezys.io/api-reference/intent-api.md#permit-on-chain-inputs). - **`beneficiary`** — required for any `fiat` output, and for remittances (`outputRecipient` ≠ `user`). ### Accepted Responds `202 Accepted` with the execution plan, returned directly (no envelope). Acceptance starts asynchronous execution; it does not prove that funds have moved: ```json { "intentId": "INT-9C1D2E3F", "status": "accepted", "planId": "DVP-6205F17AEC4CE6BCD8F0E56728F73397C4028789A488007E66ADBD7E99D7B6C6", "solverId": "solver-1", "inputAmountMinor": "10010250000", "fees": [], "path": { "outputAmountMinor": "1000000", "surplusAmountMinor": "0", "estimatedFillTime": 3600, "steps": [] } } ``` The response above omits fee and step entries for brevity. With a `quoteId` attached, top-level `fees` is itemized from the locked quote — the receipt must later match it per fee `type`. See [Intent API → Accepted Response](https://docs.ezys.io/api-reference/intent-api.md#accepted-response) for every field. ### Rejected A business rejection responds `422` with the original rejection object: ```json { "intentId": "INT-9C1D2E3F", "status": "rejected", "reason": "no_liquidity" } ``` A rejected attempt starts no new execution. `intent_already_submitted` and `intent_in_progress` can refer to an earlier submission that is executing; query its original `clientReference`. See [Intent API → Rejected Response](https://docs.ezys.io/api-reference/intent-api.md#rejected-response) for the full list of reasons, and [Error Handling & Retries](https://docs.ezys.io/api-reference/errors.md) for which errors are safe to retry. ## Step 3: Track execution After acceptance, settlement proceeds asynchronously with **intent**, **offramp**, and **payout** phase events. They are not a strict chronological pipeline: payout confirmation can precede off-ramp completion. Track it by: - **Webhooks** (push) — status events delivered to your endpoint; see [Webhooks](https://docs.ezys.io/api-reference/webhooks.md). - **Status API** (pull) — `GET /v1/executions/{reference}`, resolvable by your own `clientReference`; see [Status API](https://docs.ezys.io/api-reference/status-api.md). A webhook carries a snapshot of the execution at the time of its event, and webhooks can arrive out of order — apply them in `sequence` order (see [Webhooks](https://docs.ezys.io/api-reference/webhooks.md#sequence)). On a timeout or missed event, re-query the Status API rather than re-submitting. ## Handling expiry A firm quote lapses at `validUntil`. An Intent that arrives after that is **rejected, never silently re-priced** — request a fresh firm quote and submit again. There is no quote re-fetch endpoint; quotes are short-lived by design. ## Cancellation The public REST API has no intent-cancel operation. An HTLC refund is a separate contract operation after its current timelock, callable by the sender or settlement operator; it is not cancellation of a signed intent. Do not assume that an expired intent deadline refunds funds. See [Cancellation](https://docs.ezys.io/api-reference/intent-api.md#cancellation) and [settlement recovery](https://docs.ezys.io/for-fsps/settlement.md#when-the-payout-is-late-or-fails). ## Full API Reference For complete field descriptions, amount-bounds semantics, and error codes, see the [Intent API](https://docs.ezys.io/api-reference/intent-api.md) and [Quote API](https://docs.ezys.io/api-reference/quote-api.md). --- > Source: [/for-fsps/strategy](https://docs.ezys.io/for-fsps/strategy.html) · Markdown: https://docs.ezys.io/for-fsps/strategy.md > Section: For Fintech Service Providers · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Execution Strategy Choose whether to lock a quote, which amount to fix, and whether to process the intent immediately or through batch collection. ## Locking a price Request `POST /v1/quotes/firm` and attach the returned `quoteId` to your intent. The quote fixes the amounts, rate and itemized fees until `validUntil`, and is single-use. Acceptance does not guarantee settlement success: authorization, available funds, chain execution and the payout provider still determine the result. Without a firm quote, supported routes attempt execution within your amount bounds. A Reactor order naming Ezys as its filler requires a firm quote; it cannot use this quote-free path. See [Reactor orders](https://docs.ezys.io/api-reference/intent-api.md#reactor-orders-same-chain-swaps). ## Choosing the amount | Goal | Request | |---|---| | Spend a fixed input and receive at least a minimum output | Exact-input: `inputAmountMinor` and `minOutputAmountMinor` | | Deliver a fixed output without exceeding an input cap | Exact-output: `input.limitAmountMinor` and equal `output.targetAmountMinor` / `output.limitAmountMinor` | Amounts are integer strings in the asset's minor units. The [Intent API](https://docs.ezys.io/api-reference/intent-api.md#amount-bounds-and-firm-quotes) gives complete examples and the rules for matching a firm quote. ## Immediate processing Omit `metadata.urgency` or set it to `immediate`. This starts processing without waiting for a batch window. It is the usual choice when the user is waiting. The following is a fragment to include in a complete intent: ```json { "metadata": { "urgency": "immediate" } } ``` ## Batch processing Set `metadata.urgency` to `batch` to collect the intent with others before solution selection: ```json { "metadata": { "urgency": "batch" } } ``` The request waits for batch selection and then returns its own acceptance or rejection. Allow for the collection window and solver response time. A batch can improve available matching opportunities, but does not guarantee a better price, atomic execution across intents, or a partial-fill remainder carried into another batch. See [Batch execution](https://docs.ezys.io/core-concepts/solver-network.md#batch-execution). ## Scheduling The API has no `strategy: instant`, `optimized` or `scheduled` modes. A `deadline` is an expiry in Unix milliseconds, not a scheduled start time. For recurring or future-dated payments, schedule the request in your application and obtain a fresh quote and signatures when it is time to submit. After submission, preserve the original `intentId`, `clientReference` and idempotency key and follow the [Status API](https://docs.ezys.io/api-reference/status-api.md). See [Idempotency](https://docs.ezys.io/api-reference/idempotency.md) before retrying an uncertain result. --- > Source: [/for-fsps/settlement](https://docs.ezys.io/for-fsps/settlement.html) · Markdown: https://docs.ezys.io/for-fsps/settlement.md > Section: For Fintech Service Providers · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Settlement What happens after your Intent is accepted — how and when funds are transferred. ## Overview Once your Intent is **accepted**, Ezys starts the applicable settlement path. Token-to-fiat payouts use an HTLC workflow; supported same-chain token swaps use a Reactor fill. Acceptance does not mean that asset movement has completed. Track the result through execution status and webhooks. With a firm quote attached, your accepted Intent executes at the locked rate and itemized fees — these do not change during settlement. Without one, it settles within the bounds you set (`minOutputAmountMinor` / `input.limitAmountMinor`). ## Settlement Paths Each accepted Intent settles through one or more **Legs** — individual asset movements. The settlement path depends on the asset types involved. | Asset type | Path | Timing | |------------|------|--------| | Crypto (USDC, etc.) | On-chain — smart contract execution | Depends on chain inclusion and confirmation | | Fiat (KRW, USD, etc.) | Off-chain — partner settlement rails | Varies by partner and rail | A typical Intent (e.g. USDC → KRW) involves two Legs: an on-chain outflow and an off-chain inflow. ## Tracking Settlement An execution exposes **intent** (acceptance), **offramp** (input lock and payout confirmation) and, when present, **payout** (fiat delivery) phases. A Reactor execution can have only the intent phase. These phases are not a strict chronological sequence: payout delivery precedes normal offramp completion. | Status | What it means for you | |--------|----------------------| | `in_progress` | Settlement is in progress — the `phase` field says which leg is being processed | | `completed` | The payout was delivered, or the on-chain-only settlement was recorded | | `failed` | A phase failed terminally — the `events[]` entry with `state: failed` shows which phase | Track progress via **webhooks** (push, see [Webhooks](https://docs.ezys.io/api-reference/webhooks.md)) or the **Status API** (pull): `GET /v1/executions/{reference}`, resolvable by your own `clientReference` — see [Status API](https://docs.ezys.io/api-reference/status-api.md). A webhook carries a snapshot at the time of its event; the Status API returns the current state. ## When the payout is late or fails The default escrow term is 5 days. The lock's on-chain timelock determines the exact expiry. If the payout is not confirmed 2 hours before that deadline, Ezys attempts to extend the escrow by 3 days and sends `offramp.disputed`. A timeout notification alone leaves the aggregate status unchanged; it usually remains `in_progress` while payout is pending. If the payout is still not confirmed 2 hours before the extended deadline, the escrow is extended once more without another event — up to 11 days after the lock. While the escrow is extended, the input asset cannot be refunded. - **The payout completes during the extension** — the status becomes `completed`. Matching currency and sufficient confirmed amount permit the escrow claim. If the timeout dispute was already emitted, the execution has no receipt, and `offramp.completed`, `execution.completed` and `execution.finalized` are not sent. Check the result with the Status API. - **The payout fails or is reversed before settlement finishes** — the status becomes `failed`, and the input asset is refunded to the sender after the escrow deadline. The refund sends `escrow.refunded` and does not change the status. - **The payout is still not confirmed after the second extension** — contact Ezys. After expiry the sender can refund an active lock, but Ezys holds automatic refund while the payout might still complete. ## Settlement Preference You state the settlement preference via the Intent's required `settlementType` field (on quote requests, the equivalent optional field is `settlementPref`): | Value | Behavior | |-------|----------| | `hybrid` | Allow a combination of token and fiat settlement (the indicative quote-side default) | | `onchain` | Prefer on-chain settlement where possible | | `offchain` | Prefer off-chain settlement where possible | Use a settlement type supported by the configured asset pair and route. The field does not make an unsupported settlement path available. ## What You Don't Need to Manage - **Liquidity source selection** — handled by the Solver Network - **Settlement coordination** — handled by Ezys and its partners - **On-chain transactions** — executed automatically via smart contracts - **Off-chain rail selection** — determined by the settlement path Preserve the submission identifiers, monitor status and reconcile uncertain results before retrying. Ezys coordinates execution, but failed or disputed payouts can require follow-up. ## Related - [Execution & Settlement (Core Concepts)](https://docs.ezys.io/core-concepts/execution-settlement.md) — How settlement works under the hood - [Intent API](https://docs.ezys.io/api-reference/intent-api.md) — Full API reference --- > Source: [/for-institutions/overview](https://docs.ezys.io/for-institutions/overview.html) · Markdown: https://docs.ezys.io/for-institutions/overview.md > Section: For FX Trading Institutions · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Overview — For FX Trading Institutions Institutions can integrate liquidity and execution capabilities with Ezys through an onboarded connection. Supported corridors, funding and settlement arrangements are agreed before traffic is enabled. ## Participation models | Role | Responsibility | |---|---| | Liquidity provider | Supply pricing and the ability to execute and settle the supported trades | | Registered solver | Propose complete solutions for eligible batches through an authenticated solver integration | These roles can be combined. Registering a solver does not automatically activate it: verification and corridor configuration determine whether it receives batches. ## How execution works An FSP requests a quote or submits an intent. Ezys evaluates configured liquidity sources, validates the intent's amount bounds and authorization, and starts the chosen settlement path. Batch requests can compare proposals from eligible registered solvers; immediate requests do not wait for that competition. A quote request alone does not execute a trade. A firm quote fixes terms for a limited time; an accepted intent then proceeds to settlement. Status and webhooks report its result, and eligible settlements may include an on-chain anchor when available. ## Integration preparation Agree the supported asset pairs, chains, quote lifetime, recipient requirements, funding and payout methods with Ezys. Commercial terms are part of onboarding; this documentation does not define an automatic solver reward or a platform-wide zero-fee policy. - [Liquidity Provider](https://docs.ezys.io/for-institutions/liquidity-provider.md) — pricing and settlement integration - [Solver participation](https://docs.ezys.io/for-institutions/validator.md) — registration and batch solutions - [Solver Network](https://docs.ezys.io/core-concepts/solver-network.md) — execution behavior --- > Source: [/for-institutions/liquidity-provider](https://docs.ezys.io/for-institutions/liquidity-provider.html) · Markdown: https://docs.ezys.io/for-institutions/liquidity-provider.md > Section: For FX Trading Institutions · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Liquidity Provider Provide pricing and settlement capacity for the corridors enabled in your Ezys integration. ## Integration model Liquidity sources connect through integrations configured by Ezys. The solver queries sources supporting the requested assets, evaluates their candidates and selects an execution path. The current service includes fiat-provider and ERC-4626 vault pricing integrations, as well as simulation sources for testing. The adapter interface is a service integration boundary, not a self-service public HTTP contract. A JSON example alone is not sufficient to register a provider endpoint. FIX liquidity connectivity is arranged separately during onboarding. ## What to agree before enabling a corridor | Item | Required agreement | |---|---| | Assets and units | Token addresses and chains, fiat currencies and amount scales | | Pricing | Quote request/response mapping, fees and quote validity | | Capacity | Available liquidity and amount limits | | Funding | Who supplies each asset and how transfer authorization is provided | | Settlement | Payout method, recipient requirements and completion confirmation | | Recovery | Handling of timeouts, rejected payouts and reconciliation | Provider availability is specific to the configured environment. A source type supported by an adapter does not mean that every bank, exchange, card network or asset pair is available. ## Quotes and execution Indicative quotes expose candidate information to the authenticated caller. Firm quotes return locked amounts, rate, fees and a `quoteId`. Execution starts only when an intent is accepted; the selected settlement path must still complete successfully. Commercial fees and compensation are agreed during onboarding. Use the itemized fees in the [firm quote](https://docs.ezys.io/api-reference/quote-api.md#firm-quotes) and [receipt](https://docs.ezys.io/api-reference/receipts-api.md) for transaction reconciliation. ## Related - [Solver participation](https://docs.ezys.io/for-institutions/validator.md) - [Solver Network](https://docs.ezys.io/core-concepts/solver-network.md) - [Execution & Settlement](https://docs.ezys.io/core-concepts/execution-settlement.md) --- > Source: [/for-institutions/validator](https://docs.ezys.io/for-institutions/validator.html) · Markdown: https://docs.ezys.io/for-institutions/validator.md > Section: For FX Trading Institutions · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Solver Participation A registered solver can propose solutions for batches of intents on corridors it supports. This role is distinct from validating blocks on a blockchain. ## Eligibility Solver registration, verification and activation are managed through onboarding. Only active, verified solvers whose corridor support covers the batch are considered. Registration alone does not activate a solver. The local solver can participate directly. External solvers require an authenticated integration and reachable endpoint configured with Ezys. The management and solver-to-solver endpoints are not part of the public FSP API described in the [API Reference](https://docs.ezys.io/api-reference/overview.md). ## Batch proposals A solution identifies the batch and solver and provides a fill for every intent. Amounts and deadlines are validated against the requested constraints. Ezys compares complete solutions by aggregate surplus, then fill deadlines, historical success rate and solver ID. The chosen fills are processed as individual intents. Winning batch selection is not a guarantee that every settlement succeeds, and does not create a single atomic transaction for the whole batch. Immediate requests do not enter this batch process. See [Solver Network](https://docs.ezys.io/core-concepts/solver-network.md) for the difference. ## Responsibilities - Maintain the pricing and settlement capacity needed for your supported corridors. - Return complete, timely solutions using the integration agreed with Ezys. - Coordinate funding, execution and reconciliation with the settlement integration. Commercial terms and any compensation are agreed separately. The public API does not specify a fixed reward for a winning solver proposal. ## Related - [Liquidity Provider](https://docs.ezys.io/for-institutions/liquidity-provider.md) - [Solver Network](https://docs.ezys.io/core-concepts/solver-network.md) - [Execution & Settlement](https://docs.ezys.io/core-concepts/execution-settlement.md) --- > Source: [/core-concepts/solver-network](https://docs.ezys.io/core-concepts/solver-network.html) · Markdown: https://docs.ezys.io/core-concepts/solver-network.md > Section: Core Concepts · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Solver Network Ezys selects execution paths from configured liquidity sources. An intent can be processed immediately or collected into a batch for registered solvers to propose solutions. ## Quotes and intents A quote describes available pricing; an intent authorizes execution within the amounts and conditions you supply. Request a firm quote with `POST /v1/quotes/firm` and attach its `quoteId` to `POST /v1/intents` to use the locked terms. Requesting a quote alone does not move funds. The indicative endpoint, `POST /v1/quotes`, returns candidates ordered by score. The score considers price, latency, fees, slippage and constraint violations. It is an implementation detail, not a promise that the first candidate has the highest exchange rate. An indicative candidate is not a firm quote and cannot be accepted by passing its `routeId` as a `quoteId`. ## Immediate execution When `metadata.urgency` is omitted or is `immediate`, the solver processes the intent directly. It validates the assets, recipient and authorization, resolves the amounts, and selects a route or validates the attached firm quote. An accepted intent returns `202` with a `planId` and execution path. Subsequent settlement is asynchronous. A business rejection returns `422` with a `reason`; see the [Intent API](https://docs.ezys.io/api-reference/intent-api.md). ## Batch execution Set `metadata.urgency` to `batch` to enter batch collection. The default collection window is five seconds and can end earlier when the batch fills; this is not an execution-time guarantee. 1. Ezys collects requests and asks active, verified solvers supporting the batch's corridors for solutions. 2. Each solution must cover every intent and satisfy its minimum output. Local solution building can match opposite-direction intents or ring trades and obtain quotes for the remainder. 3. Ezys selects the solution with the greatest aggregate surplus. Ties use fill deadlines, historical success rate and solver ID. 4. Each intent is processed separately using its selected fill. If no complete solution is available, the response is `422` with `no_batch_solution`. Batch selection does not make the batch a single atomic transaction. Each intent has its own acceptance and settlement result. There is no automatic carry-over of an unfilled remainder to a later batch. Participation depends on registration, verification, supported corridors and connectivity. The batch path does not imply that every request is broadcast to multiple external solvers. See [Solver participation](https://docs.ezys.io/for-institutions/validator.md). ## Liquidity sources Pricing integrations are configured by Ezys. Current source implementations include fiat-provider quotes and ERC-4626 vault pricing, alongside simulation sources for testing. Supported pairs, assets and settlement paths depend on the environment and onboarding agreement. The source adapter interface is an integration point in the service, not a public HTTP API that an institution can register merely by supplying an arbitrary URL. See [Liquidity Provider](https://docs.ezys.io/for-institutions/liquidity-provider.md). ## Visibility Indicative quote responses expose candidate provider identifiers, route information and diagnostic metadata to the authenticated caller. Firm quotes expose the locked amounts, rate and fees. On-chain transfers and settlement anchors have the visibility of their respective chains. Do not assume that every source quote is confidential or that full execution details are published on-chain. ## Related - [Execution Strategy](https://docs.ezys.io/for-fsps/strategy.md) — immediate processing, batching and amount bounds - [Execution & Settlement](https://docs.ezys.io/core-concepts/execution-settlement.md) — how accepted intents settle - [Quote API](https://docs.ezys.io/api-reference/quote-api.md) · [Intent API](https://docs.ezys.io/api-reference/intent-api.md) --- > Source: [/core-concepts/execution-settlement](https://docs.ezys.io/core-concepts/execution-settlement.html) · Markdown: https://docs.ezys.io/core-concepts/execution-settlement.md > Section: Core Concepts · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Execution & Settlement An accepted intent starts an execution. Settlement moves the assets, while status, receipts and on-chain anchors report different parts of the result. ## Acceptance and asset movement `POST /v1/intents` returns `202` when the intent is accepted and `422` for a business rejection. Acceptance is not settlement completion and does not itself publish all intent terms on-chain. A **leg** is one asset movement, such as tokens from the user's wallet or fiat delivered to a beneficiary. The selected path determines how the legs are authorized and coordinated. ## Same-chain token swaps For a supported Reactor route, the firm quote includes an order and EIP-712 signing payload. The owner signs it and submits the unchanged order with the intent. EzysReactor transfers the input and required outputs within one transaction. If a required transfer fails, the transaction reverts. This path has no HTLC escrow and no fiat payout leg. After the fill is verified and the settlement anchor is recorded, the execution becomes `completed`. Its phase can remain `intent`. The [Receipts API](https://docs.ezys.io/api-reference/receipts-api.md) returns `409 receipt_not_issued` because this receipt format covers fiat payouts. ## Token-to-fiat settlement The input is locked in an HTLC escrow using the user's signed authorization. Ezys instructs the payout provider and checks its confirmation before claiming the input. The confirmed currency must match, and the confirmed amount must cover the required payout. ```text Input locked → Payout instructed → Payout delivered and confirmed → Input claimed ``` The escrow holds the input until claim or refund. An authorized settlement operator can execute these operations, but cannot redirect a refund: it always returns to the lock's sender. ### Delayed confirmation and refunds The default escrow term is five days. Use the lock's on-chain `timelock` for its exact expiry. When payout confirmation remains unresolved two hours before expiry, Ezys attempts a three-day extension. There can be at most two extensions, giving a default maximum term of eleven days. Refunds remain unavailable until the extended deadline. - The first extension for an unconfirmed payout emits `offramp.disputed`; the second does not emit another dispute event. A confirmation delayed only in transit after payout completion has already been recorded can be handled without raising this timeout dispute. - A later confirmation with matching currency and sufficient amount permits the escrow claim. If `offramp.disputed` was already emitted, the execution has no receipt, and `offramp.completed`, `execution.completed` and `execution.finalized` are not sent. - A failed or reversed payout before claim stops the success path. Refund becomes possible after the escrow deadline. - A currency mismatch or insufficient confirmed amount raises a dispute and prevents claim. Extensions can still occur. Contact Ezys to reconcile the payout. - If the payout is still unresolved at the final deadline, contact Ezys. The sender can refund an active lock after expiry; Ezys does not automatically refund while the payout might still complete. When the input asset is refunded from escrow, the `escrow.refunded` webhook is sent and the execution's `escrow.refundTxHash` is set to the refund transaction (see [Webhooks](https://docs.ezys.io/api-reference/webhooks.md) and [Status API](https://docs.ezys.io/api-reference/status-api.md)). An escrow refund does not change the aggregate execution status or `events[]`. To test refund handling with a 1-hour lock, see [Testing a refund](https://docs.ezys.io/api-reference/webhooks.md#testing-a-refund). ## Status, receipt and finality | Surface | What it tells you | |---|---| | `status` | Aggregate result: `in_progress`, `completed` or `failed` | | `events[]` | Per-phase state, including payout delivery and offramp failure | | Receipt | Normalized evidence for an eligible fiat payout; absent for Reactor and disputed executions | | `finality` | Settlement anchor metadata, when available; it is not the input-lock or Reactor-fill transaction | A delivered payout can make `status` become `completed` even after an offramp dispute. That does not create the missing receipt or finality anchor. A timeout dispute can leave the execution `in_progress` while payout remains open. See the full [status rules](https://docs.ezys.io/api-reference/status-api.md#aggregate-status). A synchronously rejected attempt starts no new execution. A duplicate-submission rejection can refer to an earlier accepted execution; query it using the original `clientReference`. ## Related - [Solver Network](https://docs.ezys.io/core-concepts/solver-network.md) — route and batch selection - [Intent API](https://docs.ezys.io/api-reference/intent-api.md) — amounts, permits and submission - [Settlement guide](https://docs.ezys.io/for-fsps/settlement.md) — tracking delivery - [Architecture](https://docs.ezys.io/architecture.md) — services and contracts --- > Source: [/api-reference/overview](https://docs.ezys.io/api-reference/overview.html) · Markdown: https://docs.ezys.io/api-reference/overview.md > Section: API Reference · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # API Reference Complete reference for the Ezys REST API. All endpoints communicate over HTTP using JSON. ## Conventions - **Amounts** — `amountMinor` / `*AmountMinor` are integer values in the asset's smallest (minor) unit, encoded as JSON **strings**. Firm quotes and fees include `currency` and `exponent`; flat intent fields use the associated asset's scale. See [Money & Amounts](https://docs.ezys.io/api-reference/money-and-amounts.md) for the full contract. - **Currencies** — ISO 4217 codes for fiat (`KRW`, `USD`); registered tickers for stablecoins (`USDC`). - **OpenAPI** — the Partner API is also published as an [OpenAPI document](https://docs.ezys.io/api-reference/openapi.md) (YAML) alongside these pages. - **Compatibility** — treat response objects as **open to new fields**. Ezys may add fields to a response without a breaking-change announcement, so **ignore fields you do not recognize** rather than rejecting the payload; a strict parser that fails on unknown keys will break on a routine additive release. Removing a field, or changing what an existing one means, is a breaking change and is announced. Two things are explicitly **not** open, and are the ones to branch on: - **Closed value sets** — anything documented as a *closed set* enumerates every value the API can send in this spec version (`events[].state`, fee `type` and `collection`, intent rejection `reason`). New values require a spec version bump and a changelog entry. - **The receipt body** — its field set is frozen per `_schemaVersion`, so unknown fields do **not** appear there. See [Receipts](https://docs.ezys.io/api-reference/receipts-api.md). ## Base URL All endpoints are served under the `/v1` prefix: ``` Dev: https://api.ezys.dev/v1 (partner testing) Sandbox: https://api.ezys.sh/v1 (planned) Production: https://api.ezys.io/v1 (planned; confirm availability during onboarding) ``` ## Authentication All requests require an API key, passed via the `X-API-Key` header — see [Authentication](https://docs.ezys.io/api-reference/authentication.md). ## Endpoints Paths are relative to the `/v1` base above. **Available** means implemented in the current API; use the base URL and credentials supplied during onboarding. Planned surfaces are not callable API contracts. ### Quote (RFQ) | Method | Endpoint | Availability | Description | |--------|----------|--------------|-------------| | POST | `/quotes` | Available | Request indicative quotes — returns a synchronous list of scored candidates | | POST | `/quotes/firm` | Available | Request a firm, lockable quote — returns `quoteId`, locked all-in rate, itemized fees, and `validUntil` | > See the [Quote API](https://docs.ezys.io/api-reference/quote-api.md) for request/response fields and lifecycle. ### Intent | Method | Endpoint | Availability | Description | |--------|----------|--------------|-------------| | POST | `/intents` | Available | Submit an Intent — returns `accepted` or `rejected` | | POST | `/intents/technical-fields` | Available | Resolve escrow, NfxPermit2, and current permit nonce before signing | > Single-step submit — there is no separate `confirm` step. See the [Intent API](https://docs.ezys.io/api-reference/intent-api.md). ### Execution status & receipts | Method | Endpoint | Availability | Description | |--------|----------|--------------|-------------| | GET | `/executions/{reference}` | Available | Aggregate status of one execution — phase and `events[]` history | | GET | `/intents/{intentId}/status` | Coming soon | Lightweight intent-only status (planned public exposure) | | GET | `/executions/{reference}/receipt` | Available | Normalized machine-readable receipt (offramp + payout + fees). Issued when the payout reaches `completed`; until then it returns `409 receipt_not_issued`. An execution with `offramp.disputed` has no receipt and returns `409 receipt_not_issued`. See [Receipts](https://docs.ezys.io/api-reference/receipts-api.md) for issuance conditions | | GET | `/reconciliation` | Coming soon | Aggregate reconciliation view over a date range | > See the [Status API](https://docs.ezys.io/api-reference/status-api.md) and [Receipts & Reconciliation](https://docs.ezys.io/api-reference/receipts-api.md). ### Webhooks | Method | Endpoint | Availability | Description | |--------|----------|--------------|-------------| | POST | `/webhook_endpoints` | Available | Register an endpoint — returns its webhook signing secret | | GET | `/webhook_endpoints` | Available | List endpoints | | PATCH | `/webhook_endpoints/{endpointId}` | Available | Update an endpoint's URL, event types, channels or description | | DELETE | `/webhook_endpoints/{endpointId}` | Available | Disable an endpoint | | POST | `/webhook_endpoints/{endpointId}/test` | Available | Send a `test.ping` event to the endpoint | | POST | `/webhook_endpoints/{endpointId}/verify` | Available | Verify an endpoint with the `test.ping` token | | POST | `/webhook_endpoints/{endpointId}/rotate` | Available | Rotate the endpoint's webhook signing secret | | GET | `/webhook_endpoints/{endpointId}/deliveries` | Available | List deliveries to an endpoint | | GET | `/events` | Available | List events | | GET | `/events/{eventId}` | Available | Get an event | | POST | `/events/{eventId}/resend` | Available | Resend an event | > See [Webhooks](https://docs.ezys.io/api-reference/webhooks.md) for the event format, signature verification and delivery rules. ## Request Format ### Headers | Header | Required | Description | |--------|----------|-------------| | `Content-Type` | Yes | Must be `application/json` | | `X-API-Key` | Yes | Your API key — see [Authentication](https://docs.ezys.io/api-reference/authentication.md) | | `X-Idempotency-Key` | Yes on [signed](https://docs.ezys.io/api-reference/authentication.md#signed-endpoints) requests; recommended on other `POST` | Canonical RFC 9562 UUID. A signed request without it is rejected with `401 INVALID_SIGNATURE`. An unsigned `POST` without it runs without retry protection — see [Idempotency](https://docs.ezys.io/api-reference/idempotency.md) | ### Body Request bodies are endpoint-specific. Use the literal request shapes in the [Quote API](https://docs.ezys.io/api-reference/quote-api.md) and [Intent API](https://docs.ezys.io/api-reference/intent-api.md); there is no common `pair` / `strategy` request body. ## Response Format Endpoint responses are returned **directly** — the body is the endpoint's own result object, with no `{ success, data }` wrapper. Errors are likewise returned as the **original error object**, not re-wrapped; the exceptions are the infrastructure checks that run in front of the endpoints (authentication, idempotency) and the [webhook Management API](https://docs.ezys.io/api-reference/webhooks.md#errors), which use the error envelope described below. ### Success The body is the response object documented per endpoint — e.g. a quote response or an intent accepted response: ```json { "intentId": "INT-2F8A3B1C", "status": "accepted", "planId": "DVP-EFB9DF2D8B9ACE242BBB3E4DA220ED9C83FEB7EE4FB5873B8424D421D5842F91", "solverId": "solver-1", "path": { "outputAmountMinor": "1000000", "surplusAmountMinor": "0", "estimatedFillTime": 3600, "steps": [] } } ``` ### Business rejections Synchronous business rejections return `422` with the endpoint's **original rejection object** — `status` plus a machine-readable reason, as documented per endpoint (see [Intent API → Rejected Response](https://docs.ezys.io/api-reference/intent-api.md#rejected-response)): ```json { "intentId": "INT-2F8A3B1C", "status": "rejected", "reason": "no_liquidity" } ``` ### Validation errors Malformed requests return `400` with the framework's standard error body: ```json { "statusCode": 400, "message": "minOutputAmount or output.limit is required", "error": "Bad Request" } ``` ### Infrastructure errors Authentication (`401`/`403`) and idempotency (`400`/`409`/`503`) checks run in front of the endpoints and return the error envelope: ```json { "success": false, "error": { "code": "ERROR_CODE", "message": "A human-readable description of the error." } } ``` See [Authentication](https://docs.ezys.io/api-reference/authentication.md) and [Idempotency](https://docs.ezys.io/api-reference/idempotency.md) for the concrete codes each layer emits. ## HTTP Status Codes | Code | Meaning | |------|---------| | `200` | Request succeeded | | `201` | Resource created | | `202` | Accepted for asynchronous processing | | `204` | Request succeeded, no response body | | `400` | Invalid request — check parameters | | `401` | Authentication failed — missing or invalid API key | | `403` | Insufficient permissions | | `404` | Resource not found | | `409` | `X-Idempotency-Key` conflict, or a matching request is still in progress — see [Idempotency](https://docs.ezys.io/api-reference/idempotency.md); or a webhook endpoint state conflict — see [Webhooks](https://docs.ezys.io/api-reference/webhooks.md#errors) | | `422` | Business rejection — inspect the endpoint-specific reason | | `500` | Internal server error. Either the error envelope with an `error.code`, or the platform default body `{ "statusCode": 500, "message": "Internal server error" }` | | `503` | Service unavailable; follow the endpoint-specific recovery procedure | ## Error Codes > For which of these errors are safe to retry and the recommended action for each, see [Error Handling & Retries](https://docs.ezys.io/api-reference/errors.md). The public API does not share one business-error enum. Use the exact values on each surface: - [Authentication errors](https://docs.ezys.io/api-reference/authentication.md#authentication-errors): `UNAUTHORIZED`, `FORBIDDEN`, `INVALID_SIGNATURE`. - [Idempotency errors](https://docs.ezys.io/api-reference/idempotency.md#error-codes): invalid, conflicting, or processing keys. - [Intent rejections](https://docs.ezys.io/api-reference/intent-api.md#rejected-response): top-level `reason` in a `422` body. - [Firm quote errors](https://docs.ezys.io/api-reference/quote-api.md#firm-quote-errors), [status errors](https://docs.ezys.io/api-reference/status-api.md), and [receipt errors](https://docs.ezys.io/api-reference/receipts-api.md#get-receipt). - [Webhook management errors](https://docs.ezys.io/api-reference/webhooks.md#errors). Generic labels such as `POLICY_DENIED` or `INVALID_STRATEGY` are not substitutes for the actual values returned by these endpoints. Follow [Error Handling & Retries](https://docs.ezys.io/api-reference/errors.md) for recovery. ## Detailed Guides - [Authentication](https://docs.ezys.io/api-reference/authentication.md) - [Money & Amounts](https://docs.ezys.io/api-reference/money-and-amounts.md) - [Quote API](https://docs.ezys.io/api-reference/quote-api.md) - [Intent API](https://docs.ezys.io/api-reference/intent-api.md) - [Status API](https://docs.ezys.io/api-reference/status-api.md) - [Receipts & Reconciliation](https://docs.ezys.io/api-reference/receipts-api.md) - [Webhooks](https://docs.ezys.io/api-reference/webhooks.md) --- > Source: [/api-reference/openapi](https://docs.ezys.io/api-reference/openapi.html) · Markdown: https://docs.ezys.io/api-reference/openapi.md > Section: API Reference · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # OpenAPI Specification The Ezys Partner API is also published as an OpenAPI 3.0 document: - **YAML:** [`/openapi/ezys-partner-api.v1.yaml`](https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml) ## Versions The Partner API states its own version in `info.version` inside the YAML; `openapi: 3.0.3` is the version of the OpenAPI format itself. The documentation site has a separate version (shown in the navigation bar) that tracks changes to these pages. The three numbers are independent, and a difference between them does not indicate an inconsistency. ## Using the file - Open it in any OpenAPI viewer or editor (for example Swagger Editor or Redocly) to browse endpoints, schemas and examples. - Generate a client with an OpenAPI code generator; all `$ref`s are local to the file, so no other documents are required. - Validate it with `redocly lint` or `openapi-spec-validator` if you keep a copy in your own repository. For request and response examples, status codes and operational guidance, see the prose pages under [API Reference](https://docs.ezys.io/api-reference/overview.md). --- > Source: [/api-reference/authentication](https://docs.ezys.io/api-reference/authentication.html) · Markdown: https://docs.ezys.io/api-reference/authentication.md > Section: API Reference · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Authentication How to authenticate with the EZYS API. Two credentials are issued together, and they do different jobs: | Credential | Sent? | Purpose | |---|---|---| | **API key** (`ezys_{env}_…`) | Yes, as `X-API-Key` | Identifies the caller on **every** request | | **Signing secret** | **Never** | Signs requests to the [signed endpoints](#signed-endpoints) — see [Request Signing](#request-signing) | Most endpoints need the API key alone. A [named set](#signed-endpoints), including intent submission and webhook management writes, needs a signature as well. Each [webhook endpoint](https://docs.ezys.io/api-reference/webhooks.md) has its own webhook signing secret (`whsec_…`), returned when the endpoint is registered or its secret is rotated. It is used only to verify the webhooks you receive, not to sign requests. ## API Key All API requests require a valid API key, passed via the `X-API-Key` header. ```http X-API-Key: {your_api_key} ``` ### Example ```bash curl -X POST "https://api.ezys.io/v1/quotes" \ -H "X-API-Key: {your_api_key}" \ -H "Content-Type: application/json" \ -d '{ "requestId": "RFQ-001", "actorId": "acme", "source": "USDC", "target": "KRW", "amount": "1000", "side": "sell" }' ``` ## Request Signing Some endpoints require an **HMAC-SHA256 signature** in addition to `X-API-Key`. The signature proves the request came from the holder of the signing secret and has not been altered in transit. The secret itself is never transmitted. An unsigned request to a signed endpoint is rejected — the API key alone is not sufficient. ### Signed endpoints Signing is required per endpoint, not by HTTP method. Of the endpoints in the [contract](https://docs.ezys.io/api-reference/overview.md#endpoints), these require it: | Endpoint | Scope | |---|---| | [`POST /intents`](https://docs.ezys.io/api-reference/intent-api.md#submit-intent) | `intents:write` | | [`POST /webhook_endpoints`](https://docs.ezys.io/api-reference/webhooks.md#register-an-endpoint) | `webhooks:write` | | [`PATCH /webhook_endpoints/{endpointId}`](https://docs.ezys.io/api-reference/webhooks.md#update-an-endpoint) | `webhooks:write` | | [`DELETE /webhook_endpoints/{endpointId}`](https://docs.ezys.io/api-reference/webhooks.md#disable-an-endpoint) | `webhooks:write` | | [`POST /webhook_endpoints/{endpointId}/test`](https://docs.ezys.io/api-reference/webhooks.md#send-a-test-ping) | `webhooks:write` | | [`POST /webhook_endpoints/{endpointId}/verify`](https://docs.ezys.io/api-reference/webhooks.md#verify-an-endpoint) | `webhooks:write` | | [`POST /webhook_endpoints/{endpointId}/rotate`](https://docs.ezys.io/api-reference/webhooks.md#rotate-the-signing-secret) | `webhooks:write` | | [`POST /events/{eventId}/resend`](https://docs.ezys.io/api-reference/webhooks.md#resend-an-event) | `webhooks:write` | Every other endpoint in the contract takes `X-API-Key` alone. That includes both quote endpoints — `POST /quotes` and `POST /quotes/firm` — even though a firm quote is persisted and locks a rate for its TTL. The webhook `GET` endpoints also take `X-API-Key` alone. They require the `webhooks:read` scope. Surfaces outside the contract have their own signing requirements, so "not in the table above" means "not signed" only for the endpoints published in this reference. ::: tip Sign anyway if you can Nothing rejects a signature on an endpoint that does not require one. If your client signs uniformly, keep doing so: it costs nothing and survives an endpoint joining the list above. ::: ### Headers | Header | Required | Description | |---|---|---| | `X-Signature` | Yes | Lowercase hex HMAC-SHA256 digest (64 characters) of the [canonical string](#canonical-string) | | `X-Timestamp` | Yes | Unix epoch **seconds**. Accepted within **±300 seconds** of server time | | `X-Nonce` | Yes | 16–64 characters. Fresh per request — see [Replay protection](#replay-protection) | | `X-Idempotency-Key` | Yes | Retry-safety key — see [Idempotency](https://docs.ezys.io/api-reference/idempotency.md) | ### Canonical string Concatenate five values **in this order, with no separator between them** — no newlines, no spaces, no delimiters of any kind: ``` HTTP method (uppercase) + request path including query string + X-Timestamp + X-Nonce + request body ``` ::: warning Sign the full path, including `/v1` The path is **the whole path component of the URL you call**, not the endpoint suffix — `/v1/intents`, not `/intents`. The version segment is part of the path and therefore part of the signature. ::: For `POST /v1/intents` with timestamp `1737123456`, nonce `a3f1…` and body `{"intentId":"INT-1"}`: ``` POST/v1/intents1737123456a3f1…{"intentId":"INT-1"} ``` Sign that string with the secret as the **key** — the RFC 2104 argument order: ``` X-Signature = hex( HMAC-SHA256( key = signing_secret, message = canonical_string ) ) ``` The body is part of the message, not hashed separately: the raw UTF-8 body is appended to the canonical string and the whole string is signed once. Two details decide whether a signature verifies: - **The body is signed byte for byte, exactly as sent.** Re-serializing your JSON before signing — or signing a pretty-printed copy of a minified payload — changes the message and therefore the digest. Build the body string once, sign that string, and send that same string. A request with no body contributes an empty string in that position. - **The path is signed as sent**, including the query string. If you route through a proxy that rewrites paths, sign the path the API receives. ### Example The bodies below are **abbreviated to keep the signing steps readable** — they are not valid intents. See [Intent API](https://docs.ezys.io/api-reference/intent-api.md#request-body) for the required fields; the signing procedure is identical whatever the body contains. ```bash TS=$(date +%s) # epoch SECONDS NONCE=$(openssl rand -hex 16) # 32 chars, fresh per request BODY='{"intentId":"INT-2F8A3B1C","…":"…"}' # abbreviated # The path signed here must be the path sent below, `/v1` included. CANON="POST/v1/intents${TS}${NONCE}${BODY}" SIG=$(printf %s "$CANON" \ | openssl dgst -sha256 -hmac "$EZYS_SIGNING_SECRET" -hex \ | sed 's/^.* //') curl -X POST "https://api.ezys.io/v1/intents" \ -H "X-API-Key: $EZYS_API_KEY" \ -H "X-Signature: $SIG" \ -H "X-Timestamp: $TS" \ -H "X-Nonce: $NONCE" \ -H "X-Idempotency-Key: $(uuidgen | tr 'A-Z' 'a-z')" \ -H "Content-Type: application/json" \ -d "$BODY" ``` ```javascript import crypto from 'node:crypto' import { randomUUID } from 'node:crypto' const ts = Math.floor(Date.now() / 1000).toString() const nonce = crypto.randomBytes(16).toString('hex') // Serialize ONCE. Sign this exact string and send this exact string. const body = JSON.stringify({ intentId: 'INT-2F8A3B1C' /* … */ }) // The path signed here must be the path requested below, `/v1` included. const path = '/v1/intents' const canonical = `POST${path}${ts}${nonce}${body}` const signature = crypto .createHmac('sha256', process.env.EZYS_SIGNING_SECRET) // secret is the KEY .update(canonical, 'utf8') // canonical string is the MESSAGE .digest('hex') await fetch(`https://api.ezys.io${path}`, { method: 'POST', headers: { 'X-API-Key': process.env.EZYS_API_KEY, 'X-Signature': signature, 'X-Timestamp': ts, 'X-Nonce': nonce, 'X-Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' }, body }) ``` ### Replay protection **`X-Timestamp` is the guarantee.** A timestamp more than 300 seconds from server time is rejected, so a captured request is unusable outside that window regardless of anything else. **`X-Nonce` is defence in depth on top of it.** Within the window, a nonce already seen for that API key is rejected. The check is held per API instance and is not shared across the fleet, so it does not by itself make replay impossible across instances or restarts — treat it as narrowing the window, not closing it, and keep the ±300s bound as the property you rely on. Generate a fresh nonce per request. Do not derive it from the payload, and do not reuse one across a retry: use `X-Idempotency-Key` for retry safety, which is what it is for. ## Authentication Errors ### Missing or Invalid API Key Returned when `X-API-Key` is missing, unknown, inactive or expired. The response is the same in every case. ```http HTTP/1.1 401 Unauthorized { "success": false, "error": { "code": "UNAUTHORIZED", "message": "Invalid or missing API key" } } ``` ### Insufficient Scope Returned when the key is valid but lacks a scope the endpoint requires. The `message` lists the missing scopes. An endpoint that does not accept API-key authentication returns the same code with the message `This endpoint does not accept API-key authentication`. ```http HTTP/1.1 403 Forbidden { "success": false, "error": { "code": "FORBIDDEN", "message": "API key lacks required scopes: intents:write" } } ``` ### Invalid Signature Returned for [signing](#request-signing) failures such as a missing signing header, a timestamp outside the window, a reused nonce, or a digest that does not match. The `message` names which one. A present but malformed `X-Idempotency-Key` instead returns `400 IDEMPOTENCY_KEY_INVALID`; a missing key on a signed endpoint returns `401 INVALID_SIGNATURE`. ```http HTTP/1.1 401 Unauthorized { "success": false, "error": { "code": "INVALID_SIGNATURE", "message": "Signature verification failed" } } ``` If a signature that looks correct is rejected, the cause is almost always the request body: sign the exact bytes you send. See [Canonical string](#canonical-string). ## Key Rotation When rotating keys, a new key can be issued while the existing key remains active, allowing for zero-downtime migration. Once the new key is deployed, the previous key can be deactivated. --- > Source: [/api-reference/idempotency](https://docs.ezys.io/api-reference/idempotency.html) · Markdown: https://docs.ezys.io/api-reference/idempotency.md > Section: API Reference · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Idempotency `X-Idempotency-Key` provides request deduplication and replay for partner-facing `POST` requests. A matching completed request replays its original `2xx` response during the 24-hour retention window. Errors are not cached, and expired records can execute again: this is not an unlimited exactly-once guarantee. `clientReference` is your correlation label, not the transport replay key. It remains part of the request body and changing it changes the request fingerprint. Intent submission also has separate, partner-scoped deduplication by `intentId`; preserve that identifier when recovering an uncertain submission. ## Scope Idempotency applies to every **partner-facing `POST`** — quote, technical-field lookup, intent submission, and [webhook management](https://docs.ezys.io/api-reference/webhooks.md#idempotency). Replay is `POST`-only: a key sent on an unsigned non-`POST` request is ignored, and [signed](https://docs.ezys.io/api-reference/authentication.md#signed-endpoints) `PATCH` and `DELETE` requests of the webhook Management API require the header but are not replayed. Webhooks that Ezys receives from its providers are out of scope — they are deduplicated separately by their own signature and event id. Because the guarantee is transport-level, it also covers read-ish requests such as quote/RFQ. > A retried quote replays the original candidates, which may already be past their `ttlSec`. Mint a new key when you want a fresh quote rather than a safe replay of the previous one. ## Request Header | Header | Required | Description | |--------|----------|-------------| | `X-Idempotency-Key` | Yes on [signed](https://docs.ezys.io/api-reference/authentication.md#signed-endpoints) requests; recommended on every other partner-facing `POST` | Canonical [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562) UUID identifying the operation; `v4` recommended. Use one key per operation and reuse it across every retry. | ```http POST /v1/intents X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 Content-Type: application/json ``` A key that is not a canonical RFC 9562 UUID is rejected with `IDEMPOTENCY_KEY_INVALID` (`400`). A signed request without the header is rejected with `401 INVALID_SIGNATURE` (`X-Idempotency-Key header is required on signed requests`). An unsigned `POST` without the header, such as a quote request, runs without idempotency protection: a retry executes again. ## Key Scope An idempotency record is identified by the tuple `(principal, method, operation, key)`: - **Principal** — the partner and the key's environment (`test` or `live`). A test-key request never replays into live, or the reverse. The principal is the stable identity of the authenticated partner, not the raw API key. Under [key rotation](https://docs.ezys.io/api-reference/authentication.md#key-rotation) a new key can be active alongside the old one, so scoping by raw key would route a retry into a different namespace and execute twice. The principal is rotation-stable, and the raw key is never stored. - **Operation** — the HTTP method and the request path, taken literally. A different path — including a different API version prefix — is an independent record, so reusing a key across versioned paths does not conflict and does not replay. A key is never resolvable across principals, and the same key on a different operation is an independent record, not a conflict — reuse one across a quote and an order and both run. Mint a fresh key per operation. ## Request Matching To tell a genuine retry from a different request that reuses a key, the gateway compares a **fingerprint** of the request — a one-way hash, not a stored copy of the body, isolated per principal. The fingerprint hashes the HTTP method, full path including query string, and raw request body, with length framing between components. Clients do not need to compute this server-side fingerprint. **The body is hashed as the raw bytes you sent — it is not canonicalized.** Object keys are not sorted, whitespace is not stripped, and no normalization of any kind is applied. Two requests with identical meaning but different serialization (a different field order, added whitespace, a re-encoded number) produce different fingerprints and are treated as different requests. **Retry with the exact bytes of the original request.** Keep the serialized request body as a string and resend that string; re-serializing an object at retry time can change the bytes — different JSON libraries order keys and format values differently — and the retry will conflict instead of replaying. String values are likewise compared verbatim and never numerically normalized. Every `*AmountMinor` field is an integer string in minor units, so `"1000"` and `"01000"` are different requests, and the string `"1000"` never equals the number `1000`. An empty body is fingerprinted as zero bytes, like any other body. A malformed JSON body is rejected with `400` before the endpoint runs, and nothing is stored. On a [signed](https://docs.ezys.io/api-reference/authentication.md#signed-endpoints) endpoint, a body the server cannot read as raw bytes, such as one sent without `Content-Type: application/json`, fails signature verification with `401 INVALID_SIGNATURE`. > This is easy to hit by accident — `curl` defaults to form encoding, so always send `Content-Type: application/json`. ## Request Lifecycle The gateway persists the idempotency record **before** any side effect, so the operation is recoverable across a crash. Behavior depends on the state of the existing record: | Existing record | Same request (matching fingerprint) | Different request (different fingerprint) | |-----------------|-------------------------------------|-------------------------------------------| | None | Execute; the record is persisted before the side effect | Execute (an independent operation) | | Processing | `409 IDEMPOTENCY_KEY_PROCESSING`; the request is not executed again | `409 IDEMPOTENCY_KEY_CONFLICT` | | Completed (`2xx`) | Replay the original status and body (with `X-Idempotency-Replayed: true`) | `409 IDEMPOTENCY_KEY_CONFLICT` | | Retention expired | Treated as a new request | Treated as a new request | Only a `2xx` response is stored and replayed. Any other outcome releases the record: a `4xx` such as a validation error or a `422` business rejection, and any `5xx`. A retry with the same key then executes the request again and can return a different result. If releasing the record itself fails, the record stays `Processing`, and a retry with the same key returns `409 IDEMPOTENCY_KEY_PROCESSING` until the record expires. While the first request is still running, the record is `Processing`, and a retry with the same key receives `409 IDEMPOTENCY_KEY_PROCESSING` without executing again. If the endpoint returns an error, including a timeout on a downstream call, the record is released as above. One exception: a `503 SERVICE_UNAVAILABLE` whose message says the response could not be recorded. The request itself may already have been accepted, but its response was not stored, and the record stays `Processing` until it expires. A retry with the same key then returns `409 IDEMPOTENCY_KEY_PROCESSING` for up to 24 hours instead of executing again. How to recover depends on the operation. For `POST /v1/intents`, follow [Intent API → Recovering from `503`](https://docs.ezys.io/api-reference/intent-api.md#recovering-from-503): a `409 IDEMPOTENCY_KEY_PROCESSING` on the retry does not mean the intent was accepted. For firm quotes, request a new quote with a new key — see [Firm quote errors](https://docs.ezys.io/api-reference/quote-api.md#firm-quote-errors). For webhook management calls, see the per-operation recovery table in [Webhooks](https://docs.ezys.io/api-reference/webhooks.md#idempotency). If the server stops while a request is in flight, the record stays `Processing` until it expires. The expiry is the same 24 hours that applies to completed records (see [Key Retention](#key-retention)). After it, the same key is treated as a new request and executes again. For a money-moving operation, check the [Status API](https://docs.ezys.io/api-reference/status-api.md) before you retry a key that has stayed `Processing` for a long time. Error responses are never stored or replayed. That includes `401` / `403`, `IDEMPOTENCY_KEY_INVALID`, and idempotency's own `409`s. ## Replayed Responses A replay reproduces the original response verbatim — the same HTTP status and body captured at first submission — and carries the `X-Idempotency-Replayed: true` header. The first (fresh) response omits it. Only the original status and body are reused; per-request headers (`X-Request-Id`, tracing, `Date`) are reissued. ```http HTTP/1.1 202 Accepted X-Idempotency-Replayed: true Content-Type: application/json { "intentId": "INT-2F8A3B1C", "status": "accepted" } ``` The stored response reflects state at first submission — for a money-moving `POST`, that is a submission-time acknowledgement, while the operation then advances asynchronously (settlement, reversal). A replay is not a re-evaluation of current state, so read live state from the [Status API](https://docs.ezys.io/api-reference/status-api.md) or the settlement notification, not from the replayed response. Replays of the webhook [Register an endpoint](https://docs.ezys.io/api-reference/webhooks.md#register-an-endpoint) and [Rotate the signing secret](https://docs.ezys.io/api-reference/webhooks.md#rotate-the-signing-secret) operations omit `secret` from the body and carry `X-Idempotency-Redacted: secret`. The secret is returned only in the first response. ## Concurrent Requests A second request that arrives with the same key while the first is still in flight — or its outcome is unconfirmed — returns `409 IDEMPOTENCY_KEY_PROCESSING` immediately, and the outbound is not re-sent: ```http HTTP/1.1 409 Conflict Content-Type: application/json { "success": false, "error": { "code": "IDEMPOTENCY_KEY_PROCESSING", "message": "The original request for this X-Idempotency-Key is still being processed — retry with the SAME key after a backoff" } } ``` The response carries no `Retry-After` header. A request can stay in flight for a while, so back off exponentially with a cap and jitter rather than tight-looping. `PROCESSING` clears when the first request finishes or its record expires, not by retrying harder, so bound your retries by attempt count or elapsed time. > **Do not resubmit a stuck operation.** An unresolved `PROCESSING` is not a failure. Resubmitting it — under a new `X-Idempotency-Key` or as a new order — is a new operation the idempotency layer cannot deduplicate against the first, so it can execute a second time and double-pay. Reconcile out of band instead. `IDEMPOTENCY_KEY_CONFLICT` means the query string or body differs from the first `POST` to the same path under this key. A different path or method is a separate record, not a conflict. The usual cause is a retry that re-serialized the body. Resend the original request bytes unchanged under the same key; use a new key only for a genuinely different operation. ## Key Retention A record is retained for **24 hours from when the request was accepted** — not from when its response was returned. The expiry is fixed when the record is created and is not extended on completion, so size your retry window from the moment you send the request. Within that window a retry with the same key replays the stored response; after it expires, the same key is treated as a new request, and re-sending it will execute the operation again. Complete all retries well inside the window, and do not reuse a key for a new operation. The same expiry applies to a record still in `Processing`: 24 hours after the request was accepted, the same key is treated as a new request even if the first one never finished. ## Recommended Client Pattern 1. Generate one `X-Idempotency-Key` (a v4 UUID) per operation and persist it before sending, so a retry after a crash reuses the same key. 2. Reuse that key on every retry of the operation. 3. Back off exponentially, with a cap and jitter. 4. Retry on `IDEMPOTENCY_KEY_PROCESSING`; stop on `IDEMPOTENCY_KEY_CONFLICT` (the request differs from the first one sent under this key). 5. After a bounded number of attempts still returning `PROCESSING`, stop and reconcile — do not resubmit under a new key. ```javascript import { createHmac, randomBytes } from 'node:crypto' import { setTimeout as sleep } from 'node:timers/promises' // Persist key + intentId + serializedBody BEFORE calling this function. // Credentials and baseUrl come from onboarding; body must be a complete intent. async function submitIntent({ baseUrl, apiKey, signingSecret, serializedBody, key }, maxAttempts = 6) { const url = new URL('/v1/intents', baseUrl) for (let attempt = 0; attempt < maxAttempts; attempt++) { const timestamp = Math.floor(Date.now() / 1000).toString() const nonce = randomBytes(16).toString('hex') // fresh on every attempt const canonical = `POST${url.pathname}${url.search}${timestamp}${nonce}${serializedBody}` const signature = createHmac('sha256', signingSecret).update(canonical).digest('hex') const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey, 'X-Idempotency-Key': key, 'X-Timestamp': timestamp, 'X-Nonce': nonce, 'X-Signature': signature, }, body: serializedBody, }) if (res.status !== 409) return res // caller handles other errors; not proof of failure const body = await res.clone().json() if (body.error?.code !== 'IDEMPOTENCY_KEY_PROCESSING') return res if (attempt + 1 < maxAttempts) { await sleep(Math.min(30_000, 2 ** attempt * 1000) + Math.random() * 500) } } throw new Error('Outcome unresolved: query the original execution; do not create a new intent.') } ``` ## Error Codes | Code | HTTP Status | Description | |------|-------------|-------------| | `IDEMPOTENCY_KEY_INVALID` | `400` | The key is not a canonical RFC 9562 UUID. | | `IDEMPOTENCY_KEY_CONFLICT` | `409` | The same key was reused with a different request body. | | `IDEMPOTENCY_KEY_PROCESSING` | `409` | The original request for this key is still being processed. | See the [API Reference overview](https://docs.ezys.io/api-reference/overview.md#error-codes) for the full error-code registry. ## Notes - Body values are matched verbatim. `*AmountMinor` fields are integer strings in minor units — `"1000"` and `"01000"` are different requests. - One `X-Idempotency-Key` per operation; reuse it on retries, never across a different operation, and never after its record expires. - Replay is `POST`-only. A key on an unsigned non-`POST` request is ignored; signed `PATCH` and `DELETE` requests require it but are not replayed. - Always send `Content-Type: application/json`. On a signed endpoint, a body the server cannot read as raw bytes fails signature verification with `401`. - A replay reflects submission-time state — read current state from the operation's status flow, not the replayed response. --- > Source: [/api-reference/quote-api](https://docs.ezys.io/api-reference/quote-api.html) · Markdown: https://docs.ezys.io/api-reference/quote-api.md > Section: API Reference · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Quote API Request a quote — the pair, amount, and side you want — from the Solver Network. The solver searches its liquidity sources, scores the results, and returns either a ranked list of **indicative candidates** or a **firm quote** with locked terms. You then execute by submitting an intent (see [Executing a quote](#executing-a-quote)). Quoting is a **two-step** flow. Use `POST /v1/quotes` for indicative candidates, or `POST /v1/quotes/firm` when you need a lockable RFQ quote. Execution still happens through the Intent API; there is no separate confirm endpoint and no quote re-fetch — a lapsed quote is simply re-requested. ## Base URL All endpoints are served under the `/v1` global prefix: ``` Dev: https://api.ezys.dev/v1 (partner testing runs here today) Sandbox: https://api.ezys.sh/v1 (planned; not yet reachable) Production: https://api.ezys.io/v1 (planned; confirm during onboarding) ``` The paths below are shown relative to this base — e.g. `POST /quotes` is `POST /v1/quotes`. ## Request Quotes ``` POST /v1/quotes ``` Synchronous; responds `200 OK`. The body is a [QuoteProposedEvent](#quotes-response) carrying a `candidates` array (empty if no liquidity — see [Empty candidates](#empty-candidates)). These candidates are indicative; `requestId` is a correlation value, not an executable firm-quote reference. > The response is returned **directly** — it is not wrapped in a `{ success, data }` envelope. ### Request Body ```json { "requestId": "RFQ-001", "actorId": "hana-ny", "actorType": "institution", "source": "USDC", "target": "KRW", "amount": "1000000", "side": "sell", "settlementPref": "hybrid" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `requestId` | string | Yes | Caller-supplied correlation ID echoed in the response; not an executable quote reference | | `actorId` | string | Yes | Account or ID that owns the request | | `source` | string | Yes | Asset you send, e.g. `USDC` | | `target` | string | Yes | Asset you receive, e.g. `KRW` | | `amount` | string | Yes | Amount of `source` to quote, decimal string | | `side` | string | Yes | `buy` or `sell` | | `actorType` | string | No | `retail` or `institution`. Defaults to `institution` | | `quoteType` | string | No | `A_retail` or `B_institution`. Defaults to `B_institution` | | `settlementPref` | string | No | `onchain`, `offchain`, or `hybrid`. Defaults to `hybrid` | | `maxSlippageBps` | number | No | Max slippage you accept, in basis points. Mapped to `constraints.maxSlippageBps` | ### Quotes Response The response echoes your `requestId` and returns the scored candidates (top 5 by default, best first). ```json { "requestId": "RFQ-001", "timestamp": 1737123456789, "candidates": [ { "routeId": "external-lp-sim:LP-A:USDC-KRW", "ttlSec": 30, "totalCost": "1445.00000000", "slippageBps": 5, "provider": "LP-A", "hops": ["rfq:LP-A:USDC-KRW"], "legs": [ { "from": "USDC", "to": "KRW", "venue": "LP:LP-A", "price": "1445.00000000", "feeBps": 8, "capacity": "700000000" } ], "metadata": { "venue": "ExternalLP", "lpId": "LP-A", "confidence": 0.94, "isDynamic": false, "latencyMs": 320, "basePrice": 1443.1, "feesBps": 8, "liquidityAvailable": 700000000, "score": 1445.02, "constraintViolations": [] } } ] } ``` | Field | Type | Description | |-------|------|-------------| | `requestId` | string | Echoes the submitted ID | | `timestamp` | number | Response time (Unix ms) | | `candidates` | array | Scored quote candidates, best first. Empty `[]` means no liquidity. See [Candidate](#candidate) | #### Candidate | Field | Type | Description | |-------|------|-------------| | `routeId` | string | **Opaque route handle** identifying the candidate's route. Its internal shape varies by liquidity source (e.g. `external-lp-sim:LP-A:USDC-KRW`) — treat it as opaque | | `ttlSec` | number | Quote validity window, in seconds. Honor it client-side and re-request once it lapses — see [Status & lifecycle](#status-lifecycle) | | `totalCost` | string | Output-per-input rate for the route, decimal string | | `slippageBps` | number \| null | Estimated slippage in basis points. Nullable in the schema, but populated by all current sources | | `provider` | string \| null | Liquidity provider / source ID. Nullable in the schema, but populated by all current sources | | `hops` | array | Routing path (defaults to `[]`; populated by all current sources) | | `legs` | array | Per-segment breakdown. See [Leg](#leg) | | `metadata` | object | Scoring / diagnostic detail — see [Metadata](#metadata) | #### Leg | Field | Type | Description | |-------|------|-------------| | `from` | string | Segment source asset | | `to` | string | Segment target asset | | `venue` | string \| null | Where the segment fills, e.g. `LP:`, `ExternalLP`, `AMM-Pool`, `Orderbook` | | `price` | string \| null | Segment price | | `feeBps` | number \| null | Segment fee in basis points | | `capacity` | string \| null | **Supply ceiling** — the LP's available balance in the target asset. The most this candidate can fill; used to bound fill slices | #### Metadata Diagnostic detail attached to each candidate on the HTTP response. Some keys are always computed during scoring; others are source-specific. | Field | Type | Description | |-------|------|-------------| | `latencyMs` | number | Source's quote latency, milliseconds (scoring input) | | `basePrice` | number | Reference/oracle price before spread and fees | | `feesBps` | number | Fee applied by the route, basis points | | `liquidityAvailable` | number | Available liquidity considered during scoring | | `score` | number | Solver's internal score for ranking (lower is better) | | `constraintViolations` | array | Constraint checks the candidate failed (e.g. `slippage>50`); **always present**, `[]` when none | > Source-specific keys may also appear — e.g. ExternalLP adds `venue`, `lpId`, `lpName`, `policyBps`, `confidence`, `isDynamic`; Orderbook adds `orderbookDepth`. Treat `metadata` as an open, source-dependent object. ### Empty candidates A `200` response with `candidates: []` means no candidates were returned. This can mean no available liquidity, but the endpoint also uses this response when candidate generation throws an error; it does not distinguish the two. Submit a fresh request (optionally adjusting the pair or amount) to try again. ## Executing a quote Execution converges on the **Intent API**: express what you want as amount bounds, optionally attach a firm `quoteId` (see [Firm quotes](#firm-quotes)) to accept a locked quote, and submit the intent — see [Intent API → Amount bounds and firm quotes](https://docs.ezys.io/api-reference/intent-api.md#amount-bounds-and-firm-quotes). ## Status & lifecycle There is no quote re-fetch endpoint — quotes are short-lived by design. Each candidate carries a `ttlSec` validity window: honor it client-side and request a fresh quote once it lapses. An indicative candidate does not lock execution terms. Only a firm `quoteId` is consumed and checked for expiry when submitting an intent. After an intent is accepted, follow the [Status API](https://docs.ezys.io/api-reference/status-api.md) and [Webhooks](https://docs.ezys.io/api-reference/webhooks.md). Later settlement failures appear as state; webhook events do not carry rejection reason codes. ## Firm quotes ``` POST /v1/quotes/firm ``` Synchronous; responds `201 Created`. The response is returned **directly** — it is not wrapped in a `{ success, data }` envelope. A **firm quote** records a rate, amounts, and itemized fees until `validUntil`. It provides fixed terms for a subsequent intent; it is not proof that the transfer will succeed. Submission still validates the intent, and settlement can fail or become disputed. 1. Request a firm quote — the response carries a `quoteId`, the locked all-in rate, an itemized `fees` breakdown (`protocol_fee`, `payout_fee`, `network_fee` — the same taxonomy as the receipt, so quote and receipt reconcile per `type`), and `validUntil`. 2. Attach the `quoteId` to an Intent (see [Intent API → Amount bounds and firm quotes](https://docs.ezys.io/api-reference/intent-api.md#amount-bounds-and-firm-quotes)). 3. Within the TTL, settlement enforces delivery at the quoted terms; past `validUntil` the quote lapses and the intent is rejected — never silently re-priced. The quote fixes its itemized fee values. The receipt uses the saved fee ledger for the execution; compare items per `type` and currency. `absorbed` network-fee entries are informational and are not a separate charge to the caller. They are not a live gas-cost measurement returned by this API. ### Firm Quote Request Body ```json { "requestId": "RFQ-FIRM-001", "actorId": "fsp-narae", "actorType": "institution", "side": "sell", "inputAsset": { "type": "erc20", "symbol": "USDC", "chain": "ethereum", "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" }, "outputAsset": { "type": "fiat", "symbol": "USD", "currency": "USD" }, "amountMinor": "1000000", "amountType": "target" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `requestId` | string | Yes | Caller-supplied unique ID for the quote request | | `actorId` | string | Yes | Account or partner ID that owns the request | | `actorType` | string | No | `retail` or `institution`. Defaults to `institution` | | `side` | string | No | `buy` or `sell`. Defaults to `sell` | | `inputAsset` | object | Yes | Asset you pay. Same `AssetDescriptor` shape as the [Intent API](https://docs.ezys.io/api-reference/intent-api.md#assetdescriptor) | | `outputAsset` | object | Yes | Asset the recipient receives. Same `AssetDescriptor` shape as the [Intent API](https://docs.ezys.io/api-reference/intent-api.md#assetdescriptor) | | `amountMinor` | string | Yes | Amount to quote, in the minor unit of the asset selected by `amountType` | | `amountType` | string | Yes | `source` means `amountMinor` is the input you will pay; `target` means `amountMinor` is the exact recipient amount to lock | | `swapper` | string | No | Address that holds the input funds (EOA or contract account), `0x` + 40 hex. Send it for a same-chain token swap to receive `reactorOrder` in the response. Omit it for fiat output. An invalid address returns `400` | | `recipient` | string | No | Address that receives the output. Defaults to `swapper`. Sending it without `swapper` returns `400` (`recipient requires swapper`) | Use `amountType: "target"` for **destination-fixed payout**. In that mode the solver computes the required input by adding locked deducted fees to the target output and rounding the required input up to price the requested recipient amount. Actual delivery must still be confirmed. The returned `input` is the maximum amount expected for the attached intent; the intent can still set an `input.limitAmountMinor` cap when accepting the quote. ### Firm Quote Response Amounts and fees below are illustrative, not a published fee schedule. Read the actual quote for your route; the protocol fee can be zero. Amounts in `input`, `output`, and `fees[]` follow the [Money & Amounts](https://docs.ezys.io/api-reference/money-and-amounts.md) minor-unit convention. ```jsonc { "requestId": "RFQ-FIRM-001", "quoteId": "fq_9ab32c1f", // ← attach this to the Intent to accept "pair": "USDC/USD", "allInRate": "1", // quote terms valid until validUntil "input": { "amountMinor": "10010250000", "currency": "USDC", "exponent": 6 }, // 10,010.25 USDC required "output": { "amountMinor": "1000000", "currency": "USD", "exponent": 2 }, // exactly USD 10,000.00 "fees": [ // itemized, separate from principal — same taxonomy as the receipt { "type": "protocol_fee", "amountMinor": "1000", "currency": "USD", "exponent": 2, "collection": "deducted" }, { "type": "payout_fee", "amountMinor": "25", "currency": "USD", "exponent": 2, "collection": "deducted" }, { "type": "network_fee", "amountMinor": "0", "currency": "USDC", "exponent": 6, "collection": "absorbed" } ], // FX spread is NOT a fee item — it is inside allInRate "validUntil": "2026-07-15T12:00:30Z" } ``` | Field | Type | Description | |-------|------|-------------| | `requestId` | string | Echoes the submitted request ID | | `quoteId` | string | Opaque, single-use firm quote handle. Attach it to `POST /v1/intents` to accept the quote | | `pair` | string | Input/output pair | | `allInRate` | string | Locked all-in rate, expressed as output per one input. Decimal string, no trailing zeros. FX spread is inside this rate, not a fee item | | `input` | object | Required input amount, minor-unit object | | `output` | object | Locked output amount, minor-unit object | | `fees` | array | Locked itemized fees, using the same taxonomy as receipts: `protocol_fee`, `payout_fee`, `network_fee`. `payout_fee` depends on the payout provider and is omitted when that provider charges no per-transaction fee | | `validUntil` | string | ISO 8601 expiry. The accepting intent must arrive before this time | | `reactorOrder` | object | Present only when the request carried `swapper` and both assets are tokens on the same chain. Absent for fiat output and cross-chain quotes. See [On-chain swaps (reactor order)](#on-chain-swaps-reactor-order) | ### Accepting a firm quote Attach the returned `quoteId` to `POST /v1/intents`. A firm quote is **single-use**: once an intent consumes it, the same `quoteId` cannot be used again. Pair, amount, and TTL are checked at acceptance; mismatches are rejected rather than silently re-priced. For destination-fixed payout, the accepting intent should use exact-output bounds: ```jsonc { "input": { "limitAmountMinor": "10100000000" }, // cap: most you will pay "output": { "targetAmountMinor": "1000000", "limitAmountMinor": "1000000" }, "quoteId": "fq_9ab32c1f" } ``` Without a `quoteId`, quotes are **indicative** and intents remain best-effort auction orders. ### On-chain swaps (reactor order) When both assets are ERC-20 tokens on the same chain, include `swapper`, and `chain` and `address` on both assets. The response then carries `reactorOrder`, the order to sign. If `status` is `"built"`, sign `typedData` with the `swapper` wallet (`eth_signTypedData_v4`). Then submit `order` unchanged, plus the signature, as `reactorOrder` on `POST /v1/intents` with the same `quoteId` — see [Intent API → Reactor orders (same-chain swaps)](https://docs.ezys.io/api-reference/intent-api.md#reactor-orders-same-chain-swaps). If `status` is `"rejected"`, no order could be built and `reason` says why. Whether the quote can still be used depends on the reason: `quote_not_found`, `quote_expired` and `quote_already_used` mean the quote could not be read as active, `build_failed` is a server error, and an `unsupported_chain` quote cannot execute at all — request a new quote in all five cases. The other reasons leave the quote active, but no reactor order could be built from this request. See [Rejected reactor orders](#rejected-reactor-orders). Request: ```json { "requestId": "RFQ-1786370002", "actorId": "inst-demo", "actorType": "institution", "side": "sell", "inputAsset": { "type": "erc20", "symbol": "USDC", "chain": "sepolia", "address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", "decimals": 6 }, "outputAsset": { "type": "erc20", "symbol": "skbKRW", "chain": "sepolia", "address": "0x9de9474F8BbabE561A0F589b4860A1f017F3f9D2", "decimals": 18 }, "amountMinor": "10000000", "amountType": "source", "swapper": "0x80efbfc96a7F1d6EC95A3a42f1eBeC51955F8F68" } ``` Response (the fields before `reactorOrder` have the same shape as in [Firm Quote Response](#firm-quote-response); the values are illustrative): ```jsonc { "requestId": "RFQ-1786370002", "quoteId": "fq_7c2e91d4", "pair": "USDC/skbKRW", "allInRate": "1378.62", // input, output, fees, validUntil: as in the firm quote response "reactorOrder": { "status": "built", "chainId": 11155111, "reactor": "0x3B6e5C3fA7d1E2b94c08A1f5D9e6c27B8a4F1d02", "executor": "0x8F2a6D41c9E0b7A35d1C4e8B92f06A7c3D5e1B94", "permit2": "0x5C1d9E7a2B4f8306D7e1A9c4B2f0E6d83A7c5F21", "orderHash": "0x4e1f7a9c2b3d5e8f0a1b6c7d9e2f4a3b5c8d0e1f2a4b6c9d7e3f5a1b8c0d2e4f", "digest": "0x9a2c4e6f8b1d3a5c7e9f0b2d4a6c8e1f3b5d7a9c0e2f4b6d8a1c3e5f7b9d0a2c", "order": { "info": { "reactor": "0x3B6e5C3fA7d1E2b94c08A1f5D9e6c27B8a4F1d02", "swapper": "0x80efbfc96a7F1d6EC95A3a42f1eBeC51955F8F68", "nonce": "48213907761524398126509837162049571384265907315842609783150264937180", "deadline": "1786370030" }, "exclusiveFiller": "0x8F2a6D41c9E0b7A35d1C4e8B92f06A7c3D5e1B94", "exclusivityEnd": "1786370030", "exclusivityOverrideBps": "0", "input": { "token": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", "amount": "10000000" }, "outputs": [ { "token": "0x9de9474F8BbabE561A0F589b4860A1f017F3f9D2", "amount": "13786200000000000000000", "recipient": "0x80efbfc96a7F1d6EC95A3a42f1eBeC51955F8F68" } ] }, "typedData": { "domain": { "name": "NfxPermit2", "version": "1", "chainId": 11155111, "verifyingContract": "0x5C1d9E7a2B4f8306D7e1A9c4B2f0E6d83A7c5F21" }, "types": { "PermitWitnessTransferFrom": [ { "name": "permitted", "type": "TokenPermissions" }, { "name": "spender", "type": "address" }, { "name": "nonce", "type": "uint256" }, { "name": "deadline", "type": "uint256" }, { "name": "witness", "type": "RfqOrder" } ], "TokenPermissions": [ { "name": "token", "type": "address" }, { "name": "amount", "type": "uint256" } ], "RfqOrder": [ { "name": "info", "type": "OrderInfo" }, { "name": "exclusiveFiller", "type": "address" }, { "name": "exclusivityEnd", "type": "uint256" }, { "name": "exclusivityOverrideBps", "type": "uint256" }, { "name": "input", "type": "TokenPermissions" }, { "name": "outputs", "type": "OutputToken[]" } ], "OrderInfo": [ { "name": "reactor", "type": "address" }, { "name": "swapper", "type": "address" }, { "name": "nonce", "type": "uint256" }, { "name": "deadline", "type": "uint256" } ], "OutputToken": [ { "name": "token", "type": "address" }, { "name": "amount", "type": "uint256" }, { "name": "recipient", "type": "address" } ] }, "primaryType": "PermitWitnessTransferFrom", "message": { "permitted": { "token": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", "amount": "10000000" }, "spender": "0x3B6e5C3fA7d1E2b94c08A1f5D9e6c27B8a4F1d02", "nonce": "48213907761524398126509837162049571384265907315842609783150264937180", "deadline": "1786370030", "witness": { /* same object as reactorOrder.order */ } } }, "witnessTypeString": "RfqOrder witness)OrderInfo(address reactor,address swapper,uint256 nonce,uint256 deadline)OutputToken(address token,uint256 amount,address recipient)RfqOrder(OrderInfo info,address exclusiveFiller,uint256 exclusivityEnd,uint256 exclusivityOverrideBps,TokenPermissions input,OutputToken[] outputs)TokenPermissions(address token,uint256 amount)", "quoteValidUntil": 1786370030000 } } ``` A built `reactorOrder` has these fields: | Field | Type | Description | |-------|------|-------------| | `status` | string | `built` | | `chainId` | integer | Chain the order settles on | | `reactor` | string | EzysReactor address. It is the `spender` in the typed data | | `executor` | string | The filler. Ezys sets it as `exclusiveFiller`; you cannot change it | | `permit2` | string | NfxPermit2 address. It is the typed-data domain's `verifyingContract` | | `orderHash` | string | Order hash. It links the on-chain `Fill` to the execution | | `digest` | string | EIP-712 digest computed by the server, for comparison only. Sign `typedData`, not this value | | `order` | object | Serialized RfqOrder. Submit it unchanged as `reactorOrder.order` | | `typedData` | object | Full EIP-712 payload: `domain`, `types`, `primaryType`, `message`. Pass it to the wallet as is | | `witnessTypeString` | string | Witness type string. Use it as received; do not build your own | | `quoteValidUntil` | integer | Quote expiry, Unix epoch milliseconds. The on-chain `deadline` is this value in seconds | #### Rejected reactor orders A rejected `reactorOrder` is `{ "status": "rejected", "reason": "…" }`, with one of these reasons: | `reason` | Meaning | Quote still usable? | |----------|---------|---------------------| | `reactor_not_deployed` | No reactor contract is deployed on the chain | Yes, but without a reactor order | | `unsupported_chain` | The chain is not supported | No — correct the chain and request a new quote; an intent on the same chain is rejected with `unsupported_chain` | | `invalid_address` | `swapper` or `recipient` is not a valid EVM address | Yes, but without a reactor order | | `token_address_unavailable` | The token is listed but has no address on this chain | Yes, but without a reactor order | | `quote_not_found`, `quote_expired`, `quote_already_used` | The quote could not be read as active when the order was built | No — request a new quote | | `build_failed` | Server error while building the order | No — do not retry with this `quoteId`; request a new quote | ### Firm quote errors | HTTP | Body | When | |------|------|------| | `400` | `{ statusCode, message, error }` | `amountType` is not `source` or `target`; `swapper` or `recipient` is not a valid address; `recipient` without `swapper`; a `fiat` asset without `currency`; `amountMinor` is not an integer in the minor unit | | `401` / `403` | Error envelope | The API key is missing or invalid (`401`), or lacks the `quotes:write` scope (`403`). See [Error Handling & Retries](https://docs.ezys.io/api-reference/errors.md#common-errors-all-endpoints) | | `422` | `{ "status": "no_liquidity", "requestId": "…" }` | No route can price the request, or the deducted fees use up the whole output | | `500` | Error envelope, or `{ statusCode, message }` | Server error. A `500` with `error.code` is a classified failure; one without it is the platform default body `{ "statusCode": 500, "message": "Internal server error" }`. Retry with backoff, and contact Ezys if a `500` without `error.code` persists | | `503` `SERVICE_UNAVAILABLE` | Error envelope | The idempotency layer failed. The quote may or may not have been issued, and there is no quote lookup. Do not retry with the same key (it returns `409 IDEMPOTENCY_KEY_PROCESSING` for up to 24 hours if the quote was issued); request a new quote with a new `X-Idempotency-Key`. An unused quote expires at `validUntil` | ## Notes - Firm-quote amounts (`amountMinor`, `input`, `output`, `fees[]`) are integer strings in minor units — see [Money & Amounts](https://docs.ezys.io/api-reference/money-and-amounts.md). Indicative-quote values (`amount`, `totalCost`, prices) and `allInRate` are decimal strings. No floating-point. - Defaults: `actorType` → `institution`, `quoteType` → `B_institution`, `settlementPref` → `hybrid`. - `routeId` is an opaque handle — do not parse it. --- > Source: [/api-reference/intent-api](https://docs.ezys.io/api-reference/intent-api.html) · Markdown: https://docs.ezys.io/api-reference/intent-api.md > Section: API Reference · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Intent API Submit an Intent — what you want to exchange and the constraints you accept — to the Solver Network. The solver computes an execution path and either **accepts** it (reserving liquidity and starting settlement) or **rejects** it with a reason. Submission is **single-step**: the response is terminal (`accepted` or `rejected`) and there is no separate confirm step — see [Status & lifecycle](#status-lifecycle) and [Cancellation](#cancellation). To lock a price before committing, request a firm quote first and attach its `quoteId` (see [Quote API](https://docs.ezys.io/api-reference/quote-api.md) → [Firm quotes](https://docs.ezys.io/api-reference/quote-api.md#firm-quotes), and [Amount bounds and firm quotes](#amount-bounds-and-firm-quotes)). ## Submit Intent ``` POST /intents ``` Synchronous. An [accepted](#accepted-response) Intent responds `202 Accepted`; a [rejected](#rejected-response) one responds `422 Unprocessable Entity`. On acceptance the solver reserves balance, derives an HTLC hashlock, and starts the DvP settlement workflow — except for [reactor orders](#reactor-orders-same-chain-swaps), which have no hashlock and no balance reservation. ::: warning Signature required This endpoint state-changes, so `X-API-Key` alone is rejected. Send `X-Signature`, `X-Timestamp`, `X-Nonce` and `X-Idempotency-Key` as well — see [Request Signing](https://docs.ezys.io/api-reference/authentication.md#request-signing). ::: ### Request Body ```json { "intentId": "INT-2F8A3B1C", "clientReference": "your-payment-id-124", "user": "0x2521f0a5a2B78dE703510798342AbaE2F95872B5", "inputAsset": { "type": "erc20", "symbol": "USDC", "chain": "avalanche-fuji", "address": "0x5425890298aed601595a70AB815c96711a31Bc65" }, "outputAsset": { "type": "fiat", "symbol": "USD", "currency": "USD" }, "inputAmountMinor": "10000000", "minOutputAmountMinor": "900", "deadline": 1798761600000, "settlementType": "hybrid", "beneficiary": { "account": "4412789033", "name": "Jane Doe", "country": "US", "payoutMethod": "ach", "routingNumber": "021000021", "accountType": "checking", "bank": "JPMorgan Chase", "address": { "line1": "420 Montgomery St", "city": "San Francisco", "state": "CA", "postalCode": "94104" } }, "permit": { "token": "0x5425890298aed601595a70AB815c96711a31Bc65", "amountMinor": "10000000", "spender": "0x1417c8D5Cd1BcC2bC4146Fe49F3fdFcbcEaD0d73", "nonce": "0", "deadline": 1798761600, "signature": "0x<65-byte sig>" } } ``` From `POST /v1/intents/technical-fields`, use `escrowAddress` as `permit.spender` and `permitNonce` as `permit.nonce`, and set `permit.deadline` a few hours ahead; the example values are illustrative. | Field | Type | Required | Description | |-------|------|----------|-------------| | `intentId` | string | Yes | Caller-supplied ID, unique within your partner account. 1–128 characters from `A-Z`, `a-z`, `0-9`, `.`, `_`, `:`, `-`; case-sensitive. Invalid format returns `400` | | `clientReference` | string | Yes | **Target.** Your own correlation key for this transaction (e.g. your payment-pipeline ID). At most 256 characters. Required on every partner-authenticated request: a missing or blank value is rejected with `400` (`clientReference is required for partner-authenticated intents`). Bound at submission and echoed **verbatim** on the read surfaces — [Status](https://docs.ezys.io/api-reference/status-api.md), [Webhooks](https://docs.ezys.io/api-reference/webhooks.md), [Receipts](https://docs.ezys.io/api-reference/receipts-api.md), reconciliation. The submission response itself does not echo it; it returns only server-issued values (`intentId`, `planId`, `solverId`, `status`, `quoteId`, `fees`, `path`). Independent of the `X-Idempotency-Key` header, which is transport-level retry safety — though the field is part of the request body and therefore covered by its [fingerprint](https://docs.ezys.io/api-reference/idempotency.md#request-matching) | | `user` | string | Yes | Account or address that owns the Intent. For on-chain inputs this must be the wallet that **signed the `permit`** — funds are pulled from this address. For a [reactor order](#reactor-orders-same-chain-swaps) it must be the quote's `swapper`. May be a **smart contract wallet** (Safe, CA vault) — see [Contract wallets (EIP-1271)](#contract-wallets-eip-1271) | | `inputAsset` | object | Yes | Asset you send. See [AssetDescriptor](#assetdescriptor) | | `outputAsset` | object | Yes | Asset you receive. See [AssetDescriptor](#assetdescriptor) | | `inputAmountMinor` | string | Conditional | Input amount you send, in the input asset's minor unit (see [Money & Amounts](https://docs.ezys.io/api-reference/money-and-amounts.md)). **Either this or `input.limitAmountMinor` is required** — omitting both returns `400` (`inputAmount or input.limit is required`). In the generalized bounds model this is `input.targetAmountMinor == input.limitAmountMinor` — see [Amount bounds and firm quotes](#amount-bounds-and-firm-quotes) | | `minOutputAmountMinor` | string | Conditional | Minimum output you accept, in the output asset's minor unit — your slippage floor. Below this → `below_minimum_output`. **Either this or `output.limitAmountMinor` is required** — omitting both returns `400` (`minOutputAmount or output.limit is required`). **Attaching `quoteId` does not substitute for it**: the floor is yours to set, and filling it from the quote would leave a stale figure as your floor after the quote re-prices or expires | | `quoteId` | string | No | Reference to a **locked firm quote**. Submit within its TTL to use its recorded amounts, rate and fees. See [Amount bounds and firm quotes](#amount-bounds-and-firm-quotes) | | `deadline` | number | Yes | Intent expiry, Unix epoch milliseconds (`permit.deadline` is seconds) | | `settlementType` | string | Yes | `onchain`, `offchain`, or `hybrid` | | `outputRecipient` | string | No | Third-party recipient. If set and ≠ `user`, this is a remittance and `beneficiary` is required | | `beneficiary` | object | Conditional | Required for a remittance, and for any `fiat` output. See [Beneficiary](#beneficiary) | | `permit` | object | Conditional | **Required for on-chain (`erc20`) inputs**, except [reactor orders](#reactor-orders-same-chain-swaps). A signed NfxPermit2 authorization that lets settlement pull the input amount from `user`'s wallet into the HTLC escrow. Without it the intent is rejected with `permit_required`. See [Permit (on-chain inputs)](#permit-on-chain-inputs) | | `erc2612Permit` | object | No | ERC-2612 `permit` signature for the input token. The platform submits it and pays the gas, so you do not send the one-time `approve` yourself. Always sent together with `permit`. Not used for [reactor orders](#reactor-orders-same-chain-swaps). See [Gasless approve (`erc2612Permit`)](#gasless-approve-erc2612permit) | | `reactorOrder` | object | Conditional | Required when both assets are tokens on the same chain where a reactor is deployed and both carry `address`. Shape `{ order, signature }`: `order` is the firm-quote `reactorOrder.order` unchanged; `signature` is the user wallet's EIP-712 signature over that response's `typedData`. A mismatch with the quote is `reactor_order_mismatch`. See [Reactor orders (same-chain swaps)](#reactor-orders-same-chain-swaps) | | `actorType` | string | No | `retail` or `institution`. Defaults to `institution` | | `side` | string | No | `buy` or `sell`. Defaults to `sell` | | `metadata` | object | No | Compliance / routing metadata. See [Metadata](#metadata) | | `channels` | string[] | No | Webhook channels for this intent's events — 1–10 distinct values, each matching `^[A-Za-z0-9._-]{1,64}$`. Any other value, including `null` and `[]`, returns `400`. Endpoints registered with `channels` receive this intent's events when one value matches, and none when `channels` is omitted. Endpoints registered without `channels` receive all events. See [Webhooks → Channels](https://docs.ezys.io/api-reference/webhooks.md#channels) | > `settlementMode` is not part of the request — the solver currently settles `atomic`. #### Amount bounds and firm quotes The generalized bounds form and `quoteId` support destination-fixed payout and firm quote acceptance. The exact-input shape (`inputAmountMinor` + `minOutputAmountMinor`) is sugar for the same bounds model. Use amount bounds to express what you will pay and accept: | Mode | `input` | `output` | Behavior | |---|---|---|---| | Exact-input | Equal `targetAmountMinor` and `limitAmountMinor` | `limitAmountMinor` floor | Fixed input; computed output must meet the floor. `inputAmountMinor` and `minOutputAmountMinor` are shorthand for this. | | Exact-output | `limitAmountMinor` cap | Equal `targetAmountMinor` and `limitAmountMinor` | Fixed recipient amount; computed input must fit the cap. | | Non-collapsed range | Target differs from limit on either side | Same restriction | Not supported for execution; returns `amount_bounds_range_not_supported`. There is no Dutch-decay order flow on this endpoint. | A firm quote is independent of the bounds shape. Obtain it with `POST /v1/quotes/firm`, then attach its `quoteId` before `validUntil`. The intent uses the stored amounts, rate and fees, subject to validation and your bounds. **The TTL is the quote acceptance window, not a guarantee that settlement finishes or succeeds within that window.** Without `quoteId`, Ezys computes a route against your bounds using immediate or batch solving. The request returns acceptance or a rejection such as `no_liquidity`; it does not remain as a resting auction order waiting to expire. Exact-output without a firm quote: ```jsonc { "intentId": "INT-9C1D2E3F", "clientReference": "your-payment-id-125", "user": "0x2521f0a5a2B78dE703510798342AbaE2F95872B5", "inputAsset": { "type": "erc20", "symbol": "USDC", "chain": "avalanche-fuji", "address": "0x5425890298aed601595a70AB815c96711a31Bc65" }, "outputAsset": { "type": "fiat", "symbol": "USD", "currency": "USD" }, "input": { "limitAmountMinor": "100305000000" }, // cap: most you'll pay — 100,305 USDC "output": { "targetAmountMinor": "10000000", "limitAmountMinor": "10000000" }, // exactly USD 100,000.00 "deadline": 1798761600000, "settlementType": "hybrid", "beneficiary": { "account": "4412789033", "name": "Jane Doe", "country": "US", "payoutMethod": "ach", "routingNumber": "021000021", "accountType": "checking", "bank": "JPMorgan Chase", "address": { "line1": "420 Montgomery St", "city": "San Francisco", "state": "CA", "postalCode": "94104" } }, "permit": { "token": "0x5425890298aed601595a70AB815c96711a31Bc65", "amountMinor": "100305000000", // covers the input cap "spender": "0x1417c8D5Cd1BcC2bC4146Fe49F3fdFcbcEaD0d73", "nonce": "0", "deadline": 1798761600, "signature": "0x<65-byte sig>" } } ``` From `POST /v1/intents/technical-fields`, use `escrowAddress` as `permit.spender` and `permitNonce` as `permit.nonce`, and set `permit.deadline` a few hours ahead; the example values are illustrative. Exact-output with a firm quote (fragment to add to the complete request above): ```jsonc { // ...same as above, plus: "quoteId": "fq_9ab32c1f" // ← from the firm-quote response (Quote API → Firm quotes): // locked allInRate, required input, // itemized fees, validUntil 2026-07-15T12:00:30Z } ``` Amounts follow the [Money & Amounts](https://docs.ezys.io/api-reference/money-and-amounts.md) convention. For exact-output intents the accepted response returns the **computed** `inputAmountMinor`; when a `quoteId` is attached, the `fees` breakdown is itemized from the locked quote (see [Accepted Response](#accepted-response)). Firm quotes are issued by the [Quote API](https://docs.ezys.io/api-reference/quote-api.md) RFQ flow — the `fq_9ab32c1f` above is the `quoteId` returned by the firm-quote response example in [Quote API → Firm quotes](https://docs.ezys.io/api-reference/quote-api.md#firm-quotes). #### AssetDescriptor | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | Yes | `erc20`, `fiat`, or `native` | | `symbol` | string | Yes | e.g. `USDC`, `KRW`, `ETH` | | `address` | string | **Conditional** | ERC20 contract address. Required for `erc20` | | `chain` | string | No | e.g. `ethereum` (on-chain assets) | | `decimals` | integer | No | Token decimals (0–36). On a [firm quote](https://docs.ezys.io/api-reference/quote-api.md#firm-quotes) request it is honored and is required for a token the platform does not list. An intent request ignores it: the platform applies the decimals it knows for the token symbol, so only tokens the platform knows are supported, and a value that differs from the platform's is not supported. Request-only: no response returns it. Omit it for `fiat` | | `currency` | string | No | ISO 4217 code (`fiat`), e.g. `KRW` | #### Beneficiary | Field | Type | Required | Description | |-------|------|----------|-------------| | `account` | string | Yes | Recipient account number. A pre-provisioned recipient reference (`bene_…:dest_…`) also goes here — when you send one, the fields below are not needed | | `name` | string | Yes | Recipient name | | `country` | string | **Conditional** | Recipient country, ISO 3166-1 alpha-2 (e.g. `US`). Required when you send recipient details inline | | `payoutMethod` | string | **Conditional** | Payout rail — `wire`, `swift`, `ach`, `sepa`. Required inline, unless `swiftCode` is present. A rail the payout route does not support can be rejected at submit | | `swiftCode` | string | No | Recipient bank BIC. Sent without `payoutMethod`, `address` is checked at submit as for `wire` and `swift`, and the payout route decides the rail | | `bank` | string | No | Recipient bank name | | `routingNumber` | string | **Conditional** | ABA routing number. US USD payouts: required for `ach`; for `wire`, this or `swiftCode` is required. `sepa` has none — the IBAN in `account` identifies the account | | `accountType` | string | No | `checking` or `savings`, lowercase. Send it for `ach`; some payout partners default to `checking` when it is omitted, others reject the payout without it. `sepa` does not use the distinction | | `address` | object | **Conditional** | Recipient postal address. Required for the `wire` and `swift` rails. On other rails it depends on the payout partner — see the list below. See [BeneficiaryAddress](#beneficiaryaddress) | | `travelRuleData` | object | No | Travel Rule payload | Several of these are **conditionally required** — the request schema cannot express it statically because the requirement depends on the rail and on how you supply the recipient. They are checked when the intent is submitted, before any funds move: - `country` missing, or neither `payoutMethod` nor `swiftCode` present → `beneficiary_incomplete_for_jit_payout` - a US USD payout without `routingNumber` on `ach`, or without `routingNumber` or `swiftCode` on `wire` → `beneficiary_incomplete_for_jit_payout` - `payoutMethod` is `wire` or `swift`, or only `swiftCode` is sent, and `address` is incomplete → `beneficiary_address_required_for_rail` - On every rail, the payout partner the payout routes to can require an address. Some partners require `address.line1` and `address.city` for a first-time recipient (one not yet registered with that partner). Others require `line1`, `city` and `postalCode` on every payout, and some require no address. A missing field → `beneficiary_address_required_for_rail` - A payout route that validates supported rails rejects an unsupported rail with `beneficiary_incomplete_for_jit_payout`. - These partner-specific checks run at submit. If they cannot complete, the API returns `503` — see [Infrastructure errors](#infrastructure-errors) For the `ach` rail, `accountType` is `checking` or `savings`, lowercase. Send it for `ach`; some payout partners default to `checking` when it is omitted, others reject the payout without it. For `sepa` put the IBAN in `account` — there is no routing number and no account-type distinction. `country` is required on every offramp payout; it is what pins the payout country on the order. An incomplete recipient is caught **at submit** and returned as `beneficiary_incomplete_for_jit_payout` on the intent — you do not have to wait for the payout to execute to find out. The rail decides which fields are checked: `ach` needs `routingNumber`, `sepa` does not. ::: tip The check runs twice, on purpose The same fields are re-checked when the payout actually executes. The recipient can change between submit and execution, so passing the submit-time check is not a promise that the payout will go out — it just means nothing was missing when you sent it. ::: ::: warning Recipient details are never inferred For USD offramp payouts the recipient is taken **only** from this object. There is no server-side default — if a required field is missing the payout is rejected up front rather than falling back to some other account. Sending a partial recipient does not produce a partial payout; it produces an error. ::: Sending a pre-provisioned recipient reference in `account` skips these checks — the recipient is already registered with the payout partner. ##### BeneficiaryAddress | Field | Type | Required | Description | |-------|------|----------|-------------| | `line1` | string | Yes | Street address | | `line2` | string | No | Second address line (suite, unit) | | `city` | string | Yes | City | | `state` | string | Yes | State or province | | `postalCode` | string | Yes | Postal code | **All four are required for explicit wire/swift, or when only `swiftCode` is supplied.** For other rails, address requirements depend on the selected payout partner; the table describes the complete wire address, not a universal requirement for every payout. #### Metadata | Field | Type | Description | |-------|------|-------------| | `beneficiaryRef` | string | Previously provisioned recipient reference, when supported by the configured payout integration. Obtain it through onboarding; no recipient-registration endpoint is defined in this API | | `urgency` | `immediate` \| `batch` | Omitted means immediate solving; `batch` enters a collection window and proposal selection. See [Execution strategy](https://docs.ezys.io/for-fsps/strategy.md) | | `senderKycHash` | string | Sender KYC reference | | `depositReference` | string | Deposit reference for the fiat on-ramp leg | | `currency` | string | Settlement currency hint | | `targetStablecoin` | string | Stablecoin used for the fiat on-ramp leg | | `travelRuleData` | object | Travel Rule payload | | `beneficiaryAccount` | string | **Deprecated** — use top-level `beneficiary.account` | | `beneficiaryName` | string | **Deprecated** — use top-level `beneficiary.name` | #### Permit (on-chain inputs) On-chain (`erc20`) inputs are **user-funded**: settlement never spends its own inventory on your behalf. Instead, you attach a one-time [NfxPermit2](https://github.com/soohoio/ezys-core) authorization — an EIP-712 signature that allows the escrow to pull the input amount **from `user`'s wallet** when the settlement workflow locks it. You pay no gas per intent and broadcast no transaction; the platform relays execution. **One-time prerequisite** (per wallet, per token): approve the NfxPermit2 contract on the ERC20 you will send — `ERC20.approve(nfxPermit2, largeAllowance)`. This is the only on-chain transaction you ever submit; every intent afterwards needs only a signature. To have the platform pay the gas for that approval instead, attach [`erc2612Permit`](#gasless-approve-erc2612permit). | Field | Type | Required | Description | |-------|------|----------|-------------| | `token` | string | Yes | ERC20 contract address. Must equal `inputAsset.address` | | `amountMinor` | string | Yes | Signed spending **cap**, in the input token's minor unit. Must be ≥ the input amount; settlement pulls at most this much | | `spender` | string | Yes | The HTLC escrow address for your target environment (provided during onboarding, alongside the NfxPermit2 address) | | `nonce` | string | Yes | Single-use nonce. Read the next value on-chain: `NfxPermit2.nonces(user)`. One signature = one execution; a reused nonce is rejected | | `deadline` | number | Yes | Unix timestamp in **seconds** (unlike the intent-level `deadline`, which is milliseconds). Settlement locks asynchronously after the `202` — leave generous margin (hours, not minutes), or the lock fails with `permit deadline has already expired` | | `signature` | string | Yes | Hex signature over the `PermitSingle` digest. **Must be produced by the `user` wallet** — any other signer fails on-chain verification. An EOA signature is exactly **65 bytes**; a contract wallet's signature has **no fixed length** — see [Contract wallets (EIP-1271)](#contract-wallets-eip-1271) | ##### Signing Sign the `PermitSingle` struct with `eth_signTypedData_v4` (natively supported by MetaMask, Rabby, Ledger, WalletConnect wallets — no plugin needed) or any server-side EIP-712 signer: ```ts // viem — works in the browser (walletClient from a connected wallet) // and server-side (privateKeyToAccount / KMS signer) const signature = await walletClient.signTypedData({ account: user, domain: { name: 'NfxPermit2', version: '1', chainId, // target chain verifyingContract: nfxPermit2, // NfxPermit2 address (per environment) }, types: { PermitSingle: [ { name: 'token', type: 'address' }, { name: 'amount', type: 'uint256' }, { name: 'spender', type: 'address' }, { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint256' }, ], }, primaryType: 'PermitSingle', message: { token, amount, spender, nonce, deadline }, }) ``` The REST field `permit.amountMinor` is mapped into the EIP-712 `PermitSingle.amount` value. The signature is bound to the `spender` (only the escrow can consume it), capped by that amount, expires at `deadline`, and its nonce is burned on use — a leaked signature cannot redirect funds elsewhere, and an unused one simply expires. Malformed permits are rejected with `400` before submission is accepted (e.g. `permit.token must match inputAsset.address`, `permit.signature must be a hex string`, `permit.amount must cover the ERC20 input amount`). A structurally valid but missing permit on an `erc20` input is rejected with [`permit_required`](#rejected-response). ##### Contract wallets (EIP-1271) `user` may be a smart contract wallet — a Safe, or a custody/CA vault. Verification tries ECDSA recovery first and falls back to the wallet's own [EIP-1271](https://eips.ethereum.org/EIPS/eip-1271) `isValidSignature`, so **the typed data above is unchanged**: you sign the same `PermitSingle`, and existing EOA integrations are unaffected. The one-time `ERC20.approve(nfxPermit2, …)` prerequisite applies to the **wallet** (the address holding the funds), not to its owners. For a Safe that means executing the approve as a Safe transaction. **Signature length is not fixed.** Do not validate it client-side: | Signer | Length | |--------|--------| | EOA | exactly **65 bytes** (`r \|\| s \|\| v`) | | Safe with N signing owners | owner signatures concatenated — **65 × N** bytes | | Safe whose owner is itself a contract wallet | a dynamic part is appended, so **not a multiple of 65** | | Safe with the message pre-approved on-chain | **0 bytes** (`0x`) | The API checks only that the value is even-length hex within an upper bound (2080 bytes). Validity is decided on-chain: a 65-byte signature is attempted via ECDSA first. If recovery fails or does not recover `user`, a contract wallet then falls back to EIP-1271. Any other length skips ECDSA and is checked through EIP-1271 for a contract wallet. An EOA cannot use a 64-byte compact signature (ERC-2098), because it is not an ECDSA candidate and an EOA implements no `isValidSignature`. ###### Signing with a Safe Safe does not sign the permit digest directly — it requires a signature over that digest wrapped in its own `SafeMessage` domain. **Ask the wallet for the hash instead of rebuilding the domain off-chain**; a reconstruction that is wrong by one field fails on-chain as a single `InvalidSignature`, with nothing to indicate which part was wrong. ```ts // ① the permit digest — ask NfxPermit2 const digest = await publicClient.readContract({ address: nfxPermit2, abi: [{ name: 'getPermitHash', type: 'function', stateMutability: 'view', inputs: [{ type: 'tuple', components: [ { name: 'token', type: 'address' }, { name: 'amount', type: 'uint256' }, { name: 'spender', type: 'address' }, { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint256' }] }], outputs: [{ type: 'bytes32' }] }], functionName: 'getPermitHash', args: [{ token, amount, spender, nonce, deadline }], }) // ② the hash the owners must actually sign — ask the Safe const safeMessageHash = await publicClient.readContract({ address: safe, abi: [{ name: 'getMessageHash', type: 'function', stateMutability: 'view', inputs: [{ type: 'bytes' }], outputs: [{ type: 'bytes32' }] }], functionName: 'getMessageHash', args: [encodeAbiParameters([{ type: 'bytes32' }], [digest])], }) // ③ concatenate owner signatures in **ascending owner-address order**, up to the threshold const parts = [] for (const owner of ownersSortedByAddressAscending.slice(0, threshold)) { parts.push(await owner.sign({ hash: safeMessageHash })) } const signature = concatHex(parts) // 65 × threshold bytes ``` Three things fail silently if you get them wrong, all surfacing as the same on-chain rejection: 1. **Signing the permit digest instead of the `SafeMessage` hash.** 2. **Concatenating owner signatures out of order** — ascending by owner address is required. 3. **Not reaching the threshold** — a 1-of-2 blob on a 2-of-3 Safe is rejected. Safe's EIP-1271 lives in its `CompatibilityFallbackHandler`, which must have been set when the Safe was deployed. A Safe created without a fallback handler has no `isValidSignature` at all, and the permit will fail with no indication that this is the reason. ##### Gasless approve (`erc2612Permit`) The one-time `approve` is an on-chain transaction that needs native gas. If the input token supports ERC-2612 `permit`, sign an ERC-2612 permit instead and attach it as `erc2612Permit`. The platform submits it to the token contract and pays the gas; when the allowance is already at least `value`, it submits nothing. The allowance it sets is for NfxPermit2. The escrow pull itself is still authorized by `permit` (`PermitSingle`), so always send both. The two signatures are different messages: | | `erc2612Permit` | `permit` | |---|---|---| | EIP-712 domain | The input token contract | NfxPermit2 | | Spender | NfxPermit2, set by the platform (not a request field) | The HTLC escrow, `permit.spender` | | Amount field | `value` | `amountMinor` (`PermitSingle.amount`) | | Nonce | The token's `nonces(owner)` | The NfxPermit2 nonce | | Deadline | Unix seconds | Unix seconds | | Field | Type | Required | Description | |-------|------|----------|-------------| | `owner` | string | Yes | Token owner and signer. Must equal `user` (case-insensitive) | | `token` | string | Yes | Must equal `inputAsset.address` and `permit.token` | | `value` | string | Yes | Must equal the lock amount (`inputAmountMinor`, or `input.limitAmountMinor`) and `permit.amountMinor` exactly. A larger value (a cap) is rejected with `400`, so no unused allowance is left behind | | `nonce` | string | Yes | Current `nonces(owner)` of the token contract. Independent of `permit.nonce` | | `deadline` | number | Yes | Unix seconds. Must be in the future and not later than `permit.deadline`. It bounds submission only; the allowance does not expire | | `signature` | string | Yes | Hex signature over the token's `Permit(owner, spender, value, nonce, deadline)` digest, with `spender` = NfxPermit2. Even-length hex, at most 2,080 bytes. EOA: exactly 65 bytes. Safe (EIP-1271): the owners' signatures concatenated in ascending owner-address order (130 bytes for 2-of-3; `eth_sign` signatures with `v` = 31/32 are accepted), or `0x` when the Safe approved the digest on-chain through `SignMessageLib.signMessage` | With `erc2612Permit` attached, `permit.amountMinor` must equal `value`; it is no longer a cap. The platform chooses the call by the owner's code: an address without code is treated as an EOA and the signature is split into `v`, `r`, `s`; a contract owner's signature is passed unchanged to the token's `permit(…, bytes signature)`. It checks only length and format; the token contract decides whether the signature is valid. Limits: - Exact-input only. With `output` set the intent is rejected with `gasless_exact_output_unsupported`. - The input token must be on the platform's ERC-2612 list, or the intent is rejected with `token_permit_unsupported`. A signature that is not 65 bytes also needs a token that supports the `permit(…, bytes signature)` form; otherwise the same reason is returned. - Contract wallets: only a deployed Safe with `CompatibilityFallbackHandler`. A Safe that is not yet deployed has no code and is handled as an EOA, so its signature cannot be used. - Not used for [reactor orders](#reactor-orders-same-chain-swaps). Both reasons are returned before the quote is used up. Fix the request, or drop `erc2612Permit` and approve NfxPermit2 yourself, then resubmit with the same quote. The Safe transaction that calls `signMessage` is not sponsored. #### Reactor orders (same-chain swaps) When both assets are tokens on the same chain where a reactor is deployed, and both carry `address`, the intent must carry `reactorOrder`. Get it from a firm quote requested with `swapper` — see [Quote API → On-chain swaps (reactor order)](https://docs.ezys.io/api-reference/quote-api.md#on-chain-swaps-reactor-order). `user` must be the quote's `swapper`, and `outputRecipient` (or `user`) must be its `recipient`. Attach the same `quoteId` and the same input amount. No `permit` is needed: the reactor pulls the input through the order's Permit2 witness signature. No HTLC lock or balance reservation is made. The accepted response carries one `reactor_fill` step. ```json { "intentId": "INT-5B7E0A92", "clientReference": "your-swap-id-001", "user": "0x80efbfc96a7F1d6EC95A3a42f1eBeC51955F8F68", "inputAsset": { "type": "erc20", "symbol": "USDC", "chain": "sepolia", "address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238" }, "outputAsset": { "type": "erc20", "symbol": "skbKRW", "chain": "sepolia", "address": "0x9de9474F8BbabE561A0F589b4860A1f017F3f9D2" }, "inputAmountMinor": "10000000", "minOutputAmountMinor": "13786200000000000000000", "quoteId": "fq_7c2e91d4", "deadline": 1786370030000, "settlementType": "onchain", "reactorOrder": { "order": { "info": { "reactor": "0x3B6e5C3fA7d1E2b94c08A1f5D9e6c27B8a4F1d02", "swapper": "0x80efbfc96a7F1d6EC95A3a42f1eBeC51955F8F68", "nonce": "48213907761524398126509837162049571384265907315842609783150264937180", "deadline": "1786370030" }, "exclusiveFiller": "0x8F2a6D41c9E0b7A35d1C4e8B92f06A7c3D5e1B94", "exclusivityEnd": "1786370030", "exclusivityOverrideBps": "0", "input": { "token": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", "amount": "10000000" }, "outputs": [ { "token": "0x9de9474F8BbabE561A0F589b4860A1f017F3f9D2", "amount": "13786200000000000000000", "recipient": "0x80efbfc96a7F1d6EC95A3a42f1eBeC51955F8F68" } ] }, "signature": "0xcde3b846e3efbace1c2c4443bbb5ad850b7872f253e310be2b4fdc5cbd135ef8228cd6019573b046988ca4f9c1158ead1739d8f85a96708329f54d56910dac9e1b" } } ``` The `order` is the firm-quote `reactorOrder.order` copied unchanged, and `quoteId` is the `fq_7c2e91d4` from the [firm quote example](https://docs.ezys.io/api-reference/quote-api.md#on-chain-swaps-reactor-order). The values are illustrative. An EOA signature must be exactly 65 bytes. A 64-byte compact signature is rejected. A contract wallet (EIP-1271) signature has no fixed length. An EOA signature that does not recover to `user` is rejected at submit as `reactor_order_invalid` when the platform can check on-chain whether `user` is a contract. If that lookup fails, the check is left to the chain: the intent can be accepted and the order then fails on-chain. After acceptance, `status` becomes `completed` when settlement is recorded. `phase` can remain `intent`. `events[]` holds only the `intent` event. `providerReference` and `escrow` are `null`. No receipt is issued. See the [Status API](https://docs.ezys.io/api-reference/status-api.md). ### Accepted Response ```json { "intentId": "INT-2F8A3B1C", "status": "accepted", "planId": "DVP-EFB9DF2D8B9ACE242BBB3E4DA220ED9C83FEB7EE4FB5873B8424D421D5842F91", "solverId": "solver-1", "path": { "intentId": "INT-2F8A3B1C", "solverId": "solver-1", "outputAmountMinor": "975", "surplusAmountMinor": "75", "estimatedFillTime": 3600, "steps": [ { "type": "fiat_gateway", "asset": "USD", "amountMinor": "975", "provider": "fiat-gateway", "currency": "USD", "beneficiaryAccount": "4412789033" } ], "score": 0, "expiresAt": 1737123756789 } } ``` > The response is returned **directly** — it is not wrapped in a `{ success, data }` envelope. All four amount fields — `inputAmountMinor`, `path.outputAmountMinor`, `path.surplusAmountMinor`, and `path.steps[].amountMinor` — are **integer strings in minor units**. The input amount uses `inputAsset`; path amounts use `outputAsset`. These flat fields do not include `exponent`: use the asset's ISO 4217 scale for fiat or token decimals for crypto. Firm-quote amounts and `fees[]` instead carry an explicit `currency` and `exponent`. For example, a USD payout of **8.77** with a minimum of **8.65** has the following path fields: ```json { "outputAmountMinor": "877", "surplusAmountMinor": "12", "steps": [ { "type": "fiat_gateway", "asset": "USD", "amountMinor": "877", "provider": "fiat-gateway", "currency": "USD" } ] } ``` USD has exponent 2, so `"877"` means USD 8.77. `amountMinor: "8.77"` is invalid. For an exact-output intent funded with EUR 12.34, the computed `inputAmountMinor` is `"1234"`. | Field | Type | Description | |-------|------|-------------| | `intentId` | string | Echoes the submitted ID | | `status` | string | `accepted` | | `planId` | string | Settlement plan ID. Treat it as an opaque string. It is `DVP-` followed by 64 upper-case hex characters for a settlement execution, or `RCT-` followed by the order hash without `0x`, as 64 upper-case hex characters, for a Reactor execution | | `solverId` | string | The solver that accepted the Intent | | `inputAmountMinor` | string | Present for exact-output intents: computed input required to fill the intent (≤ `input.limitAmountMinor`). Omitted for exact-input intents | | `fees` | array | Present when the intent references a firm `quoteId`. Itemized from the locked quote, with the **same taxonomy as the [receipt](https://docs.ezys.io/api-reference/receipts-api.md#fees)**: `[{ type, amountMinor, currency, exponent, collection }]` (amounts follow the [Money & Amounts](https://docs.ezys.io/api-reference/money-and-amounts.md) convention), `type` = `protocol_fee` \| `payout_fee` \| `network_fee`. Quote-time values are the locked charge; the receipt reports what was actually charged — reconcile the two per `type`. FX spread is **not** a fee item (it is a component of the all-in rate). Kept **separate from principal**, not baked into the amounts above | | `path` | object | The chosen execution plan. See [SolvedPath](#solvedpath) | #### SolvedPath | Field | Type | Description | |-------|------|-------------| | `intentId` | string | The Intent this path fulfils | | `solverId` | string | Solver that produced the path | | `outputAmountMinor` | string | Final amount delivered to the recipient, in the output asset's minor unit | | `surplusAmountMinor` | string | Output above the requested floor: `outputAmountMinor − minOutputAmountMinor` (or `output.limitAmountMinor` in the bounds form). Always in the output asset's minor unit; zero when exact-output target and floor are equal | | `estimatedFillTime` | number | Estimated fill time in seconds (≈30 for crypto output, ≈3600 for fiat output) | | `steps` | array | Execution steps. See [Fill steps](#fill-steps) | | `score` | number | Solver-internal score (lower is better) | | `expiresAt` | number | Path validity deadline (Unix ms; ≈ now + 5 min) | #### Fill steps Each `path.steps[]` entry is one of the following types: `reserve`, `dex_swap`, `cex_otc`, `bridge`, `cow_match`, `fiat_gateway`, `reactor_fill`. Every step carries `type`, `asset`, `amountMinor`, and optional `chain`; type-specific fields are added per kind. The solver currently emits one step per Intent: - **`fiat_gateway`** — for `fiat` output: `{ type, asset, amountMinor, provider, currency, beneficiaryAccount }` - **`reactor_fill`** — for same-chain crypto legs with a configured reactor and both token addresses supplied: `{ type, asset, amountMinor, chain, reactor, executor, inputAsset, outputAsset }`. The asset fields contain token addresses; `amountMinor` uses the output token's decimals. - **`reserve`** — for other crypto output: `{ type, asset, amountMinor, chain, source }` (filled from the solver's own reserve) ### Compatibility with earlier responses Earlier solver versions returned fiat major-unit decimals in these four fields despite their `AmountMinor` names. The corrected response uses integer minor-unit strings throughout; remove any client workaround that treats these fields as major units. The response shape is unchanged, and no `exponent` field has been added to steps. Retries use the assets saved at original acceptance. Reusing an `intentId` with a different request does not change those amounts. Transport idempotency still rejects a changed body under the same `X-Idempotency-Key`. A response cached before the upgrade can replay the old format for the remainder of its [24-hour idempotency window](https://docs.ezys.io/api-reference/idempotency.md). During a rolling deployment, older instances can also return the old format until they have been replaced. Older instances can also **accept new intents during the rolling deployment** without saving the asset context. Those records have the same replay limitation as pre-upgrade records: once the old response cache expires, retries on upgraded instances return the 409 below. Completing the rollout does not backfill those records or repair their accepted-response replay. If a pre-upgrade accepted record lacks the original asset context and the transport cache cannot replay it, the API returns `409 INTENT_AMOUNT_CONTEXT_UNAVAILABLE`. **The intent was already accepted**; this is not a rejection or evidence of failed settlement. Check [execution status](https://docs.ezys.io/api-reference/status-api.md) using the original `clientReference` (or a known execution ID). Do not create a new `intentId` to work around this response. ### Rejected Response A rejection responds HTTP `422` with this body: ```json { "intentId": "INT-2F8A3B1C", "status": "rejected", "reason": "no_liquidity" } ``` A rejected submission itself moves no funds. One caveat: `intent_already_submitted` means an earlier submission with the same `intentId` was already accepted, so that execution may be moving funds — query it with the original submission's `clientReference`. A rejection can still use up an attached firm quote: the quote is single-use and is consumed before the pricing checks. The **Uses up quote** column says which reasons do this; it applies only when `quoteId` is attached. When the quote is used up, resubmit with a fresh quote. The checks run in the order of the table, and the first failure is returned. One exception: the asset checks (`chain_required` to `unregistered_token`) run in full on the input asset before the output asset. | `reason` | Raised when | Uses up quote | Action | |----------|-------------|:---:|--------| | `intent_identity_invalid` | The request could not be identified on the Ezys side. A malformed `intentId` is not this reason; it returns `400` | No | Contact Ezys; changing the request does not help | | `intent_in_progress` | Another submission with the same `intentId` is still processing or settling | No | Retry later with the same `intentId`. If this keeps returning, query [execution status](https://docs.ezys.io/api-reference/status-api.md) and contact Ezys if no execution exists. Do not create a new `intentId` | | `beneficiary_required_for_remittance` | `outputRecipient` ≠ `user`, but `beneficiary.account` / `beneficiary.name` is missing | No | Complete the beneficiary | | `chain_required` | An on-chain asset has no `chain`. Input is checked first, then output | No | Fix the asset | | `unsupported_chain` | The asset's chain is not supported | No | Fix the asset | | `invalid_asset_address` | The asset address is malformed | No | Fix the asset | | `unregistered_token` | The token address is not in the registry | No | Fix the asset | | `permit_required` | The input is an on-chain (`erc20`) asset, the intent is not a reactor order, and no `permit` was attached — see [Permit (on-chain inputs)](#permit-on-chain-inputs) | No | Sign and attach the permit | | `gasless_exact_output_unsupported` | `erc2612Permit` is attached to an exact-output intent (`output` set) | No | Use exact-input, or drop `erc2612Permit` and approve NfxPermit2 yourself; the same quote can be reused | | `token_permit_unsupported` | `erc2612Permit` is attached, but the input token is not on the platform's ERC-2612 list, or the signature is not 65 bytes and the token does not support `permit(…, bytes signature)`. See [Gasless approve](#gasless-approve-erc2612permit) | No | Drop `erc2612Permit` and approve NfxPermit2 yourself; the same quote can be reused | | `beneficiary_incomplete_for_jit_payout` | Recipient details are sent inline, but `country` or the rail is missing, or a payout-partner rule fails (an unsupported rail, or a US USD `wire` without `routingNumber` or `swiftCode`). See [Beneficiary](#beneficiary) | No | Fix the beneficiary; the same quote can be reused | | `beneficiary_address_required_for_rail` | The rail is `wire` or `swift` and `beneficiary.address` is incomplete, or the payout partner requires an address field that is missing. See [Beneficiary](#beneficiary) | No | Fix the address; the same quote can be reused | | `quote_provider_mismatch` | The recipient would be paid through a different payout provider than the one quoted | No | Contact Ezys | | `reactor_order_required` | The intent is reactor-eligible but carries no `reactorOrder` | No | Attach the signed order | | `reactor_order_invalid` | The order or signature is malformed, or an EOA signature does not recover to `user` | No | Fix the order; the same quote can be reused | | `intent_already_submitted` | The `intentId` is already held by an earlier order or settlement | No\* | Query [execution status](https://docs.ezys.io/api-reference/status-api.md) | | `reactor_order_expired` | Open order only: the order `deadline` has passed | No | Sign a new order | | `reactor_order_deadline_too_far` | Open order only: the order `deadline` is more than 3600 seconds ahead | No | Sign with a nearer `deadline` | | `reactor_order_nonce_used` | Open order only: the `nonce` is already used | No | Sign with a new `nonce` | | `quote_id_required_for_named_order` | A reactor order filled by Ezys (`exclusiveFiller` set) has no `quoteId` | No | Attach the quote | | `amount_bounds_range_not_supported` | A bound's `targetAmountMinor` differs from its `limitAmountMinor` | No | Fix the bounds | | `quote_not_found` | No quote with that ID | No | Fix `quoteId` or request a new quote | | `quote_pair_mismatch` | The quote's pair differs from the intent's pair | No | Fix the request or request a new quote | | `quote_amount_mismatch` | The quote's amount differs from the intent's amount | No | Fix the amount or request a new quote | | `quote_already_used` | The quote was already consumed | n/a | Request a new quote | | `quote_expired` | The quote's `validUntil` has passed | n/a | Request a new quote | | `input_limit_required_for_exact_output` | Exact-output intent without `input.limitAmountMinor` | Yes | Fix the request and use a new quote | | `no_liquidity` | No route can fill the request. Only without `quoteId` | No | Submitting later is a new request | | `above_maximum_input` | The input required to deliver the fixed output exceeds `input.limitAmountMinor` (exact-output intents) | Yes | Request a new quote | | `below_minimum_output` | The computed output is less than `minOutputAmountMinor` / `output.limitAmountMinor` (exact-input intents) | Yes | Request a new quote | | `beneficiary_account_required_for_fiat_output` | `outputAsset.type` is `fiat`, but no beneficiary account was provided | Yes | Fix the beneficiary and use a new quote | | `reactor_order_mismatch` | The signed order differs from the order re-derived from the quote | Yes | Request a new quote and sign its `reactorOrder` | | `no_batch_solution` | Batch intents (`metadata.urgency: "batch"`) only: the batch produced no solution | No | Try again later | \* `intent_already_submitted` can also fire after the quote is consumed, when a settlement for the `intentId` already exists. In that case the quote is used up. The three **open order** reasons apply to open orders: reactor orders whose `exclusiveFiller` is the zero address. An open order carries its own price and no quote, so these reasons never use up a quote. A `reactor_order_mismatch` on an open order does not use up a quote either. Treat any `reason` not listed here as a non-retryable rejection. ### Infrastructure errors Infrastructure failures (e.g. misconfiguration, or the settlement workflow failing to start) propagate as `5xx`. When settlement startup fails after a balance reservation, the reservation is released before the error surfaces. | HTTP | When | Action | |------|------|--------| | `400` `beneficiary rejected by the payout preflight: …` | The payout partner rejected the recipient input itself | Fix the beneficiary | | `503` `SERVICE_UNAVAILABLE` | Either the request was not accepted (the recipient could not be checked with the payout partner, or the idempotency record could not be started), or it was accepted but its response could not be recorded. The body does not say which. In the second case a retry with the same key returns `409 IDEMPOTENCY_KEY_PROCESSING` for up to 24 hours — see [Idempotency](https://docs.ezys.io/api-reference/idempotency.md#request-lifecycle) | See [Recovering from `503`](#recovering-from-503) | #### Recovering from `503` 1. Resend the same request with the same `X-Idempotency-Key`. 2. If that returns `409 IDEMPOTENCY_KEY_PROCESSING`, the outcome of the first request is unknown — it does not mean the intent was accepted. Query `GET /v1/executions/{clientReference}`. If the execution exists, do not resend. 3. If it is not found, resend the same request — same `intentId` and `clientReference` — with a new `X-Idempotency-Key`. The same `intentId` is never accepted twice: if it was already accepted, you receive the stored acceptance (or `intent_in_progress`). An execution can take a moment to appear after acceptance, so a `404` alone does not prove the intent was not accepted — step 3 is safe either way. Request validation runs first, so the resend can return `400` if `permit.deadline` has passed in the meantime; that `400` does not mean the first request was not accepted. ## Status & lifecycle Submission returns `accepted` or `rejected`. After acceptance, settlement proceeds asynchronously through the applicable HTLC or Reactor path. Use the [Status API](https://docs.ezys.io/api-reference/status-api.md) (`GET /v1/executions/{reference}`) and [webhooks](https://docs.ezys.io/api-reference/webhooks.md) to track the execution. An on-chain settlement anchor records completion evidence; it is not a separate intent lifecycle API. ## Cancellation There is no REST cancel or confirm endpoint for an accepted intent, and no on-chain intent-cancellation contract. FIX order cancellation is a separate institutional integration and does not cancel an already executed transfer. When an execution locks input in HTLC escrow, an active lock can be refunded after its current timelock expires. The sender or an authorized settlement operator can call refund; funds always return to the sender. A refund does not change the execution status; it sends the [`escrow.refunded`](https://docs.ezys.io/api-reference/webhooks.md) webhook and sets the execution's `escrow.refundTxHash`. See [delayed confirmation and refunds](https://docs.ezys.io/core-concepts/execution-settlement.md#delayed-confirmation-and-refunds). ## Notes - All amounts are integer strings in minor units — see [Money & Amounts](https://docs.ezys.io/api-reference/money-and-amounts.md). No floating-point. - `actorType` defaults to `institution`; `side` defaults to `sell`. - `AssetDescriptor.decimals` is request-only. Firm quotes honor it (and require it for an unlisted token). Intents ignore it and apply the decimals the platform knows for the token symbol; do not rely on an override there. Omit it for `fiat`. ## Prepare technical fields ``` POST /v1/intents/technical-fields ``` Call before signing an NfxPermit2 authorization. Requires `X-API-Key` with `intents:write`; HMAC signing is not required. Returns `200` without moving funds. ```json { "chain": "avalanche-fuji", "owner": "0x2521f0a5a2B78dE703510798342AbaE2F95872B5" } ``` `chain` accepts a registered chain name, integer chain ID, or numeric string. `owner` must be a non-zero EVM address. | Response field | Type | Use | |---|---|---| | `chainId` | integer | EIP-712 domain chain ID | | `escrowAddress` | string | `permit.spender` | | `permit2Address` | string | NfxPermit2 verifying contract and ERC20 approval spender | | `permitNonce` | string | Current on-chain nonce for this owner | The nonce is a read of current state, not a reservation. Concurrent submissions from the same owner can consume it; serialize authorization creation and refresh after use. If using idempotency on this read-like POST, a replay can contain a stale nonce: use a new key for a fresh read. Invalid chain/owner returns `400`; unavailable contract configuration returns `500`; an on-chain read failure returns `503`. These use the framework error body. Authentication and idempotency can return their own error envelopes before the resolver runs. --- > Source: [/api-reference/status-api](https://docs.ezys.io/api-reference/status-api.html) · Markdown: https://docs.ezys.io/api-reference/status-api.md > Section: API Reference · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Status API > `GET /v1/executions/{reference}` is available. Use the base URL supplied during onboarding. The separate `GET /v1/intents/{intentId}/status` surface is not part of the current public API. Query the current state and recorded phase history of a single **execution** — one end-to-end fill spanning up to three phases: **intent** (acceptance), **offramp** (input locking and payout confirmation), and **payout** (fiat delivery to the beneficiary). This API is the authoritative source for resolving timeouts and undetermined results: if you miss a submission response or end up with an undetermined outcome, re-query here rather than re-submitting. It returns both the aggregate status and the per-phase event history, so callers can reconstruct exactly where an execution stands. ## Get execution status ``` GET /v1/executions/{reference} ``` Returns the aggregate status of one execution, with the per-phase transition history (`events[]`). When `status` is `failed`, the `events[]` entry with `state: failed` (or `reversed`) shows which phase failed. > Read endpoints are authenticated with an API key (`X-API-Key`) — see [Authentication](https://docs.ezys.io/api-reference/authentication.md). ### Path & query parameters | Parameter | In | Required | Description | |-----------|----|----------|-------------| | `reference` | path | Yes | Your own reference (`clientReference`, echoed from submission) **or** the Ezys-issued execution ID | | `refType` | query | No | `clientReference` (default) or `executionId`. Selects which namespace `{reference}` is resolved in — resolution is explicit, there is no prefix-based inference. An unrecognized value returns `400` | ### Response ```json { "clientReference": "NR-2026-000123", "reference": "exe_7d0c3b52-9a41-4e8f-b6d2-1f05c8a9e347", "providerReference": "whn_pay_9f2c1d", "status": "in_progress", "phase": "payout", "events": [ { "phase": "intent", "state": "initiated", "occurredAt": "2026-07-15T02:10:04.113Z", "observedAt": "2026-07-15T02:10:04.113Z", "isTerminal": false, "isReversible": false }, { "phase": "offramp", "state": "completed", "occurredAt": "2026-07-15T02:10:11.902Z", "observedAt": "2026-07-15T02:10:12.048Z", "isTerminal": true, "isReversible": false }, { "phase": "payout", "state": "initiated", "occurredAt": "2026-07-15T02:10:14.330Z", "observedAt": "2026-07-15T02:10:15.101Z", "isTerminal": false, "isReversible": false } ], "finality": null, "escrow": { "lockTxHash": "0x0000000000000000000000000000000000000000000000000000000000000003", "refundTxHash": null } } ``` | Field | Type | Description | |-------|------|-------------| | `clientReference` | string \| null | Your reference key, echoed back verbatim on every read surface (status, receipts, reconciliation) | | `reference` | string | Ezys-issued execution ID | | `providerReference` | string \| null | Downstream rail / payout-provider reference, when one exists. `null` for Reactor executions | | `status` | string | Aggregate result: `in_progress`, `completed`, or `failed`. Computed from execution and payout state using the [priority rules](#aggregate-status), not the last event. A refund of the input asset (the `escrow.refunded` webhook) does not change the status | | `phase` | string | The phase the execution is currently in: `intent`, `offramp`, or `payout`. A Reactor execution can stay `intent` even when completed; use `status` | | `events` | array | Transition history, grouped by phase. See [Events](#events). A Reactor execution has only the `intent` event | | `finality` | object \| null | On-chain anchor recording completion — `txHash`, `contractAddress`, `chainId`. `txHash` is the anchor transaction, not a reactor `Fill`. A Reactor execution is anchored without a receipt. For an execution with a receipt, it is the same anchor the [Receipts API](https://docs.ezys.io/api-reference/receipts-api.md#anchoring) returns. `finality` can be `null` even when `status` is `completed`, and remains `null` after `offramp.disputed` | | `escrow` | object \| null | Escrow of the input asset. `lockTxHash` is the transaction that locked the input asset in escrow, and `refundTxHash` is the transaction that refunded it from escrow. The refund returns the asset to the address it was pulled from when it was locked — for `erc20` inputs, the intent's `user`, which signed the permit. The platform sends the lock transaction, so its `from` address is not `user`. The refund transaction is sent either by the platform after the deadline (`from` is a platform address) or by `user` directly (`from` is `user`). `lockTxHash` is set once the input asset is locked (the `offramp.initiated` webhook) and `refundTxHash` once the refund is confirmed (the `escrow.refunded` webhook); neither changes afterwards. `lockTxHash` is `null` when the lock transaction could not be identified, and `refundTxHash` is `null` until a refund. `escrow` is an object when either is set, and `null` before the lock, for executions that ended without a lock, and for Reactor executions. For on-chain inputs, the lock transaction is sent to the HTLC escrow on the input asset's chain — the permit `spender` (see [Permit](https://docs.ezys.io/api-reference/intent-api.md#permit-on-chain-inputs)). | ### Aggregate status The execution result is computed independently of event ordering. The first matching rule wins: | Condition | Aggregate status | |---|---| | Payout completed and execution marked disputed | `completed` | | Execution marked failed or disputed | `failed` | | Payout completed | `completed` | | Payout failed or reversed | `failed` | | No payout leg, and settlement completed (including Reactor) | `completed` | | Otherwise | `in_progress` | Emitting a confirmation-timeout dispute notification does not itself change the stored execution state. An independent execution failure still takes precedence over payout state. After `offramp.disputed`, payout delivery can change `status` to `completed` while the offramp event remains `failed`, with no receipt or finality anchor. ### Events Each `events[]` entry is one observed state transition: | Field | Type | Description | |-------|------|-------------| | `phase` | string | `intent`, `offramp`, or `payout` | | `state` | string | Phase-specific state. See [Phase & state model](#phase-state-model) | | `occurredAt` | string | When the transition happened at the source (e.g. vendor webhook `created` timestamp) | | `observedAt` | string | When Ezys recorded it. A large gap from `occurredAt` can indicate delayed vendor notification | | `isTerminal` | boolean | Ezys will not advance **this event's phase** any further — the signal to stop polling that phase. Scoped to the phase, not to the execution as a whole | | `isReversible` | boolean | Whether this API's normalized state machine formally permits a transition to `reversed` from this state. Not a prediction, and not a guarantee that the underlying rail supports reversal — see [Payout finality](#payout-finality) | `offramp` and `payout` events are appended, never rewritten. The `intent` phase is different: it is a single entry that reflects the current intent state, so when the intent moves from `initiated` to `completed` or `failed`, that entry's `state` and `occurredAt` change rather than a new entry being added. Out-of-order vendor delivery is resolved internally — the recorded state never regresses. `events[]` is grouped by phase. Within a phase, events are in progress order: `initiated`, `in_progress`, `completed` or `failed`, then `reversed`. Phases are ordered by the `occurredAt` of each phase's first event, so `occurredAt` is not guaranteed to ascend across phase boundaries. A Reactor execution has only the `intent` event. ### Phase & state model `events[].state` is a **closed vocabulary shared by all phases** — exactly these five values: | State | Terminal | Reversible | |-------|----------|------------| | `initiated` | no | no | | `in_progress` | no | no | | `completed` | yes | only in the `payout` phase | | `failed` | yes | no | | `reversed` | yes | no | `isTerminal` is scoped to **the phase the event belongs to**, not to the execution as a whole — `offramp.completed` is terminal for the off-ramp leg; payout delivery normally precedes this confirmation. It reports that Ezys will not advance that phase any further, so you can stop polling it. #### Payout finality A `payout` event with `state: completed` and `isTerminal: true` is a **delivered payout** — the funds reached the beneficiary and Ezys does not move that payout again. Settle on that combination. `isReversible` describes the shape of the state machine, not an expectation about a given payout. `completed → reversed` is the single edge that leads to `reversed`, so `payout` + `completed` is the only state the flag can be `true` for. It is **not** a forecast that a reversal will happen, and **not** a guarantee that a payout rail supports chargeback or refund — no supported rail records that transition today. Do not hold a delivered payout open in your books on the strength of this flag. Not every phase emits every value; the table above is the closed union across phases. Each phase uses these states: | Phase | States | |-------|--------| | `intent` | `initiated`, `completed`, `failed` | | `offramp` | `initiated`, `completed`, `failed` | | `payout` | `initiated`, `in_progress`, `completed`, `failed`, `reversed` | `reversed` is reserved; no rail produces it today. After `offramp.disputed`, the `offramp` phase remains `failed`. A later delivered payout has `payout` state `completed`; use the aggregate `status` for the current result. - The **offramp** track follows settlement: `initiated` when the input asset is locked in escrow, `completed` when the payout has been confirmed and matched against the locked amount (not when an FX trade fills), and `failed` when the payout instruction could not be delivered or the confirmation was disputed (`offramp.disputed`). - **`rejected` is not an event-stream state.** A rejected attempt creates no new execution. However, `intent_already_submitted` or `intent_in_progress` can refer to an earlier submission that is already executing. Query it using the original `clientReference`; a rejection does not guarantee that the lookup returns `404`. ## Get intent status (lightweight) ``` GET /v1/intents/{intentId}/status ``` A lightweight, intent-only status check — returns the aggregate status without the full `events[]` history. This endpoint is planned for **public** exposure (reduced authentication scope relative to the execution endpoint above; the exact auth split is defined with the gateway authentication work). ## Errors Failed lookups return errors in the format described in [Response Format](https://docs.ezys.io/api-reference/overview.md#response-format). An unknown reference — or one that belongs to a different partner — returns `404`; the two are indistinguishable by design, so a reference cannot be probed for existence across tenants. `refType` defaults to `clientReference` when omitted; an unrecognized `refType` value returns `400`. | Status | Code | When | |--------|------|------| | `400` | `invalid_request` | Unrecognized `refType` | | `401` | `UNAUTHORIZED` | Missing or invalid API key | | `403` | `FORBIDDEN` | The key lacks the `executions:read` scope | | `404` | `execution_not_found` | Unknown reference, or one belonging to another tenant | | `500` | `execution_state_corrupt` | A stored state is outside the documented set. Loud by design; retrying does not help — contact Ezys | | `500` | — | Other server errors return `{ "statusCode": 500, "message": "Internal server error" }` without the error envelope. A `500` without `error.code` is unclassified: retry with backoff, and contact Ezys if it persists | ## Notes - All timestamps are ISO-8601 UTC strings. - `clientReference` is echoed on every read surface — status, receipts, and reconciliation — so your ledger can join on a single key. - Batch lookup (multiple references per call) is planned alongside the reconciliation surface — see [Receipts & Reconciliation](https://docs.ezys.io/api-reference/receipts-api.md). --- > Source: [/api-reference/receipts-api](https://docs.ezys.io/api-reference/receipts-api.html) · Markdown: https://docs.ezys.io/api-reference/receipts-api.md > Section: API Reference · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Receipts & Reconciliation `GET /v1/executions/{reference}/receipt` returns a stored receipt after eligible payout execution completes and receipt issuance succeeds. Payout completion, receipt availability, and on-chain anchoring are separate milestones. Reconciliation endpoints are not yet public. The current body version is `ezys-receipt-1`. It contains payout amounts, available FX records, and itemized fees. It does not contain a beneficiary block. ## Get receipt ``` GET /v1/executions/{reference}/receipt ``` Returns the normalized receipt for one **`completed`** execution. `{reference}` resolution follows the same rules as the [Status API](https://docs.ezys.io/api-reference/status-api.md#path-query-parameters) (`refType` — `clientReference` (default) or `executionId`). Track progress on the [Status API](https://docs.ezys.io/api-reference/status-api.md) until the receipt is issued. | Status | Code | When | |--------|------|------| | `400` | `invalid_request` | Unrecognized `refType` | | `401` | `UNAUTHORIZED` | Missing or invalid API key | | `403` | `FORBIDDEN` | The key lacks the `executions:read` scope | | `404` | `receipt_not_found` | Unknown reference, or one belonging to another tenant | | `409` | `receipt_not_issued` | The reference is valid but the execution has not reached `completed` — including one that terminally `failed` before completion — or has `offramp.disputed`, which has no receipt even if its payout completes — and for a Reactor (same-chain swap) execution, which has no payout and never gets a receipt, even when completed. A partial body is never returned in its place | | `500` | `receipt_corrupt` | The stored body failed to read back as a JSON object. Loud by design: a malformed receipt is never silently reshaped | | `500` | — | Other server errors return `{ "statusCode": 500, "message": "Internal server error" }` without the error envelope. A `500` without `error.code` is unclassified: retry with backoff, and contact Ezys if it persists | **An execution has at most one issued receipt.** This version covers a single payout leg: a `clientReference` maps to at most one payout, so a receipt never combines legs. > Read endpoints are authenticated with an API key (`X-API-Key`) — see [Authentication](https://docs.ezys.io/api-reference/authentication.md). ### Response ```json { "_schemaVersion": "ezys-receipt-1", "clientReference": "NR-2026-000123", "provider": "hopnow", "offramp": { "fromAsset": "USDC", "toAsset": "USD", "fromAmountMinor": "10003250000", "toAmountMinor": "1000004", "fxRate": "0.99968", "roundingRule": "floor", "occurredAt": "2026-07-15T02:10:11.902Z" }, "payout": { "amountMinor": "1000004", "currency": "USD", "exponent": 2, "bankRef": "FEDWIRE-20260715-00123", "occurredAt": "2026-07-15T02:14:02.000Z" }, "fees": [ { "type": "protocol_fee", "amountMinor": "1001", "currency": "USD", "exponent": 2, "collection": "deducted" }, { "type": "payout_fee", "amountMinor": "2500", "currency": "USD", "exponent": 2, "collection": "deducted" }, { "type": "network_fee", "amountMinor": "0", "currency": "USDC", "exponent": 6, "collection": "absorbed" } ], "issuedAt": "2026-07-15T02:14:02.000Z", "txHash": "0x9c1d8f2e…", "finality": { "txHash": "0x9c1d8f2e…", "contractAddress": "0x7b2f…c41a", "chainId": 127 } } ``` | Field | Type | Description | |-------|------|-------------| | `_schemaVersion` | string | Body-schema version — currently `ezys-receipt-1`. **This is the freeze unit**: it changes only when the field set changes, so branch your parser on it rather than on field presence | | `clientReference` | string | Your reference key, echoed verbatim — the join key against your ledger | | `provider` | string | Payout provider that executed the delivery (normalized identifier) | | `offramp` | object \| null | The selected FX record, or `null` when no FX record is associated with the payout. See [Offramp block](#offramp-block) | | `payout` | object | The fiat delivery that actually happened. See [Payout block](#payout-block) | | `fees` | array | Fees actually charged (or absorbed), itemized and **kept separate from principal**. An empty array means "explicitly no fees". See [Fees](#fees) | | `issuedAt` | string | Recorded payout completion time, also used as `payout.occurredAt` | | `txHash` | string | Anchoring transaction hash. Omitted until the anchor is confirmed | | `finality` | object | Ledger anchor — `txHash`, `contractAddress`, `chainId`. Present under the same condition as `txHash`. See [Anchoring](#anchoring) | `txHash` and `finality` are **response metadata, not body**: the receipt content hash is computed over the body *without* them, so the body is byte-identical before and after anchoring. ### Offramp block The block is populated from an associated FX record: an executed record is preferred, otherwise the latest quoted record is used. It is `null` when no such record exists, including on some crypto off-ramp routes. **A null block does not prove there was no conversion.** Use `payout` for the delivered fiat amount. | Field | Type | Description | |-------|------|-------------| | `fromAsset` / `toAsset` | string | Conversion pair (e.g. `USDC` → `USD`) | | `fromAmountMinor` / `toAmountMinor` | string | Amounts in minor units, integer strings — see [Money & Amounts](https://docs.ezys.io/api-reference/money-and-amounts.md) | | `fxRate` | string | Rate recorded in the selected FX record | | `roundingRule` | string | Rounding rule recorded in the selected FX record | | `occurredAt` | string | FX execution timestamp when available, otherwise its quote timestamp | ### Payout block | Field | Type | Description | |-------|------|-------------| | `amountMinor` | string | Amount delivered to the beneficiary, minor units | | `currency` | string | Delivery currency (ISO 4217) | | `exponent` | integer | Decimal places of `currency` — apply it to read `amountMinor` | | `bankRef` | string \| null | Rail-level reference of the credit (e.g. wire reference). `null` when the provider returns none | | `occurredAt` | string | Confirmation time of the delivery | The current receipt does not return beneficiary names, account numbers, or account hashes. Do not require a `payout.beneficiary` field. ### Fees Each `fees[]` entry is one fee item: | Field | Type | Description | |-------|------|-------------| | `type` | string | Closed set: `protocol_fee` — the Ezys network fee for executing the intent; `payout_fee` — the payout rail / provider charge, omitted when the provider charges no per-transaction fee; `network_fee` — on-chain gas for the settlement legs | | `amountMinor` | string | Amount in minor units of this item's `currency` — items may be denominated in different currencies, so sum only per currency | | `currency` | string | Currency / ticker of this item. Not always ISO 4217 — `network_fee` is denominated in the input asset, so a token ticker such as `USDC` is a valid value | | `exponent` | integer | Decimal places of this item's `currency` — apply it to read `amountMinor` | | `collection` | string | Closed set of two: `deducted` — taken in-flow from the transferred asset (transaction-fee style); `absorbed` — fronted by Ezys and reported for transparency, not charged | Two invariants: - **Fees are never baked into the principal amounts** in the `offramp` / `payout` blocks. - **FX spread is not a fee item.** `offramp.fxRate` is the rate from the selected FX record; the spread is a component of that rate, not a separately charged fee. (A mid-rate reference for rate verification belongs to the quote surface, not the receipt.) The `protocol_fee` is computed on the output amount, rounded up to the output currency's minor unit, and denominated in the output currency. It is collected transaction-fee style (`collection: "deducted"`). ### Anchoring Receipt issuance does not wait for the finality transaction. `txHash` and `finality` are added to the response only after a confirmed anchor is available; the stored receipt body stays unchanged. There are **two different hashes**: 1. The receipt content hash is **SHA-256** of its JSON body, with object keys sorted recursively and array order preserved. Exclude the response-only `txHash` and `finality` fields. 2. The anchor records a **Keccak-256 evidence hash**. Its input includes the receipt content hash together with settlement identifiers, status, and settlement legs. Consequently, hashing the receipt alone does **not** produce the on-chain evidence hash. The current receipt endpoint does not return the complete evidence payload or an inclusion proof, so this response alone is insufficient to independently reconstruct and verify that anchor. `finality` identifies the confirmed transaction, contract, and chain for reconciliation. ## Reconciliation `GET /v1/reconciliation` and daily reconciliation report endpoints are not implemented public surfaces. Reconcile using your persisted `clientReference`, execution status, received events, and available receipts. Do not build against a future response schema. ## Notes - Amounts are integer strings in the documented minor unit. Receipt generation rejects amounts that cannot be represented exactly; it does not silently round them to fit. - A receipt is immutable once issued. The current version has no revision, correction, or credit-note mechanism. - A Reactor swap has no payout receipt. A disputed execution also has no receipt or finality anchor, even when its payout status becomes completed. - For crypto off-ramp payouts, `payout.amountMinor` uses the recorded delivered fiat amount, not the source token amount. Missing or inconsistent delivery evidence can prevent receipt issuance. --- > Source: [/api-reference/webhooks](https://docs.ezys.io/api-reference/webhooks.html) · Markdown: https://docs.ezys.io/api-reference/webhooks.md > Section: API Reference · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Webhooks Ezys sends an HTTPS `POST` to your registered endpoints when an execution changes state. Each request carries one event. The event's `data.object` is the execution snapshot returned by the [Status API](https://docs.ezys.io/api-reference/status-api.md) at the time of the event. ## Quick start 1. Register an endpoint with `POST /v1/webhook_endpoints`. Store the `secret` from the response. It is returned only once. 2. Call `POST /v1/webhook_endpoints/{endpointId}/test`. Your URL receives a `test.ping` event containing a token. 3. Send the token to `POST /v1/webhook_endpoints/{endpointId}/verify`. The endpoint becomes `active`. 4. In your receiver, verify the signature on the raw body, store the event, and respond with `2xx` within 15 seconds. Write operations are signed in the same way as `POST /v1/intents`. See [Authentication](#authentication). ## Credentials and scopes | Credential | Format | Used for | |---|---|---| | API key | `ezys_{env}_…` | `X-API-Key` header on every request | | Signing secret | 64 hex characters | `X-Signature` header on write requests. See [Request Signing](https://docs.ezys.io/api-reference/authentication.md#request-signing) | | Webhook signing secret | `whsec_…` | Verifying webhooks you receive. One per endpoint | Webhooks are signed with the endpoint's webhook signing secret, not with the API key's signing secret. | Scope | Operations | |---|---| | `webhooks:read` | `GET` operations of the [Management API](#management-api) | | `webhooks:write` | `POST`, `PATCH` and `DELETE` operations of the Management API | A request without the required scope returns `403 FORBIDDEN`. ## Endpoints ### Status | Status | Receives events | Next status | |---|---|---| | `pending_verification` | No | `active` after `/test` and `/verify` | | `active` | Yes | `pending_verification` after a URL change | | `disabled` | No | None | An endpoint becomes `disabled` through [Disable an endpoint](#disable-an-endpoint) or [automatic deactivation](#automatic-deactivation). A `disabled` endpoint cannot be reactivated. Register a new endpoint instead; the same URL can be used, and a new secret is issued. ### Environments and limits - An endpoint belongs to the environment (live or test) of the API key that registered it. Management API requests apply to the environment of the API key only. - Events of an execution are delivered to endpoints in the environment of the API key that submitted the intent. The event's `livemode` field shows the environment. - An environment can have up to 50 endpoints that are not `disabled`. - A URL can be registered only once per environment among endpoints that are not `disabled`. ### URL requirements - `https` scheme, port 443 - 2048 characters or fewer - No user info, query string, fragment or IP address host Use a separate path for each endpoint, for example `/ezys/webhooks/t001` and `/ezys/webhooks/t002`. Webhook requests do not identify the endpoint. ### Verification A new endpoint, and an endpoint whose URL was changed, receive no events until verified. 1. [Send a test ping](#send-a-test-ping). The URL receives a `test.ping` event with `data.object.token`. 2. [Verify the endpoint](#verify-an-endpoint) with that token. The endpoint becomes `active`. - The token is valid for 30 minutes. - Each `/test` request with a new `X-Idempotency-Key` issues a new token and invalidates the previous one. - An endpoint receives execution events only while it is `active`. Events that occurred before it became `active` are available through [List events](#list-events). ## Channels Channels route an account's events to different endpoints, for example one endpoint per tenant. Register endpoints with `channels`, and set `channels` on each intent. ```jsonc // POST /v1/webhook_endpoints { "url": "https://partner.example.com/ezys/webhooks/t001", "channels": ["t001"] } // POST /v1/intents { "intentId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301", "clientReference": "t001-order-0001", "channels": ["t001"], … } ``` | Rule | Value | |---|---| | Format | `^[A-Za-z0-9._-]{1,64}$` | | Count | 1 to 10 values, without duplicates | | Matching | Exact and case-sensitive | | Endpoint `channels` | Intent `channels` | Delivered | |---|---|---| | Not set (all events) | Any | Yes | | `["t001"]` | `["t001"]` | Yes | | `["t001"]` | `["t002"]` | No | | `["t001", "t002"]` | `["t001"]` or `["t002"]` | Yes | | `["t001", "t002"]` | `["t001", "t002"]` | Yes, once | | Set (any value) | Not set | No | - Within one environment, the endpoints that are not `disabled` either all have `channels` or all have none. A registration or update that would combine the two kinds returns `409 channel_mode_conflict`. - Several endpoints can subscribe to the same channel. Each receives the event. - Events that match no endpoint are available through [List events](#list-events). - The event's `channels` field contains the value sent with the intent. - On an intent, `channels` set to `null`, an empty array, or an invalid value returns `400`. ## Receiving webhooks ### Request ```http POST /ezys/webhooks/t001 HTTP/1.1 Host: partner.example.com Content-Type: application/json User-Agent: Ezys-Webhooks/1.0 Ezys-Signature: t=1790845923, v1=k1:5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd Ezys-Webhook-Id: evt_0f4d2b81c7a95e36 Ezys-Webhook-Event: payout.completed Ezys-Delivery-Attempt: 1 {"id":"evt_0f4d2b81c7a95e36","data":{"object":{…}},"type":"payout.completed","object":"event",…} ``` | Header | Value | |---|---| | `Ezys-Signature` | `t={timestamp}, v1={kid}:{signature}` | | `Ezys-Webhook-Id` | Event `id`. The same on every retry and resend | | `Ezys-Webhook-Event` | Event `type` | | `Ezys-Delivery-Attempt` | Attempt number, starting at 1. A resend starts again at 1 | The body is one [event](#event-object) in JSON. ### Signature verification | Item | Value | |---|---| | Algorithm | HMAC-SHA256, lowercase hex | | Key | The webhook signing secret, including the `whsec_` prefix | | Signed content | `{t}.{raw request body}` | | Tolerance | 300 seconds between `t` and your clock | `kid` is the generation of the secret: `k1` at registration, `k2` after the first rotation, and so on. During a [secret rotation](#rotate-the-signing-secret), the header contains two `v1` values. Accept the request if either value matches. ```http Ezys-Signature: t=1790845923, v1=k2:{signature}, v1=k1:{signature} ``` ```js import crypto from 'node:crypto' // rawBody: the request body as received (Buffer) // header: the Ezys-Signature header value // secret: the webhook signing secret (whsec_…) function verifyEzysSignature(rawBody, header, secret) { const t = /t=(\d+)/.exec(header)?.[1] if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest() return [...header.matchAll(/v1=([^,\s]+)/g)] .map((match) => match[1].split(':')[1]) .filter((signature) => /^[0-9a-f]{64}$/.test(signature ?? '')) .some((signature) => crypto.timingSafeEqual(Buffer.from(signature, 'hex'), expected)) } ``` With Express, read the raw body on the webhook route: ```js import express from 'express' const app = express() app.post('/ezys/webhooks/t001', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifyEzysSignature(req.body, req.get('Ezys-Signature') ?? '', process.env.EZYS_WEBHOOK_SECRET)) { return res.sendStatus(401) } await saveEvent(req.body) // store the event, then process it asynchronously res.sendStatus(200) }) ``` - Verify the body exactly as received. Re-serializing a parsed body can change the bytes and fail verification. - `t` is the time of each delivery attempt, not the event's `occurredAt`. ### Responses and retries Respond with `2xx` within 15 seconds. Store the event and process it asynchronously. | Your response | Result | |---|---| | `2xx` | Delivered | | `408`, `429`, `5xx`, no response within 15 seconds, network error | Retried | | `410` | Not retried. The endpoint is disabled | | Any other status, including `3xx`, `401` and `404` | Not retried | Redirects are not followed. For `429`, a `Retry-After` value in seconds is applied when it is longer than the scheduled delay, up to 24 hours. | Attempt | Delay after the previous attempt | |---|---| | 1 | — | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | 6 | 6 hours | | 7 | 24 hours | | 8 | 24 hours | After the eighth failed attempt, use [List events](#list-events) or [Resend an event](#resend-an-event). ### Delivery - Events are delivered at least once. Deduplicate on the event `id`. - Events can arrive out of order, and several requests can be in progress at the same time. Order the events of an execution by `sequence`. - The endpoint's host name is resolved before each request. Requests to non-public addresses, such as loopback, private or link-local addresses, are not sent and not retried. The delivery status is `dropped`. - Events and delivery history are kept for 15 days. ### Automatic deactivation | Condition | Result | |---|---| | Response `410` to an event delivery | Disabled immediately | | Failed deliveries for 5 days: no successful delivery since the first failure, and a failure in the last 24 hours | Disabled | | No successful delivery and no registration, verification, update, test or rotation for 180 days | Disabled | A failed delivery is a response other than `2xx`, no response within 15 seconds, a network error, or a request blocked because the host resolved to a non-public address. A successful delivery resets the 5-day count. The 180-day rule also applies to `pending_verification` endpoints. ## Event object ```json { "object": "event", "id": "evt_0f4d2b81c7a95e36", "type": "payout.completed", "apiVersion": "2026-08-01", "livemode": true, "intentId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301", "clientReference": "t001-order-0001", "sequence": 5, "occurredAt": "2026-10-01T09:12:03.000Z", "observedAt": "2026-10-01T09:12:03.180Z", "isTerminal": false, "channels": ["t001"], "data": { "object": { "clientReference": "t001-order-0001", "reference": "exe_7d0c3b52-9a41-4e8f-b6d2-1f05c8a9e347", "providerReference": "po_2VbX8kQm4Tn7Rw1Lc9HsYp", "status": "completed", "phase": "payout", "events": [ { "phase": "intent", "state": "initiated", "occurredAt": "2026-10-01T09:00:00.000Z", "observedAt": "2026-10-01T09:00:00.000Z", "isTerminal": false, "isReversible": false }, { "phase": "offramp", "state": "initiated", "occurredAt": "2026-10-01T09:00:11.000Z", "observedAt": "2026-10-01T09:00:11.090Z", "isTerminal": false, "isReversible": false }, { "phase": "payout", "state": "initiated", "occurredAt": "2026-10-01T09:01:40.000Z", "observedAt": "2026-10-01T09:01:40.000Z", "isTerminal": false, "isReversible": false }, { "phase": "payout", "state": "in_progress", "occurredAt": "2026-10-01T09:02:15.000Z", "observedAt": "2026-10-01T09:02:15.070Z", "isTerminal": false, "isReversible": false }, { "phase": "payout", "state": "completed", "occurredAt": "2026-10-01T09:12:03.000Z", "observedAt": "2026-10-01T09:12:03.180Z", "isTerminal": true, "isReversible": true } ], "finality": null, "escrow": { "lockTxHash": "0x0000000000000000000000000000000000000000000000000000000000000003", "refundTxHash": null } } } } ``` | Field | Type | Description | |---|---|---| | `object` | string | `event` | | `id` | string | Event ID. The same on every retry and resend | | `type` | string | [Event type](#event-types) | | `apiVersion` | string | `2026-08-01` | | `livemode` | boolean | `true` for live, `false` for test | | `intentId` | string \| null | `intentId` of the intent | | `clientReference` | string \| null | `clientReference` of the intent | | `sequence` | integer | Number of the event within the execution, starting at 1 | | `occurredAt` | string | Time of the change, ISO 8601 | | `observedAt` | string | Time Ezys created the event, ISO 8601 | | `isTerminal` | boolean | `true` for event types that can end an execution. See [`isTerminal`](#isterminal) | | `channels` | string[] \| null | `channels` of the intent. `null` if not set | | `data.object` | object | [Execution snapshot](#execution-snapshot) at the time of the event | Read fields by name. Key order is not fixed. ### `sequence` - Use `sequence`, not `occurredAt`, to decide which event is newer. Ignore an event whose `sequence` is lower than one you have already applied. - Gaps are expected for events that occurred before the endpoint was registered, while it was `pending_verification`, or while its `subscribedTypes` or `channels` did not match the event. - Other gaps mean an event has not been delivered yet. Read it with [List events](#list-events). ### `isTerminal` `isTerminal` is `true` for `intent.failed`, `offramp.failed`, `offramp.disputed`, `payout.failed`, `payout.reversed` (reserved), `execution.completed` and `execution.finalized`. An execution can receive more than one terminal event, for example `execution.completed` followed by `execution.finalized`, or `payout.failed` followed by `offramp.disputed`. Use `data.object.status` of the event with the highest `sequence` as the state of the execution. ## Execution snapshot `data.object` has the same schema as the response of `GET /v1/executions/{reference}`, at the time of the event. ```json { "clientReference": "t001-order-0001", "reference": "exe_7d0c3b52-9a41-4e8f-b6d2-1f05c8a9e347", "providerReference": "po_2VbX8kQm4Tn7Rw1Lc9HsYp", "status": "completed", "phase": "payout", "events": [ { "phase": "offramp", "state": "initiated", "occurredAt": "2026-10-01T09:00:11.000Z", "observedAt": "2026-10-01T09:00:11.090Z", "isTerminal": false, "isReversible": false }, { "phase": "offramp", "state": "completed", "occurredAt": "2026-10-01T09:12:09.000Z", "observedAt": "2026-10-01T09:12:09.140Z", "isTerminal": true, "isReversible": false }, { "phase": "payout", "state": "initiated", "occurredAt": "2026-10-01T09:01:40.000Z", "observedAt": "2026-10-01T09:01:40.000Z", "isTerminal": false, "isReversible": false }, { "phase": "payout", "state": "in_progress", "occurredAt": "2026-10-01T09:02:15.000Z", "observedAt": "2026-10-01T09:02:15.070Z", "isTerminal": false, "isReversible": false }, { "phase": "payout", "state": "completed", "occurredAt": "2026-10-01T09:12:03.000Z", "observedAt": "2026-10-01T09:12:03.180Z", "isTerminal": true, "isReversible": true }, { "phase": "intent", "state": "completed", "occurredAt": "2026-10-01T09:12:20.000Z", "observedAt": "2026-10-01T09:12:20.000Z", "isTerminal": true, "isReversible": false } ], "finality": { "txHash": "0x0000000000000000000000000000000000000000000000000000000000000001", "contractAddress": "0x0000000000000000000000000000000000000002", "chainId": 127 }, "escrow": { "lockTxHash": "0x0000000000000000000000000000000000000000000000000000000000000003", "refundTxHash": null } } ``` | Field | Type | Description | |---|---|---| | `clientReference` | string \| null | `clientReference` of the intent | | `reference` | string | Execution ID. Use it with `GET /v1/executions/{reference}?refType=executionId` | | `providerReference` | string \| null | The payout provider's reference for the payout, when one exists. The format depends on the provider | | `status` | string | `in_progress`, `completed` or `failed` | | `phase` | string | `intent`, `offramp` or `payout` | | `events` | array | State history, grouped by phase. The `intent` phase has one element, with its current state | | `finality` | object \| null | On-chain record of the settlement: `txHash`, `contractAddress`, `chainId` | | `escrow` | object \| null | Escrow of the input asset. `lockTxHash` is the transaction that locked the input asset in escrow, and `refundTxHash` is the transaction that refunded it from escrow. The refund returns the asset to the address it was pulled from when it was locked — for `erc20` inputs, the intent's `user`, which signed the permit. The platform sends the lock transaction, so its `from` address is not `user`. The refund transaction is sent either by the platform after the deadline (`from` is a platform address) or by `user` directly (`from` is `user`). `lockTxHash` is set from `offramp.initiated` and `refundTxHash` from `escrow.refunded`; neither changes afterwards. `lockTxHash` is `null` when the lock transaction could not be identified, and `refundTxHash` is `null` until a refund. `escrow` is an object when either is set, and `null` before the input asset is locked and for executions that ended without a lock. For on-chain inputs, the lock transaction is sent to the HTLC escrow on the input asset's chain — the permit `spender` (see [Permit](https://docs.ezys.io/api-reference/intent-api.md#permit-on-chain-inputs)). | | `events[]` field | Type | Description | |---|---|---| | `phase` | string | `intent`, `offramp` or `payout` | | `state` | string | `initiated`, `in_progress`, `completed`, `failed` or `reversed` (reserved) | | `occurredAt` | string | Time of the change | | `observedAt` | string | Time Ezys recorded the change | | `isTerminal` | boolean | `true` when the phase has ended | | `isReversible` | boolean | `true` for a completed payout | | Phase | States | |---|---| | `intent` | `initiated`, `completed`, `failed` | | `offramp` | `initiated`, `completed`, `failed` | | `payout` | `initiated`, `in_progress`, `completed`, `failed`, `reversed` (reserved; no rail produces it today) | Aggregate `status` follows the [Status API priority rules](https://docs.ezys.io/api-reference/status-api.md#aggregate-status). In particular, a completed payout plus a disputed execution is `completed`, but a separate failed execution takes precedence over payout completion. An `offramp.disputed` timeout notification alone need not change persisted status. Receipt and finality availability must be checked separately. ## Event types | Type | Description | `isTerminal` | |---|---|---| | `intent.accepted` | The intent was accepted | `false` | | `intent.failed` | The input asset could not be locked | `true` | | `offramp.initiated` | The input asset was locked in escrow. `data.object.escrow` is set | `false` | | `offramp.completed` | Settlement of the payout was confirmed | `false` | | `offramp.failed` | The payout could not be created | `true` | | `offramp.disputed` | Settlement of the payout could not be confirmed | `true` | | `payout.initiated` | The payout was created | `false` | | `payout.in_progress` | The payout provider is processing the payout | `false` | | `payout.completed` | The beneficiary was paid | `false` | | `payout.failed` | The payout failed | `true` | | `payout.reversed` | Reserved; not sent | `true` | | `execution.completed` | The execution completed | `true` | | `execution.finalized` | The settlement was recorded on-chain. `data.object.finality` is set | `true` | | `escrow.refunded` | The input asset locked in escrow was refunded. `data.object.escrow.refundTxHash` is set. `status` and `events[]` do not change | `false` | | `test.ping` | Sent by [Send a test ping](#send-a-test-ping) | `false` | ### Flows Order of occurrence. Events can arrive in a different order. ``` Fiat payout intent.accepted → offramp.initiated → payout.initiated → payout.in_progress → payout.completed → offramp.completed → execution.completed → execution.finalized Execution without a payout intent.accepted → offramp.initiated → execution.completed → execution.finalized (offramp.initiated only when the input asset is locked in escrow) Failure intent.failed before the input asset is locked offramp.failed before the payout is created payout.failed after the payout is created offramp.disputed when settlement of the payout cannot be confirmed Refund escrow.refunded after the escrow deadline, when the input asset locked in escrow is refunded ``` - A fiat payout receives `execution.completed` when its receipt is issued and `execution.finalized` after the settlement is recorded on-chain. If the settlement is not recorded on-chain, the execution ends with `execution.completed`. - `payout.in_progress` is sent 0 to 2 times per execution. Each is a separate event with its own `id` and `sequence`. - `payout.completed` does not end the execution. `offramp.completed`, `execution.completed` and `execution.finalized` follow. In an execution with `offramp.disputed`, none of them are sent. - If no terminal event arrives within 1 hour after `payout.completed`, retrieve the execution with `GET /v1/executions/{reference}?refType=executionId`. - `offramp.disputed` can follow `payout.completed`. In that case `status` stays `completed`. Contact Ezys. - At most one `escrow.refunded` event is created per execution. Like any event, it can be delivered more than once; retries and resends keep its `id`. A refund happens only after the escrow deadline. It does not change `status` or `events[]`. ### Testing a refund To test refund handling, submit an intent with a test API key and `beneficiary.account` set to `000000000`. The intent is accepted and its input asset is locked as usual, and the payout fails without being sent. - The other beneficiary fields must still pass the checks for the payout method. - The input asset is locked in escrow for 1 hour. - Events: `intent.accepted` → `offramp.initiated` → `payout.initiated` → `payout.failed`. The execution `status` is `failed`. - After the lock expires, Ezys refunds the input asset and sends `escrow.refunded`. The sender can also refund the lock after it expires; `escrow.refunded` is then sent with the sender's refund transaction as `refundTxHash`. - With a live API key, a payout to `000000000` also fails without being sent, but the input asset is locked for the default escrow term. ### `payout.failed` example `data.object` of a `payout.failed` event: ```json { "clientReference": "t001-order-0004", "reference": "exe_c3a18f60-2e7b-4d95-8a04-6b19e2f7d350", "providerReference": "po_8Kd3Wq7Zp1Yv5Nh2Tc6Bx9", "status": "failed", "phase": "payout", "events": [ { "phase": "intent", "state": "initiated", "occurredAt": "2026-10-01T12:01:02.000Z", "observedAt": "2026-10-01T12:01:02.000Z", "isTerminal": false, "isReversible": false }, { "phase": "offramp", "state": "initiated", "occurredAt": "2026-10-01T12:01:19.000Z", "observedAt": "2026-10-01T12:01:19.070Z", "isTerminal": false, "isReversible": false }, { "phase": "payout", "state": "initiated", "occurredAt": "2026-10-01T12:02:30.000Z", "observedAt": "2026-10-01T12:02:30.000Z", "isTerminal": false, "isReversible": false }, { "phase": "payout", "state": "in_progress", "occurredAt": "2026-10-01T12:03:05.000Z", "observedAt": "2026-10-01T12:03:05.060Z", "isTerminal": false, "isReversible": false }, { "phase": "payout", "state": "failed", "occurredAt": "2026-10-01T12:04:12.000Z", "observedAt": "2026-10-01T12:04:12.090Z", "isTerminal": true, "isReversible": false } ], "finality": null, "escrow": { "lockTxHash": "0x0000000000000000000000000000000000000000000000000000000000000003", "refundTxHash": null } } ``` ### `test.ping` ```json { "object": "event", "id": "evt_5c1e8a90d24b7f63", "type": "test.ping", "apiVersion": "2026-08-01", "livemode": false, "occurredAt": "2026-10-01T00:00:05.000Z", "observedAt": "2026-10-01T00:00:05.000Z", "isTerminal": false, "channels": null, "data": { "object": { "token": "vtok_8f3a1c7d9e05b264" } } } ``` - Sent only by [Send a test ping](#send-a-test-ping), with the same headers and signature as other events. - Has no `intentId`, `clientReference` or `sequence`. `livemode` is the environment of the endpoint. - Not retried, and not returned by [List events](#list-events) or [List deliveries](#list-deliveries). ## Not included in events - Amounts, fees, exchange rates and beneficiary details. See the [Receipts API](https://docs.ezys.io/api-reference/receipts-api.md). - Failure reason codes. The event type shows the step that failed. - Cancellation and expiry notifications. - Escrow extensions. When the payout is not confirmed in time, `offramp.disputed` is sent with the first extension. See [When the payout is late or fails](https://docs.ezys.io/for-fsps/settlement.md#when-the-payout-is-late-or-fails). - Events for intents rejected at submission. The `POST /v1/intents` response contains the result. ## Management API | Operation | Method and path | Scope | |---|---|---| | [Register an endpoint](#register-an-endpoint) | `POST /v1/webhook_endpoints` | `webhooks:write` | | [List endpoints](#list-endpoints) | `GET /v1/webhook_endpoints` | `webhooks:read` | | [Update an endpoint](#update-an-endpoint) | `PATCH /v1/webhook_endpoints/{endpointId}` | `webhooks:write` | | [Disable an endpoint](#disable-an-endpoint) | `DELETE /v1/webhook_endpoints/{endpointId}` | `webhooks:write` | | [Send a test ping](#send-a-test-ping) | `POST /v1/webhook_endpoints/{endpointId}/test` | `webhooks:write` | | [Verify an endpoint](#verify-an-endpoint) | `POST /v1/webhook_endpoints/{endpointId}/verify` | `webhooks:write` | | [Rotate the signing secret](#rotate-the-signing-secret) | `POST /v1/webhook_endpoints/{endpointId}/rotate` | `webhooks:write` | | [List deliveries](#list-deliveries) | `GET /v1/webhook_endpoints/{endpointId}/deliveries` | `webhooks:read` | | [List events](#list-events) | `GET /v1/events` | `webhooks:read` | | [Get an event](#get-an-event) | `GET /v1/events/{eventId}` | `webhooks:read` | | [Resend an event](#resend-an-event) | `POST /v1/events/{eventId}/resend` | `webhooks:write` | ### Authentication `GET` requests require `X-API-Key`. `POST`, `PATCH` and `DELETE` requests also require a request signature and an idempotency key, as for `POST /v1/intents`. See [Request Signing](https://docs.ezys.io/api-reference/authentication.md#request-signing). | Header | Value | |---|---| | `X-API-Key` | API key | | `X-Timestamp` | Unix time in seconds, within 300 seconds of Ezys time | | `X-Nonce` | 16 to 64 characters, unique per request | | `X-Signature` | HMAC-SHA256, lowercase hex, of `{method}{path}{X-Timestamp}{X-Nonce}{body}` with the API key's signing secret | | `X-Idempotency-Key` | Lowercase UUID (RFC 9562), for example from `crypto.randomUUID()` | | `Content-Type` | `application/json` when the request has a body | - `{method}` is uppercase. - `{path}` includes `/v1` and the query string. - For a request without a body, `{body}` is an empty string. ### Idempotency - A `POST` request repeated with the same `X-Idempotency-Key` within 24 hours returns the original `2xx` response with `X-Idempotency-Replayed: true`, without running the operation again. A request that failed with `4xx` or `5xx` can be retried with the same key, except the `503` described below. - Replayed responses of [Register an endpoint](#register-an-endpoint) and [Rotate the signing secret](#rotate-the-signing-secret) do not contain `secret` and include `X-Idempotency-Redacted: secret`. - For `POST`, the same key on the same path with a different body or query string returns `409 IDEMPOTENCY_KEY_CONFLICT`, and the same key while the original request is in progress returns `409 IDEMPOTENCY_KEY_PROCESSING`. A key used on a different path is a separate request. - `PATCH` and `DELETE` requests require the header but are not replayed. - Idempotency keys apply per environment. If the response is lost, or the request returns `503 SERVICE_UNAVAILABLE` from the idempotency layer (the operation may already have run, and the same key then returns `409 IDEMPOTENCY_KEY_PROCESSING` for up to 24 hours), recover per operation: | Operation | Recovery | |---|---| | Register | Get `endpointId` by repeating the request with the same key, or from [List endpoints](#list-endpoints). Then [rotate](#rotate-the-signing-secret) with a new key to get a new secret | | Rotate | Rotate with a new key and `"revokePrevious": true` | | Test ping | Send the ping again with a new key. Each ping issues a new verification `token` and invalidates the previous one, so verify with the token from the latest ping received | | Verify | Check the endpoint's `status` in [List endpoints](#list-endpoints). If it is still `pending_verification`, verify again with a new key | | Resend | A resend updates the endpoint's delivery for the event (or creates it), so the presence of a delivery says nothing. A delivery that is being sent (`in_flight`) is skipped and its `resendCount` does not increase. For each target endpoint, check the event in [List deliveries](#list-deliveries): if `resendCount` increased or `status` is `pending` or `in_flight`, do not resend. Otherwise resend with a new key | ### Register an endpoint ```http POST /v1/webhook_endpoints ``` ```json { "url": "https://partner.example.com/ezys/webhooks/t001", "subscribedTypes": [], "channels": ["t001"], "description": "tenant t001" } ``` | Field | Type | Required | Description | |---|---|---|---| | `url` | string | Yes | Endpoint URL. See [URL requirements](#url-requirements) | | `subscribedTypes` | string[] | No | [Event types](#event-types) to receive. Omitted or empty: all types. `payout.reversed` is accepted but never sent | | `channels` | string[] \| null | No | See [Channels](#channels). Omitted or `null`: all events | | `description` | string | No | Up to 256 characters | Response `201`: ```json { "endpointId": "ep_3d91c4a7b20e", "url": "https://partner.example.com/ezys/webhooks/t001", "status": "pending_verification", "secret": "whsec_kQ2mZ8vR-tX7nB1pL5wD_fH6jS0aC9eY", "kid": "k1", "createdAt": "2026-10-01T00:00:00.000Z" } ``` | Field | Description | |---|---| | `endpointId` | Endpoint ID | | `status` | `pending_verification`. See [Verification](#verification) | | `secret` | Webhook signing secret. Returned only in this response | | `kid` | Generation of the secret, `k1` | ### List endpoints ```http GET /v1/webhook_endpoints ``` Returns the endpoints of the environment, newest first, including `disabled` endpoints. Response `200`: ```json { "data": [ { "endpointId": "ep_3d91c4a7b20e", "url": "https://partner.example.com/ezys/webhooks/t001", "status": "active", "channels": ["t001"], "subscribedTypes": [], "description": "tenant t001", "createdAt": "2026-10-01T00:00:00.000Z" } ] } ``` `channels` is `null` for an endpoint without channels. `subscribedTypes` is `[]` for all types. ### Update an endpoint ```http PATCH /v1/webhook_endpoints/{endpointId} ``` ```json { "channels": ["t001", "t002"] } ``` Only the fields in the request change. | Field | Value | `null` | |---|---|---| | `url` | New URL | Not allowed | | `subscribedTypes` | Replaces the list. `[]`: all types | Not allowed | | `channels` | Replaces the list | All events | | `description` | New description | Removes the description | A request with none of these fields returns `400 invalid_request`. Changing `url` sets the endpoint to `pending_verification` until the new URL is [verified](#verification). The signing secret does not change. Response `200`: ```json { "endpointId": "ep_3d91c4a7b20e", "url": "https://partner.example.com/ezys/webhooks/t001", "status": "active", "channels": ["t001", "t002"], "subscribedTypes": [], "description": "tenant t001", "urlChanged": false, "updatedAt": "2026-10-02T04:11:09.221Z" } ``` `urlChanged` is `true` when the request changed the URL. ### Disable an endpoint ```http DELETE /v1/webhook_endpoints/{endpointId} ``` Response `204`. The endpoint becomes `disabled`, and its pending deliveries are not sent. Its delivery history stays available through [List deliveries](#list-deliveries). An unknown `endpointId` also returns `204`. ### Send a test ping ```http POST /v1/webhook_endpoints/{endpointId}/test ``` Sends a [`test.ping`](#test-ping) event to the endpoint URL. No request body. Response `200`: ```json { "data": { "status": "sent" } } ``` ```json { "data": { "status": "unreachable", "reason": "http 503" } } ``` | Field | Description | |---|---| | `status` | `sent`: the URL responded with `2xx`. `unreachable`: it did not | | `reason` | With `unreachable`: `http {status}`, `timeout`, `network:{cause}` or `blocked:{reason}` | A request repeated with the same `X-Idempotency-Key` returns the original result without sending. Use a new key to send again. ### Verify an endpoint ```http POST /v1/webhook_endpoints/{endpointId}/verify ``` ```json { "token": "vtok_8f3a1c7d9e05b264" } ``` | Field | Type | Required | Description | |---|---|---|---| | `token` | string | Yes | `data.object.token` of the `test.ping` event | Response `200`: ```json { "data": { "status": "activated" } } ``` `status` is `activated`, or `already_active` if the endpoint was already `active`. ### Rotate the signing secret ```http POST /v1/webhook_endpoints/{endpointId}/rotate ``` ```json { "revokePrevious": false } ``` | Field | Type | Required | Description | |---|---|---|---| | `revokePrevious` | boolean | No | `false` (default): the previous secret signs for 24 more hours. `true`: the previous secret stops immediately | Response `200`: ```json { "endpointId": "ep_3d91c4a7b20e", "secret": "whsec_7pN2xQ9mK-vB1tR6wZ3hL_jY5cD0aF2e", "kid": "k2", "generation": 2, "previousRetiresAt": "2026-10-02T04:11:09.221Z" } ``` | Field | Description | |---|---| | `secret` | New webhook signing secret. Returned only in this response | | `kid` | Generation of the new secret | | `generation` | Number of the generation. The first secret is `1` | | `previousRetiresAt` | Time the previous secret stops signing. `null` with `revokePrevious: true` | - For 24 hours after a rotation, each event is signed with both secrets. Switch your receiver to the new secret within this period. - A rotation within these 24 hours returns `409 rotation_in_progress`, unless `revokePrevious` is `true`. - Use `revokePrevious: true` if the secret was exposed. - With `revokePrevious: true`, deliveries sent before your receiver switches to the new secret fail verification. Read them with [List events](#list-events) or [resend](#resend-an-event) them. ### List deliveries ```http GET /v1/webhook_endpoints/{endpointId}/deliveries ``` Returns the deliveries to an endpoint, oldest first. | Parameter | Description | |---|---| | `after` | `nextCursor` of the previous page | | `limit` | 1 to 100. Default 50 | Response `200`: ```json { "data": [ { "eventId": "evt_0f4d2b81c7a95e36", "eventType": "payout.completed", "status": "exhausted", "attempt": 8, "resendCount": 0, "createdAt": "2026-10-01T09:12:03.412Z", "deliveredAt": null, "nextAttemptAt": null, "lastAttempt": { "attempt": 8, "outcome": "timeout", "statusCode": null, "durationMs": 15001, "errorDetail": "timeout", "startedAt": "2026-10-03T17:49:52.087Z" } } ], "hasMore": false, "nextCursor": null } ``` | Field | Description | |---|---| | `status` | `pending`, `in_flight`, `delivered`, `exhausted` (all attempts failed) or `dropped` (not retried) | | `attempt` | Number of attempts since the delivery was created or last resent | | `resendCount` | Number of resends | | `nextAttemptAt` | Time of the next attempt. `null` when finished | | `lastAttempt` | Result of the last attempt. `null` before the first attempt | | `lastAttempt.outcome` | `delivered`, `retryable`, `timeout`, `permanent` or `blocked` | | `lastAttempt.statusCode` | HTTP status of your response. `null` if there was none | ### List events ```http GET /v1/events ``` Returns the events of the environment, oldest first, whether or not they were delivered. | Parameter | Description | |---|---| | `after` | `nextCursor` of the previous page | | `type` | [Event type](#event-types) | | `limit` | 1 to 100. Default 100 | ```bash curl -H "X-API-Key: {api_key}" \ "https://api.ezys.io/v1/events?type=payout.completed&limit=100" ``` Response `200`: ```json { "data": [ { "object": "event", "id": "evt_0f4d2b81c7a95e36", "type": "payout.completed", "…": "…" } ], "hasMore": true, "nextCursor": "" } ``` | Field | Description | |---|---| | `data` | [Events](#event-object), the same as delivered | | `hasMore` | `true` if another page exists | | `nextCursor` | Value for `after` on the next page. `null` on the last page | A page can contain fewer events than `limit` while `hasMore` is `true`. Continue until `hasMore` is `false`. ### Get an event ```http GET /v1/events/{eventId} ``` Response `200` with one [event](#event-object). ### Resend an event ```http POST /v1/events/{eventId}/resend ``` ```json { "endpointId": "ep_3d91c4a7b20e" } ``` | Field | Type | Required | Description | |---|---|---|---| | `endpointId` | string | No | Resend to this endpoint only. `queued` is `0` if it is not an endpoint that receives the event. Omitted: all endpoints that receive the event | An event is resent to endpoints that are `active`, were registered before the event was created, and match the event's type and channels. Response `200`: ```json { "eventId": "evt_0f4d2b81c7a95e36", "queued": 1, "endpointIds": ["ep_3d91c4a7b20e"], "alreadyInFlight": [] } ``` | Field | Description | |---|---| | `queued` | Number of deliveries queued | | `endpointIds` | Endpoints of the queued deliveries | | `alreadyInFlight` | Endpoints with a delivery of this event in progress | A resent event keeps its `id`, and delivery starts again from attempt 1. ## Errors ```json { "success": false, "error": { "code": "endpoint_not_found", "message": "no endpoint found for the given id" } } ``` Handle errors by `error.code`. Codes are case-sensitive: `UNAUTHORIZED` and `unauthorized` are different codes. | Status | Code | Description | |---|---|---| | `400` | `invalid_url` | The URL does not meet the [URL requirements](#url-requirements) | | `400` | `invalid_channels` | `channels` does not follow the [channel rules](#channels) | | `400` | `invalid_subscribed_types` | Not an array of known event types, or `null` in an update | | `400` | `invalid_description` | Neither a string nor `null`, or longer than 256 characters | | `400` | `invalid_request` | An update without any field to change | | `400` | `expired` | The verification token expired, or no token is pending: `/test` was not called after registration or after the last URL change | | `400` | `invalid_token` | The verification token is missing, invalid or does not match | | `400` | `invalid_revoke_previous` | `revokePrevious` is not a boolean | | `400` | `invalid_endpoint_id` | `endpointId` is not a string | | `400` | `invalid_limit` | `limit` is not an integer from 1 to 100 | | `400` | `invalid_cursor` | `after` is not a cursor returned by the API | | `400` | `invalid_type` | Unknown event type | | `400` | `IDEMPOTENCY_KEY_INVALID` | `X-Idempotency-Key` is not a lowercase RFC 9562 UUID | | `401` | `UNAUTHORIZED` | Missing or invalid API key | | `401` | `unauthorized` | The API key is not associated with a partner or an environment | | `401` | `INVALID_SIGNATURE` | Missing or invalid signature headers, or missing `X-Idempotency-Key` | | `403` | `FORBIDDEN` | The API key does not have the required scope | | `404` | `endpoint_not_found` | The endpoint does not exist | | `404` | `event_not_found` | The event does not exist | | `409` | `endpoint_already_registered` | An endpoint with this URL that is not `disabled` exists in the environment | | `409` | `channel_mode_conflict` | Endpoints with and without `channels` cannot be combined | | `409` | `endpoint_limit_reached` | The environment has 50 endpoints that are not `disabled` | | `409` | `endpoint_disabled` | The endpoint is `disabled` | | `409` | `rotation_in_progress` | The previous secret is still valid. Retry after `previousRetiresAt`, or rotate with `revokePrevious: true` | | `409` | `IDEMPOTENCY_KEY_CONFLICT` | The idempotency key was used for a different request | | `409` | `IDEMPOTENCY_KEY_PROCESSING` | The original request with this idempotency key is in progress. Retry with the same key. After a `503`, this code can persist for up to 24 hours | | `503` | `SERVICE_UNAVAILABLE` | The idempotency layer failed before or after the operation ran. Do not retry blindly with the same key; follow the per-operation table under [Idempotency](#idempotency) | A request body that is not valid JSON returns `400` without `error.code`. Server errors return `500`, either as the error envelope or as the platform default body `{ "statusCode": 500, "message": "Internal server error" }`. ## Receiver checklist 1. Verify `Ezys-Signature` on the raw body with the endpoint's webhook signing secret. Respond with `401` if verification fails. 2. Store the event and respond with `2xx` within 15 seconds. Process it asynchronously. 3. Deduplicate on the event `id`. 4. Apply the events of an execution in `sequence` order. 5. Use `data.object.status` of the event with the highest `sequence` as the state of the execution. 6. If no terminal event arrives within 1 hour after `payout.completed`, retrieve the execution with `GET /v1/executions/{reference}?refType=executionId`. 7. Read missed events with [List events](#list-events). --- > Source: [/api-reference/errors](https://docs.ezys.io/api-reference/errors.html) · Markdown: https://docs.ezys.io/api-reference/errors.md > Section: API Reference · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Error Handling & Retries Choose recovery from the endpoint, HTTP status, and error code together. A `5xx` does not prove that an operation failed before acceptance, and a `422` does not always require a different intent. Errors have different shapes: business rejections carry the endpoint's own result, authentication and idempotency use an error envelope, and validation may use the framework error body. See [Response format](https://docs.ezys.io/api-reference/overview.md#response-format). ## Is an error retryable? | Response | Recommended action | |---|---| | `401` / `403` | Correct the API key, scope, or signature. Generate fresh signing headers for a retry. | | `400` | Correct the request. A malformed idempotency key is `400 IDEMPOTENCY_KEY_INVALID`. | | `409 IDEMPOTENCY_KEY_PROCESSING` | Retry the same request bytes and key with bounded backoff; reconcile if it stays unresolved. | | `409 IDEMPOTENCY_KEY_CONFLICT` | Stop: the key was used for a different request. Recover the original serialized body. | | `409 receipt_not_issued` | Follow execution status. A failed/disputed or Reactor execution may never receive a receipt. | | `422 intent_in_progress` | Keep the same `intentId`; wait and query the original execution. | | Other `422` business rejection | Follow the reason-specific table below. Check whether the quote was consumed before preparing another attempt. | | `500 receipt_corrupt` / `execution_state_corrupt` | Contact Ezys; repeatedly submitting the operation cannot repair stored data. | | Other `5xx`, timeout, or connection loss | Outcome may be unknown. Use the endpoint's recovery procedure and bounded backoff; do not create a new money-moving intent. | | `429` from the network edge | Back off, respecting `Retry-After` if present. | For a lost intent acknowledgement, see [Recovering from `503`](https://docs.ezys.io/api-reference/intent-api.md#recovering-from-503). Firm quotes and webhook management have their own recovery procedures. HTTP status alone is not a retry policy. **Indicative quotes are an exception to rejection semantics:** `POST /v1/quotes` can return `200` with an empty `candidates` array, including when candidate generation fails. Firm quotes use their documented errors. An accepted intent's later failure appears in status and webhook state, not as an error on the original HTTP request. ## Common errors (all endpoints) | Code | HTTP | Recommended action | |---|---|---| | `UNAUTHORIZED` | `401` | Check the API key. | | `FORBIDDEN` | `403` | Check the endpoint's required scope. | | `INVALID_SIGNATURE` | `401` | Check signed path, raw body, timestamp, nonce, and required headers. | | `IDEMPOTENCY_KEY_INVALID` | `400` | Supply a canonical RFC 9562 UUID. | | `IDEMPOTENCY_KEY_CONFLICT` | `409` | Recover the original request bytes. | | `IDEMPOTENCY_KEY_PROCESSING` | `409` | Bounded retries with the same key, then reconcile. | | `SERVICE_UNAVAILABLE` | `503` | Read the message and the endpoint's recovery procedure; acceptance may already have happened. | ## Quote & Intent Errors raised while pricing a quote or placing an intent. Intent business rejections return **`422`** with this body: ```json { "intentId": "…", "reason": "", "status": "rejected" } ``` Note that the intent-rejection body carries the reason at the **top level** (it is not wrapped in the standard error envelope). Malformed requests (missing or mis-shaped fields) return **`400`** with a human-readable `message` — those are not coded and are always non-retryable: fix the request. A rejected submission itself moves no funds. One caveat: `intent_already_submitted` means an earlier submission with the same `intentId` was already accepted, so that execution may be moving funds — query it with the original submission's `clientReference`. A rejection can still use up an attached firm quote: the quote is single-use and is consumed before the pricing checks. The [Intent API rejection table](https://docs.ezys.io/api-reference/intent-api.md#rejected-response) lists the reasons in the order the checks run and marks which ones use up the quote. When the quote is used up, resubmit with a fresh quote. The `reason` values form a **closed set** — the full list as of this document version is below. Branch on these values, not on message text. New codes may be added over time and are announced in the documentation changelog before they ship; treat any unknown `reason` as a non-retryable generic rejection. ### Intent identity | `reason` | When it fires | Recommended action | |---|---|---| | `intent_identity_invalid` | The request could not be identified on the Ezys side. A malformed `intentId` returns `400`, not this reason | Contact Ezys; changing the request does not help | | `intent_in_progress` | Another submission with the same `intentId` is still processing or settling | Retry later with the same `intentId`. If this keeps returning, query [execution status](https://docs.ezys.io/api-reference/status-api.md) and contact Ezys if no execution exists. Do not create a new `intentId` | | `intent_already_submitted` | The `intentId` is already held by an earlier order or settlement | Query [execution status](https://docs.ezys.io/api-reference/status-api.md); do not resubmit | ### Quote reference (`quoteId` attached) The attached firm quote could not be consumed. **Action for the first five: request a fresh firm quote and resubmit within its TTL.** | `reason` | When it fires | |---|---| | `quote_not_found` | No quote with that ID (or it has already been discarded) | | `quote_expired` | Quote TTL elapsed before the intent arrived | | `quote_already_used` | Firm quotes are single-use; this one was already consumed | | `quote_pair_mismatch` | Quote currency pair differs from the intent's pair | | `quote_amount_mismatch` | Quote input amount differs from the intent amount (canonical comparison) | | `quote_provider_mismatch` | The beneficiary would be paid through a different payout provider than the one the quote was priced against. A fresh quote does not resolve this — contact Ezys | ### Amounts & bounds | `reason` | When it fires | Recommended action | |---|---|---| | `below_minimum_output` | Computed output (after fees/rate) falls below your minimum | Raise the amount, lower the minimum, or re-quote | | `above_maximum_input` | Exact-output back-calculation needs more input than your cap | Raise the cap or re-quote | | `amount_bounds_range_not_supported` | The requested bounds combination is not supported | Fix the request | | `input_limit_required_for_exact_output` | Exact-output requested without `input.limit` | Supply the input cap. An attached quote is used up — request a new one | ### Beneficiary All four are request faults — complete the beneficiary and resubmit. Only `beneficiary_account_required_for_fiat_output` uses up an attached quote; for the other three the same quote can be reused. | `reason` | When it fires | |---|---| | `beneficiary_account_required_for_fiat_output` | Fiat output without a beneficiary account | | `beneficiary_required_for_remittance` | Remittance-class intent without a beneficiary | | `beneficiary_incomplete_for_jit_payout` | Inline beneficiary missing required fields (`country`, payout rail), or a payout-partner rule fails (an unsupported rail, or a US USD `wire` without `routingNumber` or `swiftCode`) | | `beneficiary_address_required_for_rail` | Wire/SWIFT rail requires a postal address (`line1`, `city`, `state`, `postalCode`), or the payout partner requires an address field that is missing (see [Intent API → Beneficiary](https://docs.ezys.io/api-reference/intent-api.md#beneficiary)) | ### Asset & chain | `reason` | When it fires | |---|---| | `chain_required` | On-chain asset without a `chain` | | `unsupported_chain` | Chain is not registered on the platform | | `unregistered_token` | Token address is not in the registry | | `invalid_asset_address` | Asset address is malformed | ### Signatures & permits | `reason` | When it fires | Recommended action | |---|---|---| | `permit_required` | On-chain input without a Permit2 signature (not a reactor order) | Sign and attach the permit | | `gasless_exact_output_unsupported` | `erc2612Permit` attached to an exact-output intent | Use exact-input, or drop `erc2612Permit` and approve NfxPermit2 yourself; the same quote can be reused | | `token_permit_unsupported` | `erc2612Permit` attached, but the token is not on the platform's ERC-2612 list, or a non-65-byte signature is used with a token that does not support `permit(…, bytes signature)` | Drop `erc2612Permit` and approve NfxPermit2 yourself; the same quote can be reused | See [Intent API → Gasless approve (`erc2612Permit`)](https://docs.ezys.io/api-reference/intent-api.md#gasless-approve-erc2612permit). ### Reactor orders See [Intent API → Reactor orders (same-chain swaps)](https://docs.ezys.io/api-reference/intent-api.md#reactor-orders-same-chain-swaps). | `reason` | When it fires | Recommended action | |---|---|---| | `reactor_order_required` | Reactor-eligible intent without `reactorOrder` | Attach the signed order; the same quote can be reused | | `reactor_order_invalid` | Malformed order or signature, or an EOA signature that does not recover to `user` | Fix the order; the same quote can be reused | | `reactor_order_mismatch` | The signed order differs from the order re-derived from the quote | Request a new quote and sign its `reactorOrder` | | `quote_id_required_for_named_order` | A reactor order filled by Ezys (`exclusiveFiller` set) without `quoteId` | Attach the quote | | `reactor_order_expired` | Open order only: the order `deadline` has passed | Sign a new order | | `reactor_order_deadline_too_far` | Open order only: the order `deadline` is more than 3600 seconds ahead | Sign with a nearer `deadline` | | `reactor_order_nonce_used` | Open order only: the `nonce` is already used | Sign with a new `nonce` | Open orders are reactor orders whose `exclusiveFiller` is the zero address. They carry their own price and no quote. ### Liquidity & solving Not a request fault — a valid business outcome. Resubmitting later is a **new** request, not an automatic retry; surface the outcome to the user. | `reason` | When it fires | |---|---| | `no_liquidity` | No liquidity candidates for the corridor (only without `quoteId`) | | `no_batch_solution` | Batch auction produced no solution | ### Accepted-record replay `409 INTENT_AMOUNT_CONTEXT_UNAVAILABLE` means a pre-upgrade intent was already accepted, but its saved record lacks the asset context needed to replay the amount fields safely. It is **not retryable as a new intent**. Query [execution status](https://docs.ezys.io/api-reference/status-api.md) using the original `clientReference` or a known execution ID; do not create a new `intentId`. See [response compatibility](https://docs.ezys.io/api-reference/intent-api.md#compatibility-with-earlier-responses) for cached old responses. ## Off-ramp An accepted execution can later fail or become disputed. These are resource states, not synchronous `SETTLEMENT_FAILED` responses from `POST /v1/intents`. Use [execution status](https://docs.ezys.io/api-reference/status-api.md#aggregate-status), its phase events, and [settlement recovery](https://docs.ezys.io/for-fsps/settlement.md#when-the-payout-is-late-or-fails). ## Payout A transport timeout is not proof that a payment failed. Keep the original `intentId`, `clientReference`, serialized body, and idempotency key. Query the original execution before taking further action. An idempotent replay returns the submission acknowledgement, **not** the latest payout status. A completed payout can coexist with a disputed off-ramp and an absent receipt or anchor. Check all three separately when reconciling. There is no public payout-resubmit endpoint in this reference. ## How retryability is signaled The public responses do not provide a universal `retryable` field. Branch on the documented endpoint-specific code or `reason`, and treat an unknown error as unresolved until its outcome can be established. --- > Source: [/api-reference/money-and-amounts](https://docs.ezys.io/api-reference/money-and-amounts.html) · Markdown: https://docs.ezys.io/api-reference/money-and-amounts.md > Section: API Reference · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Money & Amounts How monetary values are represented across the Ezys API. This convention applies to every amount-bearing field in requests, responses, webhooks, and reconciliation data. Some endpoints use a structured money object; others use flat fields such as `inputAmountMinor` whose asset is specified by the request. The per-endpoint pages define the exact shape. Fields named `amountMinor` or ending in `AmountMinor` always contain integer strings, never decimals. ## Representation Firm quotes and fee items use an **integer amount in the currency's smallest (minor) unit**, encoded as a **JSON string**, together with the currency code and its exponent: ```jsonc { "amountMinor": "1523456", "currency": "USD", "exponent": 2 } // = USD 15,234.56 ``` | Field | Type | Description | |-------|------|-------------| | `amountMinor` | string | Integer amount in the currency's minor unit. **Always a string** (see [String encoding](#string-encoding)). | | `currency` | string | Currency/asset code. ISO 4217 for fiat (`KRW`, `USD`); registered ticker for stablecoins (`USDC`). | | `exponent` | integer | Number of minor-unit digits. `major = amountMinor / 10^exponent`. | ## Minor unit & exponent The exponent follows **ISO 4217** for fiat, and the token's decimals for on-chain assets: | Currency | Exponent | Minor unit | Example (major → `amountMinor`) | |----------|:--------:|------------|----------------------------------| | KRW | 0 | 1 won | `1,500,000` → `"1500000"` | | USD | 2 | cent | `15,234.56` → `"1523456"` | | USDC | 6 | micro-USDC | `10,000` → `"10000000000"` | | JPY | 0 | 1 yen | `100,000` → `"100000"` | Conversion is deterministic in both directions: ``` amountMinor = major × 10^exponent major = amountMinor / 10^exponent ``` In structured money objects, `currency` and `exponent` are sent together. Flat Intent API fields do not include `exponent`: `inputAmountMinor` uses `inputAsset`, while `path.outputAmountMinor`, `path.surplusAmountMinor`, and the current `path.steps[].amountMinor` use `outputAsset`. A fiat step also identifies its currency with `currency`. Apply the corresponding ISO 4217 scale or token decimals; see the [USD example](https://docs.ezys.io/api-reference/intent-api.md#accepted-response). ## String encoding `amountMinor` is **always a JSON string**, never a JSON number. A minor-unit amount can exceed `2^53`, which is the largest integer a JSON/JavaScript number can hold exactly. Sending it as a number would silently corrupt large values (e.g. high-value KRW or `USDC` micro amounts). Encoding as a string preserves every digit. ## Rounding Rounding depends on what is being calculated. Amounts are finalized at the asset's minor-unit precision before execution; serializing an accepted amount to `*AmountMinor` only changes its unit. | Calculation | Rounding | |-------------|----------| | General indicative/auction conversion | Nearest, with ties to even (half-even) | | Input required for an exact-output transfer | Up, so the required input is sufficient | | Net recipient output of a source-fixed firm quote | Down (floor) | | Accumulated CoW output and solver fills before execution | Down (floor), then checked against the requested minimum | For USD, a payout calculated as `8.775` is floored to `8.77` (`"877"` cents), while required exact-output input of `12.341` is rounded up to `12.35` (`"1235"` cents). A half-even conversion of `2.005` becomes `2.00`; `2.015` becomes `2.02`. Fees follow the fee rules for the quote; this table does not redefine fee rounding. A currency or token scale needed for conversion must be known; the service does not guess a scale. ## On-chain assets On-chain amounts can exceed a 64-bit integer. The REST `amountMinor` and `*AmountMinor` fields remain JSON strings, including for 18-decimal tokens, and contain the token's native base units (e.g. wei). On-chain signing payloads use their own field names, such as `amount`, with the same integer-string encoding. ## Display Decimal points, thousands separators, and currency symbols are a **client (UX) concern only**. The `*AmountMinor` fields carry minor-unit integers. Clients format them using the explicit `exponent` on structured money objects or the associated asset's scale on flat fields. ## See also - [Quote API](https://docs.ezys.io/api-reference/quote-api.md) · [Intent API](https://docs.ezys.io/api-reference/intent-api.md) — per-endpoint amount fields - [Overview → Conventions](https://docs.ezys.io/api-reference/overview.md#conventions) --- > Source: [/using-ezys-with-ai](https://docs.ezys.io/using-ezys-with-ai.html) · Markdown: https://docs.ezys.io/using-ezys-with-ai.md > Section: For agents · [Documentation index](https://docs.ezys.io/llms.txt) > Partner API v0.9.10 (OpenAPI): https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml # Using Ezys with AI AI coding agents can read this documentation and the Partner API contract directly. Connect your agent through one of the options below so it answers from these pages instead of from memory. Everything on this page is read-only. None of it calls the Ezys API, uses an API key or signing secret, signs a request or registers a webhook. ## Install the plugin The `docs` plugin in [`soohoio/ezys-plugins`](https://github.com/soohoio/ezys-plugins) bundles the MCP server below and the `ezys-docs` skill. **Claude Code** ```sh claude plugin marketplace add soohoio/ezys-plugins claude plugin install docs@ezys ``` **Codex** ```sh codex plugin marketplace add soohoio/ezys-plugins codex plugin add docs@ezys ``` The repository also contains a Cursor marketplace manifest (`.cursor-plugin/`). ## Connect the MCP server only The MCP server is at `https://docs.ezys.io/mcp` (Streamable HTTP, no authentication). ```sh claude mcp add --transport http ezys-docs https://docs.ezys.io/mcp ``` For other clients, add a remote MCP server of type Streamable HTTP with that URL. | Tool | Use it to… | | --- | --- | | `search` | Find the page sections that answer a question. | | `list_pages` | List every page with its section, summary and Markdown URL. | | `read_page` | Read a page's Markdown by path or URL. Long pages are returned in parts; follow `nextOffset`. | | `get_api_operation` | Get one OpenAPI operation by `operationId` or `METHOD /path`, with its parameters, request body, responses and schemas resolved. Call it without arguments to list the operations. | The server answers only from the documentation published with it. If it returns HTTP `429`, wait and retry. ## Read the documentation without MCP | File | Contents | | --- | --- | | `https://docs.ezys.io/SKILL.md` | Agent instructions: which page to read for which task, and rules for citing them. The plugin's `ezys-docs` skill is a copy of this file. | | [`/llms.txt`](https://docs.ezys.io/llms.txt) | Every page with a one-line summary, in navigation order. | | `https://docs.ezys.io/llms-full.txt` | Every page in one file. | | [`/openapi/ezys-partner-api.v1.yaml`](https://docs.ezys.io/openapi/ezys-partner-api.v1.yaml) | The Partner API contract. See [OpenAPI Specification](https://docs.ezys.io/api-reference/openapi.md). | Any page is also available as Markdown: replace `.html` with `.md` in its URL, for example `https://docs.ezys.io/api-reference/quote-api.md`. ## Check what the agent tells you Agents can still misread a page. Before you rely on an answer, open the page or OpenAPI operation it cites and check base URLs, header names, the request-signing string, scopes and amount encoding against it. Use only endpoints the [API Reference](https://docs.ezys.io/api-reference/overview.md) marks as Available.