Skip to main content

Pub/Sub API

This page lists every endpoint on the Pub/Sub service, with request fields, response shapes, and the exact error messages the server returns. For a guided introduction, start with the Pub/Sub overview and quickstart.

Base URL

Pub/Sub has its own API service, separate from the core platform API, so it gets its own environment variable: $CAI_PUBSUB_API.

Where it lives

On the public API, https://api.codyhill.dev, which serves the Pub/Sub routes alongside every other service. platformctl goes there by default; curl needs the variable set.

export CAI_PUBSUB_API=https://api.codyhill.dev

Authentication

Every route except GET /healthz requires a bearer credential:

Authorization: Bearer <token-or-api-key>

The credential is a session token (12-hour life, from platformctl login or the console) or an API key (prefix cai_). Roles are re-read from the database on every request. See API authentication.

RoleCan call
memberlist and get topics and subscriptions; :publish, :pull, :acknowledge; quota; usage
admineverything a member can, plus create, update, and delete topics and subscriptions, and GET /pubsub/credentials

Conventions

  • Error envelope. Every non-2xx response is {"error": "<message>", "request_id": "<id>"}. Unknown paths return 404 in the envelope; a wrong method returns 405.
  • The 404 rule. Malformed project ids, other projects' resources, and nonexistent resources all answer 404 with identical bodies.
  • Pagination. Lists accept page_size (1–200, default 50; out-of-range is a 400, never clamped) and page_token; a forged or cross-scope token is a 400: page_token is invalid or was issued for a different list; start from the first page. Responses carry next_page_token, omitted on the last page.
  • Custom methods. Message operations use GCP-style :verb suffixes: :publish on topics; :pull and :acknowledge (alias :ack) on subscriptions. A bare POST to a topic returns 405 POST to a topic requires a method suffix, e.g. .../topics/<name>:publish; an unknown verb returns 404 unknown method :<verb>.
  • Delivery guarantee. At-least-once, everywhere. Duplicates are possible; make consumers idempotent.

Endpoints at a glance

MethodPathAuthPurpose
GET/healthznoneHealth check
GET/v1/projects/{projectID}/topicsmemberList topics
POST/v1/projects/{projectID}/topicsadminCreate a topic
GET/v1/projects/{projectID}/topics/{topic}memberGet one topic
PATCH/v1/projects/{projectID}/topics/{topic}adminEdit a topic
DELETE/v1/projects/{projectID}/topics/{topic}adminDelete a topic and its subscriptions
POST/v1/projects/{projectID}/topics/{topic}:publishmemberPublish messages
GET/v1/projects/{projectID}/topics/{topic}/subscriptionsmemberList subscriptions on a topic
POST/v1/projects/{projectID}/topics/{topic}/subscriptionsadminCreate a subscription
GET/v1/projects/{projectID}/topics/{topic}/subscriptions/{sub}memberGet one subscription
PATCH/v1/projects/{projectID}/topics/{topic}/subscriptions/{sub}adminEdit a subscription
DELETE/v1/projects/{projectID}/topics/{topic}/subscriptions/{sub}adminDelete a subscription
POST.../subscriptions/{sub}:pullmemberPull messages
POST.../subscriptions/{sub}:acknowledge (alias :ack)memberAcknowledge messages
GET/v1/projects/{projectID}/pubsub/quotamemberStorage budget
GET/v1/projects/{projectID}/pubsub/usagememberPer-topic counters
GET/v1/projects/{projectID}/pubsub/credentialsadminDirect broker credential

Health check

GET /healthz

No auth. Returns 200 or 503:

{"status":"ok","dependencies":{"orchestration":{"status":"ok","critical":true},"database":{"status":"ok","critical":true},"broker":{"status":"ok","critical":true},"session-key":{"status":"ok","critical":true}}}

Each dependency reports {"status":"ok|down|not configured","error":"...","critical":true}.

The topic object

