# Vurto Swap AI API Guide

Public API for AI agents and integrators to quote, build, sign, and track EVM token swaps across
9 chains (Ethereum, Optimism, BNB Chain, Gnosis, Polygon, Base, Arbitrum One, Avalanche, Unichain),
at the best net price across Velora, KyberSwap, 1inch, OpenOcean, and CoW Protocol.

> **Always use the latest version.** This guide, `https://swap.vurto.cc/agent-quickstart.md`, and
> `https://swap.vurto.cc/openapi.json` change over time — fetch them live each run, don't cache a
> copy across sessions.

## 1. Base URLs

- Production: `https://swap.vurto.cc/v1`
- Development: `https://swap-dev.vurto.cc/v1`

`/v1` is versioned independently of the app's own `/api/*` (which powers the web UI and is
same-origin-only for the routes that carry a browser cookie). Everything under `/v1` is public,
CORS-open (`access-control-allow-origin: *`), and stable within the `v1` line — a breaking change
gets a `v2`, not a silent change under caller's feet.

## 2. Agent Contract

- **No API key for reads.** `GET /health`, `GET /chains`, `GET /tokens`, `GET /token`,
  `GET /balances`, and `POST /quote` all work anonymously. `POST /quote` without a credential
  returns *indicative* quotes — real routes, real pricing, but `quotes[].id` can't be built into a
  plan. This lets an agent compare providers before deciding whether the request is worth
  authenticating for.
- **A machine credential is required for anything that spends or writes**: `POST /swap`,
  `POST /swap/{buildId}/refresh`, `POST /multi-quote/build`, `POST /orders`, `POST /executions`,
  `GET /executions*`, `POST /ethflow/refund`. Send it as `Authorization: Bearer vswap_<id>_<secret>`.
- **Custody never changes hands.** This API builds calldata and EIP-712 typed data; it never asks
  for a private key or seed phrase, and rejects any request body that looks like one
  (`400 secret_in_payload`) before touching the database. The agent signs and sends everything
  itself.
- **Idempotency**: `POST /executions` is safe to retry with the same `txHash` — it looks the
  transaction up by `(chainId, txHash)` first and returns the existing record instead of erroring.
  `POST /swap` and `POST /swap/{buildId}/refresh` are not idempotent by design: each call is a
  fresh build with its own `buildId`, because the market moves between calls.
- **Rate limits are per-IP for anonymous calls, per-wallet plus per-credential-budget for
  authenticated ones.** A `429` means back off, not retry-immediately-in-a-loop.

## 3. Common Rules

### 3.1 Auth

Obtaining a credential is a same-origin browser flow (challenge → EIP-191 signature → issue),
outside the scope of this HTTP API — get one from whoever operates the wallet you're acting for.
Once you have one:

```
Authorization: Bearer vswap_<id>_<secret>
```

Credentials carry scopes (`read`, `build`) and a budget of 240 cost units per rolling 60-second
window. Cost per call: quote=4, build=8 (`/swap`, `/swap/{buildId}/refresh`), token=3, balances=2,
everything else=1. `403 scope_required` means the credential you have doesn't cover this call;
`429 budget_exhausted` means you're over budget for this window.

### 3.2 Amounts

- `amount` (request only): human decimal string, e.g. `"1.5"`. Converted server-side using the
  real token decimals.
- `amountRaw` / every response amount field (`amountIn`, `amountOut`, `minimumAmountOut`,
  `platformFeeAmount`, balances, etc.): raw integer string in the token's smallest unit. Parse as
  a bigint, never as a float — these regularly exceed float53 precision for 18-decimal tokens.

### 3.3 Tokens: address or symbol, never a silent guess

`tokenIn`/`tokenOut` in `POST /quote` and `POST /swap` accept a contract address or a symbol
(case-insensitive). A symbol that matches more than one token on that chain (a bridged variant,
usually) returns `409 ambiguous_token` with every match in `error.details.candidates` — the API
never silently picks the first one, because that decides where real money goes. Re-issue with the
address you meant.

### 3.4 Ranking

