# ZendIQ Agent API — Full Reference > Execution-layer swap protection for Solana, exposed as four HTTP endpoints an agent can call > and pay for per request. ZendIQ never holds keys or funds. `/analyse` builds no transaction; > `/optimize` returns an unsigned transaction you sign yourself. You submit it, except on a Jito > bundle venue, where you post the signed transaction back to the free `/v1/agent/bundle`. --- ## Read this before you call anything **Mainnet: calls are paid in real USDC.** Everything runs on Solana mainnet: paying for the call, the analysis, and the transaction `/optimize` builds. | Part of a call | Network | Notes | |---|---|---| | Paying for any call (x402), `/optimize` included | **Mainnet USDC** | $0.01 for `/analyse`, $0.02 for `/optimize`; `/analyse-token` is free. The paying wallet needs USDC and no SOL: the facilitator sponsors the payment's network fee. | | The analysis: token scores, sandwich exposure, quotes | **Mainnet** | Read from live mainnet liquidity, so a verdict describes a trade you can actually make. | | The swap you sign, as built by `/optimize` | **Mainnet** | It is built against real liquidity for a real wallet, the `taker`, and it lands on mainnet if you sign it. Treat it accordingly. | One mainnet wallet can both pay and trade, or you can keep them apart (see "Paying wallet and taker"). The x402 challenge and the `network` field of `GET /v1/agent` name the payment network, so read it from there rather than hardcoding it. ### Paying wallet and taker A paid `/optimize` call involves two roles, which one wallet can fill or two can split: - The **paying wallet** signs the x402 payment authorization. It holds **mainnet USDC** and needs no SOL: the facilitator sponsors the payment's network fee. - The **`taker`** is the wallet the swap is built for, and the one that signs and submits it. It is a **mainnet** wallet. The `taker` must hold both: - the input amount of the input token, and - mainnet **SOL** for the network fee, any priority fee, and rent for each token account the swap has to open, such as the output token's account the first time you buy that token. On a SOL input, the SOL being swapped comes on top. The one exception is a gasless Jupiter Ultra fill, where Jupiter is the fee payer and `plan.priorityFee.feePayer` is not your `taker`. The SOL requirement does not apply to it. A `taker` that cannot fund the trade gets `422 taker_insufficient_balance`, uncharged, naming the token and the shortfall (see "When the taker cannot fund the trade"). ### Trying `/optimize` without a funded wallet (build only) `taker` is only a public key. `/optimize` signs nothing and never needs the taker's key, so you can pass any funded mainnet address, one that holds the input amount and some SOL, and read the full response: the `plan`, the `simulation` and `plan.venueDecision`. **This is build-only.** You cannot sign or submit a transaction built for a wallet you do not control. To land a trade, pass your own mainnet wallet as `taker`. **The server is the source of truth.** Everything in this file is also declared, in machine form, at `GET https://api.zendiq.ai/v1/agent`. That endpoint is unauthenticated and free. If this document and that manifest ever disagree, the manifest is correct — fetch it at runtime rather than hardcoding what you read here. --- ## Base URL ``` https://api.zendiq.ai ``` `https://zendiq-backend.onrender.com` serves the same API and remains valid, but use the address above. All endpoints are `POST` and take `Content-Type: application/json`, except the manifest (`GET /v1/agent`), which takes nothing and costs nothing. Every response carries an `X-API-Version` header. When a version is being retired, `Deprecation` and `Sunset` headers (RFC 8594) appear alongside it. Both headers are set before the payment gate, so you will see them on a `402` challenge too — you can detect a sunset without paying for a call. --- ## Paying for a call: the x402 flow ZendIQ uses the [x402](https://x402.org) protocol. There are no API keys and no accounts. 1. You `POST` your request body to a paid endpoint. 2. The server validates the request **before** asking for payment. A malformed body gets `400 invalid_request`, an address that is not a token mint gets `422 not_a_mint`, and on `/optimize` a `taker` that does not hold the input amount gets `422 taker_insufficient_balance`. None of these is preceded by a `402`, so you never sign a payment for a request that cannot be served. 3. A valid request gets `402 Payment Required` with a base64-encoded `PAYMENT-REQUIRED` header describing the price, the asset, the network, and the address to pay. 4. Your client signs a payment authorization for exactly that amount and retries the same request with a `PAYMENT-SIGNATURE` header. `X-PAYMENT` is accepted as an alias; a bare `PAYMENT` header is not read and will loop you back to another `402`. 5. The server verifies and settles, then returns `200` with the result and a `PAYMENT-RESPONSE` header containing the settlement transaction. Notes that matter in practice: - **A `429` does not consume a payment authorization.** Back off for `Retry-After` seconds and present the *same* authorization again. Do not sign a new one. - **Any response `≥ 400` cancels settlement.** A failed `/optimize` build charges nothing. - **`/analyse` always settles on success**, because the verdict itself is the deliverable — a `Refuse` verdict is a useful answer, not a failure. - **`/analyse-token` is registered ahead of the payment gate** and is never charged. ### Settlement can hold the response for up to 90 seconds The server settles your payment after your result is ready, and replies only once it knows whether the transfer landed. That usually takes a second or two. If the facilitator cannot confirm the transfer itself (seen when the payment network's RPC is rate-limiting it), the server reads the chain until the transfer confirms, fails, or can no longer land because the blockhash you signed against has expired, about 60 s after you signed. It never waits more than **90 s**. Keep the connection open. Disconnecting does not stop a transfer that was already sent. If you do disconnect, present the same authorization again: while it is still settling you get `409` with `priorOutcome: in_flight`, and once it has settled you get the original `200` with `replayed: true`. How each outcome reaches you: | Response | `errorReason` in `PAYMENT-RESPONSE` | Charged | What to do | |---|---|---|---| | `200` | — | Yes | The result is in the body. | | `402` | `settlement_failed_on_chain` or `settlement_not_landed` | No | The transfer did not move. Sign a fresh authorization. | | `402` | `settlement_pending` | **Maybe** | Not confirmed either way within 90 s. **Keep the payment header and retry once, with the same header, after a few seconds** (see below). Do not sign a new authorization. | ### Retry once after `settlement_pending` The server keeps checking the chain after it answers. If the transfer lands, the payment is recorded as charged and not served. Presenting the **same** `PAYMENT-SIGNATURE` header again then redeems it for one fresh response. The request body may differ, so an `/optimize` is rebuilt from current quotes. Wait about 5 s before the retry so the server has seen the transfer. - **One redemption per payment.** A redeemed response carries `PAYMENT-RESPONSE` with `success: true`, `redeemed: true` and the original settlement `transaction`. A later retry with the same header gets `409 already_served`. - **Same route only.** A payment made for `/analyse` ($0.01) cannot be redeemed on `/optimize` ($0.02), or the other way round: `409 redemption_route_mismatch`. - **Within 24 h of payment.** After that the retry gets `409 redemption_expired`; email support@zendiq.ai with the settlement signature for a refund. - **A failed build does not use it up.** If the redemption returns an error status, for example a `502`, the payment stays redeemable. - **Still unknown.** If the chain has not answered yet, the retry gets `409 settlement_unresolved` with `charged: null`. Do not pay again; retry later or contact support with the signature. Every one of these `409`s carries `charged` (`true`, `false` or `null`) and the settlement signature, so you can book the payment without guessing. The example client and the MCP server do this retry for you. Rate limits: **60 requests per IP per minute**, **30 per payer per minute** on the paid endpoints. `/analyse-token` has its own per-IP limit, **240 per minute**. --- ## Latency, completeness and reusing an analysis ### How long a call takes Most of a call's time is **token screening**: sixteen checks against on-chain data, RugCheck, DexScreener, GeckoTerminal and pump.fun. One scan is shared across all callers and cached per mint for **60 seconds**. | Situation | Typical | Upper bound | |---|---|---| | First scan of a token (not cached) | 1–5 s | ~12 s | | Same mint again within 60 s, by anyone | under 1 s | under 1 s | | `/optimize` with a valid `analysisId` | 1–2 s | token screening skipped | Measured on the live service on 2 Oct 2026, across USDC, BONK, JUP, RAY, POPCAT and six pump.fun mints under a minute old: first scans took 0.5–4.6 s. The upper bound comes from the per-source timeouts; the longest is the token's signature history, read in two pages of up to 6 s each. Creator history has its own 4 s budget (see "No partial results"). **Set your client timeout to at least 120 seconds** on `/analyse` and `/optimize`: up to ~12 s of scanning plus up to 90 s of settlement (see "Settlement can hold the response"). `/analyse-token` is not paid, so **30 seconds** is enough there. A shorter timeout can abandon a call that would have succeeded. The x402 payment authorization is valid for 60 seconds, which covers the scan. ### No partial results A scan always runs to completion. It is never cut short and returned early with some checks missing, because an agent cannot act on a partly scanned token: a missing creator-history check looks exactly like a clean one to anything that does not read every field. So a first scan takes as long as its slowest source, bounded by that source's timeout. One read is bounded on purpose. Creator history (`creator_launches_30d`) has a 4 s budget. When a creator's record is longer than that allows, the check returns the launches counted so far as a **lower bound**, with `reason: "partial_scan_lower_bound"`. Its status is then `warn` or `fail`, never `pass`: "two launches found so far" does not rule out a serial launcher. ### Reading unknowns Each source has its own timeout. A source that errors, or runs its full timeout without answering, is a real **unknown**. That is not a partial result: the scan finished, and it is reporting what it could not find out. - Every check appears in `signals[]` with a `status` of `pass`, `warn`, `fail` or `unknown`. An `unknown` carries a `reason`, such as `creator_history_unavailable` or `rugcheck_unavailable`. - `signals_resolved` is the coverage, as resolved checks over the total: `"11/16"` means five checks came back unknown. - An unknown never lowers the score. Most add an explicit factor to `tokenRisk.factors`, such as `Creator history: unavailable` with severity `MEDIUM` and +5 caution points, so a token that could not be checked cannot score as clean. If no data source answered at all, the score gets +15 and a `Token data unavailable` factor. - A market figure no real token can have is an unknown too, with `reason: "implausible_value"`: a market cap above $5T, or a 24h gain above +10,000% on a token more than 30 days old. `value` is null and the rejected figure is in `rejected_value`, in the same unit. Do not quote it as fact. It adds a MEDIUM `…: implausible value` factor with +5 caution points. - Market fields come from one DexScreener pool, named in `inputs.marketPair` with how many pools were considered and how many were dropped for disagreeing with the median price by more than 5×. - The three price-history checks (`price_change_3m`, `price_change_long_term`, `volume_trend`) read daily candles that are cached for up to 6 hours. `inputs.priceHistory` says whether they were fetched on this scan (`source: "fresh"`) or came from the cache (`"cache"`, with `fetched_at` and `cache_age_s`). When the price-history source is rate-limiting, all three are `unknown` with `reason: "rate_limited"` and a `Price history unavailable` factor (+5); a later call can resolve them. - The exception is a short list of established assets: regulated stablecoins (USDC, USDT and a few bridged assets), established protocol tokens (SOL, JUP, RAY and others) and liquid staking tokens. They carry an asset-class factor such as `Regulated stablecoin`, whose detail names the checks that do not apply to that class (for a stablecoin: authorities, holder concentration, LP lock, price history, creator history, bundle check). Those checks are not scored, so an unknown on one of them adds nothing. Every other check still bites. **Treat unknown as unknown, never as safe.** A `LOW` score with `signals_resolved: "6/16"` means six checks passed and ten were never answered. It does not mean the token is low-risk. Weigh coverage alongside the score, and require the checks you care about to be resolved. ### Reusing an analysis: the `analysisId` handshake `/analyse-token` and `/analyse` return an `analysisId` for the token scan behind the response. Pass it to `/optimize` within 60 seconds and `/optimize` reuses that scan instead of running a new one. That turns a multi-second call into one of 1–2 s. **What is reused, and what is fetched again.** Token facts are stable over seconds; market state is not. | Reused from the analysis (token facts) | Fetched fresh by `/optimize` (market state) | |---|---| | Mint and freeze authority | The Jupiter quote and route | | Holder concentration | Price impact at the moment of building | | RugCheck report and LP lock | Sandwich exposure for this trade size | | Launch platform | Network congestion | | Token age | The SOL price and the snapshot slot | | Creator history and creator rug rate | Every venue quote, the build and the simulation | | Bundled-launch check (creation slot) | | | Liquidity, market cap and price history as observed at scan time | | The reused score is the one returned with the `analysisId`, at most 60 seconds old, and `/optimize` states its age. A liquidity pull in those seconds still shows up in the fresh quote and price impact. A worked example. Screen first: ``` POST /v1/agent/analyse-token { "mint": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" } ``` ```json { "stage": "screen", "mint": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", "analysisId": "an_Q2hsZ3v0mK7pX1aRt9Yc", "analysisExpiresAt": "2026-09-25T21:41:07.512Z", "signals_resolved": "9/16", "tokenRisk": { "symbol": "Bonk", "score": 35, "level": "MEDIUM", ... }, "cache": { "hit": false, "ageSeconds": 0, "observedAt": "2026-09-25T21:40:07.512Z" }, ... } ``` Then build, passing the ID, within 60 seconds: ``` POST /v1/agent/optimize { "inputMint": "So11111111111111111111111111111111111111112", "outputMint": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", "amount": "10000000", "slippageBps": 50, "taker": "", "analysisId": "an_Q2hsZ3v0mK7pX1aRt9Yc" } ``` ```json { "analysis": { "source": "reused", "reason": null, "analysisId": "an_Q2hsZ3v0mK7pX1aRt9Yc", "expiresAt": "2026-09-25T21:41:07.512Z", "observedAt": "2026-09-25T21:40:07.512Z", "ageSeconds": 4 }, "tokenRisk": { "symbol": "Bonk", "score": 35, "level": "MEDIUM" }, "plan": { ... }, "transaction": "", ... } ``` The `evidence_fingerprint` in both responses is identical, which is how you can confirm the build was scored on the evidence you screened. **The ID belongs to one mint.** It must match `/optimize`'s `outputMint`, the token being bought. For `/analyse` that is its `outputMint`; for `/analyse-token` it is `mint`. ### When the ID cannot be used **A missing, expired or unknown `analysisId` is not an error.** `/optimize` runs the token scan itself and says so in `analysis`: | `analysis.source` | Meaning | |---|---| | `reused` | Your `analysisId` was valid; its scan was reused. `reason` is `null`. | | `cached` | No usable ID, but a completed scan of this mint from the last 60 s (by any caller) was used. | | `full_scan` | A new scan was run for this call. | `reason` explains why the ID was not used: `no_analysis_id`, `analysis_id_expired` (older than 60 seconds), or `analysis_id_unknown` (never issued by this server). IDs are held in server memory, so a server restart makes every earlier ID unknown. The next call is slower, but it does not fail. The `analysisId` in `analysis` is always the one for the scan actually used, so you can reuse it. **An `analysisId` for a different mint is rejected**, with `400 analysis_mint_mismatch`, and you are not charged. It is not quietly replaced with a scan of the right mint. Pairing one token's analysis with another token's trade is a bug in the caller, and fixing it silently would hide that bug while you believed you were trading on a screened token. The error body names both mints (`analysisMint`, `outputMint`). Screen the right mint, or omit `analysisId`. --- ## POST /v1/agent/analyse-token **Free.** Screens a single SPL token mint. ### Request ```json { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" } ``` | Field | Type | Required | Notes | |--------|--------|----------|-----------------------------------------------| | `mint` | string | yes | Base58 pubkey, 32–44 chars, `[1-9A-HJ-NP-Za-km-z]` | No wallet, payment or key is needed. With curl: ```bash curl -s -X POST https://api.zendiq.ai/v1/agent/analyse-token \ -H "content-type: application/json" \ -d '{"mint":"DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263"}' ``` With Python, standard library only: ```python import json, urllib.request req = urllib.request.Request( "https://api.zendiq.ai/v1/agent/analyse-token", data=json.dumps({"mint": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263"}).encode(), headers={"content-type": "application/json"}, ) with urllib.request.urlopen(req, timeout=30) as resp: body = json.load(resp) risk = body["tokenRisk"] print(risk["symbol"], risk["score"], risk["level"], "signals", body["signals_resolved"]) ``` ### Response `200` ```json { "schema_version": "1.3", "stage": "screen", "mint": "...", "snapshot": { "slot": 361244019, "observed_at": "2026-09-21T08:00:00.000Z" }, "analysisId": "an_Q2hsZ3v0mK7pX1aRt9Yc", "analysisExpiresAt": "2026-09-21T08:01:00.000Z", "evidence_fingerprint": "a1b2c3d4e5f60718", "signals_resolved": "12/16", "signals": [ ... ], "inputs": { ... }, "tokenRisk": { "mint": "...", "symbol": "USDC", "score": 4, "level": "LOW", "factors": [ ... ], "dataSource": "..." }, "cache": { "hit": false, "ageSeconds": 0, "observedAt": "..." }, "disclaimer": "...", "latencyMs": 412 } ``` If the token cannot be screened, the call returns `502 analysis_unavailable` rather than a score it could not compute. There is no degraded `200` on this endpoint: a screen that could not run never looks like a clean screen. A slow source does not cause this: sources that time out are reported as unknowns inside a `200` (see "Reading unknowns"). `evidence_fingerprint` is a 16-character SHA-256 prefix over the evidence used. `signals_resolved` tells you how many of the 16 signals actually resolved — treat a low count as low confidence. `analysisId` lets `/optimize` reuse this scan for the next 60 seconds (see "Reusing an analysis"). --- ## POST /v1/agent/analyse **$0.01.** Returns a verdict for a proposed swap. Builds no transaction. ### Request ```json { "inputMint": "So11111111111111111111111111111111111111112", "outputMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "amount": "1000000000", "slippageBps": 50 } ``` | Field | Type | Required | Notes | |---------------|---------|----------|--------------------------------------------------------------| | `inputMint` | string | yes | Base58 pubkey. Must differ from `outputMint`. | | `outputMint` | string | yes | Base58 pubkey. | | `amount` | string | yes | **Atomic units, as a decimal string**, 1–20 digits, `> 0`. Not a number, not a float. `1 SOL` is `"1000000000"`. | | `slippageBps` | integer | no | `0`–`10000`. Omit to let the router decide. | ### Response `200` ```json { "schema_version": "1.3", "snapshot": { "slot": 361244019, "observed_at": "2026-09-21T08:00:00.000Z" }, "analysisId": "an_7bYk2QmPz4Lr9WcX0eNd", "analysisExpiresAt": "2026-09-21T08:01:00.000Z", "evidence_fingerprint": "a1b2c3d4e5f60718", "signals_resolved": "12/16", "signals": [ ... ], "inputs": { ... }, "overallRisk": { "score": 22, "level": "LOW", "floored": false }, "executionRisk": { "score": 18, "level": "LOW", "factors": [ ... ] }, "verdict": "Safe", "confidence": "high", "reasons": [ "..." ], "recommendedExecution": { "path": "jupiter_direct", "priorityFeeLamports": null, "jitoTipLamports": null }, "tokenRisk": { "mint": "...", "symbol": "USDC", "score": 4, "level": "LOW", "factors": [ ... ], "dataSource": "..." }, "sandwichExposure": { "score": 12, "level": "LOW", "estimatedLossPercentage": 0.1, "estimatedLossUsd": 0.02, "confidence": "medium", "factors": [ ... ] }, "route": { "type": "...", "swapType": "...", "hops": 1, "legs": 2, "split": true, "priceImpactPct": 0.0004, "slippageBps": 50, "inAmount": "...", "outAmount": "...", "inUsdValue": 0, "outUsdValue": 0 }, "marketContext": { ... }, "degraded": null, "disclaimer": "...", "latencyMs": 730 } ``` ### The verdict `verdict` is a **closed enum within v1**. It will only ever be one of: | Value | Meaning | |-----------|-------------------------------------------------------------------------| | `Safe` | Proceed. No material token or execution risk found. | | `Protect` | Proceed only with the protection described in `recommendedExecution`. | | `Refuse` | Do not execute this swap. | New verdict values will not be added inside `v1`. You can switch on it exhaustively. `recommendedExecution.path` is `jupiter_direct` on `Safe`, `jito_bundle` on `Protect`, and `none` on `Refuse`. `priorityFeeLamports` is `null` when the venue should size the fee itself; `jitoTipLamports` is non-null only on `jito_bundle`, because a tip outside a bundle buys nothing. ### Reading `sandwichExposure` `estimatedLossPercentage` and `estimatedLossUsd` are a **model estimate, not a measurement**. The sandwich risk `score` maps to a fixed share of the trade: | `score` | `estimatedLossPercentage` | |----------|---------------------------| | 80+ | 2% | | 60–79 | 0.8% | | 40–59 | 0.3% | | 20–39 | 0.05% | | below 20 | 0.01% | It is the expected cost, not the worst case. If a trade is actually sandwiched, the loss is bounded by its slippage tolerance, which can be many times larger. `confidence` is `high` when pool liquidity was known when scoring, or when the fill is off-chain (RFQ: there is no pool to sandwich), and `medium` otherwise. ### Degraded responses `tokenRisk`, `sandwichExposure` and `route` each degrade independently to an explicit `{ "available": false, ... }` object when their data source fails. They never silently return a safe-looking score they could not actually compute. The top-level `degraded` field names what failed. It is `null` when all three sub-analyses (token screening, route and sandwich exposure) produced a result, and otherwise a non-empty array of `"source:reason"` strings — for example `["token_screening:unavailable", "sandwich_exposure:unavailable"]`. Test it for `null`; do not compare it against `true`. Check it before acting on the verdict at full confidence. `degraded` does not report individual token checks. A completed scan with `signals_resolved: "7/16"` still has `degraded: null`, because screening ran; the nine unknowns are in `signals[]`. Read `signals_resolved` for coverage and `degraded` for whole sub-analyses that failed. **When token screening does not complete** (an unexpected upstream failure), `/analyse` still returns `200` and fails closed. A slow source is not a failure: it is reported as an unknown inside a completed scan. ```json "tokenRisk": { "mint": "...", "available": false, "error": "...", "assumedScore": 50, "assumedLevel": "HIGH", "note": "Screening did not complete. Fees and overall risk were sized as if this token scored HIGH." } ``` There is no `score` or `level`. Fees and `overallRisk` are sized as if the token scored `assumedLevel`, never as if it scored 0. The verdict is `Protect` with `confidence: "low"`, `degraded` contains `token_screening:`, and `reasons` says screening did not complete. A `Protect` reached this way means the token was **not checked**, not that it was found risky. No `analysisId` is returned for a scan that did not complete. --- ## POST /v1/agent/optimize **$0.02.** Returns an unsigned transaction plus the arithmetic behind it. Takes every field `/analyse` takes, plus a required `taker` and optional `analysisId` and `method`. | Field | Type | Required | Notes | |--------------|--------|----------|----------------------------------------------------------| | `taker` | string | yes | Base58 pubkey of the mainnet wallet that will sign and submit. Must hold the input amount and SOL for fees and rent, except on a gasless Ultra fill. Can be the paying wallet or a different one; see "Paying wallet and taker". | | `analysisId` | string | no | From `/analyse-token` or `/analyse` for this `outputMint`, within 60 s. Skips the token scan. A missing, expired or unknown ID means a full scan, never an error; an ID for another mint is `400 analysis_mint_mismatch`. | | `method` | string | no | `"jito"` forces a Jito bundle venue (`raydium_jito` or `jupiter_swap_jito`), even where risk scoring would not bundle. Only bundle venues are considered and nothing unbundled is substituted; `plan.choice` reads `"forced"`. Any other value is `400`. | ### Response `200` A real response for 0.003 SOL → BONK on mainnet data (3 Oct 2026), shortened only where marked: ```json { "schema_version": "1.2", "snapshot": { "slot": 452897507, "observed_at": "2026-10-03T09:48:15.109Z" }, "analysis": { "source": "full_scan", "reason": "no_analysis_id", "analysisId": "an_...", "expiresAt": "...", "observedAt": "...", "ageSeconds": 0 }, "evidence_fingerprint": "1bfbcdf8bf5df8f3", "signals_resolved": "13/16", "signals": [ ... ], "inputs": { ... }, "overallRisk": { "score": 50, "level": "HIGH", "floored": true }, "executionRisk": { "score": 35, "level": "MEDIUM", "factors": [ ... ] }, "verdict": "Protect", "confidence": "high", "reasons": [ "Output token scored HIGH (61/100) — the token class most associated with sandwich targeting.", "..." ], "tokenRisk": { "mint": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", "symbol": "Bonk", "score": 61, "level": "HIGH" }, "sandwichExposure": { "score": 5, "level": "LOW", "estimatedLossPercentage": 0.01, "estimatedLossUsd": 0.000036, "confidence": "medium" }, "plan": { "venue": "jupiter_ultra", "venueLabel": "Jupiter Ultra", "venueFallback": null, "venueDecision": { "evaluated": true, "basis": "net_benefit", "baseline": "jupiter_ultra", "chosen": "jupiter_ultra", "marginUsd": 0.000358, "advantageUsd": null, "candidates": [ { "venue": "jupiter_ultra", "status": "quoted", "reason": null, "error": null, "inAmount": "3000000", "outAmount": "9902965899", "outUsd": 0.357519, "unspentInputUsd": 0, "priorityFeeUsd": 0.000068, "tipUsd": 0, "expectedMevLossUsd": 0, "landingRiskUsd": 0, "netUsd": 0.357451 }, { "venue": "raydium", "status": "ineligible", "reason": "the verdict is not Safe; an unprotected route is not eligible", "error": null }, { "venue": "raydium_jito", "status": "quoted", "reason": null, "error": null, "inAmount": "3000000", "outAmount": "9890851920", "outUsd": 0.357082, "unspentInputUsd": 0, "priorityFeeUsd": 0, "tipUsd": 0.002386, "expectedMevLossUsd": 0, "landingRiskUsd": 0.001, "netUsd": 0.353696 }, { "venue": "jupiter_swap_jito", "status": "quoted", "reason": null, "error": null, "inAmount": "3000000", "outAmount": "9912881624", "outUsd": 0.357877, "unspentInputUsd": 0, "priorityFeeUsd": 0, "tipUsd": 0.002386, "expectedMevLossUsd": 0, "landingRiskUsd": 0.001, "netUsd": 0.354492 } ], "note": "Routing via Jupiter: no direct venue beats it by more than $0.0004 on this trade after costs." }, "choice": "selected", "override": null, "bundle": false, "mevProtection": "upstream", "transaction": { "programs": ["ComputeBudget111111111111111111111111111111", "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", "11111111111111111111111111111111", "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"], "bytes": 660, "venueVerified": true, "verifiedBy": "programs", "jitoTip": null, "note": "Venue confirmed from the transaction's programs." }, "priorityFee": { "source": null, "control": "venue_managed", "requestedLamports": null, "appliedLamports": 574, "appliedUsd": 0.000068, "microLamports": 7707, "computeUnitLimit": 74387, "feePayer": "", "solPriceUsd": 119.29, "solPriceSource": "derived_from_quote", "note": "Jupiter Ultra sizes the priority fee itself; nothing was requested. appliedLamports is decoded from the returned transaction ..." }, "jitoTipLamports": 0, "jito": null, "slippageBps": 100, "route": { "type": "unknown", "swapType": "aggregator", "router": "metis", "hops": 1, "legs": 1, "split": false, "inAmount": "3000000", "outAmount": "9902965899", "priceImpactPct": -0.00096 }, "reason": "Routing via Jupiter Ultra — the verdict is not Safe, so the Jito bundle venues were considered against it and Ultra was kept (venueDecision shows each candidate and why). Ultra's upstream MEV protection applies; a priority fee would not protect it." }, "submit": { "method": "jupiter_ultra_execute", "url": "https://lite-api.jup.ag/ultra/v1/execute", "requestId": "01a1012a-15b4-7718-86d1-50c2a775284c", "body": "{ signedTransaction, requestId }", "note": "..." }, "netBenefit": { "currency": "USD", "expectedMevLossUsd": 0.000036, "priorityFee": "managed_by_jupiter — the decoded fee is in plan.priorityFee", "jitoTipUsd": 0, "priorityFeeUsd": 0.000068, "jupiterPlatformFeeUsd": 0.000358, "jupiterPlatformFee": { "feeBps": 10, "feeMint": "So11111111111111111111111111111111111111112", "side": "input", "source": "quote", "amount": "3000", "usd": 0.000358, "note": "Jupiter charges 10 bps, taken from the input token. The quoted amounts already include it; it is itemised here and deducted from netUsd as a cost of this route." }, "zendiqFeeUsd": 0.02, "netUsd": -0.020390, "netUsdBasis": "computed", "note": "Jupiter Ultra manages the priority fee and MEV protection; no Jito tip is added on this route." }, "simulation": { "status": "ok", "err": null, "unitsConsumed": 56042, "logs": [ "..." ] }, "transaction": "", "requestId": "01a1012a-15b4-7718-86d1-50c2a775284c", "custody": "none — transaction is unsigned; sign and submit with your own wallet", "latencyMs": 4441 } ``` BONK scored HIGH as a token, so the verdict is Protect even though this trade's own sandwich exposure is LOW. Unprotected Raydium was therefore not eligible, and the two Jito bundle venues were priced against Ultra. `jupiter_swap_jito` quoted the most BONK, but its tip ($0.0024) plus its landing risk ($0.001) cost more than the extra output was worth, so Ultra was kept. In `netBenefit`, Jupiter's 10 bps on the 0.003 SOL input is 3,000 lamports, $0.000358, and the priority fee decoded from the transaction is 574 lamports, $0.000068. `netUsd` = 0.000036 − 0.02 − 0 − 0.000358 − 0.000068 = −0.020390. It is negative because on a trade this small ZendIQ's $0.02 fee exceeds the sandwich exposure it helps avoid; that is reported, not hidden. ### Reading `netBenefit` ``` netUsd = expectedMevLossUsd − zendiqFeeUsd − jitoTipUsd − jupiterPlatformFeeUsd − priorityFeeUsd ``` It is the sandwich loss the route avoids, less every fee you pay to execute it. It is stated only on routes that claim MEV protection (`jupiter_ultra` and the Jito bundle venues); on `jupiter_swap` and `raydium` it is `null` by design. The 5,000-lamport base signature fee is the same on every route and is not included. - `priorityFeeUsd` is the priority fee decoded from the transaction your taker signs, priced in USD. It is `0` on a bundle, which sets none, and on a gasless fill, where Jupiter pays it. - `jupiterPlatformFeeUsd` is Jupiter's own fee. On `jupiter_ultra` it is the fee Ultra reports, 0–50 bps by pair (2 bps on SOL–USDC, 10 bps on most other pairs). `jupiterPlatformFee` shows the derivation: `feeBps`, `feeMint`, which `side` of the trade it is taken from, and the raw `amount`. The fee is already inside Ultra's quoted amounts, so the venue comparison is fair as it stands; it is itemised here so the net is complete. On every other venue it is `0` with `source: "not_applicable"`. - A cost that cannot be priced is unknown, never zero. `netUsd` is then `null`, and `netUsdBasis` says why: `unavailable_no_mev_estimate`, `unavailable_no_sol_price` (a Jito tip with no SOL price), `unavailable_platform_fee` (Jupiter's fee not reported or not priceable) or `unavailable_priority_fee` (the priority fee could not be read from the transaction or priced). `not_claimed_on_this_route` is the by-design `null`. `latencyMs` is typically several seconds without an `analysisId`. Most of it is token screening, which on a first scan can take up to ~12 s (see "How long a call takes"). With a valid `analysisId` the scan is skipped and the call typically takes 1–2 s. A direct-venue quote is cut off at 2.5 s and never extends it further. ### Do not blind-sign `plan` and `netBenefit` exist so you can verify the bytes before you sign them. `plan` states the venue, the slippage, and the route the transaction is supposed to contain. `netBenefit` itemises every cost so the stated net can be checked rather than trusted. Decode `transaction` and confirm it matches `plan` before signing. `plan.transaction` is ZendIQ's own reading of those bytes: the top-level `programs` the transaction invokes, and `jitoTip` (a SystemProgram transfer to a Jito tip account, or `null`). Three kinds of transaction are never returned; the call fails with `optimize_unavailable` and is not charged: - its programs belong to a different venue than `plan.venue`; - it is described as a bundle but carries no tip; - its venue cannot be verified at all, because none of its programs is recognised as `plan.venue`'s and the venue cannot prove where the bytes came from, or the bytes cannot be decoded. So `venueVerified` is always `true` in a returned plan. `verifiedBy` says how: | `verifiedBy` | Meaning | |---|---| | `programs` | A program belonging to `plan.venue` is among the transaction's top-level programs. | | `provenance` | `jupiter_ultra` only. Ultra filled the order through a third-party router, named in `plan.route.router` (for example `okx`), whose programs Jupiter does not publish. The venue is proven from where the bytes came from instead: they are exactly the transaction Ultra returned for this `requestId`, the `taker` signs it, and Ultra `/execute` only lands the transaction it issued for that `requestId`. | `provenance` is how a new Ultra router works without ZendIQ having to learn its program id first. It never excuses bytes that do not decode or a bundle without a tip, and venues other than `jupiter_ultra` are verified by `programs` only. `programs` always lists what the bytes contain, including a third-party router's program. `verifiedBy` is a cross-check, not a substitute for decoding the transaction yourself. **Read `verdict` before signing.** `/optimize` returns the same `verdict` (Safe / Protect / Refuse), `confidence` and `reasons` as `/analyse`, computed from the same analysis. It does not refuse to build: calling `/optimize` means you have decided to trade, so a `Refuse` still comes back with a transaction. Do not sign a `Refuse` unless you mean to trade against ZendIQ's verdict; the published example will not sign one without `--sign-refused`. If screening did not complete, `tokenRisk` is the same `available: false` object described under `/analyse`, and the transaction is still returned. `simulation.status` is one of `ok`, `failed`, or `unknown`. On `failed`, `err` holds the Solana error and `logs` the last 10 log lines. On `unknown`, `reason` explains why simulation could not run (timeout, RPC error). An `unknown` simulation is not a passing simulation. `transaction` is unsigned. ZendIQ cannot move your funds and never sees a key. On a Jito bundle venue ZendIQ broadcasts the transaction after you sign it, and only those exact bytes: a signed transaction cannot be altered. `schema_version` is per-payload and versioned per endpoint. `/optimize` is at `1.2`; `/analyse` and `/analyse-token` are at `1.3`. A difference between them is expected, not an error. ### Venues: chosen by risk, then by net benefit `/optimize` picks a Jupiter route by risk, then quotes eligible direct venues against it. Read `plan.venue` rather than assuming. | `plan.venue` | When it is used | `plan.priorityFee.control` | `plan.mevProtection` | |-----------------|-----------------------------------------------------|-----------------------------|----------------------| | `jupiter_ultra` | Low risk, or when the verdict calls for protection | always `"venue_managed"` | `"upstream"` | | `jupiter_swap` | Risk scoring calls for a specific priority fee | always `"ceiling"` | `"none"` | | `raydium` | Safe verdict only, and a direct quote beats the Jupiter route after costs | `"exact_budget"`, or `"venue_managed"` when no fee was requested | `"none"` | | `raydium_jito`, `jupiter_swap_jito` | The bundle beats the Jupiter route after its tip and landing risk, on any verdict, or `method: "jito"` | always `"venue_managed"`; a bundle carries no priority fee | `"jito_bundle"` | A bundle venue is the Raydium or Jupiter Swap API transaction with a Jito tip added as its last instruction: a SystemProgram transfer from the `taker` to a Jito tip account, read back into `plan.transaction.jitoTip` and `plan.jito`. There is no priority fee, because Jito ranks bundles by tip. There is no Ultra bundle: Ultra transactions carry Jupiter's `jitodontfront` account, which Jito honours by refusing third-party bundles. A pump.fun bonding-curve token is bundled through `jupiter_swap_jito`, since Jupiter routes the curve. The tip is at least 10,000 lamports. Above that it is sized by ZendIQ's risk model from the trade size and sandwich exposure, up to 500,000 lamports (`plan.jito.tipSource` says which applied). `method: "jito"` asks for a bundle even where risk scoring would not choose one. `jupiter_swap_jito` is compiled by ZendIQ from Jupiter's `/swap-instructions`, not taken from Jupiter's `/swap` build. Jupiter builds a pump.fun curve buy with every account written in full (about 1,200 bytes), which leaves no room for the tip under Solana's 1,232-byte limit. ZendIQ resolves pump.fun's fixed accounts through its own address lookup table instead, which brings a curve buy to about 925 bytes before the tip. Jupiter's own instructions are used unchanged, except that any SetComputeUnitPrice is dropped. The table is `HHhmPLodSmawV1EtMCNBiQTHdYpECQB2X7w1MbM6pdEw`. It holds 17 protocol-wide addresses: the pump.fun program, Global, event authority, global volume accumulator, fee config, fee program and its 8 fee recipients, Jupiter's event authority, the Token-2022 program and the wrapped-SOL mint. Nothing in it is specific to a user or a token. It is **frozen**: no authority exists, so its contents can never change. ZendIQ checks at start that it is frozen, active and holds exactly those addresses; if the check fails, `jupiter_swap_jito` refuses to build rather than use it. The result is in the manifest under `bundleLookupTable`. You can read the table yourself and confirm every account your transaction resolves through it. An account the table does not hold is written into the transaction in full, as a 32-byte key instead of a 1-byte table index, and the build still succeeds if it fits. On bundle venues `plan.jito` carries: | Field | Meaning | |---|---| | `bytes`, `headroomBytes` | Size of the transaction with the tip, and what is left under 1,232. | | `lookupTable.address`, `frozen`, `verifiedAtSlot` | The ZendIQ table and the slot of the start-up check. | | `lookupTable.used` | Whether the transaction resolves through it. On routes where Jupiter's own tables already cover the accounts, compiling without it is smaller, and the smaller one is kept. | | `lookupTable.writtenInFull` | Accounts no table resolved, and their byte cost. | `plan.transaction.bytes` is the size of the returned transaction on every venue. ZendIQ also checks at start which Jito block-engine regions it forwards bundles to. If that configuration names a region Jito does not run, every bundle venue refuses to build, rather than failing at submit after you have paid for the build. The result is in the manifest under `bundleRegions`: `regions` (the block engines each bundle is sent to), `valid` and `error`. When `valid` is false, `method: "jito"` returns `optimize_unavailable` and you are not charged. `plan.choice` is `"selected"` when ZendIQ chose the venue and `"forced"` when the request forced it; `plan.override` then says how. Jupiter Ultra sizes the priority fee itself and accepts no override, so a trade that needs a specific fee is built through the Jupiter Swap API (Quote + Build) instead, which honours it. The reverse also holds, and it is why `jupiter_swap` is not the default: a priority fee buys block inclusion, not sandwich protection. When the verdict calls for protection (Protect or Refuse), the trade stays on Ultra unless a Jito bundle beats it, because those are the instruments that actually address sandwich exposure. #### The venue decision `plan.venueDecision` is the comparison behind `plan.venue`. The Jupiter route above is the `baseline`. Each eligible direct venue is quoted in parallel and valued in USD as ``` netUsd = outUsd + unspentInputUsd − priorityFeeUsd − tipUsd − expectedMevLossUsd − landingRiskUsd ``` - `outUsd` is the candidate's own quoted output, priced at the reference quote's USD rate. - `unspentInputUsd` counts back input a venue did not spend — Raydium reserves part of a SOL input for fees and rent, and that SOL stays in your wallet. - `expectedMevLossUsd` is charged only to routes exposed to the public mempool (`raydium`, `jupiter_swap`); a protected route carries `0`. It is `sandwichExposure.estimatedLossUsd`, the modelled expected cost, not a measurement (see "Reading `sandwichExposure`"). - `landingRiskUsd` is charged only to Jito bundle venues. A bundle lands whole or not at all; if it does not land, nothing moves, but you pay for a fresh `/optimize` to try again. So a bundle carries that expected cost: an assumed 5% not-landed rate × the `/optimize` fee ($0.02), which is $0.001. The 5% is a conservative placeholder until ZendIQ has measured the rate. - ZendIQ's own fee is identical for every candidate, so it cannot change the ranking and is left out. A direct venue wins only when its `netUsd` beats the baseline by more than `marginUsd`, and Jupiter wins ties. The margin is **0.1% of the trade, capped at $1**: | Trade | `marginUsd` | |---:|---:| | $0.50 | $0.0005 | | $100 | $0.10 | | $1,000 | $1.00 | | $10,000 | $1.00 (0.01%) | Why both parts. Quotes are estimates and the price moves in the second or two before a trade lands; that noise grows with trade size, so the margin is a share of the trade. Without it the venue would flip on whichever quote happened to be a fraction higher, adding risk for no expected gain. But on a large trade a gap of a few dollars between venues is a real price difference, not noise, so the margin stops growing at $1. There is no fixed dollar floor: on a small trade a real cost difference, such as one route's priority fee against another's tip, is small in dollars and still wins. Often the answer is Jupiter, and `note` says so. Eligibility comes first and is not traded against price: - **An unprotected direct venue** (`raydium`) competes only on a **Safe** verdict. It is `ineligible` on Protect or Refuse, or when Jupiter's fill is off-chain (RFQ or gasless). - **A Jito bundle venue** competes on **every** verdict, since it is never less protected than the Jupiter route. On Refuse too: calling `/optimize` on a Refuse means you are trading anyway. - **Capacity.** ZendIQ submits bundles under one Jito rate limit, about one bundle a second for the whole service. On a Safe verdict, bundle venues step aside while recent submissions are above half of that budget, so trades that need protection always have it. On Protect and Refuse they never step aside. An ineligible row carries only `venue`, `status`, `reason` and `error`: ```json { "venue": "raydium", "status": "ineligible", "error": null, "reason": "the verdict is not Safe; an unprotected route is not eligible" } ``` Each candidate has a `status` — `quoted`, `ineligible` (see `reason`), `failed`, or `unknown` (a quote slower than 2.5 s, or a cost that could not be priced; see `error`). An `unknown` candidate does not compete. A candidate that won but could not be built is marked `build_failed` with the reason in `buildError` — for example a route too large to carry a Jito tip under the 1,232-byte limit — and the trade is built on the next venue. `basis` is `net_benefit` when the comparison ran, `unavailable_no_usd_rates` or `unavailable_baseline_unpriced` when it could not, `not_evaluated_venue_forced` under the debug override, and `forced_method_ranked` (or `forced_method_unpriced`) under `method: "jito"`, where bundle venues are ranked against each other only. `venueDecision.chosen` is the ranking's winner. If it cannot be built, `plan.venue` is the venue that was, and `plan.venueFallback` carries the build error. **An unprotected route is a trade-off, and `plan.reason` states both sides.** A direct venue can win on a low-risk trade because its better price outweighs a small modelled sandwich cost. When the built route has no MEV protection (`raydium`, `jupiter_swap`), `plan.reason` gives the modelled cost and the worst case: the loss if the trade is sandwiched, bounded by the slippage floor encoded in the transaction. From a 0.01 SOL → USDC build: ``` Routing direct to raydium: raydium beats jupiter_ultra by $0.0117 after priority fee, tip, modelled sandwich cost and bundle landing risk (win margin $0.0012). This transaction has no MEV protection: it is not routed through Jupiter Ultra and carries no Jito tip. The modelled sandwich cost for this trade is $0.00012 (0.01% of the trade at LOW sandwich risk): an estimate, not a measurement. If it is sandwiched, the loss is bounded by the 50 bps slippage tolerance in the transaction, up to $0.0061. ``` If that worst case is more than you accept, tighten `slippageBps` or do not sign. `plan.priorityFee` is always an object, on every venue. Read `control` before reading any number under it — it states what `requestedLamports` means. It is a property of the response, not of the venue: the same venue returns different values depending on whether a fee was requested at all. | `control` | `requestedLamports` means | |------------------|-------------------------------------------------------------| | `venue_managed` | nothing was requested; the venue sized the fee itself — `requestedLamports` is `null` | | `ceiling` | an upper bound the venue may price below | | `exact_budget` | a total to spend, rounded up to the compute-unit price — `appliedLamports` can exceed it by 1–2 lamports. `raydium` produces this; neither Jupiter venue does. | In every case the figure to verify against is `appliedLamports`, decoded from the transaction's ComputeBudget instructions — it is the fee in the bytes you were handed, not the fee that was requested. That holds on `jupiter_ultra` too: nothing is requested there, but the fee Jupiter chose is read out of the returned transaction. It is `null` only when those instructions could not be read. When it is `null`, nothing else in the object is a verified charge, and `note` says so in prose. `plan.priorityFee.feePayer` names the transaction's fee payer. On a gasless `jupiter_ultra` fill that is Jupiter rather than your `taker`, so the decoded fee is not charged to your wallet. A priority fee is capped at 0.5% of trade value on every venue. On `jupiter_swap` the cap is always sent, even when it is below the 5,000-lamport base signature fee: the Swap API does not fall back to a small fee when none is given, it sizes one itself up to about 100,000 lamports, which on a sub-dollar trade can be a double-digit percentage of the trade. The trade-off is disclosed in `note` — a fee held that low keeps the cost proportionate but may not land under congestion. ```json "priorityFee": { "source": "trade_size_cap", "control": "ceiling", "requestedLamports": 15000, "appliedLamports": 662, "appliedUsd": 0.0001, "microLamports": 9501, "computeUnitLimit": 69677, "solPriceUsd": 150.12, "solPriceSource": "derived_from_quote", "note": "requestedLamports is a ceiling: Jupiter sizes the fee from network conditions and may apply less. ..." } ``` That gap is normal, not an error: Jupiter prices the fee for current network conditions and only clamps it to the bound. Budgeting against `requestedLamports` will overstate the cost of the trade, sometimes by an order of magnitude. `source` names what produced the requested figure — `zendiq_risk_model` when risk scoring set it, `trade_size_cap` when the 0.5% ceiling overrode it, and `null` when nothing was requested. Separately, when `appliedLamports` is `null` the fee could not be read back from the build, and nothing in the object is a verified charge; `note` says so in prose. If Quote + Build cannot be reached, the trade falls back to Ultra and `plan.venueFallback` carries the reason. When that field is non-null, the fee is Jupiter's rather than ours — it is surfaced so you can tell the difference instead of inferring it. The same holds when a direct venue won the comparison but could not be built: the trade falls back to the Jupiter route. A fallback only ever moves toward the Jupiter route. A direct venue is built only when it won the comparison, never as a substitute: if the Jupiter route could not be priced and Ultra cannot be built, the call fails with `optimize_unavailable` (not charged) and says so — "No priced baseline and Ultra could not be built; refusing to substitute an unprotected route" — with Ultra's build error in `causes`. ### When the taker cannot fund the trade `/optimize` reads the `taker`'s balances alongside the token scan. If the `taker` cannot fund the trade, the call returns `422 taker_insufficient_balance` and you are not charged. It is checked twice: - **The input token**, before anything is quoted or built. No venue can spend tokens the `taker` does not hold. - **SOL for fees and rent**, against each built transaction's own bytes: the base fee, the priority fee and any Jito tip in it, and rent for token accounts it must open. A transaction whose fee payer is not the `taker` (a gasless Ultra fill) is exempt, so a lower-ranked venue that can fill gaslessly is still tried before refusing. ```json { "error": "taker_insufficient_balance", "message": "The taker 9xQe… holds 0 USDC, but this trade needs 1.5 USDC: it is short 1.5 USDC. Token: USDC (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v). The taker is the mainnet wallet that signs the trade, not the wallet that pays for this call. You were not charged.", "need": "input_token", "taker": "9xQe…", "token": { "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "symbol": "USDC", "decimals": 6 }, "required": "1.5", "held": "0", "shortfall": "1.5", "requiredAtomic": "1500000", "heldAtomic": "0", "shortfallAtomic": "1500000", "causes": [] } ``` `need` is `input_token` or `sol_for_fees`. On `sol_for_fees`, `token` is SOL and `causes` holds what each venue returned. `symbol` is `null` for tokens ZendIQ does not name; `required`, `held` and `shortfall` are decimal strings in whole tokens, and the `…Atomic` fields are the same figures in atomic units. Token balances are read from the `taker`'s associated token accounts, which is where every venue spends from. Every SOL figure is a lower bound: a cost ZendIQ cannot read, such as the rent rate, counts as zero, so the check errs toward building rather than refusing. If the balances cannot be read at all, the check is skipped and the build goes ahead. ### Submitting the transaction **Submission differs by venue.** Follow the returned `submit` object; do not hardcode any path. On `plan.venue: "jupiter_ultra"` — `submit.method: "jupiter_ultra_execute"`. Sign `transaction` with the `taker` wallet, then submit to Jupiter, not to a plain RPC: ``` POST https://lite-api.jup.ag/ultra/v1/execute { "signedTransaction": "", "requestId": "" } ``` `requestId` is what ties your signed bytes back to the order Jupiter built; the submission is rejected without it. Ultra owns broadcast, retries, and MEV protection on this route — that is where `plan.mevProtection: "upstream"` comes from. Sending the raw transaction through your own RPC will usually still land, but it forfeits that protection, and the `netBenefit` figures above no longer hold. On `plan.venue: "jupiter_swap"` — `submit.method: "rpc_send_transaction"`. There is no `requestId` (it is `null`) and there is no `/execute` step. Sign and send to your own Solana RPC. The priority fee is already inside the transaction; adding another will not replace it. `submit.lastValidBlockHeight` is supplied for retry handling. Posting a `jupiter_swap` transaction to `/execute` will fail — that endpoint requires a `requestId` that only Ultra issues. On `plan.venue: "raydium"` — `submit.method: "rpc_send_transaction"`, `requestId: null`. Sign and send to your own Solana RPC **promptly**: Raydium embeds a blockhash it chose at build time and returns no expiry, so `submit.lastValidBlockHeight` is `null`. The route has no MEV protection. On a SOL input, Raydium may swap less than you asked: `plan.route.solReserveLamports` is held back so the wallet can pay the fees and stay rent-exempt afterwards, and it stays in the wallet. `plan.route.requestedInAmount` is what you asked for, `plan.route.inAmount` what is swapped, and `plan.route.reserveNote` explains the difference whenever the reserve is above 0, including when the wallet balance could not be read and the full reserve was taken. A wallet that holds less SOL than the requested amount is refused rather than built. On `plan.venue: "raydium_jito"` or `"jupiter_swap_jito"` — `submit.method: "jito_bundle"`. Sign `transaction` and post it back to ZendIQ, not to an RPC or a block engine: ``` POST /v1/agent/bundle { "signedTransaction": "" } ``` Sent through an ordinary RPC the transaction loses its atomicity and sits in the public mempool while still paying the tip. Sent to a Jito block engine without a whitelisted key it rarely lands under congestion, and ZendIQ's key never leaves its server. The blockhash is re-stamped fresh when the bundle is built and `submit.lastValidBlockHeight` is its expiry, so sign and post promptly. ### Reading `netBenefit` `netBenefit.netUsd` is `null` on `jupiter_swap` and `raydium`, by design rather than by omission. `netBenefit.netUsdBasis` says which: `computed` (a real figure), `not_claimed_on_this_route` (null by design, as here), or `unavailable_no_mev_estimate` (null because no exposure estimate existed). Read it before treating a `null` as a failure. On Ultra, `expectedMevLossUsd` is exposure the route genuinely avoids, so it is credited against ZendIQ's fee to produce a net. Quote + Build carries no upstream MEV protection, so crediting the same figure there would claim a benefit the route does not deliver. Instead the costs stay itemised, `plan.priorityFee.appliedUsd` gives the fee actually charged, and `expectedMevLossUsd` should be read as exposure that **remains**. The benefit of that venue is landing reliability, which has no honest dollar figure attached to it. ### Acting on a `Protect` verdict `/analyse` can return `recommendedExecution.path: "jito_bundle"`. On `/optimize` the same sandwich-driven verdict keeps Ultra as the baseline, whose upstream protection already covers it, makes plain `raydium` ineligible, and lets the bundle venues compete: a bundle is built when it beats Ultra after its tip. Send `method: "jito"` to get a bundle regardless, and submit it through `POST /v1/agent/bundle`. ## POST /v1/agent/bundle **Free, rate-limited.** You paid at `/optimize`. Forwards a signed Jito bundle transaction to Jito's block engine under ZendIQ's whitelisted key, then reports whether it landed. Only a transaction that is provably ZendIQ's to forward is accepted: | Check | Otherwise | |---|---| | It pays a non-zero tip to a Jito tip account, read from its bytes | `422 no_jito_tip` | | Its message is exactly one `/optimize` issued in the last 5 minutes. Signing does not change the message, so this also proves the bytes were not altered | `422 transaction_not_issued` | | The `taker`'s signature verifies | `400 invalid_signature` | | It decodes and is signed | `400 invalid_transaction` | `transaction_not_issued` also follows a ZendIQ restart, which forgets issued transactions; request a fresh one from `/optimize`. **Idempotent.** Posting the same signed transaction again — a retry, or two requests racing — returns the first submission with `replayed: true` and a fresh status check. A second bundle is never sent. The call waits up to about 25 s for landing, then returns `200`: ```json { "signature": "", "bundleId": "", "acceptedBy": "slc", "state": "landed", "slot": 450448334, "err": null, "jitoTip": { "lamports": 1000000, "account": "" }, "submittedAt": "2026-09-27T12:00:00.000Z", "replayed": false, "errors": null, "note": "Landed on chain as a Jito bundle." } ``` Landing is read from the chain, never from Jito's tracker alone: | `state` | Meaning | What to do | |---|---|---| | `landed` | On chain. | Done. | | `failed_on_chain` | Included, but the swap failed (`err`). | It did not execute. | | `pending` | Accepted; not on chain yet, and its blockhash is still valid. | Poll `GET /v1/agent/bundle/`. Do not sign a new transaction yet. | | `not_landed` | The blockhash has expired and the signature is not on chain. Nothing was charged. | Request a fresh transaction from `/optimize`. | | `unknown` | Landing could not be confirmed either way: status checks did not answer, or the block engine did not reply. | It may have landed. Poll the GET before trying again. | | `rejected` | Every block engine refused it (`errors`). | It was not forwarded and cannot land. | `not_landed` is only ever reported with positive evidence — an expired blockhash and no signature on chain. A status check that could not run is `unknown`, never `not_landed`. `429 rate_limited` with `Retry-After` means nothing was sent: Jito submissions are rate-limited across all callers. Retry the same signed transaction. `GET /v1/agent/bundle/` returns the same body for a submission made through this server in the last 30 minutes, or `404 unknown_submission`. --- ## Field stability contract `v1` is **additive-only**: new fields may appear, existing ones will not be removed or change type within the version. Fields are split into two tiers, and the split is published in the manifest at `GET /v1/agent` under `compatibility.fields`. **Stable** — safe to depend on: ``` /analyse verdict /analyse recommendedExecution.path /analyse recommendedExecution.priorityFeeLamports /analyse recommendedExecution.jitoTipLamports /analyse reasons /analyse disclaimer /optimize transaction /optimize requestId /optimize plan.venue /optimize plan.slippageBps /optimize plan.priorityFee /optimize submit.method /optimize netBenefit.netUsd /optimize custody ``` `requestId` is stable in the sense that the field is always present — it is `null` on `jupiter_swap` and `raydium`, where no `requestId` is issued. **Experimental** — may change shape without a version bump: ``` /analyse confidence basis tokenRisk sandwichExposure /analyse route marketContext degraded latencyMs /optimize simulation plan.route plan.reason netBenefit.expectedMevLossUsd /optimize plan.venueFallback plan.venueDecision plan.transaction /analyse analysisId analysisExpiresAt (also on /analyse-token) /optimize analysis ``` If you parse an experimental field, guard it. An `analysisId` is safe to pass even if its shape changes: an ID `/optimize` cannot use only costs you a full scan. --- ## Errors Only a `200` from a paid endpoint is charged. Every status `≥ 400` cancels settlement, and `/analyse-token` is never charged at all. | Status | Code | Charged | Meaning | |--------|-------------------------|---------|--------------------------------------------------------------------------| | `400` | `invalid_request` | No | A field failed validation. The message names the field. | | `400` | `analysis_mint_mismatch` | No | `/optimize` only. `analysisId` was issued for a different mint than `outputMint`. The body names both (`analysisMint`, `outputMint`). Screen the right mint, or omit `analysisId`. | | `402` | — | No | Payment required. See the x402 flow above. Not an error. | | `409` | `authorization_already_used` | No | This payment authorization was already presented. Read `priorOutcome`: `in_flight` — retry shortly with the same one; `settle_threw` or `settle_unknown` — the transfer may have landed, check it on chain before paying again; otherwise request a fresh `402`. | | `409` | `already_served`, `redemption_in_progress`, `redemption_route_mismatch`, `redemption_amount_mismatch`, `redemption_expired`, `settlement_unresolved` | Already, or unknown | A retry of a payment that came back `settlement_pending`. `charged` says whether that payment was charged (`null` = not yet known), `settlement` names its transaction. See "Retry once after `settlement_pending`". Nothing new is charged. | | `422` | `not_a_mint` | No | Any endpoint. `field` (`mint`, `inputMint` or `outputMint`) is not an SPL Token or Token-2022 mint, so no score and no `analysisId` are returned. `reason` is `account_not_found` (usually a typo; a mint created seconds ago may also not be visible yet, so retry shortly) or `not_a_mint_account` (a wallet, token account or program; `owner` names its program). Returned before any `402`. | | `422` | `taker_insufficient_balance` | No | `/optimize` only. The `taker` does not hold the input amount (`need: "input_token"`), or the SOL to pay for the transaction it would sign (`need: "sol_for_fees"`). The body names the token, the amount required, the amount held and the shortfall. See "When the taker cannot fund the trade". | | `429` | `rate_limited` | No | Rate limited. Back off `Retry-After` seconds and reuse the same authorization; it is not consumed. | | `502` | `analysis_unavailable` | No | An upstream data source failed and no honest verdict could be produced. Not caused by a slow source: those are reported as unknowns inside a `200`. | | `502` | `optimize_unavailable` | No | `/optimize` only. No transaction is returned. `causes` names why: no venue could build, including after fallback; the Jupiter route could not be priced and Ultra could not be built (no unprotected route is substituted); or the built transaction contradicts its venue, is a bundle with no tip, or its venue could not be verified from its bytes. | | `500` | `internal_error` | No | Unexpected server error. | A missing, expired or unknown `analysisId` is **not** an error; see "When the ID cannot be used". Re-presenting an authorization that **did** settle is not an error: it returns the original `200` body with `replayed: true` and the original `PAYMENT-RESPONSE`, and charges nothing further. ZendIQ returns `502` rather than a guessed verdict when it cannot see enough to answer. A safety check that fails open is worse than no check, because you have already calibrated trust on it. --- ## Using it over MCP The [reference repository](https://github.com/ZendIQ/ZendIQ-Agent-API) ships a Model Context Protocol server over stdio, published to npm as `@zendiq/mcp`. In Claude Code it is one command, with no clone: ``` claude mcp add --scope user zendiq -- npx -y @zendiq/mcp@latest ``` For any other client, put this in its user config (`~/.claude.json` for Claude Code, or the client's own config file), which connects straight away. A project `.mcp.json` works too, but the client asks you to approve the server first. ```json { "mcpServers": { "zendiq": { "command": "npx", "args": ["-y", "@zendiq/mcp@latest"] } } } ``` `zendiq_screen_token` works with that alone: it is free and needs no wallet. The paid tools also need a spend ceiling (`npx -y @zendiq/mcp@latest budget init 1.00`, which spends nothing) and a paying key holding mainnet USDC at `~/.zendiq/payer-mainnet.key.json`, any file `solana-keygen` writes. The key is never generated for you, and the server refuses to keep keys inside `node_modules` or the npx cache. It exposes three tools: | Tool | Cost | Call it when | Input | |------------------------|--------|------------------------------------------------------|---------------------------------------------------------------------------| | `zendiq_screen_token` | free | You are considering a token and have no trade yet | `mint` | | `zendiq_triage_swap` | $0.01 | You must decide whether and how to trade a swap | `inputMint`, `outputMint`, `amount`, `slippageBps?` | | `zendiq_optimize_swap` | $0.02 | You have decided to trade and need the transaction | `inputMint`, `outputMint`, `amount`, `taker`, `slippageBps?`, `method?` | `zendiq_optimize_swap` returns the same response as `POST /v1/agent/optimize`: an unsigned swap transaction (the Jupiter route, or a direct venue or Jito bundle that beats it after every cost), the `plan` to verify it against, `submit` instructions, a simulation, an itemised `netBenefit`, and the same verdict as `zendiq_triage_swap`. Every tool returns the full HTTP response unchanged. MCP transport is stdio only, so this adapter runs as a local subprocess of your agent client and is not reachable over the network. It is a convenience wrapper, not the integration path: the endpoints behind these tools, and the free `/bundle`, are callable directly over HTTP with x402, which requires no installation and is how an agent that discovers ZendIQ from the web should connect. The MCP tools do not pass `analysisId` yet, so `zendiq_optimize_swap` always resolves the token scan itself. Over HTTP, use the handshake. Over MCP, a screen of the same mint in the last 60 seconds is still picked up from the shared cache (`analysis.source: "cached"`). Configuration is via environment variables: | Variable | Default | |-------------------------|------------------------------------------| | `ZENDIQ_AGENT_URL` | `https://api.zendiq.ai` | | `ZENDIQ_AGENT_NETWORK` | `mainnet` (`devnet` only against a devnet server) | | `AGENT_STATE_DIR` | `~/.zendiq` from npm, `runtime/` beside a clone: where the paying key and ledger live | | `ZENDIQ_AGENT_BUDGET_FILE` | `/budget-.json`, created by `budget init`; a paid call on mainnet refuses without it | | `ZENDIQ_AGENT_KEYPAIR` | *(devnet only)* | --- ## Custody and privacy ZendIQ holds **no keys and no funds**, on any endpoint. `/analyse` and `/analyse-token` build nothing. `/optimize` returns an unsigned transaction that only your own wallet can sign and submit. There is no deposit, no approval, and no custody relationship to unwind. --- *Not financial advice. Risk scores are derived from on-chain and market data and cannot guarantee an outcome. Verify the transaction before signing it.*