{
"name": "orders",
"display_name": "Order events",
"path": "<project resource path>/topics/orders",
"max_bytes": "16Mi",
"max_age": "24h0m0s",
"discard": "old",
"created_at": "2026-08-06T12:00:00Z",
"address": "persistent://p-<short>/main/orders",
"state": "pending",
"ready": false,
"message": "...",
"stats": {
"published": 0, "delivered": 0,
"published_bytes": 0, "delivered_bytes": 0,
"storage_bytes": 0, "backlog_bytes": 0,
"subscriptions": 0, "producers": 0
}
}
FieldTypeMeaning
namestringTopic name. Immutable — it is the broker address
display_namestringFree-text label; omitted when empty
pathstringStable resource path
max_bytesstringBacklog cap, written as a binary size like 16Mi or 1Gi. This amount is claimed against your project's storage budget the moment the topic exists
max_agestringMessage age limit; omitted when zero (no limit)
discardstringold (drop oldest when full) or new (refuse new messages when full)
addressstringThe underlying broker address
statestringpending | ready | degraded | deleting. What to show
readyboolCan I publish to this right now. What to branch on
messagestringWhy it is not ready; omitted when it is
stats.publishedstats.producersintLive counters (may lag up to ~5 minutes)

state is a word, not an object, and it is the same word — with the same ready beside it — that every other resource on the platform answers with. The counters live under stats because they are a different kind of fact on a different clock: they move continuously on a healthy topic, where state changes a handful of times in the topic's whole life.

List topics

GET /v1/projects/{projectID}/topics

Auth: member. Standard pagination. Response 200:

{"topics": [], "next_page_token": "..."}

Create a topic

POST /v1/projects/{projectID}/topics

Auth: admin. Body limit 1 MiB. Returns 201 with the topic object (state starts at pending and ready at false; the broker topic converges shortly).

{"name":"orders","display_name":"","max_bytes":"16Mi","max_age":"24h","discard":"old"}
FieldTypeRequiredDefaultRules
namestringyes^[a-z]([a-z0-9-]*[a-z0-9])?$, max 63 chars
display_namestringnoFree text
max_bytesstringno16MiA binary size string (16Mi, 1Gi)
max_agestringnononeA Go duration (24h, 30m); empty means no age limit
discardstringnooldold or new

Errors:

StatusMessage
400topic name is required
400topic name must be 63 characters or fewer
400topic name "X" is invalid: use lowercase letters, digits and dashes, starting with a letter
400max_bytes "X" is not a valid quantity (e.g. "16Mi", "1Gi")
400max_age "X" is not a valid duration (e.g. "24h", "30m")
400discard must be old or new (got "X")
409this project's Pub/Sub storage budget is 1.0GiB, of which <N> is already claimed by <n> topic(s); a topic of <M> does not fit. Delete a topic, lower an existing topic's max_bytes, or ask the platform operator to raise the project's quota.
Budget is spent by claim, not by use

A topic reserves its max_bytes against the project's 1 GiB budget the instant it exists. Four empty 16Mi topics have spent 64Mi even though every byte counter reads zero. Free budget by deleting a topic or lowering max_bytes; consuming messages does not help.

Get a topic

GET /v1/projects/{projectID}/topics/{topic}

Auth: member. Returns 200 with the topic object; 404 if absent or not yours.

Edit a topic

PATCH /v1/projects/{projectID}/topics/{topic}

Auth: admin. Pointer semantics: omitted fields keep their value. "max_age":"" clears the age limit. The name is immutable. Returns 200 with the updated topic.

{"display_name":"Orders","max_bytes":"32Mi","max_age":"","discard":"new"}

Raising max_bytes re-checks the budget as a delta:

StatusMessage
409…raising this topic's max_bytes to <M> does not fit. Lower it, delete another topic, or ask the platform operator to raise the project's quota.

Delete a topic

DELETE /v1/projects/{projectID}/topics/{topic}

Auth: admin. Deletes the topic and every subscription attached to it. Returns 202:

{"name":"orders","state":"deleting","ready":false}

Publish messages

POST /v1/projects/{projectID}/topics/{topic}:publish

Auth: member. Body limit 4 MiB.

{"messages":[{"text":"hello","data":"<base64>","attributes":{"region":"eu"},"key":"customer-42"}]}
FieldTypeRequiredRules
messagesarrayyes1–100 messages per request
messages[].textstringone ofPlain-text payload
messages[].datastringone ofBase64-encoded payload. Set either data or text, not both; both empty is legal (attributes-only message)
messages[].attributesobjectnoString key-value metadata; names must be non-empty and single-line
messages[].keystringnoOrdering key for key-shared subscriptions

