Geo API, webhooks and MCP
Everything a paid Geo plan gives a program: the records over REST, a CSV export, signed webhooks the minute a record is written, and an MCP server for AI assistants. One key opens all of it. The free pages and the open JSON at /geo/api stay free and need no key.
Machine-readable: OpenAPI 3.1 for both the free JSON and the paid API.
Quick start
- Take the API or Team plan.
- Open Account, Geo, API keys and create a key. It starts with
gk_and is shown once. - Call the API with the key as a Bearer header:
curl -H "Authorization: Bearer gk_YOUR_KEY" \ "https://geo.firmtape.com/v1/items?country=IR&min_severity=2&limit=20"
Limits
| API | Team | |
|---|---|---|
| Calls a month (REST and MCP together) | 20,000 | 100,000 |
| API keys | 2 | 5 |
| Webhook endpoints | 5 | 25 |
| Alert rules | 25 | 100 |
| CSV export per call | 7 days | 31 days |
| History | 365 days | 365 days |
The month is the calendar month in UTC. GET /v1/usage is free and says where you stand; the account page shows the same numbers.
Records
GET https://geo.firmtape.com/v1/items
| Parameter | Meaning |
|---|---|
since, until | ISO 8601 UTC. Default: the last 7 days. Up to 365 days back. |
sector | gov air gps sea internet ground space attention |
country | ISO 3166-1 alpha-2, for example IR |
feed | one feed id or several, comma separated; ids are on the sources page and in /v1/sources |
min_severity | 2 notable and above, 3 critical only |
q | words in the title |
limit | 1 to 1000, default 200 |
cursor | from next in the previous answer |
Newest first. When more records match, next holds the full URL of the next page; each page is one call. Times are UTC. Each record:
{
"uid": "8f1c2a90d4b7e613",
"feed": "faa_tfr",
"sector": "air",
"type": "new",
"category": "security",
"severity": 2,
"routine": false,
"countries": ["US"],
"region": "north_america",
"title": "FAA flight restriction: VIP movement, Washington DC",
"summary": "",
"published_utc": "2026-09-17T13:02:11Z",
"event_utc": "2026-09-18T14:00:00Z",
"first_seen_utc": "2026-09-17T13:04:02Z",
"backfill": false,
"source": "FAA",
"url": "https://tfr.faa.gov/...",
"attribution": null,
"value": null, "baseline": null, "score": null,
"lat": 38.89, "lon": -77.03,
"facts": {}
}
routine marks the records the pages hide by default (gunnery exercises, small quakes, green alerts). backfill marks a record found in a source's own history rather than caught live. value, baseline and score are set on spikes: what was measured, its own normal, and the ratio.
GET /v1/sources
Every feed with its source name, when it last answered and how many reads in a row failed.
GET /v1/usage
{ "tier": "api", "month": "2026-09", "calls": 4210, "limit": 20000 }
CSV export
GET /v1/export.csv?since=…&until=…
The same filters as /v1/items, every matching record in one file with every column. A call covers at most 7 days on API and 31 on Team, and counts as one call.
curl -H "Authorization: Bearer gk_YOUR_KEY" -o week.csv \ "https://geo.firmtape.com/v1/export.csv?since=2026-09-07T00:00:00Z&until=2026-09-14T00:00:00Z"
Webhooks
- In Account, Geo, Webhooks add a public
https://address. Copy the signing secret (whsec_…); it is shown once. - In Account, Geo, Alerts add a rule and tick Webhooks. Every record the rule matches is posted to each of your endpoints the minute it is written.
- Press Test next to the endpoint to receive a
geo.testevent.
Each delivery is a POST with a JSON body. Answer with any 2xx within 10 seconds. A delivery is attempted once; its status code and time show under Webhooks in the account, where a failed delivery can be retried. Up to 600 records an hour go to one account's webhooks.
POST /your/path
Content-Type: application/json
X-FirmTape-Webhook-Id: 4k2p9x1m7q3r8s
X-FirmTape-Signature: t=1789650131,v1=5f0c…
{
"event": "geo.record",
"sent_utc": "2026-09-17T13:02:12Z",
"record": { "uid": "8f1c2a90d4b7e613", "feed": "faa_tfr", "sector": "air", "severity": 2, "title": "…", "url": "…", … }
}
Verify the signature
v1 is the hex HMAC-SHA256 of t + "." + raw body with your secret. Compare it in constant time and reject old t values.
// Node.js
const crypto = require("node:crypto");
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const mac = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(v1));
}
# Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
mac = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, parts["v1"])
MCP for AI assistants
Endpoint https://geo.firmtape.com/mcp (streamable HTTP), with the same key as a Bearer header. Tool calls count toward the month's calls; listing tools does not. Tools:
| Tool | What it returns |
|---|---|
geo_records | records newest first; arguments since, until, sector, country, feed, min_severity, q, limit (up to 200), cursor |
geo_sources | every feed and when it last answered |
Claude Code
claude mcp add --transport http firmtape-geo https://geo.firmtape.com/mcp \ --header "Authorization: Bearer gk_YOUR_KEY"
Cursor, Windsurf and other clients that take headers
{
"mcpServers": {
"firmtape-geo": {
"url": "https://geo.firmtape.com/mcp",
"headers": { "Authorization": "Bearer gk_YOUR_KEY" }
}
}
}
Claude Desktop
{
"mcpServers": {
"firmtape-geo": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://geo.firmtape.com/mcp", "--header", "Authorization:${GEO_AUTH}"],
"env": { "GEO_AUTH": "Bearer gk_YOUR_KEY" }
}
}
}
Then ask, for example: "What notable Geo records mention Iran this week? Cite each source." Geo is not on FirmTape's free MCP server; it lives here, behind the key.
Errors
| Status | Meaning |
|---|---|
400 | a parameter is wrong; error says which. A malformed request costs no call |
401 | no key, an unknown key or a revoked one |
403 | the key's account has no active API or Team plan, or the key is newer than the plan's key count allows (after a move to a smaller plan the oldest keys keep working) |
429 | the month's calls are used; Retry-After says when the count resets |
Credit
Every record carries its source and url, and attribution where the source asks for a specific citation (UCDP, GDACS, Copernicus, RIPE NCC and others). Show them next to anything you display. Team may show records to its own customers with that credit; no plan allows reselling the records as a feed. Records are public information with their time and source: context for a session, never a trading signal.