callsign is one MCP server over a curated network of real-world data streams. connect it, search the directory for the stream that answers your question, read it live or back through history, and ask what it looked like at any past moment.
api.callsign.sh/mcp — search_streams,
get_events_at, the discover_* reads, subscriptions, and
request_verified_stream.
connect with a key or OAuth (github/email).
?since /
?until need an API key on the events endpoint.
drop this into your agent's system prompt or tool config. it has everything the agent needs to find a stream, read it, and query it at a point in time.
You read real-world information from Callsign — live and historical data
streams that Callsign publishes and verifies from named sources (weather,
markets, economy, code releases, security advisories, sports, and more). Every
channel, topic and event carries a "verification" block naming its source.
MCP: https://api.callsign.sh/mcp (streamable-http)
API Base: https://api.callsign.sh/v1
API Key: cs_live_... (set as environment variable CALLSIGN_API_KEY)
Auth: Authorization: Bearer $CALLSIGN_API_KEY
Structure:
- A channel groups related streams (e.g. "weather", "markets", "security").
- A topic IS one stream (e.g. "weather/miami").
- Events are immutable JSON payloads, newest-first, retained in full.
To answer a question:
1. search_streams { "q": "temperature" } → find channel + topic
2. get_events_at { "channel": "weather", "topic": "miami",
"at": "2026-11-15" } → nearest reading; a plain date is
read in the stream's timezone
or discover_events { "channel": ..., "topic": ..., "since": ..., "until": ... }
3. Nothing fits? request_verified_stream { "title": "hourly pollen for Tokyo" }
Over REST instead of MCP:
GET /v1/directory (no auth) browse every stream
GET /v1/public/channels/weather/topics/miami/events?limit=5
GET /v1/verified/channels/weather/topics/miami/at?at=2026-11-15
GET /v1/public/channels/weather/topics/miami/events?until=2026-11-15T18:00:00Z&limit=3
To be notified of new events — pick one:
- Webhook POST /v1/topic-subscriptions { "channel":"weather","topic":"miami","webhook_url":"https://..." }
- Pull GET https://api.callsign.sh/v1/public/channels/weather/topics/miami.json (no auth, newest 25)
History accrues from each stream's launch — there is no backfill yet. Check
gap_seconds and before_history on get_events_at results to judge how close a
reading is to the moment asked for.
Streams are curated: Callsign publishes and verifies them. Creating channels or
topics and publishing events return 403 WRITES_CURATED.
Browse the catalog at https://callsign.sh/directory.
export CALLSIGN_API_KEY="cs_live_..."
BASE="https://api.callsign.sh/v1"
# one-time: create the channel + topic
curl -X POST "$BASE/channels" \
-H "Authorization: Bearer $CALLSIGN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"slug": "weather", "title": "Weather"}'
curl -X POST "$BASE/channels/weather/topics" \
-H "Authorization: Bearer $CALLSIGN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"slug": "miami", "title": "Miami"}'
# every report: publish a JSON event
curl -X POST "$BASE/channels/weather/topics/miami/events" \
-H "Authorization: Bearer $CALLSIGN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "6pm report",
"summary": "84F, 71% humidity",
"payload": { "tempF": 84, "humidity": 71 }
}'
import os, requests
API_KEY = os.environ["CALLSIGN_API_KEY"]
BASE = "https://api.callsign.sh/v1"
H = { "Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json" }
# one-time: create the channel + topic
requests.post(f"{BASE}/channels",
headers=H,
json={"slug": "weather", "title": "Weather"})
requests.post(f"{BASE}/channels/weather/topics",
headers=H,
json={"slug": "miami", "title": "Miami"})
# every report: publish a JSON event
resp = requests.post(
f"{BASE}/channels/weather/topics/miami/events",
headers=H,
json={
"title": "6pm report",
"summary": "84F, 71% humidity",
"payload": {"tempF": 84, "humidity": 71},
},
)
resp.raise_for_status()
event = resp.json()
print(f"Published event {event['id']} at {event['published_at']}")
const API_KEY = process.env.CALLSIGN_API_KEY;
const BASE = "https://api.callsign.sh/v1";
const H = {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
};
// one-time: create the channel + topic
await fetch(`${BASE}/channels`, {
method: "POST", headers: H,
body: JSON.stringify({ slug: "weather", title: "Weather" }),
});
await fetch(`${BASE}/channels/weather/topics`, {
method: "POST", headers: H,
body: JSON.stringify({ slug: "miami", title: "Miami" }),
});
// every report: publish a JSON event
const res = await fetch(
`${BASE}/channels/weather/topics/miami/events`,
{ method: "POST", headers: H, body: JSON.stringify({
title: "6pm report",
summary: "84F, 71% humidity",
payload: { tempF: 84, humidity: 71 },
}) },
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const event = await res.json();
console.log(`Published event ${event.id}`);
POST /v1/register (no human in the loop), or a human claims an account at console.callsign.sh/claim.api.callsign.sh/mcp — with that key, or via OAuth with no key to paste.search_streams to find the stream that answers a question, then discover_events for the live picture or get_events_at for a past moment.1. self-register to mint an API key (no human in the loop). 2. create a channel + topic. 3. publish an event. subscribers receive it immediately.
curl -X POST https://api.callsign.sh/v1/register \
-H "Content-Type: application/json" \
-d '{}'
# response — stash api_key for the next three calls; surface
# claim_url to your human within 24h so they can bind the account.
# {
# "api_key": "cs_live_...",
# "claim_url": "https://console.callsign.sh/claim?token=...",
# "claim_token_expires_at": "2026-05-28T12:00:00Z",
# "user_id": "..."
# }
import requests
r = requests.post(
"https://api.callsign.sh/v1/register",
headers={"Content-Type": "application/json"},
json={},
)
r.raise_for_status()
data = r.json()
# stash data["api_key"] for the next three calls; surface
# data["claim_url"] to your human within 24h so they can bind the account.
print(data)
const r = await fetch("https://api.callsign.sh/v1/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
// stash data.api_key for the next three calls; surface
// data.claim_url to your human within 24h so they can bind the account.
console.log(data);
curl -X POST https://api.callsign.sh/v1/channels \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"slug":"weather","title":"Weather"}'
curl -X POST https://api.callsign.sh/v1/channels/weather/topics \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"slug":"miami","title":"Miami"}'
curl -X POST https://api.callsign.sh/v1/channels/weather/topics/miami/events \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "6pm report",
"summary": "84F, 71% humidity",
"payload": { "tempF": 84, "humidity": 71 }
}'
import os, requests
H = {"Authorization": f"Bearer {os.environ['CALLSIGN_API_KEY']}",
"Content-Type": "application/json"}
requests.post("https://api.callsign.sh/v1/channels",
headers=H,
json={"slug": "weather", "title": "Weather"}).raise_for_status()
requests.post("https://api.callsign.sh/v1/channels/weather/topics",
headers=H,
json={"slug": "miami", "title": "Miami"}).raise_for_status()
r = requests.post(
"https://api.callsign.sh/v1/channels/weather/topics/miami/events",
headers=H,
json={
"title": "6pm report",
"summary": "84F, 71% humidity",
"payload": {"tempF": 84, "humidity": 71},
},
)
r.raise_for_status()
print(r.json())
const H = {
Authorization: `Bearer ${process.env.CALLSIGN_API_KEY}`,
"Content-Type": "application/json",
};
await fetch("https://api.callsign.sh/v1/channels", {
method: "POST", headers: H,
body: JSON.stringify({ slug: "weather", title: "Weather" }),
});
await fetch("https://api.callsign.sh/v1/channels/weather/topics", {
method: "POST", headers: H,
body: JSON.stringify({ slug: "miami", title: "Miami" }),
});
const r = await fetch(
"https://api.callsign.sh/v1/channels/weather/topics/miami/events",
{
method: "POST", headers: H,
body: JSON.stringify({
title: "6pm report",
summary: "84F, 71% humidity",
payload: { tempF: 84, humidity: 71 },
}),
},
);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
console.log(await r.json());
{
"id": "ev_8x7k2m",
"channel": "weather",
"topic": "miami",
"title": "6pm report",
"summary": "84F, 71% humidity",
"payload": { "tempF": 84, "humidity": 71 },
"public_metadata": null,
"private_metadata": null,
"published_at": "2026-05-25T18:00:00Z"
}
https://api.callsign.sh/v1/public/channels/weather/topics/miami.json, or POST /v1/topic-subscriptions with a webhook URL.
every stream on the network is in the directory: browse it at callsign.sh/directory, fetch it as JSON without an API key, or search it from your agent with the search_streams tool.
curl "https://api.callsign.sh/v1/directory?q=temperature"
# {
# "generated_at": "2026-08-29T04:30:00.000Z",
# "channel_count": 1,
# "stream_count": 25,
# "channels": [
# {
# "slug": "weather",
# "title": "Weather",
# "topics": [
# {
# "slug": "miami",
# "title": "Miami",
# "last_event_at": "2026-08-29T04:02:11.000Z",
# "events_url": "https://api.callsign.sh/v1/public/channels/weather/topics/miami/events"
# }
# ]
# }
# ]
# }
?q= is a word search: every word must match a stream's slugs, titles, descriptions, source, or aliases, and filler such as "what was the … in" is ignored, so ?q=temperature in miami finds weather/miami. when no stream matches every word, the closest partial matches come back with "match": "partial" ("all" or "none" otherwise). ?channel= narrows to one channel. omit both to list the whole catalog. the response is cached for 60s and served with permissive CORS, so a browser or an agent can read it directly.
events are never discarded, so every stream carries its history. to answer “what was the temperature in Miami on November 15th”, find the stream, then read it at that instant.
get_events_at {
"channel": "weather",
"topic": "miami",
"at": "2026-11-15", // plain date → read in America/New_York (noon)
"mode": "nearest" // before | nearest | after
}
# → the reading closest to that moment, and how close it is:
# {
# "at": "2026-11-15T17:00:00.000Z", "tz": "America/New_York",
# "gap_seconds": 180, "before_history": false,
# "events": [{
# "observed_at": "2026-11-15T16:45:00.000Z",
# "payload": { "temp_c": 27.8, "temp_f": 82.0,
# "condition": "partly cloudy", "humidity_pct": 74 },
# "verification": { "status": "verified", "source": "Open-Meteo" }
# }]
# }
curl -H "Authorization: Bearer $CALLSIGN_API_KEY" \ "https://api.callsign.sh/v1/verified/channels/weather/topics/miami/at?at=2026-11-15&mode=nearest" # "latest at or before" on the events list still works: curl -H "Authorization: Bearer $CALLSIGN_API_KEY" \ "https://api.callsign.sh/v1/public/channels/weather/topics/miami/events?until=2026-11-15T18:00:00Z&limit=3"
at takes an ISO 8601 instant or a plain local date/time, read in the stream's own timezone unless you pass tz. until and since are inclusive ISO 8601 bounds on the events endpoint, and results come back newest-first — so until=<instant>&limit=1 is “the state as of then”, and pairing both bounds gives a window.
before_history and gap_seconds to see whether, and how closely, the stream covers the moment you asked for.
the primary primitive. a channel is a user-owned, globally-named container. its slug is unique across all of callsign. one user can own any number of channels; one API key publishes to all of them. channels live at /v1/channels/:slug and surface publicly at console.callsign.sh/channels/{slug} (browsable from the network index).
a topic belongs to a channel and groups related events. its slug is unique per channel, so weather/miami and markets/miami are independent. topics carry an optional title, description, and metadata. soft-deleting a channel cascades to its topics.
an event is an immutable JSON payload published to a topic. payload can be any JSON value — object, array, string, number, or boolean — up to 1 MB stringified. optional title + summary strings give each event a human-readable label. events list newest-first and accept ISO 8601 since / until filters. events have no PATCH or DELETE; subscribers expect an append-only log.
subscribe to a topic with POST /v1/topic-subscriptions. supply an HTTPS webhook_url for push delivery, or omit it for pull-only (REST). delivery: up to 4 attempts with exponential backoff via pg-boss, then dead-lettered; 50 failures in a row disable the subscription until you re-set its URL. every delivery is signed with the subscription's webhook_secret. SSRF guard allows only public addresses, at create time and on the delivery socket.
API keys authenticate your agent. they're scoped to a user, so one key reads every public stream and manages all your subscriptions. keys are prefixed with cs_live_ and hashed with SHA-256 before storage. you see the key once at creation. store it somewhere safe.
channels and topics each carry two free-form JSON fields: public_metadata (visible to any authenticated reader, including via the /v1/public/* discovery endpoints) and private_metadata (visible only to the owner). max 16KB stringified each. PATCH replaces the whole stored object. send null to clear, omit the key to leave unchanged. events carry both fields too: public_metadata is returned everywhere (including public reads), private_metadata only on the owner's authenticated reads — never on a public event surface.
https://callsign.sh/ and https://callsign.sh/docs support content negotiation. send Accept: text/markdown to receive the markdown source instead of HTML. responses carry Content-Type: text/markdown; charset=utf-8, Vary: Accept, and an X-Markdown-Tokens header.
callsign publishes standard well-known resources so agents can auto-configure: /.well-known/api-catalog (RFC 9727 linkset), /.well-known/oauth-protected-resource (RFC 9728), /.well-known/oauth-authorization-server (RFC 8414), /.well-known/agent-card.json (A2A), /.well-known/mcp/server-card.json (MCP, SEP-1649), and /.well-known/agent-skills/index.json (Agent Skills index). all live under https://callsign.sh.
base URL: https://api.callsign.sh/v1
auth: Authorization: Bearer YOUR_API_KEY
| method | path | description |
|---|---|---|
| § onboarding | ||
| POST | /v1/register | Self-register (no auth): returns api_key + claim_url (24h token). Optional email contact hint in body. 10 per client IP per hour. |
| POST | /v1/claim/refresh | Re-issue claim_url when the original is lost or expired. Self-registered accounts only; 409 if claimed or a human account. |
| § channels — pub/sub for agents | ||
| POST | /v1/channels | Create a channel (globally-unique slug) |
| GET | /v1/channels | List your channels (?limit=50&offset=0) |
| GET | /v1/channels/:slug | Get a single channel |
| PATCH | /v1/channels/:slug | Update title, description, or metadata |
| DELETE | /v1/channels/:slug | Soft-delete a channel (cascades to topics and events) |
| POST | /v1/channels/:slug/topics | Create a topic on a channel |
| GET | /v1/channels/:slug/topics | List topics on a channel |
| GET | /v1/channels/:slug/topics/:topicSlug | Get a topic |
| PATCH | /v1/channels/:slug/topics/:topicSlug | Update a topic |
| DELETE | /v1/channels/:slug/topics/:topicSlug | Soft-delete a topic |
| POST | /v1/channels/:slug/topics/:topicSlug/events | Publish an event (JSON payload, optional title/summary) |
| GET | /v1/channels/:slug/topics/:topicSlug/events | List events with ?since=&until=&limit=&offset= |
| GET | /v1/channels/:slug/topics/:topicSlug/events/:id | Get a single event |
| POST | /v1/topic-subscriptions | Subscribe to a topic (webhook or pull-only) |
| GET | /v1/topic-subscriptions | List your topic subscriptions |
| PATCH | /v1/topic-subscriptions/:id | Update webhook URL (null = pull-only); also re-enables a disabled subscription |
| POST | /v1/topic-subscriptions/:id/rotate-secret | Mint a new signing secret (returned once as webhook_secret) |
| DELETE | /v1/topic-subscriptions/:id | Unsubscribe from a topic (soft-delete) |
| POST | /v1/stream-requests | Request a verified stream (+1 if an open request matches). 10 requests / 100 votes per account per day. |
| GET | /v1/stream-requests | Requests you filed or +1'd, with votes and status |
| GET | /v1/verified/channels/… | Verified stream reads — same shape as /v1/public, each item with a verification block |
| GET | /v1/verified/channels/:slug/topics/:t/at | Point in time: ?at=&tz=&mode=before|nearest|after&limit= |
| GET | /v1/public/channels | Public channel directory |
| GET | /v1/public/channels/:slug/topics/:t.json | Per-topic JSON pull (no auth, newest 25, no filters) |
Two endpoints live at the root of api.callsign.sh, not under the /v1 prefix above.
| method | url | description |
|---|---|---|
| POST | https://api.callsign.sh/mcp | MCP streamable-http endpoint (stateless: one JSON-RPC message per POST; GET/DELETE return 405). mirrors the REST API as MCP tools for auto-discovery via the MCP Server Card |
callsign publishes standard well-known documents so agents and MCP clients can auto-configure against the service.
application/linkset+json, not application/json.All of the JSON documents above except agent-skills/index.json are also mirrored at https://api.callsign.sh/.well-known/<path> by the Fastify API. The agent-skills index and its SKILL.md files (served as text/markdown) are landing-only.
Point an MCP client (e.g. @modelcontextprotocol/inspector) at https://api.callsign.sh/mcp to use Callsign as an MCP server. Authenticate with Authorization: Bearer cs_live_..., or connect via OAuth 2.1 — leave the token blank and a compliant client registers itself (Dynamic Client Registration) and signs you in with GitHub or email; Supabase is the authorization server and the API verifies the issued JWT. The server exposes 14 tools — search_streams and get_events_at, the discover_* reads, the subscription tools, and request_verified_stream / list_stream_requests — plus 6 MCP resources and 2 prompts. Every tool declares readOnlyHint, destructiveHint and openWorldHint.
Create a channel. The slug is globally unique across Callsign.
| field | type | required | description |
|---|---|---|---|
| slug | string | required | Globally-unique slug (a-z, 0-9, '-') |
| title | string | required | Human-readable channel title |
| description | string | optional | Short channel description |
| public_metadata | object | optional | Free-form JSON, visible to any reader. Max 16KB stringified. |
| private_metadata | object | optional | Free-form JSON, visible only to the owner. Max 16KB stringified. |
curl -X POST https://api.callsign.sh/v1/channels \
-H "Authorization: Bearer cs_live_..." \
-H "Content-Type: application/json" \
-d '{"slug": "weather", "title": "Weather"}'
import os, requests
r = requests.post(
"https://api.callsign.sh/v1/channels",
headers={"Authorization": f"Bearer {os.environ['CALLSIGN_API_KEY']}"},
json={"slug": "weather", "title": "Weather"},
)
r.raise_for_status()
print(r.json())
const r = await fetch("https://api.callsign.sh/v1/channels", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CALLSIGN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ slug: "weather", title: "Weather" }),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
console.log(await r.json());
Create a topic under one of your channels. Topic slugs are unique per channel.
| field | type | required | description |
|---|---|---|---|
| slug | string | required | Topic slug, unique within the channel |
| title | string | required | Human-readable topic title |
| description | string | optional | Short topic description |
| public_metadata | object | optional | Same semantics as channel metadata |
| private_metadata | object | optional | Owner-only metadata |
curl -X POST https://api.callsign.sh/v1/channels/weather/topics \
-H "Authorization: Bearer cs_live_..." \
-H "Content-Type: application/json" \
-d '{"slug": "miami", "title": "Miami"}'
import os, requests
r = requests.post(
"https://api.callsign.sh/v1/channels/weather/topics",
headers={"Authorization": f"Bearer {os.environ['CALLSIGN_API_KEY']}"},
json={"slug": "miami", "title": "Miami"},
)
r.raise_for_status()
print(r.json())
const r = await fetch(
"https://api.callsign.sh/v1/channels/weather/topics",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CALLSIGN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ slug: "miami", title: "Miami" }),
},
);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
console.log(await r.json());
Publish an event. Events are immutable. no PATCH or DELETE. Subscribers receive every event via webhook (push) or REST pull.
| field | type | required | description |
|---|---|---|---|
| payload | any (JSON) | required | Any JSON value — object, array, string, number, or boolean. Max 1 MB stringified. |
| title | string | optional | Human-readable title for the event |
| summary | string | optional | Human-readable description of the event |
| public_metadata | object | optional | Searchable metadata (16KB max) |
curl -X POST https://api.callsign.sh/v1/channels/weather/topics/miami/events \
-H "Authorization: Bearer cs_live_..." \
-H "Content-Type: application/json" \
-d '{
"title": "6pm report",
"summary": "84F, 71% humidity",
"payload": { "tempF": 84, "humidity": 71 }
}'
import os, requests
r = requests.post(
"https://api.callsign.sh/v1/channels/weather/topics/miami/events",
headers={"Authorization": f"Bearer {os.environ['CALLSIGN_API_KEY']}"},
json={
"title": "6pm report",
"summary": "84F, 71% humidity",
"payload": {"tempF": 84, "humidity": 71},
},
)
r.raise_for_status()
print(r.json())
const r = await fetch(
"https://api.callsign.sh/v1/channels/weather/topics/miami/events",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CALLSIGN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "6pm report",
summary: "84F, 71% humidity",
payload: { tempF: 84, humidity: 71 },
}),
},
);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
console.log(await r.json());
{
"id": "ev_8x7k2m",
"channel": "weather",
"topic": "miami",
"title": "6pm report",
"summary": "84F, 71% humidity",
"payload": { "tempF": 84, "humidity": 71 },
"public_metadata": null,
"published_at": "2026-05-25T18:00:00Z"
}
List events on a topic, newest-first. Supports ISO 8601 since / until filters and pagination.
| query | type | description |
|---|---|---|
| since | ISO 8601 | Lower bound on published_at (inclusive) |
| until | ISO 8601 | Upper bound on published_at (inclusive) |
| limit | number | 1–100, default 50 |
| offset | number | default 0 |
curl 'https://api.callsign.sh/v1/channels/weather/topics/miami/events?since=2026-05-01T00:00:00Z&limit=20' \ -H "Authorization: Bearer cs_live_..."
import os, requests
r = requests.get(
"https://api.callsign.sh/v1/channels/weather/topics/miami/events",
headers={"Authorization": f"Bearer {os.environ['CALLSIGN_API_KEY']}"},
params={"since": "2026-05-01T00:00:00Z", "limit": 20},
)
r.raise_for_status()
print(r.json())
const url = new URL(
"https://api.callsign.sh/v1/channels/weather/topics/miami/events",
);
url.searchParams.set("since", "2026-05-01T00:00:00Z");
url.searchParams.set("limit", "20");
const r = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.CALLSIGN_API_KEY}` },
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
console.log(await r.json());
Subscribe to a topic. Omit webhook_url (or send null) for pull-only subscriptions (REST). Resubscribing after unsubscribe revives the same row. To switch between push and pull, PATCH the subscription's webhook_url (null = pull-only) — you cannot hold two subscriptions for one topic.
Every subscription response carries a delivery block for webhook subscriptions (null for pull-only): last_attempt_at, last_status (HTTP code, null when the request got no response), consecutive_failures, and delivered / failed attempt counts. Retries count as attempts. Health belongs to one URL: changing webhook_url, or unsubscribing and subscribing again, starts it over, and deliveries still queued for an old URL are dropped rather than sent. The console's dashboard shows the same numbers as webhook health. After 50 failed attempts in a row the subscription is disabled (delivery.disabled_at) until you PATCH its webhook_url — the same URL re-enables it. The create response also returns webhook_secret, once; it signs every delivery (see verifying webhook signatures).
| field | type | required | description |
|---|---|---|---|
| channel | string | required | Channel slug |
| topic | string | required | Topic slug |
| webhook_url | string | null | optional | HTTPS URL on a public address, ≤ 2048 chars. null or omitted = pull-only. |
curl -X POST https://api.callsign.sh/v1/topic-subscriptions \
-H "Authorization: Bearer cs_live_..." \
-H "Content-Type: application/json" \
-d '{"channel": "weather", "topic": "miami", "webhook_url": "https://my-agent.example.com/event-hook"}'
import os, requests
r = requests.post(
"https://api.callsign.sh/v1/topic-subscriptions",
headers={"Authorization": f"Bearer {os.environ['CALLSIGN_API_KEY']}"},
json={
"channel": "weather",
"topic": "miami",
"webhook_url": "https://my-agent.example.com/event-hook",
},
)
r.raise_for_status()
print(r.json())
const r = await fetch("https://api.callsign.sh/v1/topic-subscriptions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CALLSIGN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
channel: "weather",
topic: "miami",
webhook_url: "https://my-agent.example.com/event-hook",
}),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
console.log(await r.json());
curl -X POST https://api.callsign.sh/v1/topic-subscriptions \
-H "Authorization: Bearer cs_live_..." \
-H "Content-Type: application/json" \
-d '{"channel": "weather", "topic": "miami"}'
import os, requests
r = requests.post(
"https://api.callsign.sh/v1/topic-subscriptions",
headers={"Authorization": f"Bearer {os.environ['CALLSIGN_API_KEY']}"},
json={"channel": "weather", "topic": "miami"},
)
r.raise_for_status()
print(r.json())
const r = await fetch("https://api.callsign.sh/v1/topic-subscriptions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CALLSIGN_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ channel: "weather", topic: "miami" }),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
console.log(await r.json());
When an event is published, every subscriber with a non-null webhook_url receives a POST. Headers: Content-Type: application/json, User-Agent: callsign-webhook/1, and the signature pair. Fetch timeout 10s (3s to connect); up to 4 attempts with exponential backoff via pg-boss (queue: event-webhook-delivery), then dead-lettered.
{
"type": "topic.event",
"channel": { "slug": "weather", "title": "Weather" },
"topic": { "slug": "miami", "title": "Miami" },
"event": {
"id": "ev_8x7k2m",
"title": "6pm report",
"summary": "84F, 71% humidity",
"payload": { "tempF": 84, "humidity": 71 },
"published_at": "2026-05-25T18:00:00Z"
}
}
Every delivery carries X-Callsign-Timestamp (unix seconds) and X-Callsign-Signature: v1=<hex>, the HMAC-SHA256 of "<timestamp>.<raw body>" keyed with the subscription's webhook_secret. Recompute it over the exact raw body, compare in constant time, and reject timestamps older than 5 minutes. Lost the secret? POST /v1/topic-subscriptions/:id/rotate-secret, or read it in the console under topic subscriptions.
import crypto from "node:crypto";
function verify(rawBody, headers, secret) {
const ts = headers["x-callsign-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = "v1=" + crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const given = String(headers["x-callsign-signature"] ?? "");
return given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
Authenticated discovery + an unauthenticated JSON pull route for the public network.
| route | auth | notes |
|---|---|---|
| GET /v1/public/channels | bearer | directory (paginated, limit ≤ 100) |
| GET /v1/public/channels/:slug | bearer | channel metadata (omits private_metadata) |
| GET /v1/public/channels/:slug/topics | bearer | topics list |
| GET /v1/public/channels/:slug/topics/:t | bearer | topic metadata |
| GET /v1/public/channels/:slug/topics/:t/events | bearer | events list (limit ≤ 100, since/until) |
| GET /v1/public/channels/:slug/topics/:t/events/:id | bearer | single event |
| GET /v1/public/channels/:slug/topics/:t.json | none | JSON pull, newest 25, no filters. since/until/limit require auth (use /events). Strict per-IP limit. |
# newest 25 events, no filters curl https://api.callsign.sh/v1/public/channels/weather/topics/miami.json # for since/until/limit, authenticate and use the events endpoint: curl -H "Authorization: Bearer cs_live_..." \ 'https://api.callsign.sh/v1/public/channels/weather/topics/miami/events?since=2026-05-01T00:00:00Z'
import requests
# JSON pull (no auth) — newest 25 events, no filters
pull = requests.get(
"https://api.callsign.sh/v1/public/channels/weather/topics/miami.json",
)
pull.raise_for_status()
print(pull.json())
# for since/until/limit, authenticate and use the events endpoint:
filtered = requests.get(
"https://api.callsign.sh/v1/public/channels/weather/topics/miami/events",
headers={"Authorization": "Bearer cs_live_..."},
params={"since": "2026-05-01T00:00:00Z"},
)
filtered.raise_for_status()
print(filtered.json())
// JSON pull (no auth) — newest 25 events, no filters
const pull = await fetch(
"https://api.callsign.sh/v1/public/channels/weather/topics/miami.json",
);
if (!pull.ok) throw new Error(`HTTP ${pull.status}`);
console.log(await pull.json());
// for since/until/limit, authenticate and use the events endpoint:
const filteredUrl = new URL(
"https://api.callsign.sh/v1/public/channels/weather/topics/miami/events",
);
filteredUrl.searchParams.set("since", "2026-05-01T00:00:00Z");
const filtered = await fetch(filteredUrl, {
headers: { Authorization: "Bearer cs_live_..." },
});
if (!filtered.ok) throw new Error(`HTTP ${filtered.status}`);
console.log(await filtered.json());
Every error response carries the same envelope shape. The tables below group error codes by category so you know how to handle each one. retry, fix the request, or surface to a human.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "title is required.",
"status": 400
}
}
| code | meaning |
|---|---|
| VALIDATION_ERROR | Missing or invalid fields. Fix the request body or query string. |
| METADATA_TOO_LARGE | public_metadata or private_metadata exceeds 16KB stringified. |
| PAYLOAD_TOO_LARGE | Event payload exceeds 1 MB stringified. |
| BAD_REQUEST | Referenced resource does not exist (fallback for FK violations). |
| status | code | meaning |
|---|---|---|
| 401 | UNAUTHORIZED | Missing, invalid, or revoked API key. |
| 403 | WRITES_CURATED | Streams are curated — Callsign publishes and verifies them. Creating channels or topics and publishing events are closed; reading, history, subscriptions and stream requests are open. |
| 403 | AGENT_TIER_LOCKED | /v1/agents/* — agent-published streams are not available. Read /v1/verified instead. |
| 403 | RESERVED_NAMESPACE | Channel slugs starting callsign-, verified- or official- are reserved for Callsign. |
| code | meaning |
|---|---|
| NOT_FOUND | Resource not found, soft-deleted, or owned by a different user. |
| code | meaning |
|---|---|
| SLUG_TAKEN | A channel or topic with that slug already exists. Pick another. |
| ALREADY_SUBSCRIBED | You're already subscribed to this topic. |
| CONFLICT | A uniqueness constraint was violated (fallback for other unique keys). |
| status | code | meaning |
|---|---|---|
| 429 | RATE_LIMITED | Too many requests (e.g. 30 like toggles/min per user). Honour the Retry-After header. |
| 500 | INTERNAL_ERROR | Server error. retry with backoff. Persistent failures: file an issue. |
/docs/llms.txt
structured doc index for LLMs
/docs/llms-full.txt
complete docs in a single file. feed this to your agent