Response 200 — message ids in request order:

{"message_ids":["1234:0"]}

Partial failure (some messages were already stored) returns 207:

{"message_ids":["1234:0"],"error":"...","published":3,"requested":5}

Errors:

StatusMessage
400messages must contain at least one message
400a publish carries at most 100 messages (got N)
400messages[i]: set either data (base64) or text, not both
400messages[i]: data is not valid base64
400messages[i]: attribute names must be non-empty and single-line
409this topic is not ready yet: <reason>
409this topic is full and is configured to refuse new messages (discard: new). Consume its backlog, raise its max_bytes, or set discard: old to drop the oldest instead.
Create subscriptions before you publish

A message is retained only while some subscription owes an acknowledgement. Publishing to a topic with no subscriptions succeeds and returns an id, but the message is reclaimed on the broker's own schedule. See topics and subscriptions.

The subscription object

{
"name": "workers",
"display_name": "",
"path": ".../subscriptions/workers",
"topic": "orders",
"type": "shared",
"active_type": "shared",
"ack_deadline_seconds": 30,
"max_deliver": 5,
"start_from": "all",
"max_ack_pending": 1000,
"deliver": {"mode":"pull","push":{"url":"http://...","content_mode":"cloudevents-structured"}},
"dead_letter": {"topic":"orders-dead","after_attempts":5},
"address": "persistent://p-<short>/main/orders",
"created_at": "2026-08-06T12:00:00Z",
"state": "ready",
"ready": true,
"stats": {
"backlog":0,"unacknowledged":0,"delivered":0,
"consumers":0,"redeliver_rate":"..."
}
}

Same shape as a topic: state is a word, ready is the boolean to branch on, message explains a false and is omitted otherwise, and the live counters are grouped under stats.

Two fields are worth reading together. type is the delivery type you asked for; active_type is what the broker currently sees, which can differ if a direct client attached with another one, and is absent until anything connects at all. stats.consumers is how many readers are attached — a zero there on a push subscription is the single most useful thing to see when the answer to "why does my push target get nothing" is "nothing is reading".

GET /v1/projects/{projectID}/topics/{topic}/subscriptions

Auth: member. Returns 404 if the topic does not exist. Standard pagination. Response 200:

{"subscriptions": [], "next_page_token": "..."}

There is no project-wide subscription list — enumerate topics and fan out (this is what the console does).

Create a subscription

POST /v1/projects/{projectID}/topics/{topic}/subscriptions

Auth: admin. Returns 201 with the subscription object.

{"name":"workers","display_name":"","type":"shared","ack_deadline_seconds":30,
"max_deliver":5,"start_from":"all","max_ack_pending":1000,
"deliver":{"mode":"pull"},
"dead_letter":{"topic":"orders-dead","after_attempts":5}}
FieldTypeRequiredDefaultRules
namestringyesSame rule as topic names: lowercase letters, digits, dashes, starts with a letter, max 63 chars
typestringnosharedshared (a queue), key-shared (a queue ordered per key), exclusive or failover (total order, one active reader)
ack_deadline_secondsintno301–600. For push, this is also the per-request timeout
max_deliverintno50 is read as unset; must not be negative. Enforced by the push worker only
start_fromstringnoallall or new; applied at creation only. Immutable
max_ack_pendingintno1000Max unacknowledged messages in flight
deliver.modestringnopullpull or push
deliver.push.urlstringpush onlyMust be an http:// address of another workload in this project (see below)
deliver.push.content_modestringnocloudevents-structuredcloudevents-structured or cloudevents-binary
dead_letter.topicstringnoMust exist in the project and differ from this subscription's own topic
dead_letter.after_attemptsintno5Attempts before a push message is parked in the dead-letter topic

Validation errors (all 400):

