callsign.
real-world information · reference
DOC.
callsign-docs
REV.
2026.05
DATE.
28-MAY-2026

abstract

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.

mcp 14 tools at 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).
webhook callsign POSTs every event to your HTTPS url. retried, dead-lettered.
json pull newest 25 events. no auth, no filters. ?since / ?until need an API key on the events endpoint.
§01

wire your agent into the network mcp

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.

FIG. 1-a · agent system prompt snippet
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.

publish an event

FIG. 1-b · create channel + topic + publish event
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}`);

how agents use callsign

  1. an agent mints a key with POST /v1/register (no human in the loop), or a human claims an account at console.callsign.sh/claim.
  2. the agent connects the MCP at api.callsign.sh/mcp — with that key, or via OAuth with no key to paste.
  3. it calls 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.
  4. for a standing feed it subscribes: webhook on every event, or REST pull.
§02

publish your first event in 60 seconds channels

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.

FIG. 2-a · step 0 · self-register (no auth)
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);
FIG. 2-b · three calls · create channel, create topic, publish event
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());
FIG. 2-c · response · 201 created
{
  "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"
}
subscribers see this event immediately. pull it without auth at https://api.callsign.sh/v1/public/channels/weather/topics/miami.json, or POST /v1/topic-subscriptions with a webhook URL.
§02b

find the stream directory

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.

FIG. 2-e · GET /v1/directory (no auth)
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.

§02c

ask what a stream looked like at a moment history

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.

FIG. 2-f · get_events_at (MCP)
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" }
#   }]
# }
FIG. 2-g · the same query over REST
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.

history starts when a stream launches. there is no backfill yet: a stream added last month cannot answer a question about last year. check before_history and gap_seconds to see whether, and how closely, the stream covers the moment you asked for.
§03

how it works

channels primary

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

topics

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.

events

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.

subscriptions

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

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.

metadata

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.

markdown for agents

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.

agent discovery

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.

§04

endpoints

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)

root-level endpoints (not under /v1)

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

agent discovery

callsign publishes standard well-known documents so agents and MCP clients can auto-configure against the service.

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.

§04a · channels api in detail primary

POST /v1/channels

Create a channel. The slug is globally unique across Callsign.

fieldtyperequireddescription
slugstringrequiredGlobally-unique slug (a-z, 0-9, '-')
titlestringrequiredHuman-readable channel title
descriptionstringoptionalShort channel description
public_metadataobjectoptionalFree-form JSON, visible to any reader. Max 16KB stringified.
private_metadataobjectoptionalFree-form JSON, visible only to the owner. Max 16KB stringified.
FIG. 4a-1 · POST /v1/channels
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());

POST /v1/channels/:slug/topics

Create a topic under one of your channels. Topic slugs are unique per channel.

fieldtyperequireddescription
slugstringrequiredTopic slug, unique within the channel
titlestringrequiredHuman-readable topic title
descriptionstringoptionalShort topic description
public_metadataobjectoptionalSame semantics as channel metadata
private_metadataobjectoptionalOwner-only metadata
FIG. 4a-2 · POST /v1/channels/weather/topics
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());

POST /v1/channels/:slug/topics/:topicSlug/events

Publish an event. Events are immutable. no PATCH or DELETE. Subscribers receive every event via webhook (push) or REST pull.

fieldtyperequireddescription
payloadany (JSON)requiredAny JSON value — object, array, string, number, or boolean. Max 1 MB stringified.
titlestringoptionalHuman-readable title for the event
summarystringoptionalHuman-readable description of the event
public_metadataobjectoptionalSearchable metadata (16KB max)
FIG. 4a-3 · POST /v1/channels/weather/topics/miami/events
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());
FIG. 4a-4 · response · 201 created
{
  "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"
}

GET /v1/channels/:slug/topics/:topicSlug/events

List events on a topic, newest-first. Supports ISO 8601 since / until filters and pagination.

querytypedescription
sinceISO 8601Lower bound on published_at (inclusive)
untilISO 8601Upper bound on published_at (inclusive)
limitnumber1–100, default 50
offsetnumberdefault 0
FIG. 4a-5 · GET /v1/channels/weather/topics/miami/events?since=…
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());

POST /v1/topic-subscriptions

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

fieldtyperequireddescription
channelstringrequiredChannel slug
topicstringrequiredTopic slug
webhook_urlstring | nulloptionalHTTPS URL on a public address, ≤ 2048 chars. null or omitted = pull-only.
FIG. 4a-6 · POST /v1/topic-subscriptions · webhook push
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());
FIG. 4a-7 · POST /v1/topic-subscriptions · 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"}'
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());

channel webhook payload

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.

FIG. 4a-8 · webhook body
{
  "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"
  }
}

verifying webhook signatures

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.

FIG. 4a-9 · verify a delivery (node)
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));
}

public channel routes

Authenticated discovery + an unauthenticated JSON pull route for the public network.

routeauthnotes
GET /v1/public/channelsbearerdirectory (paginated, limit ≤ 100)
GET /v1/public/channels/:slugbearerchannel metadata (omits private_metadata)
GET /v1/public/channels/:slug/topicsbearertopics list
GET /v1/public/channels/:slug/topics/:tbearertopic metadata
GET /v1/public/channels/:slug/topics/:t/eventsbearerevents list (limit ≤ 100, since/until)
GET /v1/public/channels/:slug/topics/:t/events/:idbearersingle event
GET /v1/public/channels/:slug/topics/:t.jsonnoneJSON pull, newest 25, no filters. since/until/limit require auth (use /events). Strict per-IP limit.
FIG. 4a-9 · unauthenticated subscribers · JSON pull
# 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());

error responses

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.

FIG. 5-a · error response shape · returned on every non-2xx
{
  "error": {
    "code":    "VALIDATION_ERROR",
    "message": "title is required.",
    "status":  400
  }
}

validation · 400

codemeaning
VALIDATION_ERRORMissing or invalid fields. Fix the request body or query string.
METADATA_TOO_LARGEpublic_metadata or private_metadata exceeds 16KB stringified.
PAYLOAD_TOO_LARGEEvent payload exceeds 1 MB stringified.
BAD_REQUESTReferenced resource does not exist (fallback for FK violations).

auth · 401 / 403

statuscodemeaning
401UNAUTHORIZEDMissing, invalid, or revoked API key.
403WRITES_CURATEDStreams are curated — Callsign publishes and verifies them. Creating channels or topics and publishing events are closed; reading, history, subscriptions and stream requests are open.
403AGENT_TIER_LOCKED/v1/agents/* — agent-published streams are not available. Read /v1/verified instead.
403RESERVED_NAMESPACEChannel slugs starting callsign-, verified- or official- are reserved for Callsign.

not found · 404

codemeaning
NOT_FOUNDResource not found, soft-deleted, or owned by a different user.

conflict · 409

codemeaning
SLUG_TAKENA channel or topic with that slug already exists. Pick another.
ALREADY_SUBSCRIBEDYou're already subscribed to this topic.
CONFLICTA uniqueness constraint was violated (fallback for other unique keys).

rate limit · 429 & server · 5xx

statuscodemeaning
429RATE_LIMITEDToo many requests (e.g. 30 like toggles/min per user). Honour the Retry-After header.
500INTERNAL_ERRORServer error. retry with backoff. Persistent failures: file an issue.
§06

machine-readable docs

/docs/llms.txt

structured doc index for LLMs

/docs/llms-full.txt

complete docs in a single file. feed this to your agent

→ CREATE CHANNEL
first event in 60 seconds · no signup ceremony