`GET /quote`'s `quotes[]` array is sorted by `netValueUsd` (output value minus estimated gas minus
platform fee), not by raw `amountOut`. The provider with the biggest number out isn't always the
best deal once gas is priced in — that's the whole point of a meta-aggregator.

### 3.5 Response error shape

```json
{ "error": { "code": "insufficient_balance", "message": "...", "details": {} } }
```

Match on `code`. `message` is safe to show a human, but it's not a stable contract. `details` is
only present on some errors (`ambiguous_token`, `slippage_exceeded`, `route_changed`) and its
shape depends on `code` — see the OpenAPI spec or §9 below.

## 4. Command Index

- `GET /health` — liveness
- `GET /chains` — supported networks
- `GET /tokens?chainId=` — curated token list
- `GET /token?chainId=&address=` — resolve an arbitrary address on-chain
- `GET /balances?chainId=&wallet=` — wallet balances, native + every curated token
- `POST /quote` — ranked routes, no auth required (indicative without a credential)
- `POST /swap` — build a signable/sendable plan
- `POST /swap/{buildId}/refresh` — rebuild with fresh pricing, slippage ceiling enforced
- `POST /multi-quote` — quote an N:N basket (N inputs funding M outputs), no auth required (indicative without a credential)
- `POST /multi-quote/build` — build one atomic transaction executing every leg of an N:N basket
- `POST /orders` — submit a signed CoW order
- `POST /executions` — record a sent transaction
- `GET /executions` — list this wallet's history
- `GET /executions/{id}` — re-check a recorded execution's on-chain status
- `POST /ethflow/refund` — build a refund for an expired, unfulfilled ETH-flow order
- `POST /report` — report a problem, no auth required

## 5. Commands and Examples

### 5.1 Quote

```bash
curl -sS https://swap.vurto.cc/v1/quote \
  -H 'content-type: application/json' \
  --data '{
    "chainId": 42161,
    "tokenIn": "USDC",
    "tokenOut": "WETH",
    "amount": "100",
    "slippageBps": 50
  }'
```

Returns `{requestId, resolved, quotes[], bestQuoteId, failures[], indicative, refreshedAt}`. Each
entry in `quotes[]` carries `executionKind` (`transaction` | `order` | `onchain_order`) telling
you upfront whether execution needs a transaction, an off-chain EIP-712 signature (CoW selling an
ERC-20), or a transaction that only registers an order (CoW ETH-flow, selling the native asset).

### 5.2 Build a plan

With a specific route:

```bash
curl -sS https://swap.vurto.cc/v1/swap \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer vswap_<id>_<secret>' \
  --data '{"chainId": 42161, "quoteId": "<id from a prior /quote>"}'
```

Or skip straight to the best route in one call:

```bash
curl -sS https://swap.vurto.cc/v1/swap \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer vswap_<id>_<secret>' \
  --data '{
    "chainId": 42161, "tokenIn": "USDC", "tokenOut": "WETH", "amount": "100",
    "maxSlippageBps": 100
  }'
```

The response is a `SwapPlan`: `buildId`, `buildHash`, `validUntil` (10 minutes out),
`simulation.status`, and `steps[]` in the order to execute them. See §6.

`maxSlippageBps` is optional and, once set, becomes a ceiling that
`POST /swap/{buildId}/refresh` can never loosen for this build's lineage — see §7.

#### Delivering to a different wallet

Pass `receiver` on `POST /quote` (or `POST /swap` when quoting and building in one call) to have
the output delivered to an address other than the one that signs:

```bash
curl -sS https://swap.vurto.cc/v1/quote \
  -H 'content-type: application/json' \
  --data '{
    "chainId": 42161, "tokenIn": "USDC", "tokenOut": "WETH", "amount": "100",
    "receiver": "0x71A9Aa12f70eC8E8541f1D79399beAa59e585C18"
  }'
```

Only providers that support a receiver different from the signer are returned —
check `supportsReceiver` on each quote. velora, kyberswap, cowswap and 1inch support it today;
openocean and 0x don't expose an equivalent parameter and are silently excluded from `quotes[]`
whenever `receiver` is set, rather than sending the output to the wrong address. When building
with an existing `quoteId`, the receiver from that quote is used; `receiver` on `POST /swap` only
applies to the one-call quote+build path.