Message
type must be one of "shared" (a queue), "key-shared" (a queue ordered per key), "exclusive" or "failover" (ordered, one active reader) - got "X"
ack_deadline_seconds must be between 1 and 600
max_deliver must not be negative
start_from must be all or new (got "X")
deliver.mode must be "pull" or "push" (got "X")
a "exclusive" subscription admits one reader at a time, so it cannot be read through this API. Use deliver.mode "push", read it with a direct client, or choose type "shared" or "key-shared".
deliver.push is set but deliver.mode is "pull"; it would be silently ignored
deliver.mode is "push" but deliver.push.url is missing
deliver.push.url is not allowed: <reason>. A push target must be a workload in this project, and the address to use is the one the platform already gives you: the "url" field on that workload
deliver.push.content_mode must be "cloudevents-structured" or "cloudevents-binary"
dead_letter.topic "X" does not exist in this project; create it first
dead_letter.topic must differ from the subscription's own topic, or a poison message is republished into the topic it came from

Push target allowlist. A push target must be a workload running in your own project. Anything else is refused, so the platform cannot be used to probe systems elsewhere. If you get the host wrong, the deliver.push.url is not allowed error above prints the exact form your project accepts, including a ready-made example — copy the shape from there. Only http is accepted, userinfo is refused, and redirects are never followed.

Push delivery behavior. Your handler receives a POST per message. In binary mode the payload is the body and metadata rides in ce-* headers: ce-type: ai.crusoe.pubsub.message.v1, ce-id, ce-source, ce-subject, ce-subscription, ce-deliveryattempt, and your attributes as ce-attr-<name>. Respond with any 2xx to acknowledge. Anything else retries with exponential backoff (1 s, doubling, capped at 60 s) until after_attempts, after which the message is republished to the dead-letter topic with cai-dead-letter-* forensic attributes — or dropped if no dead-letter topic is configured. Response bodies are read to at most 64 KiB. A redirecting target fails delivery.

Get a subscription

GET /v1/projects/{projectID}/topics/{topic}/subscriptions/{sub}

Auth: member. Returns 200; 404 if the subscription exists but belongs to a different topic.

Edit a subscription

PATCH /v1/projects/{projectID}/topics/{topic}/subscriptions/{sub}

Auth: admin. Returns 200. Mutable in place: display_name, ack_deadline_seconds, max_deliver, max_ack_pending, deliver (push URL and content mode), and dead_letter ({"topic":""} clears it). Not mutable: topic, type, start_from — delete and recreate to change those.

Delete a subscription

DELETE /v1/projects/{projectID}/topics/{topic}/subscriptions/{sub}

Auth: admin. Returns 202:

{"name":"workers","state":"deleting","ready":false}

Pull messages

POST /v1/projects/{projectID}/topics/{topic}/subscriptions/{sub}:pull

Auth: member. Body optional (limit 1 MiB):

{"max_messages":10,"timeout_ms":2000,"auto_ack":false}
FieldTypeRequiredDefaultRules
max_messagesintno10Silently capped at 100
timeout_msintno2000Long-poll wait for the first message; capped at 20000 (20 s)
auto_ackboolnofalseAcknowledge server-side before responding. Lossy: a client crash after the response loses those messages

Response 200:

{"messages":[{"ack_id":"...","id":"1234:0","data":"<base64>","attributes":{"region":"eu"},"key":"customer-42","publish_time":"2026-08-06T12:00:00Z","delivery_attempt":1}]}

With "auto_ack":true, ack_id is omitted per message and the response carries "acknowledged": <n>. If auto-ack partially fails, the response is 207:

{"messages":[],"acknowledged":2,"error":"messages were delivered but not all acknowledged; they will be redelivered: ..."}

Errors:

StatusMessage
409this is a push subscription; its messages are delivered to <url>. Create a separate pull subscription on the same topic to read them here.
409this subscription is ordered (type: exclusive), so it admits one reader at a time and cannot be served by a load-balanced API. Read it with a direct client, use push delivery, or create a shared subscription on the same topic.
409this subscription is not ready yet: ...
Pull has no delivery cap

max_deliver is enforced by the push worker only. On a pull subscription a failing message keeps coming back forever — use delivery_attempt in the pull response to decide when to give up.

Acknowledge messages

POST /v1/projects/{projectID}/topics/{topic}/subscriptions/{sub}:acknowledge

Alias: :ack. Auth: member.

{"ack_ids":["..."]}
FieldTypeRequiredRules
ack_idsarray of stringsyes1–1000 ids per request

