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.net, 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.net

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. Editable; raising it is checked against the budget and lowering it is checked against stats.backlog_bytes — see Edit a topic
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.published … stats.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_namestringno—Free 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. A topic can only give back budget it is not using, though: lowering max_bytes to at or below what the topic is currently holding is refused unless you ask for it explicitly.

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.

Lowering max_bytes is checked against the backlog​

A max_bytes at or below what the topic is currently holding is refused with 409, and nothing is changed. The figure it is compared against is stats.backlog_bytes from this same topic object — the reading a GET returns, not a second one that could disagree with it. An equal pair is refused too: a topic sitting exactly at its ceiling drops or rejects on its very next message.

The refusal names both numbers and the consequence for this topic, because the two discard settings fail in opposite directions:

StatusdiscardMessage
409oldthis topic is holding <B> of unacknowledged messages right now, and max_bytes <M> (<M in binary units>) is at or below that. This topic discards the OLDEST messages when it is full (discard: old), so applying that ceiling would drop the messages that no longer fit as soon as it takes effect, and they cannot be recovered. Let its subscriptions acknowledge the backlog first, choose a max_bytes above <B>, or repeat this call with ?allow_discard=true to make the change anyway and accept the consequence above.
409newThe same sentence, with: This topic refuses new messages when it is full (discard: new), so nothing already stored would be lost - instead publishes to it would start failing until its subscriptions acknowledge enough of the backlog.
409eitherthis topic is not reporting live figures yet (state: <state>), so lowering its max_bytes to <M> cannot be shown to be safe: how much it is holding is unknown, and an unknown backlog is not an empty one. Wait until it reports ready and try again, or repeat this call with ?allow_discard=true to make the change anyway and accept that messages may be dropped or publishes may start failing.

The check judges the topic the request leaves behind: a call that sets discard and lowers max_bytes together is told the consequence it is actually buying, not the one the topic had a moment ago. Restating the same size in different units is not a lowering and is not checked, and raising the ceiling is never checked against the backlog — making room cannot discard anything, so an unreadable topic stays editable in the safe direction.

The override is ?allow_discard=true, on the same PATCH:

curl -sX PATCH "$CAI_PUBSUB_API/v1/projects/$CAI_PROJECT/topics/orders?allow_discard=true" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'Content-Type: application/json' \
-d '{"max_bytes":"16Mi"}'

It is the same shape as ?cascade=true on project delete: refused by default, explicit when you mean it, never inferred from the plain call. An acknowledged shrink is recorded as such: the audit row for that edit carries allow_discard: true and the backlog_bytes reading it overrode — null, never 0, when the topic was not reporting one. It covers the unreadable-topic case as well, which is what keeps a topic whose figures cannot be read from being un-shrinkable forever. In platformctl it is --allow-discard on pubsub topics update — the same word as the parameter, named for what it costs rather than for the check it lifts, though on a discard: new topic nothing already stored is discarded and publishes start failing instead. The console has no control for it yet: its edit dialog sends the plain request, so the refusal reaches it with nothing to click through. The full rule is in Lowering max_bytes below what the topic is holding.

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
namestringyes—Same 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 only—Must be an http:// address of another workload in this project (see below)
deliver.push.content_modestringnocloudevents-structuredcloudevents-structured or cloudevents-binary
dead_letter.topicstringno—Must 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; a max_bytes lowered to at or below the topic's live backlog
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