Skip to main content

Agents

Private beta

Agents ship as part of the /v1beta API — same posture as the rest of it: keys are issued by the Flip team, and the surface may evolve behind the Flip-Schema-Version header until GA.

Programmatic alerts: price crosses, yield/health-factor moves, portfolio swings, a recurring check-in, or a freeform plain-English condition — created over the API, delivered to a webhook endpoint you register (or pulled on demand). Phase A is notify-only: an agent tells you something happened; it never signs or executes anything on its own. Action agents ("when X, do Y") are a later phase — see Notify-only for now below.

  • Base URL: https://app.fliplabs.ai/api/v1beta (same as the rest of the API)
  • Auth: Authorization: Bearer fk_..., scope agents
  • Metering: not charged against your credit balance — gated instead by a per-tenant active-agent quota (beta 50 / standard 500 / high 5000, by rate tier) and the usual per-key rate limit
  • Reference: every endpoint, schema, and error code is in the API Reference (generated from the OpenAPI spec) — this page is the narrative walkthrough

An agent triggers whether or not you've registered anything to receive it. There are two ways to see its triggers:

  • Recommended — register a webhook endpoint. POST /webhook-endpoints once, and every trigger is signed and pushed to it automatically. No polling.
  • Optional alternative — poll GET /agent-events. Skip registration entirely and read triggers on your own schedule instead.

An agent created with no endpoint registered still arms, evaluates, and triggers exactly the same — it just has nowhere to push to, so nothing shows up unless you poll. If you're building an always-on integration, register an endpoint first; use the pull rail for a quick test, a backfill, or a partner that genuinely prefers polling.

Quickstart

Three calls: register where triggers get delivered, create an agent in plain English, then trigger a test trigger so you can see the whole loop without waiting for a real market move.

1. Register a webhook endpoint (recommended).

curl https://app.fliplabs.ai/api/v1beta/webhook-endpoints \
-H "Authorization: Bearer fk_beta_..." \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/webhooks/flip"}'
{
"endpoint": { "id": "k9c7f2ab", "url": "https://example.com/webhooks/flip" },
"signing_secret": "whs_3f9a7b2e1c4d8f6a0b5e9c2d7a1f4b8e6c0d3a9f7b2e5c1d8a4f0b6e3c9d7a2f"
}

Store signing_secret now — it's returned exactly once and can't be fetched again (only its hash is kept server-side). You'll use it to verify inbound deliveries; see Verifying signatures below. Up to 5 endpoints per tenant.

Skipping this step is fine too — it just means step 3 below won't push anywhere until you poll GET /agent-events instead.

2. Create an agent.

curl https://app.fliplabs.ai/api/v1beta/agents \
-H "Authorization: Bearer fk_beta_..." \
-H "Content-Type: application/json" \
-d '{"request":"notify me when ETH is above 5000","wallets":["0x8f2a...d5"]}'
{
"agent": {
"id": "w_condition_k17f2a9c",
"type": "condition",
"kind": "condition",
"status": "active",
"created_at": 1753142400000,
"wallets": ["0x8f2a...d5"],
"intent": "ETH is above 5000",
"rendered": "ETH price is above $5,000",
"cadence": "hourly"
},
"compile": { "rendered": "ETH price is above $5,000", "fire_headline": null, "cadence": "hourly" }
}

Same compile pipeline the Flip app and Telegram bot run on your text — if it's ambiguous you get a clarification instead of an agent; see The clarification round-trip.

intent is the trigger the compiler extracted, not a verbatim echo of what you sent — "notify me when ETH is above 5000" is stored as "ETH is above 5000". You already hold the original request; what you can't reconstruct is our interpretation, which is what intent and rendered give you.

Agent ids

Every response that contains an agent — create, list, get, and the agent.id inside a trigger event — returns the same public id, shaped w_{type}_{id}. Store it and pass it back verbatim on /agents/{id}, /agents/{id}/test, PATCH and DELETE.

Two things worth knowing so you don't try to build one yourself:

  • The prefix is the storage typealert, condition, or remindernot the public kind. A kind: "price" agent has a w_alert_… id. There is no reliable way to derive the id from kind, which is exactly why events carry it for you.
  • A bare id without the prefix is not valid on path endpoints and returns 404 agent_not_found.

Which wallets can I watch?

Any address you can write down. Phase A agents are read-only — they observe prices, yields, health factors and balances and emit an event — so there is no signature, no connected session, and no ownership check involved. A test wallet, a wallet your user gave you, or an address that has never touched Flip all work identically.

wallets[0] is the agent's owner for bookkeeping; the array preserves the case you send (Solana is case-sensitive).

