Skip to main content

Flip API (1beta)

Download OpenAPI specification:Download

Turn a plain-English outcome into a ready-to-sign, multi-step, cross-chain transaction plan.

Overview

The Flip API exposes the same intent-to-transaction engine that powers the Flip app, as a small set of endpoints under a stable, versioned contract.

Non-custodial by contract

No endpoint ever holds keys, signs, or broadcasts. /plan returns UNSIGNED transaction data — EVM to/data/value calldata and Solana base64 transaction messages — that your own wallet or MPC signer executes. Flip has no signing authority anywhere in the flow. The API decides what to do and how to encode it; your signer decides whether to sign it.

The flow: intent, plan, sign

  1. POST /intent — natural language in, a structured plan out (the intents[]: what to do, not yet how). May return clarifications instead when the request is ambiguous.
  2. POST /plan — the intents[] in, unsigned transactions out (the steps[]: real calldata your signer executes).
  3. POST /steps/reencode — re-quote a single step at sign time. Required for correct integrations: chained and max amounts are re-sized from live balances, and expiring routes (Jupiter blockhash, LiFi tool pinning) are refreshed. Returns 409 when a route is gone and the step must be re-planned.
  4. POST /simulate (optional) — dry-run the steps[] against a fork before signing to surface reverts and gas up front.

Authentication & metering

Every request is authenticated with a partner API key as a bearer token (Authorization: Bearer fk_...). Calls are metered against a per-tenant credit balance: 5 credits per delivered call during beta. Check the balance any time with GET /usage; feature-detect capabilities and limits with GET /meta.

Agents (programmatic alerts) and webhook delivery ship in this /v1beta API — see the Agents and Webhook delivery tags below.

Planning

Turn intent into signable transactions.

Parse a natural-language prompt into a structured plan

Wraps the app's /api/intent planner — same model routing, tools, and clarification logic. Natural language in; a structured intents[] plan out (or clarifications[] when ambiguous). Costs 5 credits per delivered call.

Multi-turn: pass prior turns in conversation_context — newline-joined User: <text> / Flip: <text> lines, most recent last, ~8 turns max — with the current message in prompt (see the followUp example).

Clarification round-trip: when clarifications[] comes back, render the options[]; on pick, re-send with prompt = original prompt + "\n\n" + Clarification — <question>: <picked label> and clarify_follow_up: true so the planner acknowledges the answer and continues (see the clarifyAnswer example).

Send Accept: text/event-stream to stream the build as Server-Sent Events; otherwise a single JSON body is returned once planning completes.

Authorizations:
PartnerKey
header Parameters
Accept
string
Default: application/json
Enum: "application/json" "text/event-stream"

Set to text/event-stream to receive the plan as Server-Sent Events. Any other value (or omitted) yields a single buffered JSON response.

Request Body schema: application/json
required
prompt
required
string [ 1 .. 2000 ] characters

The desired outcome in plain English.

required
Array of objects (Wallet) non-empty

The wallets in play — at least one.

Array of objects (Balance)

Optional pre-fetched holdings. Omit and Flip fetches them.

conversation_context
string <= 6000 characters

Prior turns of the conversation, for multi-turn planning: the prior turns newline-joined, one turn per line, each line User: <text> or Flip: <text>, most recent last, ~8 turns max. The current message goes in prompt, never in this block. Truncated at 6000 characters.

clarify_follow_up
boolean

Set true when re-sending after the user picked a clarification option. The re-send pattern: prompt = the original prompt + "\n\n" + the plain-text answer block Clarification — <question>: <picked label>. The flag makes the planner acknowledge the answer and continue planning — it never re-raises the same clarification.

object

Coarse run options. Raw model ids are never exposed.

Responses

Request samples

Content type
application/json
Example
{
  • "prompt": "move my idle USDC into the best stable yield across my chains",
  • "wallets": [
    ]
}

Response samples

Content type
Example
{
  • "reasoning": "You hold 12,400 USDC idle on Ethereum. Aave v3 on Base is paying the best stable rate right now, so I'll bridge to Base and deposit.",
  • "suggested_name": "Idle USDC → best stable yield",
  • "kind": "transactional",
  • "intents": [
    ],
  • "clarifications": [ ]
}