Response 200:

{"acknowledged": 1}

An acknowledgement may be sent to any API replica. Messages not acknowledged before the subscription's ack deadline are redelivered.

Errors:

StatusMessage
400ack_ids must contain at least one id
400at most 1000 acknowledgement ids per request
400one or more acknowledgement ids are not valid for this subscription

Storage quota

GET /v1/projects/{projectID}/pubsub/quota

Auth: member. Response 200:

{"limit_bytes":1073741824,"allocated_bytes":67108864,"backlog_bytes":0,"storage_bytes":0,
"available_bytes":1006632960,"human":"64.0MiB of 1.0GiB claimed by 4 topic(s); 0B stored, 0B unacknowledged",
"topics":4,"provisioned":true}

provisioned is false (with a note appended to human) until the project's first topic converges on the broker. limit_bytes counts unacknowledged backlog, not disk.

Usage counters

GET /v1/projects/{projectID}/pubsub/usage

Auth: member. Response 200:

{"project":"<short>",
"topics":[{"name":"orders","published":10,"delivered":10,"published_bytes":420,"delivered_bytes":420,"storage_bytes":0,"backlog_bytes":0,"subscriptions":1}],
"totals":{"name":"total","published":10,"delivered":10,"published_bytes":420,"delivered_bytes":420,"storage_bytes":0,"backlog_bytes":0,"subscriptions":1},
"caveat":"counters are cumulative since each topic was last loaded by a broker and reset when one restarts; sample and difference them for billing rather than treating them as a running total"}

Direct broker credentials

GET /v1/projects/{projectID}/pubsub/credentials

Auth: admin. Returns a credential for connecting a native messaging client directly to the underlying broker, scoped to your project's own area of it.

Response 200:

{"service_url":"pulsar://<private-hostname>:6650",
"external_endpoint":"pulsar+ssl://<endpoint>-<short>.apps.<domain>:443",
"external_service_url":"pulsar+ssl://<endpoint>-<short>.apps.<domain>:443",
"reachable_scope":"internet",
"token":"<JWT, never expires>",
"topic_prefix":"persistent://p-<short>/main/",
"note":"this credential can produce and consume only inside this project. It does not expire; ..."}
FieldMeaning
service_urlThe broker address, reachable from workloads running on the platform
external_endpointThe address of your own published endpoint for the broker, over TLS. Present only once you publish one — nothing is reachable from the internet until you do. When present it is reachable from the internet, with the project token as the only boundary; add an address allow list to the endpoint to narrow that
external_service_urlHistorical alias for external_endpoint, same value
reachable_scopeinternet, present only alongside the external endpoint. It reported vpc until private-by-default landed; that was wrong, and a client that trusted it treated an internet-facing address as VPC-only
tokenA JWT scoped to produce and consume within this project's own area of the broker only. It never expires — revoking one means rotating the platform signing key for everyone
topic_prefixPrefix for your project's broker topic names
noteHonest status of the external path. By default only connect and topic lookup are proven externally; produce and consume need the service_url from inside the platform

Server limits

LimitValue
Publish request body4 MiB
Other request bodies1 MiB
Messages per publish100
Messages per pull100 (default 10; over-asks silently capped)
Ack ids per acknowledge1000
Pull timeout_msmax 20000, default 2000
ack_deadline_seconds1–600, default 30
Names (topics, subscriptions)lowercase letters, digits, dashes; starts with a letter; max 63 chars
Project storage budget1 GiB (default), spent by claim
Topics per project100 (default)
Producers / consumers per topic100 / 100 (default)
List page_sizedefault 50, max 200
Push response body read64 KiB

See platform limits for cross-service limits.

Status codes

CodeWhen
400Validation failures (messages above)
401Unknown or invalid credential
403Member calling an admin route; this session must change its password before it can be used
404Malformed project id, another project's resource, or a nonexistent resource — deliberately identical
405Bare POST to a topic without a :verb suffix
409Quota refusal; not-ready resources; a project that is still provisioning
500internal error (details only in server logs)
503Pub/Sub is not ready to serve this project yet; retry shortly (signing key not loaded); this project is not provisioned on the Pub/Sub broker yet; it is created shortly after the project's first topic