Agents
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_..., scopeagents - 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
Delivery: recommended vs. optional
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-endpointsonce, 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 type —
alert,condition, orreminder— not the publickind. Akind: "price"agent has aw_alert_…id. There is no reliable way to derive the id fromkind, 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.
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:
| Attempt | Waits before next try |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 12 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_atas an integer epoch-ms; the pushed webhook's equivalent field iscreated, an ISO-8601 string — same instant, just a different field name and format. - Key casing is the same on both rails.
triggerissnake_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 onres.ok— check forstatus === "needs_clarification"before you go looking foragent.id. An agent that only checks the status class will silently treat a clarification as a created agent. - Send the
labelback, not theid. 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. optionscan be empty, and is capped at 4. When it's empty you still get aquestion— 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.
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.
kind | Required fields | Notes |
|---|---|---|
price | asset_symbol, coingecko_id, condition (above|below), threshold | coingecko_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. |
yield | pool_id, protocol, chain, asset, condition, threshold | Optional metric: apy (default) | supplyApy | borrowApy (these three values are the literal wire values — not converted). |
health | protocol (aave|spark), chain, threshold | Fires 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]. |
portfolio | threshold | Percent 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). |
schedule | cadence (daily|weekly|monthly), hour, minute, timezone | A 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.
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 /agentspast the cap returns429 agent_quota_exceeded— pause or delete one first. - Webhook endpoints per tenant: 5.
POST /webhook-endpointspast the cap returns429 endpoint_quota_exceeded— remove one first (GET /webhook-endpointsshows what's registered). - Rate limit: the same per-key tier as the rest of
/v1beta(GET /metafor the exact numbers) — a429 rate_limitedhere is unrelated to the agent-count quota above;POST /agentscan return either code at429. - 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.
| Status | error | Means | Do |
|---|---|---|---|
401 | invalid_key | Missing or malformed bearer key | Check the Authorization header |
403 | scope_forbidden | Key isn't authorized for agents | Ask us to add the scope — not a code bug |
404 | agent_not_found | No agent with that id for your tenant | Check you sent the full w_{type}_{id} |
400 | invalid_request | Body failed validation | Read issues[] — it names the field |
429 | agent_quota_exceeded | At your tier's active-agent cap | Pause or delete one |
429 | rate_limited | Per-key request rate | Back off and retry |
402 | insufficient_credits | Tenant is out of credits | Partner 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):
| Tool | Same as |
|---|---|
flip_agent_create | POST /agents |
flip_agent_list | GET /agents |
flip_agent_get | GET /agents/{id} |
flip_agent_update | PATCH /agents/{id} |
flip_agent_delete | DELETE /agents/{id} |
flip_agent_test | POST /agents/{id}/test |
flip_agent_events | GET /agent-events |
flip_webhook_endpoint_create | POST /webhook-endpoints |
flip_webhook_endpoint_list | GET /webhook-endpoints |
flip_webhook_endpoint_delete | DELETE /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
- API Reference — full request/response schemas, every error
code, and the
webhooksdelivery contract, generated from the OpenAPI spec - OpenAPI 3.1 spec:
https://app.fliplabs.ai/flip-openapi.yaml(source of truth) - Machine-readable index for agents:
https://app.fliplabs.ai/llms.txt