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 | the records themselves, newest first; arguments since, until, sector, country, feed, min_severity, q, limit (up to 200), cursor, or uid for one exact record |
geo_sources | every feed, its sector, when it last answered, its failure streak and what it holds in the served history |
geo_coverage | which countries a window names, with record and notable counts, most notable first; arguments since, until, days, sector, min_severity, limit |
geo_snapshot | one country in one call: counts by sector and by feed, its notable records, and every measured series against its own normal; arguments country, since, until, days, limit |
geo_activity | records per UTC day against the median of the complete days before the last one; the day still being filled is returned apart, and any feed switched on inside the window is named |
geo_series | the measured series for one country (aircraft, degraded GPS, ships at sea in the Baltic and Norwegian areas our AIS collectors watch, ship transits, routed address space, fires, night lights, attention), each with its points, its normal and normal_ready. A ratio is withheld, with ratio_note, where the normal is too small to divide by: some chokepoints pass two or three ships a day |
geo_latency | how long after publication each feed's records reached us: median, 90th percentile and worst case in seconds, backfilled rows counted apart. A feed whose source publishes no time of its own is flagged publication_time_is_ours, because its lag is zero by construction rather than by speed. This measures our collection, never the news |
A malformed call is answered without being counted, so a mistyped country costs nothing.
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.