Resolve intents into unsigned, ready-to-sign transactions

Wraps the app's /api/intent-plan resolver — trusted-vault gating, Pendle-PT expansion, route prefetch, feasibility checks, and the repair pass are all inherited. Takes the intents[] from /intent (or hand-built to the same shape) plus the signing wallets[] with their on-chain balances, and returns the ordered steps[] of unsigned transactions. Costs 5 credits per call.

Authorizations:
PartnerKey
Request Body schema: application/json
required
required
Array of objects (Intent) non-empty

The intents[] from /intent (or hand-built to the same shape).

required
Array of objects (Wallet) non-empty

Signing wallets, each ideally carrying the on-chain balances the feasibility gate reads.

Responses

Request samples

Content type
application/json
{
  • "intents": [
    ],
  • "wallets": [
    ]
}

Response samples

Content type
application/json
{
  • "steps": [
    ],
  • "summary": {
    }
}

Execution

Re-quote and simulate steps at sign time.

Re-quote a single step at sign time

Wraps the app's /api/re-encode-step. Re-encodes one step against the real amount available at sign time — required for correct integrations: chained and max amounts are re-sized from live balances, and expiring routes (Jupiter blockhash, LiFi tool pinning) are refreshed so the user signs the same aggregator and fee structure they were quoted. Returns 409 with error: route_expired when a pinned route is gone and the step must be re-planned via /plan.

Authorizations:
PartnerKey
Request Body schema: application/json
required
required
object (Step)

One unsigned transaction. EVM steps carry to/data/value calldata; Solana steps carry a base64 solana_tx (+ solana_format) instead. A step whose amount is chained is sized from the previous step's real output at sign time — re-encode it via /steps/reencode before signing.

actual_amount
required
number

The real amount, in token units (not wei), to re-encode the step for.

Responses

Request samples

Content type
application/json
{
  • "step": {
    },
  • "actual_amount": 12391.4
}

Response samples

Content type
application/json
Example
{
  • "ok": true,
  • "data": "0x617ba037...updated",
  • "amount_raw": "12391400000",
  • "value": "0"
}

Dry-run steps against a fork before signing

Wraps the app's simulation layer (Tenderly for EVM, a Solana simulate for Solana). Executes the steps[] against a fork and returns per-step success, status, and gas so you can surface reverts and cost before anything is signed. Read-only — nothing is broadcast. Costs 5 credits per call.

Calldata-bearing steps are pre-validated against the target address: a step that CALLS an address with no deployed code (dead or self-destructed contract) returns 400 before any simulation runs. Plain value transfers to EOAs are exempt and simulate normally.

Authorizations:
PartnerKey
Request Body schema: application/json
required
chain
required
string
Enum: "ethereum" "base" "arbitrum" "optimism" "polygon" "solana"

The chain the steps execute on — one simulate call per chain. EVM slugs dispatch to the Tenderly fork; solana dispatches to the Solana simulator.

sender
required
string