### 5.3 N:N — basket swaps

N input tokens funding M output tokens in **one atomic on-chain transaction**, not a sequence of
independent swaps. The backend runs a waterfall allocation deciding which input finances which
output, then quotes the real `tokenIn -> tokenOut` route for each resulting slice through the same
provider fan-out `POST /quote` uses. This is not decomposable into separate `POST /quote` calls —
the allocation itself is the thing being computed.

```bash
curl -sS https://swap.vurto.cc/v1/multi-quote \
  -H 'content-type: application/json' \
  --data '{
    "chainId": 42161,
    "inputLegs": [{"tokenIn": "USDC", "amount": "100"}, {"tokenIn": "DAI", "amount": "50"}],
    "outputLegs": [{"tokenOut": "WETH", "outputPercent": 60}, {"tokenOut": "WBTC", "outputPercent": 40}],
    "slippageBps": 50
  }'
```

- `inputLegs[]` — what you sell: `tokenIn` (address or symbol) plus `amount` or `amountRaw`, same
  rules as `POST /quote`.
- `outputLegs[]` — what you want back, as `outputPercent`: an **integer percent of the TOTAL basket
  value**, not a fixed amount, because the actual split depends on the allocation. Every
  `outputLegs[].outputPercent` in the request must sum to exactly 100. Each leg can optionally set
  its own `receiver`.
- Up to 10 combined input+output legs; the waterfall never produces more than
  `inputLegs.length + outputLegs.length - 1` real on-chain legs.
- CoW is never a candidate route for a leg — an off-chain EIP-712 signature or a separately-settled
  ETH-flow order can't be one leg of a single atomic transaction, so every leg here executes as a
  transaction.

Returns `{requestId, chainId, wallet, pivot, legs[], failures[], indicative, refreshedAt}`. Each
entry in `legs[]` is a real quoted leg — `legIndex`, `inputLegIndex`/`outputLegIndex` (which request
leg it came from), `tokenIn`/`tokenOut`/decimals, `amountIn` (this leg's actual allocated share, not
the full `inputLegs[]` amount), and the full `NormalizedQuote` under `.quote`. `failures[]` lists
any slice that found no route — a partial basket is possible and reported, never silently dropped.
`pivot` is the internal USDC reference address the allocation prices everything against; it plays
no role in execution.

Building is a separate call, and always re-quotes the whole basket fresh — there is no `quoteId`
handoff for N:N, so pass the same `inputLegs`/`outputLegs`/`slippageBps` again (or skip straight to
this call):

```bash
curl -sS https://swap.vurto.cc/v1/multi-quote/build \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer vswap_<id>_<secret>' \
  --data '{
    "chainId": 42161,
    "inputLegs": [{"tokenIn": "USDC", "amount": "100"}, {"tokenIn": "DAI", "amount": "50"}],
    "outputLegs": [{"tokenOut": "WETH", "outputPercent": 60}, {"tokenOut": "WBTC", "outputPercent": 40}]
  }'
```

Returns `{buildId, buildHash, wallet, chainId, router, validUntil, legs[], simulation, steps[],
refreshedAt}`. `steps[]` holds one `approve` per **distinct** input token that still needs
allowance, followed by one `transaction` step executing every leg atomically. The approve `spender`
is `router` (the VurtoSwapRouter address for this chain) — **not** each leg's underlying provider,
because the router pulls every input token itself inside one contract call; approving providers
individually the way a plain `POST /swap` plan does would approve the wrong address here. If
`simulation.status` is `approval_required`, send the approve step(s) first and call this endpoint
again for the executable transaction.

`POST /executions` and `GET /executions/{id}` do **not** support this transaction step — both key
off a single `quoteId`, and a basket build has none. Confirm success via the transaction receipt
directly, not the execution-tracking endpoints. There is also no refresh endpoint for N:N (unlike
`POST /swap/{buildId}/refresh`) — rebuild by calling `POST /multi-quote/build` again with the same
intent.

### 5.4 Refresh a plan

```bash
curl -sS https://swap.vurto.cc/v1/swap/<buildId>/refresh \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer vswap_<id>_<secret>' \
  --data '{"slippageBps": 100}'
```