3. Trigger a test trigger to see a real delivery immediately, instead of waiting for ETH to actually cross $5,000. It returns 202 Accepted — the event is queued into the same delivery sweep a real trigger uses, not returned inline, so expect it on the next tick rather than in the response:

curl -X POST https://app.fliplabs.ai/api/v1beta/agents/w_condition_k17f2a9c/test \
-H "Authorization: Bearer fk_beta_..."

Within the next delivery tick (every ~2 minutes) your endpoint receives:

POST /webhooks/flip HTTP/1.1
Content-Type: application/json
X-Flip-Event-Id: evt_k7m2ab91
X-Flip-Signature: t=1753142400,v1=5f6e3c9d7a2f0b6e3c9d7a2f0b6e3c9d7a2f0b6e3c9d7a2f0b6e3c9d7a2f0b6e

{
"id": "evt_k7m2ab91",
"type": "agent.test",
"created": "2026-07-30T18:41:10.000Z",
"agent": { "id": "w_condition_k17f2a9c", "kind": "condition" },
"trigger": { "headline": "🔔 ETH price is above $5,000", "kind": "condition", "condition": "above", "threshold": 0, "wallet": "0x8f2a...d5" }
}

Return any 2xx within 10 seconds. That's the full loop — a real trigger looks identical except type: "agent.triggered" and, for kinds other than condition, real trigger data (value_at_trigger / price_at_trigger / change_pct) alongside trigger.

The id in an event is the id you act on

agent.id is the same public w_{type}_{id} value create and list return, so you can pause or delete the agent straight from a trigger without a lookup table. Note the prefix is the storage type (alert | condition | reminder), not the kind — a kind: "price" agent is w_alert_…. Always pass the id back verbatim rather than rebuilding it from kind.

Verifying signatures

Every delivery carries X-Flip-Signature: t=<unix_seconds>,v1=<hex hmac> — the Stripe signed-webhooks convention: hmac_sha256(signing_secret, "<t>.<raw_body>"). Verify against the raw, unparsed request body (not a re-serialized JSON.stringify of the parsed object — whitespace differences will break the signature), with a constant-time comparison, and reject stale timestamps. This mirrors the reference implementation Flip itself uses to sign (verifySignature in lib/webhooks/sign.ts):

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);
}

// Express example — must read the raw body before any JSON-parsing middleware
app.post("/webhooks/flip", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyFlipSignature(process.env.FLIP_WEBHOOK_SECRET, req.header("X-Flip-Signature"), req.body.toString("utf8"));
if (!ok) return res.status(401).end();

const event = JSON.parse(req.body);
// ... handle event.type, dedup on event.id ...
res.status(200).end();
});

Delivery: retries, dead-lettering, dedup

Delivery is at-least-once. Dedup on the payload's id (same value as the X-Flip-Event-Id header) — never assume a trigger arrives exactly once.

Acknowledge with any 2xx within 10 seconds. Anything else — non-2xx, a timeout, or a redirect (redirects are never followed) — is a failed attempt, retried with backoff:

AttemptWaits before next try
11 minute
25 minutes
330 minutes
42 hours
512 hours

