One deposit/withdraw entry point over every earn row
GET/v1/actions/earn/:action
One action entry point for every /v1/data/earn row. The earnUid a listing row carries is the only identity needed — this route parses it, decides which existing handler owns the row (lending vs vault, and which vault family), rewrites the query into that handler's vocabulary and calls it in-process. Nothing is built here and no route is duplicated: if a market can be transacted through /v1/actions/lending/* or /v1/actions/vaults/*, it can be transacted through here.
Routing
AAVE_V3:1:0xa0b8… → /v1/actions/lending/{deposit,withdraw}
vault.savings:1:0x9d39… → /v1/actions/vaults/{deposit,withdraw}
→ /v1/actions/vaults/savings (async verbs)
vault.lst:… (async) → /v1/actions/vaults/lst (queued LST exits)
vault.lagoon:… (async) → /v1/actions/vaults/withdraw (ERC-7540 requestRedeem / claimRedeem)
vault.gmx:… → /v1/actions/vaults/gmx (keeper tickets; no claim — the keeper pays out)
vault.hypercore:… → /v1/actions/vaults/{deposit,withdraw} with interface=hypercore
vault.pendle:… → traded on Pendle's AMM (slippage REQUIRED, bps)
Pay-asset conversion (vault rows)
A deposit whose payAsset differs from the vault's underlying is served HERE and nowhere else: the pay asset is quoted through the aggregators and the whole swap output is deposited in one composed transaction, shares to receiver. slippage (bps) becomes REQUIRED, and the built transactions come back as alternatives[] — one per aggregator, best output first. Only rows whose deposit is a plain synchronous ERC-4626 call qualify (that is what acceptsPayAsset on the row's capability advertises); paying native into a wrapped-native vault stays a plain wrap with no bound. /v1/actions/vaults/deposit deliberately does NOT gain this behaviour.
Verbs
deposit/withdraw— the synchronous pair. On a lending market both are same-block; on a vault,withdrawis only published where the exit really is synchronous.request-withdraw/claim/cancel— the async exit cycle, vaults only. Asking for one on a lending market is a 400, never a silent fallback towithdraw.
Which verbs a row supports — and which extra params each needs — is published on the row itself as capabilities[] (see /v1/data/earn). Render the CTA from that, not from provider knowledge: requires names params like slippage (Pendle, bps), executionFee/longToken/shortToken (GMX), or validator (delegated LSTs).
Parameter forwarding
Only earnUid and the verb are consumed. Everything else — payAsset, receiveAsset, slippage, isShares, isAll, mode, Yield Basis's debt/minShares, Strata's claimToken, Apyx's tokenId, an LST's kind, GMX's executionFee, an ERC-7540 sub-action (action=claimWithdraw), … — rides through verbatim to the owning handler, so provider-specific inputs need no support here.
Response is the standard action envelope: permissions[] (approvals, executed first), transactions[] (signed in order), alternatives[] where a trade offers multiple routes, plus per-venue state (e.g. dbr on Inverse) in data.
Plain-text reference — GET /v1/actions/earn/{action}
Parameters
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
action | path | deposit, withdraw, request-withdraw, claim, cancel | yes | The verb. May also be passed as ?action= on the bare /v1/actions/earn path. |
earnUid | query | string | yes | The row identity from /v1/data/earn, verbatim and OPAQUE — never rebuild it from parts. Lending rows: the marketUid (AAVE_V3:1:0xa0b8…). Vault rows: vault.<provider>:<chainId>:<shareToken>. |
operator | query | string | yes | User wallet executing the action. receiver defaults to it. |
amount | query | string | no | Raw amount (wei). Required for deposit / withdraw / request-withdraw unless isAll=true. Denomination follows the owning route: assets by default, shares when isShares=true, PT units for Pendle. |
receiver | query | string | no | Recipient. Defaults to operator. |
payAsset | query | string | no | deposit — what the user actually pays, where the row publishes acceptsPayAsset. Zero address / 0xEEEE… = native. On a vault row a payAsset that differs from the underlying triggers the aggregator conversion (see above); amount is then denominated in the PAY asset and slippage is required. |
receiveAsset | query | string | no | withdraw — what the user wants out, where the row publishes acceptsReceiveAsset. |
slippage | query | string | no | Basis points. Required wherever the row’s capability lists it in requires (any leg that settles on a book — Pendle PTs in both directions). |
isShares | query | boolean | no | Interpret amount as shares (mint / redeem) instead of assets. |
isAll | query | boolean | no | Full-balance exit (vault withdraw). Not supported on swap-routed rows (Pendle) — pass the explicit amount there. |
simulate | query | boolean | no | Lending rows only — attach a post-trade simulation. |
Response 200
| Field | Type | Description |
|---|---|---|
success | True | |
data | object | Informational data (quotes, simulation results, etc.) |
actions | object | Transaction calldata and approvals. Null for quote-only responses (no account provided). |
actions.transactions | object[] | Pre-trade setup transactions (e.g. e-mode switch, collateral enable). Execute these before the main swap. Empty when no setup is needed. |
actions.transactions[].chainType | evm | Which VM executes this step. ABSENT means evm, which is the only value any endpoint returns today — every EVM response is unchanged. A non-EVM chain would return a different shape (a serialized, PERISHABLE transaction rather than to/data/value) carrying its own chainType, so a client that wants to stay forward-compatible should branch on this field rather than assume to is present. |
actions.transactions[].to | string | Target contract address |
actions.transactions[].data | string | Encoded calldata |
actions.transactions[].value | string | ETH value to send with the transaction |
actions.transactions[].description | string | Human-readable label. For alternatives, this is the aggregator name (e.g. "Paraswap"). For transactions, describes the setup action (e.g. "Switch e-mode to 1"). |
actions.alternatives | object[] | DEX aggregator swap transactions sorted by best output (descending). Each entry's description is the aggregator name. The client should pick one to execute. Present on loop action endpoints. |
actions.alternatives[].chainType | evm | Which VM executes this step. ABSENT means evm, which is the only value any endpoint returns today — every EVM response is unchanged. A non-EVM chain would return a different shape (a serialized, PERISHABLE transaction rather than to/data/value) carrying its own chainType, so a client that wants to stay forward-compatible should branch on this field rather than assume to is present. |
actions.alternatives[].to | string | Target contract address |
actions.alternatives[].data | string | Encoded calldata |
actions.alternatives[].value | string | ETH value to send with the transaction |
actions.alternatives[].description | string | Human-readable label. For alternatives, this is the aggregator name (e.g. "Paraswap"). For transactions, describes the setup action (e.g. "Switch e-mode to 1"). |
actions.permissions | object[] | Approval/delegation transactions that must execute before both transactions and alternatives. Includes ERC20 allowances (targeting the composer contract) and lender borrow/withdrawal delegations (targeting the lending protocol contract directly). Filtered against on-chain state so only missing approvals are returned. Null when no approvals are needed. |
actions.permissions[].to | string | Target contract address |
actions.permissions[].data | string | Encoded calldata |
actions.permissions[].value | string | ETH value |
actions.permissions[].description | string | Human-readable description of the approval (e.g. "Approve borrow for AAVE_V3", "Approve ERC20") |
actions.permissions[].spender | string | ERC-20 approve spender (x-chain permissions). Match it against the selected quote's approvalTarget — several bridges can share one spender, so do not match by description. |
actions.permissions[].type | ERC20, Lender | What kind of grant this is. A signatures[] offer names the type it replaces via its replaces field. |
actions.signatures | object[] | EIP-712 payloads the user can sign INSTEAD of sending the corresponding permissions entry. Only present when the caller opted in with permit=auto|required and a permit path exists. Sign with eth_signTypedData_v4, then POST the same endpoint with {permits: [{permitId, signature}]} (plus builds where the response issued buildIds) — the permit executes inside the main transaction. |
actions.signatures[].permitId | string | Opaque, self-describing handle for this permit. Round-trip it verbatim: POST {permits: [{permitId, signature}]} back to the same endpoint. |
actions.signatures[].kind | erc2612, dai, permit2, aaveCredit, morphoAuth, cometAuth | Permit flavour |
actions.signatures[].typedData | object | Ready for eth_signTypedData_v4 (domain, types incl. EIP712Domain, primaryType, message). All numeric fields are decimal strings. |
actions.signatures[].replaces | string | Which permissions entry this signature replaces (ERC20 | Lender) |
actions.signatures[].spender | string | Who the signature authorises. Match against the permission / quote (approvalTarget) it replaces — on multi-spender responses (x-chain) only routes whose approvalTarget equals this spender are covered by the signature. |
actions.signatures[].description | string | Human-readable label for this entry. |
actions.signatures[].unscoped | boolean | True when the grant is NOT amount-scoped (full position control until revoked) — surface this to the user. |
actions.signatures[].deadline | string | Unix seconds after which the signature is worthless (default: 30 minutes). |
actions.permitSkipped | object[] | Why a permit was NOT offered for a permission, when one was asked for (permit=auto|required). The corresponding approve transaction stands. |
actions.permitSkipped[].replaces | string | Which permission kind stays a transaction (ERC20 | Lender) |
actions.permitSkipped[].reason | string |
Example response
{
"success": true,
"data": {},
"actions": {
"transactions": [
{
"chainType": "evm",
"to": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2",
"data": "0x617ba037000000000000000000000000c02aaa39b2",
"value": "0",
"description": "string"
}
],
"alternatives": [
{
"chainType": "evm",
"to": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2",
"data": "0x617ba037000000000000000000000000c02aaa39b2",
"value": "0",
"description": "string"
}
],
"permissions": [
{
"to": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"data": "0x617ba037000000000000000000000000c02aaa39b2",
"value": "0",
"description": "string",
"spender": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"type": "ERC20"
}
],
"signatures": [
{
"permitId": "string",
"kind": "erc2612",
"typedData": {},
"replaces": "string",
"spender": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"description": "string",
"unscoped": true,
"deadline": "string"
}
],
"permitSkipped": [
{
"replaces": "string",
"reason": "string"
}
]
}
}
Request
Responses
- 200
- 400
- 429
- 500
- 502
Action built by the owning handler. permissions[] first, then transactions[] in order.
Validation error
Rate limited. Unauthenticated callers share a per-IP budget; send an x-api-key header to lift it. Retry with exponential backoff.
Unexpected server error. Safe to retry with backoff.
An upstream data source or protocol origin failed (error.code is ORIGIN_FAILED). error.details carries the per-origin status. This is also what a missing or malformed required parameter currently returns, rather than a 400.