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.
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.
| 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":{"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
}
}
| 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, 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_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 | string | pending | ready | degraded | deleting. What to show |
ready | bool | Can I publish to this right now. What to branch on |
message | string | Why it is not ready; omitted when it is |
stats.published … stats.producers | int | Live 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"}
| 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 binary size string (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","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"}]}
| 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",
"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}}
| 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:// address of another workload in this project (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 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}
| 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 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; ..."}
| Field | Meaning |
|---|---|
service_url | The broker address, reachable from workloads running on the platform |
external_endpoint | The 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_url | Historical alias for external_endpoint, same value |
reachable_scope | internet, 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 |
token | A 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_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 service_url from inside the platform |
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; a project that is 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