FirstFlag API
A signal is worth something once it is in the system you already work out of. This API is how it gets there: pull your signals into your CRM or sequencer on a schedule, and push your account lists back the other way so FirstFlag watches whatever your CRM says you care about today.
Base URL https://api.firstflag.io/v1 · OpenAPI spec · create a key
Authentication
Every request carries a key as a bearer token. Create one in Settings → API. The key is shown once and stored only as a hash, so if it goes missing, revoke it and make another.
curl https://api.firstflag.io/v1/me \
-H "Authorization: Bearer ff_live_your_key_here"The key identifies the account. There is no account or workspace id to pass anywhere, and one cannot be used to reach another account's data.
Treat it like a password
A key can read every contact and draft on your account. Keep it server-side. Never put it in front-end code, a mobile app, or a public repository. Revoking is instant.
Quickstart
Your ten most recent unlocked signals:
curl "https://api.firstflag.io/v1/signals?limit=10" \
-H "Authorization: Bearer ff_live_your_key_here"{
"data": [
{
"id": "9f1c…",
"signal_type": "funding",
"signal_label": "Funding rounds",
"status": "ready",
"detected_at": "2026-08-21T09:12:44Z",
"enriched_at": "2026-08-21T10:04:02Z",
"company": { "name": "Northwind", "domain": "northwind.com" },
"contact": { "first_name": "Priya", "last_name": "Nair",
"email": "priya@northwind.com", "title": "VP Revenue Operations",
"linkedin_url": "https://www.linkedin.com/in/…" },
"fit": { "score": 87, "reasons": ["Series B", "RevOps leader", "US"] },
"draft": { "subject": "Northwind's Series B and the pipeline question",
"body": "Priya — saw the round…", "opener": "…" },
"details": { "round": "Series B", "amount_usd": 24000000 }
}
],
"has_more": true,
"next_cursor": "MjAyNi0wOC0yMVQxMDowNDowMlp8OWYxYw"
}Only signals you have unlocked are returned. A signal you have not spent a credit on has no contact attached, so there would be nothing to sync.
AI agents (MCP)
FirstFlag is also a remote MCP server, so the agent you already use (Claude Code, Claude Desktop, Codex, Cursor, Grok, or any MCP client) can pull your signals, research a company, write a sequence from the verified facts and mark signals sent. Same key, same account boundary and same plan limits as the REST API.
Server https://api.firstflag.io/mcp · Streamable HTTP · Authorization: Bearer ff_live_… · Connect your AI fills these in with your key.
Claude Code
One command in your terminal. Available in every project.
Add the server
claude mcp add --transport http --scope user firstflag https://api.firstflag.io/mcp \
--header "Authorization: Bearer ff_live_your_key_here"Optional: install the FirstFlag skill so Claude knows the workflow
mkdir -p ~/.claude/skills/firstflag && curl -fsSL https://firstflag.io/docs/skills/firstflag/SKILL.md -o ~/.claude/skills/firstflag/SKILL.mdCheck it with /mcp inside Claude Code. --scope user makes it available everywhere; drop it to add it to one project only.
Reference: code.claude.com/docs/en/mcp
Claude Desktop
Settings → Developer → Edit Config, paste this, restart Claude. Needs Node.js 18+.
claude_desktop_config.json
{
"mcpServers": {
"firstflag": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://api.firstflag.io/mcp",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer ff_live_your_key_here"
}
}
}
}Desktop connects to remote servers through the mcp-remote bridge. The key sits in env with no space after "Authorization:" on purpose: Windows mangles spaces inside args. On claude.ai, custom connectors take a key only if your organization has request headers enabled: an Owner adds a custom connector with this URL, Authentication set to No sign-in, and an authorization header of Bearer <key>.
Reference: github.com/geelen/mcp-remote
Codex CLI
OpenAI's coding agent. The key stays in an environment variable.
Put the key in your shell profile
export FIRSTFLAG_API_KEY="ff_live_your_key_here"Add the server
codex mcp add firstflag --url https://api.firstflag.io/mcp --bearer-token-env-var FIRSTFLAG_API_KEYOr edit ~/.codex/config.toml directly
[mcp_servers.firstflag]
url = "https://api.firstflag.io/mcp"
bearer_token_env_var = "FIRSTFLAG_API_KEY"bearer_token_env_var holds the name of the variable, not the key. Codex adds the Bearer prefix itself.
Cursor
Add it to ~/.cursor/mcp.json (all projects) or .cursor/mcp.json (one project).
~/.cursor/mcp.json
{
"mcpServers": {
"firstflag": {
"url": "https://api.firstflag.io/mcp",
"headers": {
"Authorization": "Bearer ${env:FIRSTFLAG_API_KEY}"
}
}
}
}And set the key where Cursor can read it
export FIRSTFLAG_API_KEY="ff_live_your_key_here"${env:…} keeps the key out of the file, which matters if .cursor/mcp.json is committed. Pasting the key into the header works too.
Reference: cursor.com/docs/context/mcp
Grok (xAI API)
Grok calls FirstFlag directly as a remote MCP tool in the Responses API.
curl
curl https://api.x.ai/v1/responses \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $XAI_API_KEY" \
-d '{
"model": "grok-4.7",
"input": [{ "role": "user", "content": "Pull my stacked FirstFlag signals from this week and draft a first email for the best one." }],
"tools": [{
"type": "mcp",
"server_url": "https://api.firstflag.io/mcp",
"server_label": "firstflag",
"headers": { "Authorization": "Bearer ff_live_your_key_here" }
}]
}'Python (xai-sdk)
from xai_sdk.tools import mcp
tools = [mcp(
server_url="https://api.firstflag.io/mcp",
server_label="firstflag",
extra_headers={"Authorization": "Bearer ff_live_your_key_here"},
)]xAI's servers make the call, so this uses the public URL. allowed_tools (allowed_tool_names in xai-sdk) can restrict Grok to read-only tools.
Reference: docs.x.ai/docs/guides/tools/remote-mcp-tools
Any MCP client
Streamable HTTP with a bearer header. Clients that only speak stdio use the mcp-remote bridge.
Server
URL: https://api.firstflag.io/mcp
Header: Authorization: Bearer ff_live_your_key_herestdio-only clients
npx -y mcp-remote https://api.firstflag.io/mcp --header "Authorization: Bearer ff_live_your_key_here"Gemini CLI (~/.gemini/settings.json)
{
"mcpServers": {
"firstflag": {
"httpUrl": "https://api.firstflag.io/mcp",
"headers": {
"Authorization": "Bearer ff_live_your_key_here"
}
}
}
}The server is stateless: POST only, JSON responses, no session to keep alive.
Tools
| get_signals | Open signals, best first: stacked companies, then fit. Company, why-now facts, contact, email, drafted first email, fit score. Filters: since, play, stacked_only, status, limit. |
| get_signal | One signal in full: facts and the rules on what may be claimed, source links, the company's other signals. |
| mark_signal | approve, sent or skip. Same effect as the buttons in the app, webhooks included. |
| research_company | Everything FirstFlag knows about a domain: signals, people found, fit, whether you watch it. |
| draft_email | Regenerate the first email for a signal, optionally with an instruction. The fact check still applies: no invented numbers or names. |
| list_accounts / add_accounts / remove_accounts | Your watched account list, within your plan's cap. |
| list_plays / set_play | Which signal plays are on; switch any your plan allows on or off. |
| account_usage | Plan, signals left this period, credits, account cap. |
| list_workspaces | Agency keys: every client workspace (slug, website, plan) with this week's new and stacked signals. Pass a slug as `workspace` to any tool. |
| add_workspace | Agency keys: add a client from their website. FirstFlag reads the site, switches on the recommended plays and backfills 30 days. |
Two prompts ship with the server: weekly_prospecting (stacked signals, research, a 3-touch sequence per company) and account_brief (one company, why now, who, the angle). In Claude Code they appear as /mcp__firstflag__weekly_prospecting.
Stacked means one company has two or more signals in 30 days. Those are the accounts moving right now, and get_signals ranks them first.
Claude Code skill
SKILL.md teaches the agent the FirstFlag loop and its rules (only state facts FirstFlag returned; ask before removing accounts or switching plays).
mkdir -p ~/.claude/skills/firstflag
curl -fsSL https://firstflag.io/docs/skills/firstflag/SKILL.md -o ~/.claude/skills/firstflag/SKILL.mdLimits
120 MCP requests per minute per key, and 30 draft_email calls per hour per key. Every plan, the free Weekly Five included, can create a key and use the API and MCP on its own workspace; on free, draft_email is 10 a day and webhooks are 1 endpoint. Client workspaces and agency keys need Agency or Scale (pricing), and add_workspace on any other plan answers agency_plan_required. Every write goes through the same plan checks as the app: an account list over your cap adds nothing, and a play outside your plan is refused with the reason. No tool sends email; your agent writes, you send.
Agencies: every client, one key
Agencies run each client in its own workspace: the client's website, buyers, plays and signals, fully separate from every other client. An agency key (Settings → Connect your AI → “Copy MCP setup for all my clients”) reaches every workspace in your agency and nothing outside it. Workspace keys keep working exactly as before and cannot reach a sibling workspace.
REST
Name the client with the X-FirstFlag-Workspace header or a workspace query parameter (its slug or id). Without one, GET /v1/signals returns every client's signals, each tagged with its workspace, and the signal routes find a signal's client themselves. Watchlists and webhooks need a workspace, so each client can post to its own endpoint (every webhook payload also carries data.workspace).
# every client you run
curl -H "Authorization: Bearer $FIRSTFLAG_AGENCY_KEY" https://api.firstflag.io/v1/workspaces
# one client's signals
curl -H "Authorization: Bearer $FIRSTFLAG_AGENCY_KEY" -H "X-FirstFlag-Workspace: acme-3f2a" "https://api.firstflag.io/v1/signals?since=2026-09-21T00:00:00Z"
# add a client from its website (reads the site, picks plays, backfills 30 days)
curl -X POST -H "Authorization: Bearer $FIRSTFLAG_AGENCY_KEY" -H "Content-Type: application/json" -d '{"website":"northwind.io"}' https://api.firstflag.io/v1/workspaces
# that client's own webhook, e.g. straight into its sequencer's automation
curl -X POST -H "Authorization: Bearer $FIRSTFLAG_AGENCY_KEY" -H "X-FirstFlag-Workspace: northwind-9c1d" -H "Content-Type: application/json" -d '{"url":"https://hooks.example.com/northwind"}' https://api.firstflag.io/v1/webhooksMCP
With an agency key every tool takes an optional workspace argument, and two more appear: list_workspaces and add_workspace. get_signals without a workspace ranks every client's signals in one list, tagged by client. The agency_weekly prompt runs the whole book: for each client, pull stacked signals, draft from that client's facts, and hand you one table per client to push to that client's sequencer. The Claude Code skill knows the agency loop too.
Keeping a CRM in sync
Signals come back oldest-first, ordered by enriched_at. That is what makes an incremental sync safe: store the last timestamp you saw, ask for everything after it, and pages never shift under you when a new batch lands mid-run.
// Run this on a schedule. Store `cursor` between runs.
let cursor = await store.get("firstflag_cursor"); // null on the first run
while (true) {
const url = new URL("https://api.firstflag.io/v1/signals");
url.searchParams.set("limit", "100");
url.searchParams.set("status", "approved"); // only what you have approved
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
const page = await res.json();
for (const signal of page.data) {
await crm.upsertContact({
email: signal.contact.email,
name: `${signal.contact.first_name} ${signal.contact.last_name}`,
company: signal.company.name,
note: `${signal.signal_label}: ${signal.fit.reasons.join(", ")}`,
});
}
if (!page.has_more) break;
cursor = page.next_cursor;
}
await store.set("firstflag_cursor", cursor);Prefer a timestamp to a cursor? ?since=2026-08-01T00:00:00Z does the same job. Use the enriched_at of the last row you processed, not your own clock — your clock and ours are not the same clock.
Signals
/v1/signalssince (ISO-8601), signal_type, status, limit (1–200, default 50), cursor./v1/signals/{id}/v1/signals/{id}/approve/v1/signals/{id}/drop/v1/meWatchlists
Six plays watch lists you supply: website changes, newsroom, social listening, patents, ad activity and competitor engagement. If that list lives in your CRM, this keeps the two in step without anyone re-pasting a textarea.
/v1/watchlists?kind=websitekind is one of website, newsroom, linkedin_profile, uspto_term, ad_domain, linkedin_company./v1/watchlistsduplicates rather than failing the call, and going over your cap returns 422 without adding any of them.curl -X POST https://api.firstflag.io/v1/watchlists \
-H "Authorization: Bearer ff_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"kind": "website",
"entries": [
"northwind.com",
{ "value": "acme.io", "label": "Acme — expansion account" }
]
}'Webhooks
Push instead of poll: register a URL and we call it the moment something happens. Three events — signal.ready (a new signal finished enriching), signal.approved and signal.dropped (from the app, a digest email, or this API — you hear about it wherever it happened). Manage endpoints in Settings → API or via the API:
curl -X POST https://api.firstflag.io/v1/webhooks \
-H "Authorization: Bearer ff_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-app.com/firstflag/webhook",
"events": ["signal.ready"] }'
# The response includes "secret": "whsec_…" — shown once, like an API key.Every delivery is a POST with the same envelope, and data.signal is byte-for-byte the object GET /v1/signals returns — one field mapping for both.
{
"id": "evt_8fKq…",
"type": "signal.ready",
"created_at": "2026-08-24T10:04:02Z",
"data": { "signal": { …same shape as GET /v1/signals… } }
}Verify every delivery. The X-FirstFlag-Signature header is t=<unix>,v1=<hex>, where v1 is HMAC-SHA256 of `${t}.${rawBody}` with your signing secret. Check it against the raw request body, before parsing:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, rawBody, header) {
const t = /(?:^|,)t=(\d+)/.exec(header)?.[1];
const v1 = /(?:^|,)v1=([0-9a-f]{64})/.exec(header)?.[1];
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // stale = replay
const expect = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expect.length === v1.length &&
timingSafeEqual(Buffer.from(expect), Buffer.from(v1));
}Respond with any 2xx within 10 seconds — return first, process after. Anything else is retried on a backoff ladder (1m, 5m, 30m, 2h, 12h) before the delivery is marked failed, so a deploy or an outage on your side costs you nothing. An endpoint that fails 20 deliveries in a row is disabled rather than hammered forever; fix the receiver and re-enable it in Settings. Use POST /v1/webhooks/{id}/test (or the Send test button in Settings) to prove your receiver and signature check before any real signal exists.
Deliveries can arrive out of order and, rarely, more than once — use the event id (or the signal id plus status) as your idempotency key.
Signal fields
| Field | What it is |
|---|---|
| id | Stable identifier. Use it as your idempotency key. |
| signal_type | funding, acquisition, hiring, job_change, public_filing, champion_movement, website_change, newsroom, social_listening, uspto, ad_activity, press_mention, competitor_engagement |
| signal_label | The same thing in words, e.g. “Funding rounds”. |
| status | ready, delivered, approved, dropped, exported |
| detected_at | When we saw the event. |
| enriched_at | When we found the contact. This is what since and the cursor page on. |
| company.name / company.domain | The account the signal is about. |
| contact.* | first_name, last_name, email, email_verified_at, phone, title, linkedin_url |
| fit.score / fit.reasons | 0-100 against your ICP, and the short reasons behind it. |
| draft.subject / draft.body / draft.opener | A first-touch email written against this signal. Yours to send, edit or ignore. |
| details | Play-specific facts: the round size, the job title posted, the page that changed. |
These names match the CSV export, so one field mapping works whether you export a file or poll this API.
Errors and limits
Errors are JSON with a stable error code and a message written for a human reading a log.
{ "error": "invalid_since",
"message": "`since` must be an ISO-8601 timestamp, e.g. 2026-08-01T00:00:00Z" }| 400 | Something in the query or body is wrong. The message says which field. |
| 401 | Missing, malformed or revoked key. |
| 402 | That signal is locked — you have not unlocked it, so it has no contact. |
| 404 | No such signal on this account. Also what you get for another account's id. |
| 409 | The signal is not in a status that allows what you asked. |
| 422 | The write would exceed a plan cap. Nothing was written. |
| 429 | Over 120 requests per minute. Wait the number of seconds in Retry-After. |
Rate limit: 120 requests per minute per key, reported on every response in X-RateLimit-Remaining. A sync loop polling every 30 seconds does not come close.
Zapier, n8n, Make
There is no dedicated app yet, but the API is a plain authenticated REST endpoint and every one of those tools can call it. Point an HTTP step at https://api.firstflag.io/v1/signals, set the Authorization header, and iterate data.
For anything that imports OpenAPI — Postman, Insomnia, n8n's custom node generator — the spec is at https://api.firstflag.io/v1/openapi.json and needs no key to read.
Prefer a push? Zapier's "Webhooks by Zapier" trigger and n8n's Webhook node both hand you a catch URL — paste it into a webhook endpoint and skip polling entirely.