Skip to main content

Pub/Sub API

This page lists every endpoint on the Pub/Sub service (pubsub-api), 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. In-cluster it is pubsub-api.cai-system.svc:8080.

No public API hostname yet

The platform is in alpha and does not publish a public hostname for this API. Reach it through a port-forward (shown below), or through an endpoint your administrator provides. The platformctl CLI does this automatically when $CAI_PUBSUB_API is not set.

kubectl -n cai-system port-forward svc/pubsub-api 18082:8080 &
export CAI_PUBSUB_API=http://localhost:18082

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), an API key (prefix cai_), or the platform automation token. 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":{"kubernetes":{"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": {
"phase": "Pending", "ready": false, "message": "...",
"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, a Kubernetes quantity. 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
state.phase, state.readystring, boolConvergence status; message explains why not ready and is omitted when ready
state.publishedstate.producersintLive counters (may lag up to ~5 minutes)

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.phase starts at Pending; 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 Kubernetes quantity (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","status":"deleting"}

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",
"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},
"created_at": "2026-08-06T12:00:00Z",
"state": {
"phase":"Ready","ready":true,
"address":"persistent://p-<short>/main/orders",
"active_type":"shared",
"backlog":0,"unacknowledged":0,"delivered":0,
"consumers":0,"redeliver_rate":"..."
}
}

List subscriptions

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:// Service in this project's own namespace (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 Service in this project's own namespace (<ns>), e.g. http://my-worker.<ns>.svc.cluster.local:8080/events
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. To prevent the platform from being used to probe other systems, the push URL's host must be exactly <service>.<namespace>, <service>.<namespace>.svc, or <service>.<namespace>.svc.cluster.local, where the namespace is the project's own. 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","status":"deleting"}

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

Response 200:

{"service_url":"pulsar://pulsar-broker.cai-services.svc.cluster.local:6650",
"external_endpoint":"pulsar+ssl://pulsar.<domain>:443",
"external_service_url":"pulsar+ssl://pulsar.<domain>:443",
"reachable_scope":"vpc",
"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_urlIn-cluster broker address
external_endpointTLS endpoint, present only when an external data plane is configured; reachable from your Crusoe VPC, never the public internet
external_service_urlHistorical alias for external_endpoint, same value
reachable_scopevpc, present only alongside the external endpoint
tokenA JWT scoped to produce and consume within this project's broker namespace 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 in-cluster service_url

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; this project has no namespace yet (project 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