After attempt 5 fails, that delivery is dead-lettered — no further retries for that specific trigger (it's still visible via GET /agent-events, the pull rail, described next). If an endpoint racks up 10 consecutive dead-lettered deliveries, it's auto-disabled (GET /webhook-endpoints shows status: "disabled") — fix your receiver and re-register, or flip it back on, to resume.

The pull rail

No endpoint registered, or catching up after an outage? GET /agent-events returns the same triggers, cursor-paginated, newest-first:

curl "https://app.fliplabs.ai/api/v1beta/agent-events?limit=50" \
-H "Authorization: Bearer fk_beta_..."
{
"events": [
{
"id": "evt_k7m2ab91",
"type": "agent.triggered",
"created_at": 1753142400000,
"agent": { "id": "w_condition_k17f2a9c", "kind": "condition" },
"trigger": { "headline": "ETH crossed above $5,000", "kind": "condition", "condition": "above", "threshold": 0, "wallet": "0x8f2a...d5" }
}
],
"next_cursor": null
}

The id is the same dedup key either rail hands you, and agent/trigger are built from the same event mapping the pushed webhook uses — same fields, same values, for the same trigger. Two differences to know about:

  • Timestamp. This rail returns created_at as an integer epoch-ms; the pushed webhook's equivalent field is created, an ISO-8601 string — same instant, just a different field name and format.
  • Key casing is the same on both rails. trigger is snake_case (value_at_trigger, price_at_trigger, change_pct) whether the event arrived by push or was pulled, so one parser handles both.

condition and threshold are present on every trigger (both rails) even for kinds where they're not meaningful — condition- and schedule-kind triggers always show the placeholder "above" / 0. Trust them for price/yield/health/portfolio kinds.

The clarification round-trip

A plain-English request that's too broad to compile into one measurable check returns 200 instead of 201:

{
"status": "needs_clarification",
"question": "Which \"gas\" — Ethereum L1 gwei, or a specific chain?",
"options": [
{ "id": "opt_0", "label": "Ethereum L1 gas (gwei)" },
{ "id": "opt_1", "label": "Base gas (gwei)" }
]
}

Three things to know before you build against this:

  • It's a 200, and so is nothing else about it. A clarification and a successful create are both 2xx. Branch on the body, not on res.ok — check for status === "needs_clarification" before you go looking for agent.id. An agent that only checks the status class will silently treat a clarification as a created agent.
  • Send the label back, not the id. The ids are positional (opt_0, opt_1, …) and generated per response purely so you have a stable key for your own UI. Nothing server-side remembers them; the next request is plain text.
  • options can be empty, and is capped at 4. When it's empty you still get a question — fall back to a free-text input rather than rendering an empty picker.

Render the options, then re-POST with the picked label folded into the next request string:

{
"request": "notify me when gas is cheap\n\nClarification — Which \"gas\" — Ethereum L1 gwei, or a specific chain?: Base gas (gwei)",
"wallets": ["0x8f2a...d5"]
}

Same inline convention /intent's clarification flow uses — there's no server-side conversation state to carry, so the answer travels inside the next request string.

That string is re-compiled as plain language, not parsed, so the exact \n\nClarification — {question}: {label} template isn't magic — it's simply a shape that compiles reliably because it keeps the original request and the answer together. Any phrasing that states both will work; what fails is sending only the answer, since the original intent is gone.

The reply can itself come back as another needs_clarification if it's still ambiguous. Treat it as a loop with a cap — two or three rounds, then surface the question to a human rather than retrying forever.

Handle both outcomes for a vague request

Whether a very broad request comes back as a 200 clarification or a 422 unwatchable is not deterministic today — the same text can classify either way on different calls, because an LLM decides whether it's a watchable condition at all before the clarification step runs. Handle both: on 422, show the reason and let the user rephrase rather than assuming the clarification round-trip is broken.

PATCH /agents/{id} with a new request (recompiling a condition agent's text) follows the identical round-trip.

Display strings

Every agent carries a rendered one-liner suitable for showing a user — "BTC price is above $65,350", "aave health factor drops below 1.5". For plain-English agents it's what the compiler produced from the request; for typed kinds Flip builds it from the fields you sent, so you don't have to invent a label per kind. Plain-English agents also carry the original intent.

Prefer rendered for display and fall back to your own string only if it's absent.

Treat it as display text, not data. Formatting is not guaranteed stable or consistent between kinds — typed agents are formatted by us ("ETH price is above $1,973.26") while plain-English ones are phrased by the compiler ("ETH price is above 5,000"), so currency symbols and rounding can differ. Never parse rendered to recover a threshold or an asset; every agent returns those as structured fields.

Typed kinds reference

Skip the plain-English compile step by sending a typed body instead — { kind, wallets, ...fields }, 201 immediately, no LLM call. All fields are snake_case, same as the rest of /v1beta.

kindRequired fieldsNotes
priceasset_symbol, coingecko_id, condition (above|below), thresholdcoingecko_id must be the literal CoinGecko id ("ethereum", not "ETH") — look it up yourself, e.g. via CoinGecko's /coins/list. Optional pct_change/base_price to trigger on a percent move instead.
yieldpool_id, protocol, chain, asset, condition, thresholdOptional metric: apy (default) | supplyApy | borrowApy (these three values are the literal wire values — not converted).
healthprotocol (aave|spark), chain, thresholdFires when the health factor drops below threshold — a decimal like 1.5, never a percent (values ≥ 100 are rejected as a likely mistake). Optional target_wallet if the address to watch differs from the owner wallet in wallets[0].
portfoliothresholdPercent move, always the "above" direction (no down-only portfolio agent). Optional asset_symbol to scope to one holding instead of the whole portfolio; optional movement_window (24h default | 7d).
schedulecadence (daily|weekly|monthly), hour, minute, timezoneA recurring notify-only check-in, not a market condition. timezone is required and explicit here (contrast the plain-English schedule path, which anchors to UTC noon since there's no browser timezone to read from a raw API call). day_of_week required for weekly, day_of_month for monthly. Optional label for a display name.

condition (the plain-English kind) isn't a typed-create option — you only get one by sending request instead of kind.

Every agent's wallets array preserves the case you sent, for every kind (Solana addresses are case-sensitive).

When an agent actually triggers

Worth understanding before you test, because it's the difference between "broken" and "working exactly as designed".

Evaluation runs on a schedule, not continuously. Price, yield, and health-factor agents run frequently, about every 5 minutes. Portfolio-movement agents run hourly. Plain-English condition agents use their stored cadence floor, usually hourly or daily. An agent's last_checked_at advances on each pass whether or not it triggers.

Triggers are edge-based, not level-based. A price agent triggers when the price moves through your threshold between two consecutive checks — not merely because the price is currently on the trigger side. above needs the previous check below the threshold and the current one at or above it.

Three consequences:

  • The very first check never triggers. It establishes the baseline. This is deliberate: it stops an agent created while the price is already past the line from firing instantly and meaninglessly.
  • Movement between checks is invisible. If the price crosses your threshold and comes back within the same 5-minute window, both samples land on the same side and nothing triggers. Polling a price feed every 20 seconds will show crossings we never see.
  • Agents re-arm. They are not one-shot: each genuine crossing triggers again. A percent-move agent ratchets its reference to the price at the trigger, so the next trigger needs another move of that size from there.
Testing this deliberately

Use POST /agents/{id}/test to verify your integration. It's deterministic and immediate, and it exercises the identical delivery path a real trigger uses.

Waiting on a natural trigger needs at least two evaluations — budget 10–15 minutes — and a threshold the price genuinely crosses in that window. A threshold set a cent away from spot is less likely to trigger than one the market clearly moves through, because the crossing has to happen between two 5-minute samples.

Health, yield and portfolio agents follow the same evaluate-then-compare rhythm on their own cadences.

Quotas

  • Active agents per tenant: 50 (beta) / 500 (standard) / 5000 (high), by your key's rate tier. POST /agents past the cap returns 429 agent_quota_exceeded — pause or delete one first.
  • Webhook endpoints per tenant: 5. POST /webhook-endpoints past the cap returns 429 endpoint_quota_exceeded — remove one first (GET /webhook-endpoints shows what's registered).
  • Rate limit: the same per-key tier as the rest of /v1beta (GET /meta for the exact numbers) — a 429 rate_limited here is unrelated to the agent-count quota above; POST /agents can return either code at 429.
  • Credits: none of this — agents, events, and webhook-endpoint management are not metered against your credit balance at all.

Errors

The codes you'll actually meet on these endpoints. error is the stable machine value — match on it, not on friendly_message, which is prose and may change.

StatuserrorMeansDo
401invalid_keyMissing or malformed bearer keyCheck the Authorization header
403scope_forbiddenKey isn't authorized for agentsAsk us to add the scope — not a code bug
404agent_not_foundNo agent with that id for your tenantCheck you sent the full w_{type}_{id}
400invalid_requestBody failed validationRead issues[] — it names the field
429agent_quota_exceededAt your tier's active-agent capPause or delete one
429rate_limitedPer-key request rateBack off and retry
402insufficient_creditsTenant is out of creditsPartner ops — don't surface to end users

404 agent_not_found is deliberately returned for an agent that exists but belongs to another tenant — we never distinguish "not yours" from "not there", since that would leak whether an id is real.

Notify-only for now (actions are coming)

Even when your plain-English request reads like a "when X, do Y" with a real follow-on action, Phase A always arms it as notify-only — the action half is silently dropped. A trigger tells you something happened; deciding what to do about it (and signing) is on you, today. Action agents — where a trigger pre-plans a transaction for you to review and sign — are a planned follow-on phase, not yet part of this contract.

Pausing, resuming, deleting

PATCH /agents/{id} with { "status": "paused" } or { "status": "active" } works identically on any agent kind. DELETE /agents/{id} removes it and is idempotent — a repeated delete, or one aimed at an id you don't own, still returns 204. Both are described in full in the API Reference.

MCP

Everything above is also available as MCP tools on Flip's hosted server — same capabilities, same key, same quotas either way (REST or MCP):

ToolSame as
flip_agent_createPOST /agents
flip_agent_listGET /agents
flip_agent_getGET /agents/{id}
flip_agent_updatePATCH /agents/{id}
flip_agent_deleteDELETE /agents/{id}
flip_agent_testPOST /agents/{id}/test
flip_agent_eventsGET /agent-events
flip_webhook_endpoint_createPOST /webhook-endpoints
flip_webhook_endpoint_listGET /webhook-endpoints
flip_webhook_endpoint_deleteDELETE /webhook-endpoints/{id}

An agent working through these tools gets the same recommendation as the REST quickstart above: call flip_webhook_endpoint_create first so triggers push to you, and fall back to flip_agent_events only if you'd rather poll. Add the server once and all of the tools above (plus flip_intent/flip_plan/flip_simulate/ flip_reencode/flip_usage) come with it:

claude mcp add --transport http flip https://app.fliplabs.ai/api/mcp \
--header "Authorization: Bearer fk_beta_..."

See API (beta) for the Cursor config and more on the hosted MCP server.

Reference