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.md

Check 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_KEY

Or 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.

Reference: learn.chatgpt.com/docs/extend/mcp?surface=cli

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_here

stdio-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.

Reference: modelcontextprotocol.io/docs/learn/architecture

Tools

get_signalsOpen 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_signalOne signal in full: facts and the rules on what may be claimed, source links, the company's other signals.
mark_signalapprove, sent or skip. Same effect as the buttons in the app, webhooks included.
research_companyEverything FirstFlag knows about a domain: signals, people found, fit, whether you watch it.
draft_emailRegenerate 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_accountsYour watched account list, within your plan's cap.
list_plays / set_playWhich signal plays are on; switch any your plan allows on or off.
account_usagePlan, signals left this period, credits, account cap.
list_workspacesAgency keys: every client workspace (slug, website, plan) with this week's new and stacked signals. Pass a slug as `workspace` to any tool.
add_workspaceAgency 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.md

Limits

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/webhooks

MCP

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

GET/v1/signals
A page of unlocked signals, oldest-first. Filters: since (ISO-8601), signal_type, status, limit (1–200, default 50), cursor.
GET/v1/signals/{id}
One signal, same shape as a list row.
POST/v1/signals/{id}/approve
Mark it approved. Call this once your sequencer has queued the outreach, so the app agrees with what actually happened.
POST/v1/signals/{id}/drop
Drop it. If it was delivered less than 24 hours ago the credit comes back, and the response says whether it did.
GET/v1/me
Account, plan, this month's usage and which plays are running. The call to make first when a key is not behaving.

Watchlists

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.

GET/v1/watchlists?kind=website
Entries of one kind. kind is one of website, newsroom, linkedin_profile, uspto_term, ad_domain, linkedin_company.
POST/v1/watchlists
Add entries. Same parser and plan caps as the app: entries already present come back as duplicates 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

FieldWhat it is
idStable identifier. Use it as your idempotency key.
signal_typefunding, acquisition, hiring, job_change, public_filing, champion_movement, website_change, newsroom, social_listening, uspto, ad_activity, press_mention, competitor_engagement
signal_labelThe same thing in words, e.g. “Funding rounds”.
statusready, delivered, approved, dropped, exported
detected_atWhen we saw the event.
enriched_atWhen we found the contact. This is what since and the cursor page on.
company.name / company.domainThe account the signal is about.
contact.*first_name, last_name, email, email_verified_at, phone, title, linkedin_url
fit.score / fit.reasons0-100 against your ICP, and the short reasons behind it.
draft.subject / draft.body / draft.openerA first-touch email written against this signal. Yours to send, edit or ignore.
detailsPlay-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" }
400Something in the query or body is wrong. The message says which field.
401Missing, malformed or revoked key.
402That signal is locked — you have not unlocked it, so it has no contact.
404No such signal on this account. Also what you get for another account's id.
409The signal is not in a status that allows what you asked.
422The write would exceed a plan cap. Nothing was written.
429Over 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.