The address the simulation runs FROM (your user's signing wallet). EVM: required 0x-hex. Solana: applied to any step missing its own wallet.

required
Array of objects (Step) non-empty

The steps[] from /plan to dry-run.

Responses

Request samples

Content type
application/json
{
  • "chain": "arbitrum",
  • "sender": "0x0036534C48a754163F0f446601f36BCfdA840f57",
  • "steps": [
    ]
}

Response samples

Content type
application/json
{
  • "success": true,
  • "gas_used": "246,000",
  • "gas_price": "0.02 gwei",
  • "gas_price_source": "rpc",
  • "total_cost": "$0.02",
  • "error": null,
  • "steps": [
    ]
}

Account

Usage, credit balance, and capability discovery.

Return the tenant's credit balance and recent ledger

The current per-tenant credit balance, the flat per-call cost, and the most recent metered calls. Reads are free (not metered). This is integrator/partner metering — for checking balances and debugging 402s in your own ops, not an end-user UI element. Don't render a credits balance or refill CTA in the end-user Compose chat.

Authorizations:
PartnerKey

Responses

Response samples

Content type
application/json
{
  • "balance": 4820,
  • "cost_per_call": 5,
  • "recent": [
    ]
}

Discover schema version, supported actions, chains, and limits

Machine-readable capability discovery: the current schema version, the flat per-call credit cost, the supported intent actions, the supported chains, and the rate-limit tiers. Feature-detect from this rather than hard-coding. Free.

Authorizations:
PartnerKey

Responses

Response samples

Content type
application/json
{
  • "schema_version": "2026-07-15",
  • "cost_per_call": 5,
  • "actions": [
    ],
  • "chains": [
    ],
  • "rate_tiers": {
    }
}

Agents

Programmatic alerts (price / yield / health-factor / portfolio-move / schedule / a plain-English condition) scoped to your tenant. Not metered by credits — gated by a per-tenant active-agent quota (by rate tier) and the standard per-key rate limit.

Create a agent — plain-English or typed

Send either a plain-English body { request, wallets } or a typed body { kind, wallets, ... } — never both, never neither (400 otherwise). Not metered by credits; gated instead by a per-tenant active-agent quota (429 agent_quota_exceeded once the tier cap is hit — beta 50 / standard 500 / high 5000).

Plain-English ({ request, wallets }) runs the same compile pipeline the Flip app and Telegram bot use. Three outcomes:

  • A concrete, measurable condition → 201 with agent + compile.
  • A schedule phrase ("every day at 9am...", no measurable condition) → 201, created as a kind: schedule agent.
  • Ambiguous → 200 with status: needs_clarification, a question, and up to 4 options. Re-submit by sending request again with the picked option folded in: "<original request>\n\nClarification — <question>: <picked label>" — the same inline convention /intent's clarification round-trip uses, just folded into the next request string (there's no multi-turn state to carry here).
  • Not a watchable condition, or refused by an internal match-gate (the compiled check doesn't faithfully match what was asked) → 422 unwatchable.

Phase A is notify-only. Even when the compiler recognizes a "when X, do Y" request with a real follow-on action, the agent is armed as notify-only and the action is dropped — action agents over the API are a later phase.

Typed ({ kind, wallets, ... }) maps 1:1 onto the internal create paths for price / yield / health / portfolio / schedule — no compile step, no LLM call, always 201. kind: "condition" is not a typed-create kind — it only results from the plain-English path.

For kind: "price", coingecko_id is required in the request — this endpoint does not resolve a ticker symbol to a CoinGecko id for you; look it up first (e.g. CoinGecko's /coins/list) and pass the id directly (e.g. "ethereum", not "ETH").

Fires are delivered to any webhook endpoints you've registered (POST /webhook-endpoints) — recommended, so you don't have to poll. With none registered, a agent still arms, evaluates, and triggers normally; retrieve its triggers from GET /agent-events instead.

Authorizations:
PartnerKey
Request Body schema: application/json
required
One of
request
required
string [ 3 .. 500 ] characters
wallets
required
Array of strings (AgentWallets) [ 1 .. 10 ] items

EVM 0x + 40 hex, or Solana base58 (32-44 chars). Case is preserved on the way in/out for every kind, including health (Solana addresses are case-sensitive).

Responses

Request samples

Content type
application/json
Example
{
  • "request": "notify me when ETH is above 5000",
  • "wallets": [
    ]
}

Response samples

Content type
application/json
{
  • "status": "needs_clarification",
  • "question": "Which \"gas\" — Ethereum L1 gwei, or a specific chain?",
  • "options": [
    ]
}

List agents

Tenant-scoped, newest-first, offset-paginated. Both status and kind filter against the public vocabulary shown on the returned agent objects (not the internal per-table storage) — see the callouts on each parameter below.

Authorizations:
PartnerKey
query Parameters
status
string
Enum: "active" "paused"

Filters on the public status (see the Agent.status schema note) — matches regardless of which internal table/kind a agent lives in, so status=paused correctly surfaces a paused agent of any kind, not just schedule ones.

kind
string
Enum: "price" "yield" "health" "aave-health" "spark-health" "portfolio" "news" "condition" "schedule"

health matches both aave-health- and spark-health-kind rows (the same aggregate value shown in each agent's own kind field) — this is the filter value to use. aave-health/spark-health are also accepted individually if you want just one protocol's health agents. news agents can appear in a listing but cannot be created through this API.

wallet
string

Scope to agents whose wallet set includes this address (case-insensitive).

cursor
string

Opaque pagination cursor from a previous response's next_cursor.

limit
integer [ 1 .. 200 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "agents": [
    ],
  • "next_cursor": "string"
}

Get one agent

Authorizations:
PartnerKey
path Parameters
id
required
string
Example: w_condition_k17f2a9c

The public agent id, e.g. w_condition_k17f2a9c.

Responses

Response samples

Content type
application/json
{
  • "agent": {
    }
}

Pause/resume a agent, or recompile a condition agent's text

Send either { status } or { request }.

{ status: "paused" | "active" } works identically on any agent kind — the public vocabulary is always active/paused regardless of what's stored internally per kind (see the Agent.status schema note). An id that's well-formed but doesn't resolve to a row you own (already deleted, or another tenant's) is 404, not 500.

{ request: "<new plain-English text>" } only works on kind: "condition" agents (400 on any other kind — delete and recreate a typed agent instead). Re-runs the same compile pipeline POST /agents uses and mirrors its outcomes 1:1: ready200 { agent } (in place — same id, trigger history reset, matching the app's own edit-a-condition-agent behavior); ambiguous → 200 { status: "needs_clarification", ... }; not watchable → 422 unwatchable.

Authorizations:
PartnerKey
path Parameters
id
required
string
Example: w_condition_k17f2a9c

The public agent id, e.g. w_condition_k17f2a9c.

Request Body schema: application/json
required
One of
status
required
string
Enum: "paused" "active"

Responses

Request samples

Content type
application/json
Example
{
  • "status": "paused"
}

Response samples

Content type
application/json
Example
{
  • "agent": {
    }
}

Delete a agent

A structurally-invalid id (doesn't match the w_<type>_<id> shape) is 404.

Idempotent: a well-formed id whose row is already gone (e.g. a repeated delete, or a retry after a timed-out first attempt) returns 204 as a no-op — it never distinguishes "already gone" from "just deleted" in the response.

Authorizations:
PartnerKey
path Parameters
id
required
string
Example: w_condition_k17f2a9c

The public agent id, e.g. w_condition_k17f2a9c.

Responses

Response samples

Content type
application/json
{
  • "error": "invalid_key",
  • "friendly_message": "Missing or malformed API key. Pass it as `Authorization: Bearer fk_...`.",
  • "issues": [ ]
}

Manually trigger a agent once, for integration testing

Queues a synthetic trigger through the exact same delivery pipeline a real trigger takes (webhook push + the GET /agent-events pull rail both see it), flagged type: "agent.test" — independent of whether the agent's real trigger is currently met. 202 because the event is queued into the same delivery sweep a real trigger uses, not returned inline; it typically lands within the next delivery-cron tick (every 2 minutes).

Authorizations:
PartnerKey
path Parameters
id
required
string
Example: w_condition_k17f2a9c

Responses

Response samples

Content type
application/json
{
  • "value": {
    }
}

List/replay trigger events — the pull rail

Tenant-scoped trigger history, newest-first, cursor-paginated on triggered_at. This is how a partner without a registered webhook endpoint reconciles triggers, and how any partner replays or backfills around a delivery gap. Delivery (both this rail and the webhook push rail below) is at-least-once — dedup on id regardless of which rail an event was read from.

Note: this rail and the pushed webhook body (see the webhooks section) are built from the same event mapping — id, type, agent, and the set of trigger fields are identical for the same trigger on both rails. The one intentional difference is the timestamp: this rail returns created_at as an integer epoch-ms, while the pushed webhook body returns created as an ISO-8601 string, for the same instant. Both rails' trigger objects are snake_case (e.g. value_at_trigger), so a handler can parse either rail's event with the same code.

Authorizations:
PartnerKey
query Parameters
cursor
string

Opaque pagination cursor from a previous response's next_cursor (the exclusive-before triggered_at of the last item on that page). A garbage cursor is treated as absent (first page), not a 400.

limit
integer [ 1 .. 200 ]
Default: 50

Responses

Response samples

Content type
application/json
{
  • "events": [
    ],
  • "next_cursor": null
}

Webhook delivery

Register an HTTPS endpoint that agent triggers are pushed to, and pull/replay trigger history. See the webhooks section below for the delivery contract (signing, retries, dead-lettering).

A agent trigger pushed to your registered endpoint Webhook

Sent to every active endpoint registered for your tenant whenever a agent of yours triggers (including test triggers from POST /agents/{id}/test, which carry type: "agent.test"). Delivery is at-least-once — dedup on the payload's id (identical to the X-Flip-Event-Id header), never assume single delivery.

Acknowledge with any 2xx status within 10 seconds. Anything else — non-2xx, a timeout, or a redirect (redirects are never followed) — counts as a failed attempt and is retried with backoff: 1m → 5m → 30m → 2h → 12h after attempts 1 through 5, then the delivery is dead-lettered (no further retries for that specific trigger). An endpoint that racks up 10 consecutive dead-lettered deliveries is auto-disabled (GET /webhook-endpoints will show status: "disabled") — re-register or fix your receiver and the next trigger goes to a healthy endpoint again.

Structurally identical to the GET /agent-events pull-rail's event shape for the same triggerid, type, agent, and the set of trigger fields all come from the same event mapping. Two differences to know about:

  • The timestamp here is created (an ISO-8601 string); the pull rail's equivalent is created_at (an integer epoch-ms) — same instant, different field name and format.
  • Both rails emit snake_case keys inside trigger (e.g. value_at_trigger), matching every other public object in this API.

In both rails, agent.id is the SAME public w_<type>_<id> value returned by create/list/get — pass it straight back to /agents/{id}, /agents/{id}/test, PATCH or DELETE without rebuilding it. Note the prefix is the storage type (alert | condition | reminder), NOT the public kind: a kind: "price" agent is w_alert_..., so kind alone is not enough to construct the id — which is why the event carries it. It is identical across both rails for the same trigger, including manual test-triggers (POST /agents/{id}/test) of every kind.

Authorizations:
PartnerKey
header Parameters
X-Flip-Event-Id
required
string
Example: evt_k7m2ab91

Identical to the payload's id. The stable dedup key across retries and across both delivery rails.

X-Flip-Signature
required
string
Example: t=1753142400,v1=5f6e3c9d7a2f0b6e3c9d7a2f0b6e3c9d7a2f0b6e3c9d7a2f0b6e3c9d7a2f0b6e

t=<unix_seconds>,v1=<hex HMAC-SHA256(signing_secret, "<t>.<raw_body>")> — the Stripe signed-webhooks convention. Verify with a constant-time comparison against the raw (unparsed) request body, and reject timestamps outside a ~5 minute tolerance. Node reference implementation:

const crypto = require("crypto");

function verifyFlipSignature(secret, header, rawBody, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
    }),
  );
  const { t, v1 } = parts;
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const expectedBuf = Buffer.from(expected, "hex");
  const gotBuf = Buffer.from(v1, "hex");
  if (expectedBuf.length !== gotBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, gotBuf);
}
Request Body schema: application/json
required
id
required
string
type
required
string
Enum: "agent.triggered" "agent.degraded" "agent.test"
created
required
string <date-time>

ISO-8601. (The pull rail's equivalent field is created_at, an integer epoch-ms, for the same instant.)

required
object
required
object

Same field set as AgentEvent.trigger (headline, kind, condition, threshold, and — sparse, per-kind — value_at_trigger/price_at_trigger/ change_pct, plus wallet) — snake_case, same as the pull rail. The pull rail's trigger object carries the identical data, identically cased.

property name*
additional property
any

Responses

Request samples

Content type
application/json
Example
{
  • "id": "evt_k7m2ab91",
  • "type": "agent.triggered",
  • "created": "2026-07-30T18:40:00.000Z",
  • "agent": {
    },
  • "trigger": {
    }
}

Register a webhook endpoint

https:// only; the hostname can't be a raw private/loopback/link-local IP literal (including the 169.254.169.254 cloud metadata address), localhost/*.localhost, or *.local/*.internal400 invalid_url otherwise. This is a hostname/IP-literal check only; a public hostname that later resolves to a private address is re-checked at every send, but not pinned against rebinding between check and connect.

Maximum 5 endpoints per tenant — exceeding it is 429 endpoint_quota_exceeded. Remove an old endpoint first if you're unsure how many you have registered (GET /webhook-endpoints).

The response's signing_secret is shown exactly once — store it now. It is not derived from anything you can look up later; only its hash is kept server-side. See the webhooks section for how to verify it against inbound deliveries.

Authorizations:
PartnerKey
Request Body schema: application/json
required
url
required
string <= 2048 characters

Responses

Request samples

Content type
application/json

Response samples

Content type
application/json
{}

List registered webhook endpoints

Never includes secrets — only the POST response ever carries the raw signing_secret.

Authorizations:
PartnerKey

Responses

Response samples

Content type
application/json
{
  • "endpoints": [
    ]
}

Remove a webhook endpoint

Idempotent: an id that doesn't resolve to a row you own (already removed, or another tenant's) still returns 204 as a no-op.

Authorizations:
PartnerKey
path Parameters
id
required
string
Example: k9c7f2ab

Responses

Response samples

Content type
application/json
{
  • "error": "invalid_key",
  • "friendly_message": "Missing or malformed API key. Pass it as `Authorization: Bearer fk_...`.",
  • "issues": [ ]
}

Streaming (SSE)

Send Accept: text/event-stream to /intent (or options.stream = true) to stream the plan as it is built — the same event set the app renders: delta and reasoning (prose as it is written), tool-start / tool-end (render "thinking" status lines), and a terminal final event carrying the structured intents / clarifications. Omit the header for a single buffered JSON response.

Multi-turn conversations

/intent is stateless — carry prior turns yourself in the conversation_context request field. Join the prior turns with newlines, one turn per line, each line User: <text> or Flip: <text>, most recent last, ~8 turns max (the server truncates the block at 6000 characters). The current message always goes in prompt, never in the block. Example follow-up request:

prompt: "actually only move half of it"
conversation_context: "User: move my idle USDC into the best stable yield\nFlip: You hold 12,400 USDC idle on Ethereum. Aave v3 on Base pays the best stable rate, so I'll bridge to Base and deposit."

Clarifications

When a request is ambiguous ("which wallet funds this?", "which Morpho market?"), /intent returns intents: [] and a populated clarifications[] — each { id, kind, question, options: [{ id, label }] }. Render the options to your user; on pick, re-send /intent with clarify_follow_up: true and prompt set to the original prompt + "\n\n" + the plain-text answer block Clarification — <question>: <picked label>. The flag makes the planner acknowledge the answer and continue — it never re-raises the same clarification. Example re-send:

prompt: "deploy my idle USDC\n\nClarification — Which wallet should fund this?: Main treasury (Ethereum)"
clarify_follow_up: true

Versioning

Public fields are snake_case and stable. Internal app routes may change freely; this /v1beta surface is the frozen public contract, echoed on every response via the Flip-Schema-Version header. Breaking changes ship under a new version prefix; additive changes bump the dated schema_version in place.

2026-07-15 — additive: intent actions lp-add, lp-remove, lp-collect (Uniswap liquidity positions, full-range); chain robinhood; /simulate may now return 400 when a calldata-bearing step targets an address with no contract code (dead/self-destructed target detection). Doc correction: SimulateRequest now documents the live shape (chain + sender + steps — the previously published steps+wallets shape was never what the API accepted). Fix: /steps/reencode no longer fails same-chain swap re-quotes with LiFi error 1011; when a pinned route can no longer fill, reencode now retries once unpinned instead of hard- failing; responses carry quoted_at; default slippage is floored for small stable-input swaps ($1 → 3%, ≥$5 → unchanged 1%) so dust swaps stop missing their minimum output during the signing window.

2026-07-07 — initial beta contract.