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.
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.
| Role | Can call |
|---|---|
| member | list and get topics and subscriptions; :publish, :pull, :acknowledge; quota; usage |
| admin | everything 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) andpage_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 carrynext_page_token, omitted on the last page. - Custom methods. Message operations use GCP-style
:verbsuffixes::publishon topics;:pulland:acknowledge(alias:ack) on subscriptions. A bare POST to a topic returns 405POST to a topic requires a method suffix, e.g. .../topics/<name>:publish; an unknown verb returns 404unknown method :<verb>. - Delivery guarantee. At-least-once, everywhere. Duplicates are possible; make consumers idempotent.
Endpoints at a glance
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /healthz | none | Health check |
| GET | /v1/projects/{projectID}/topics | member | List topics |
| POST | /v1/projects/{projectID}/topics | admin | Create a topic |
| GET | /v1/projects/{projectID}/topics/{topic} | member | Get one topic |
| PATCH | /v1/projects/{projectID}/topics/{topic} | admin | Edit a topic |
| DELETE | /v1/projects/{projectID}/topics/{topic} | admin | Delete a topic and its subscriptions |
| POST | /v1/projects/{projectID}/topics/{topic}:publish | member | Publish messages |
| GET | /v1/projects/{projectID}/topics/{topic}/subscriptions | member | List subscriptions on a topic |
| POST | /v1/projects/{projectID}/topics/{topic}/subscriptions | admin | Create a subscription |
| GET | /v1/projects/{projectID}/topics/{topic}/subscriptions/{sub} | member | Get one subscription |
| PATCH | /v1/projects/{projectID}/topics/{topic}/subscriptions/{sub} | admin | Edit a subscription |
| DELETE | /v1/projects/{projectID}/topics/{topic}/subscriptions/{sub} | admin | Delete a subscription |
| POST | .../subscriptions/{sub}:pull | member | Pull messages |
| POST | .../subscriptions/{sub}:acknowledge (alias :ack) | member | Acknowledge messages |
| GET | /v1/projects/{projectID}/pubsub/quota | member | Storage budget |
| GET | /v1/projects/{projectID}/pubsub/usage | member | Per-topic counters |
| GET | /v1/projects/{projectID}/pubsub/credentials | admin | Direct 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
}
}
| Field | Type | Meaning |
|---|---|---|
name | string | Topic name. Immutable — it is the broker address |
display_name | string | Free-text label; omitted when empty |
path | string | Stable resource path |
max_bytes | string | Backlog cap, a Kubernetes quantity. This amount is claimed against your project's storage budget the moment the topic exists |
max_age | string | Message age limit; omitted when zero (no limit) |
discard | string | old (drop oldest when full) or new (refuse new messages when full) |
address | string | The underlying broker address |
state.phase, state.ready | string, bool | Convergence status; message explains why not ready and is omitted when ready |
state.published … state.producers | int | Live 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"}
| Field | Type | Required | Default | Rules |
|---|---|---|---|---|
name | string | yes | — | ^[a-z]([a-z0-9-]*[a-z0-9])?$, max 63 chars |
display_name | string | no | — | Free text |
max_bytes | string | no | 16Mi | A Kubernetes quantity (16Mi, 1Gi) |
max_age | string | no | none | A Go duration (24h, 30m); empty means no age limit |
discard | string | no | old | old or new |
Errors:
| Status | Message |
|---|---|
| 400 | topic name is required |
| 400 | topic name must be 63 characters or fewer |
| 400 | topic name "X" is invalid: use lowercase letters, digits and dashes, starting with a letter |
| 400 | max_bytes "X" is not a valid quantity (e.g. "16Mi", "1Gi") |
| 400 | max_age "X" is not a valid duration (e.g. "24h", "30m") |
| 400 | discard must be old or new (got "X") |
| 409 | this 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. |
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:
| Status | Message |
|---|---|
| 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"}]}
| Field | Type | Required | Rules |
|---|---|---|---|
messages | array | yes | 1–100 messages per request |
messages[].text | string | one of | Plain-text payload |
messages[].data | string | one of | Base64-encoded payload. Set either data or text, not both; both empty is legal (attributes-only message) |
messages[].attributes | object | no | String key-value metadata; names must be non-empty and single-line |
messages[].key | string | no | Ordering 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:
| Status | Message |
|---|---|
| 400 | messages must contain at least one message |
| 400 | a publish carries at most 100 messages (got N) |
| 400 | messages[i]: set either data (base64) or text, not both |
| 400 | messages[i]: data is not valid base64 |
| 400 | messages[i]: attribute names must be non-empty and single-line |
| 409 | this topic is not ready yet: <reason> |
| 409 | this 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. |
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}}
| Field | Type | Required | Default | Rules |
|---|---|---|---|---|
name | string | yes | — | Same rule as topic names: lowercase letters, digits, dashes, starts with a letter, max 63 chars |
type | string | no | shared | shared (a queue), key-shared (a queue ordered per key), exclusive or failover (total order, one active reader) |
ack_deadline_seconds | int | no | 30 | 1–600. For push, this is also the per-request timeout |
max_deliver | int | no | 5 | 0 is read as unset; must not be negative. Enforced by the push worker only |
start_from | string | no | all | all or new; applied at creation only. Immutable |
max_ack_pending | int | no | 1000 | Max unacknowledged messages in flight |
deliver.mode | string | no | pull | pull or push |
deliver.push.url | string | push only | — | Must be an http:// Service in this project's own namespace (see below) |
deliver.push.content_mode | string | no | cloudevents-structured | cloudevents-structured or cloudevents-binary |
dead_letter.topic | string | no | — | Must exist in the project and differ from this subscription's own topic |
dead_letter.after_attempts | int | no | 5 | Attempts 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}
| Field | Type | Required | Default | Rules |
|---|---|---|---|---|
max_messages | int | no | 10 | Silently capped at 100 |
timeout_ms | int | no | 2000 | Long-poll wait for the first message; capped at 20000 (20 s) |
auto_ack | bool | no | false | Acknowledge 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:
| Status | Message |
|---|---|
| 409 | this is a push subscription; its messages are delivered to <url>. Create a separate pull subscription on the same topic to read them here. |
| 409 | this 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. |
| 409 | this subscription is not ready yet: ... |
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":["..."]}
| Field | Type | Required | Rules |
|---|---|---|---|
ack_ids | array of strings | yes | 1–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:
| Status | Message |
|---|---|
| 400 | ack_ids must contain at least one id |
| 400 | at most 1000 acknowledgement ids per request |
| 400 | one 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; ..."}
| Field | Meaning |
|---|---|
service_url | In-cluster broker address |
external_endpoint | TLS endpoint, present only when an external data plane is configured; reachable from your Crusoe VPC, never the public internet |
external_service_url | Historical alias for external_endpoint, same value |
reachable_scope | vpc, present only alongside the external endpoint |
token | A 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_prefix | Prefix for your project's broker topic names |
note | Honest 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
| Limit | Value |
|---|---|
| Publish request body | 4 MiB |
| Other request bodies | 1 MiB |
| Messages per publish | 100 |
| Messages per pull | 100 (default 10; over-asks silently capped) |
| Ack ids per acknowledge | 1000 |
Pull timeout_ms | max 20000, default 2000 |
ack_deadline_seconds | 1–600, default 30 |
| Names (topics, subscriptions) | lowercase letters, digits, dashes; starts with a letter; max 63 chars |
| Project storage budget | 1 GiB (default), spent by claim |
| Topics per project | 100 (default) |
| Producers / consumers per topic | 100 / 100 (default) |
List page_size | default 50, max 200 |
| Push response body read | 64 KiB |
See platform limits for cross-service limits.
Status codes
| Code | When |
|---|---|
| 400 | Validation failures (messages above) |
| 401 | Unknown or invalid credential |
| 403 | Member calling an admin route; this session must change its password before it can be used |
| 404 | Malformed project id, another project's resource, or a nonexistent resource — deliberately identical |
| 405 | Bare POST to a topic without a :verb suffix |
| 409 | Quota refusal; not-ready resources; this project has no namespace yet (project still provisioning) |
| 500 | internal error (details only in server logs) |
| 503 | Pub/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 |
Related pages
- Pub/Sub overview — what the service is and when to use it
- Topics and subscriptions — the retention and ordering model
- Publish and consume — pull, push, and acknowledgement patterns
- CLI: data and messaging commands —
platformctl pubsubcommands - API overview — shared conventions across all platform APIs