Returns a new `SwapPlan` with a new `buildId`, plus a `slippageBps` field: how many bps the output
dropped versus the *original* build in this refresh chain (0 if it didn't get worse). See §7 for
the full contract.

### 5.5 Submit a signed CoW order

Only for a plan whose `steps[]` contains `{"type": "signature", ...}`. Sign `typedData` with the
wallet (EIP-712), then:

```bash
curl -sS https://swap.vurto.cc/v1/orders \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer vswap_<id>_<secret>' \
  --data '{
    "chainId": 42161,
    "quoteId": "<from the plan step'"'"'s submit.bodyTemplate>",
    "signature": "<the EIP-712 signature you just produced>"
  }'
```

`chainId` and `quoteId` come straight from `steps[].submit.bodyTemplate` — the plan already fills
them in for you, only `signature` is genuinely yours to add. Returns `{uid, buildHash}`; `uid` is
the CoW order id (56-byte hex), there's no transaction hash for an off-chain order — cite `uid` in
support requests.

### 5.6 Record a sent transaction

```bash
curl -sS https://swap.vurto.cc/v1/executions \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer vswap_<id>_<secret>' \
  --data '{"txHash": "0x...", "quoteId": "<the quoteId this build came from>"}'
```

Only for `transaction`/`approve` plans — CoW order submissions record themselves via
`POST /orders`. The server independently confirms the transaction on-chain (via `rpc.vurto.cc`)
and checks its calldata hash against a build you own before writing anything; it does not trust
the caller's word for what happened.

### 5.7 List and re-check history

```bash
curl -sS https://swap.vurto.cc/v1/executions \
  -H 'authorization: Bearer vswap_<id>_<secret>'

curl -sS https://swap.vurto.cc/v1/executions/<id> \
  -H 'authorization: Bearer vswap_<id>_<secret>'
```

The second call re-polls the on-chain receipt (and, for a CoW ETH-flow transaction, the CoW
orderbook — a confirmed ETH-flow transaction only means the order was registered, execution
happens later by a solver, or never).

### 5.8 ETH-flow refund

For an expired CoW order that sold the chain's native asset and never got filled:

```bash
curl -sS https://swap.vurto.cc/v1/ethflow/refund \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer vswap_<id>_<secret>' \
  --data '{"chainId": 42161, "txHash": "<the original ETH-flow transaction hash>"}'
```

Returns a transaction ready to send that returns the funds. Fails `403 refund_not_yours` if the
original transaction wasn't sent from your credential's wallet.

### 5.9 Report a problem

```bash
curl -sS https://swap.vurto.cc/v1/report \
  -H 'content-type: application/json' \
  --data '{
    "message": "POST /swap returned insufficient_balance for a wallet with a real balance.",
    "source": "agent",
    "chainId": 42161,
    "context": {"quoteId": "...", "provider": "velora"}
  }'
```

Works with no credential at all — whoever is stuck before authenticating is exactly who most
needs this. Rate-limited 5 per 5 minutes per IP. Returns `{id, received: true}`; cite `id` if you
follow up elsewhere.

## 6. Executing a Plan's Steps

`steps[]` in a `SwapPlan` is ordered and must be executed in order:

- **`type: "approve"`** — an ERC-20 `approve(spender, amount)` call, calldata already built
  (`tx.to`, `tx.data`, `tx.value`). If `resetFirst: true`, this step zeroes the allowance first
  (some tokens, notably USDT, reject a non-zero-to-non-zero approve); a second `approve` step with
  the real amount follows it.
- **`type: "transaction"`** — the swap itself, ready-to-send calldata in `tx`.
- **`type: "signature"`** — sign `typedData` (EIP-712) with the wallet, no transaction to send.
  Then `POST` the fields in `submit.bodyTemplate` (plus your `signature`) to `submit.url` — see
  §5.5.

After sending a `transaction`/`approve` step, wait for its receipt before moving to the next step
or considering the swap done. After an `approve` step specifically, if `simulation.status` was
`approval_required`, call `POST /swap` again with the same `quoteId` (or the same intent) to get
the executable transaction now that the allowance is in place.

## 7. Refresh and Slippage Protection

A `SwapPlan` expires (`validUntil`) 10 minutes after it's built. Don't build a fresh plan from
scratch to extend that window — use `POST /swap/{buildId}/refresh` (§5.4), which:

- **Never loosens your slippage ceiling.** If you set `maxSlippageBps` at build time, every
  refresh of that build's lineage is capped by it, even if a later refresh call passes a higher
  `slippageBps` — the effective cap is always the smaller of the two. This exists specifically so
  a compromised or buggy client-side signing page can't quietly raise your tolerance until the
  swap becomes a donation.
- **Fails closed on a real route change.** If the refreshed build's final on-chain destination
  differs from the original (not just repriced — a genuinely different router or provider), it
  returns `409 route_changed` instead of silently building around a different route than the one
  you reviewed. When the original provider disappeared from the fan-out entirely, the error
  response embeds the current best route, ready to use, in `error.details.alternative` — you
  don't need a third round trip to get an executable plan back.
- **Reports the damage.** A successful refresh includes `slippageBps`: how many bps the output
  dropped versus the original build (0 if it didn't get worse).

If a refresh returns `409 quote_expired`, the underlying quote handoff (2 minutes) is gone — call
`POST /swap` fresh instead of retrying the refresh.

## 8. Minimum Pre-Trade Checklist

- Check `simulation.status` before sending anything from `steps[]`. `approval_required` means
  send the approve step(s) first, then rebuild. `incomplete` means the transaction in `steps[]`
  was never independently verified — don't send it.
- Prefer refresh (§7) over rebuilding from scratch once you've set `maxSlippageBps`.
- Recompute `buildHash` from the plan's inputs if you don't trust the transport, and compare
  before signing — it's a canonical hash of chainId, wallet, destination, calldata, and value.
- Don't retry a `POST /swap` or `/refresh` call in a tight loop on `429` — respect the budget
  window.

## 9. Common Error Classes

| Code | Where | Meaning |
| --- | --- | --- |
| `token_required` / `unknown_symbol` / `ambiguous_token` | quote, swap, multi-quote | Token resolution — see §3.3 |
| `invalid_amount` / `amount_required` | quote, swap, multi-quote | Bad or missing `amount`/`amountRaw` |
| `same_token` | quote, swap | tokenIn and tokenOut resolved to the same address |
| `no_routes` | quote, swap, multi-quote | No provider returned a safe route for this pair/amount right now |
| `insufficient_balance` | swap | The wallet doesn't hold enough of the input token |
| `quote_expired` | swap, refresh | The underlying quote handoff (2 min) is gone — quote again |
| `route_changed` | swap, refresh | The selected route disappeared or its final destination changed |
| `slippage_exceeded` | refresh | The refreshed price dropped more than the effective cap allows |
| `build_not_found` | refresh | Wrong wallet, or the build's 10-minute window expired |
| `invalid_slippage_bps` | swap, refresh | `slippageBps`/`maxSlippageBps` outside [0, 10000] |
| `invalid_input_legs` / `invalid_output_legs` / `too_many_legs` | multi-quote | Basket shape: need >=1 of each, and inputLegs+outputLegs <= 10 — see §5.3 |
| `invalid_output_percent` | multi-quote | An outputLegs percent isn't an integer 1-100, or they don't sum to 100 |
| `multi_swap_not_supported_on_chain` | multi-quote | VurtoSwapRouter isn't deployed on this chainId yet |
| `scope_required` | any credentialed call | The credential lacks the scope this call needs |
| `budget_exhausted` | any credentialed call | Over the 240-cost/60s budget for this credential |
| `secret_in_payload` | any write | The request body looks like it contains a private key or seed phrase |

## 10. Response Fields Agents Should Persist

- `buildId` and `buildHash` from every `SwapPlan` — needed to refresh, and to prove later what was
  actually signed.
- `quotes[].id` (the `quoteId`) if you plan to build later instead of immediately.
- `uid` from `POST /orders` — the only identifier a CoW order has; there's no transaction hash.
- `id` from `POST /executions` — your handle for `GET /executions/{id}` later.
