Docs menu: Start, Rules, Reference
Developers
One key, one set of scopes, two ways in: REST and MCP.
What the API is for
The API lets a program work with a brand's posts the way the dashboard does. It can list brands and channels, create a post, set when it goes out, and read the posts waiting for the owner and how posts did. Use REST from a script or a service. Use MCP from an agent that speaks it. Both take the same keys and the same scopes.
Get a key
Sign in at house.cassit.app, open You, and find Machine keys. Choose Create key, give it a name, pick a preset (Feeder, Read-only, Agent (full) or Custom), adjust its scopes and set when it expires: never, 30 days or 90 days. Only the owner can create keys.
The key is shown once and cannot be read back. Copy it then. A lost key is replaced with a new one. Keys start with ck_.
Authorization header
Send the key as a bearer token on every request, REST and MCP alike. A missing or invalid key gets 401. A key without the scope the call needs gets 403.
Authorization: Bearer ck_...Base URLs
| Surface | URL |
|---|---|
| REST | https://house.cassit.app/api/v1 |
| MCP | https://house.cassit.app/mcp |
MCP is stateless Streamable HTTP with JSON responses. Call the product host, house.cassit.app. The public site at cassit.app redirects API paths there, so use the product host directly.
Quickstart
Create a text post for Facebook. The key needs content:draft. Replace BRAND_ID with an id from GET /brands, which needs brands:read.
curl -X POST https://house.cassit.app/api/v1/brands/BRAND_ID/items \
-H "Authorization: Bearer ck_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7c1f0e52-first-post" \
-d '{"captions":{"facebook":"Open late on Friday."}}'A successful call answers 201.
{
"ok": true,
"itemId": "...",
"slug": "...",
"outcome": "held",
"approvalId": "...",
"warnings": [],
"schedulingPaused": false
}What happens next depends on the brand's auto-publish switch. With it off, outcome is held and the post waits for the owner to approve it. With it on, outcome is ready and the post goes out on schedule, at your publishAt if you sent one. If schedulingPaused is true, the brand's Scheduling switch is off and nothing goes out until a person turns it on.
The body takes captions (platform to text, 1 to 5000 characters each) and optionally mediaRefs, subject, format, composition and publishAt (an ISO 8601 time). A caption the platform will not take gets 422 with caption_rejected and a detail field.
A complete example
This script lists your brands, picks the first channel that takes a text-only post, creates the post, sets a time for it and reads it until it is published or waiting for the owner. Save it as post.mjs and run CASSIT_KEY=ck_... node post.mjs with Node 22. The key needs brands:read, content:draft, content:schedule and content:read.
// Publish one text post with the Cassit API. Node 22, no dependencies.
//
// CASSIT_KEY=ck_... node post.mjs
//
// This creates a real post.
// With auto-publish on for the brand, it is published.
// The key needs these scopes:
// brands:read, content:draft, content:schedule, content:read.
// Optional settings: CASSIT_BASE_URL, CASSIT_TEXT, and CASSIT_BRAND
// (a brand id or name).
// Exit code: 0 published or waiting for the owner, 1 a failure
// (including a failed publish), 2 not published within 3 minutes.
import { randomUUID } from 'node:crypto';
import { pathToFileURL } from 'node:url';
const BASE_URL = 'https://house.cassit.app/api/v1';
// The platforms this example posts to. Each takes a post with no media.
const TEXT_PLATFORMS = ['facebook', 'telegram', 'threads'];
const PUBLISHED = ['published', 'verified', 'learned'];
const DEAD = ['rejected', 'expired'];
class Stop extends Error {
constructor(message, status = 0) {
super(message);
this.status = status;
}
}
export async function main({ env, fetch, log, sleep }) {
const key = env.CASSIT_KEY;
if (!key) {
log(
'Set CASSIT_KEY to your API key, ' +
'for example: CASSIT_KEY=ck_... node post.mjs',
);
return 1;
}
const base = env.CASSIT_BASE_URL || BASE_URL;
// One request. Calls that change data carry a fresh Idempotency-Key.
async function call(method, path, body) {
const headers = { authorization: `Bearer ${key}` };
if (method !== 'GET') {
headers['content-type'] = 'application/json';
headers['idempotency-key'] = randomUUID();
}
const res = await fetch(base + path, {
method,
headers,
body: body && JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (res.ok) return data;
const detail = [data.hint, data.detail].flat().filter(Boolean).join(' ');
if (res.status === 401) {
throw new Stop('The key was not accepted. Check CASSIT_KEY.', 401);
}
if (res.status === 403) {
const why = detail || 'see the Scopes table';
throw new Stop(`The key lacks a scope: ${why}`, 403);
}
if (res.status === 429) {
const wait = res.headers.get('retry-after') ?? 'a few';
throw new Stop(`Too many requests. Try again in ${wait} seconds.`, 429);
}
const what = `${method} ${path} answered ${res.status}`;
throw new Stop(`${what} ${data.error ?? ''} ${detail}`.trim(), res.status);
}
try {
const { brands } = await call('GET', '/brands');
const wanted = env.CASSIT_BRAND;
const brand = wanted
? brands.find((b) => b.id === wanted || b.name === wanted)
: brands[0];
if (!brand) {
throw new Stop(
wanted ? `No brand matches ${wanted}.` : 'The key sees no brands.',
);
}
const { channels } = await call('GET', `/brands/${brand.id}/channels`);
const channel = channels.find(
(c) =>
TEXT_PLATFORMS.includes(c.platform) &&
c.token &&
c.token.healthStatus !== 'lost',
);
if (!channel) {
const list = TEXT_PLATFORMS.join(', ');
const hint = wanted ? '' : ' Set CASSIT_BRAND to pick another brand.';
throw new Stop(
`${brand.name} has no connected channel that takes a text-only ` +
`post (${list}).${hint}`,
);
}
log(`Brand ${brand.name}, platform ${channel.platform}.`);
const text = env.CASSIT_TEXT || 'Hello from the Cassit API.';
const post = await call('POST', `/brands/${brand.id}/items`, {
captions: { [channel.platform]: text },
});
log(`Created post ${post.itemId}. Outcome: ${post.outcome}.`);
if (post.schedulingPaused) {
log(
'Scheduling is switched off for this brand, ' +
'so nothing goes out until a person turns it on.',
);
return 2;
}
// A post that is ready has no time yet. Ask for one a minute from now.
if (post.outcome === 'ready') {
try {
const at = new Date(Date.now() + 60_000).toISOString();
const path = `/brands/${brand.id}/items/${post.itemId}/schedule`;
await call('POST', path, { at });
log(`Scheduled for ${at}.`);
} catch (e) {
if (!(e instanceof Stop) || [401, 403, 429].includes(e.status)) throw e;
log(`Could not set a time (${e.message}).`);
}
}
// Read the post until it is published or held for the owner.
// The queue is read once a minute, so allow 3 minutes at most.
let status = 'unknown';
for (let i = 0; i < 36; i++) {
const { items } = await call('GET', `/brands/${brand.id}/items?limit=20`);
const found = items.find((item) => item.id === post.itemId);
status = found?.status ?? status;
const leg = found?.platforms?.[channel.platform];
if (leg === 'failed') {
// A failed publish is not tried again by itself.
throw new Stop(`Publishing to ${channel.platform} failed.`);
}
if (leg === 'published' || PUBLISHED.includes(status)) {
log(`Published to ${channel.platform}.`);
return 0;
}
if (status === 'held') {
log("Waiting for the owner's approval. Nothing has gone out.");
return 0;
}
if (DEAD.includes(status)) {
throw new Stop(`The post ended as ${status}. Nothing was published.`);
}
await sleep(5000);
}
log(
`Not published after 3 minutes. The post is ${status}. ` +
'It may still go out later.',
);
return 2;
} catch (e) {
if (!(e instanceof Stop)) throw e;
log(e.message);
return 1;
}
}
const entry = process.argv[1];
if (entry && import.meta.url === pathToFileURL(entry).href) {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
process.exitCode = await main({
env: process.env,
fetch: globalThis.fetch,
log: console.log,
sleep,
});
}
Scopes
Each call needs one scope. Name the scopes a key may use when you create it.
| Scope | Allows | * |
|---|---|---|
| brands:read | Read brands and their channels. | covered |
| content:read | Read a brand’s posts and their state. | covered |
| content:draft | Create and change posts that have not gone out. | covered |
| content:schedule | Set the time a post goes out. | covered |
| insights:read | Read post results. | covered |
| books:read | Read per-brand usage figures. | covered |
| engine:read | Read scheduler state and cooldowns. | covered |
| tray:read | Read the posts waiting for the owner. | covered |
| notifications:read | Read notifications. | covered |
| tray:decide | Approve, hold or amend a post waiting for the owner. | name it |
| connect:write | Create brands and connect accounts. | name it |
A key with * covers every scope except tray:decide and connect:write. Those must be named on the key.
Idempotency-Key
Every POST, PUT, PATCH and DELETE sent with an API key needs an Idempotency-Key header. GET does not. Without it the call gets 400 with idempotency_key_required.
- A key is remembered for 24 hours, per API key.
- The same key with the same method, path and body replays the first response exactly and adds
Idempotency-Replayed: true. - The same key with a different method, path or body gets
422withidempotency_key_reused_with_different_request. - The same key while the first call is still running gets
409withrequest_in_flightandRetry-After: 2. - A
5xxresponse is not kept, so you can retry with the same key. Any other response, a4xxincluded, is kept. If a call was rejected, fix it and send it again with a new key.
MCP tools that change data take an idempotencyKey argument in place of the header and follow the same rules.
Errors
An error has a status code and a JSON body. A hint or detail field may follow.
{"error":"forbidden","hint":"scope \"content:draft\" required"}| Status | Error | Meaning |
|---|---|---|
| 400 | bad_request | The body is not valid JSON. |
| 400 | idempotency_key_required | A call that changes data had no Idempotency-Key. |
| 401 | unauthorized | The key is missing or not valid. |
| 403 | forbidden | The key lacks the scope. The hint names it. |
| 404 | not_found | The route does not exist. |
| 409 | request_in_flight | The first call with this key is still running. |
| 413 | payload_too_large | The request body is too large. |
| 415 | unsupported_media_type | The content type is not accepted. |
| 422 | caption_rejected | A caption was refused. The detail says why. |
| 422 | idempotency_key_reused_with_different_request | The key was used for another request. |
| 429 | rate_limited | Too many requests. See Fair use. |
| 500 | internal_error | The call failed on our side. |
A body that fails its checks gets 400 in a different shape: success is false and error holds a name and a message that lists the problems.
Fair use
Requests are limited so the service stays fair for everyone. When a call is over the limit, REST and MCP answer 429 with a Retry-After header, in seconds. Wait that long, then send the call again.
REST endpoints (32)
Paths are relative to https://house.cassit.app/api/v1.
| Method | Path | Scope | What it does | Body or query |
|---|---|---|---|---|
| GET | /brands | brands:read | List the brands the key can see. | |
| POST | /brands | connect:write | Create a brand. | name, timezone? |
| POST | /brands/:brandId/rename | connect:write | Rename a brand. | name |
| GET | /brands/:brandId/channels | brands:read | List a brand’s connected channels. | |
| POST | /brands/:brandId/channels | connect:write | Add a channel to a brand. | platform, transportKind, accountRef |
| PUT | /channels/:channelId/token | connect:write | Store the access token for a channel. | token, kind, expiresAt?, scopes? |
| POST | /channels/:channelId/verify | connect:write | Check that a channel’s token still works. | |
| POST | /brands/:brandId/channels/telegram/connect | connect:write | Connect a Telegram channel with a bot token. | botToken, chatIdentifier |
| POST | /brands/:brandId/drafts | content:draft | Ask for content for the listed platforms. | platforms[], format?, subjectHint? |
| POST | /brands/:brandId/ideas | content:draft | Ask for a list of post ideas. | count?, format?, explore? |
| POST | /brands/:brandId/ingest | content:draft | Upload a media file with a caption as a post. | multipart: file, caption?, publishAt?, platforms?, subject?, register? |
| POST | /brands/:brandId/media/upload | content:draft | Upload a media file for later use in a post. | multipart: file |
| GET | /brands/:brandId/items | content:read | List a brand’s posts. Each post lists, for each platform, whether it is waiting, publishing, scheduled, published or failed. | query: limit?, offset?, since? |
| POST | /brands/:brandId/items | content:draft | Create a post from captions you supply. | captions, mediaRefs?, subject?, format?, composition?, publishAt? |
| POST | /brands/:brandId/items/:itemId/schedule | content:schedule | Set the time a post goes out. | at |
| POST | /brands/:brandId/items/:itemId/platforms | content:draft | Add a platform to a post. | platform, caption, publishAt? |
| DELETE | /brands/:brandId/items/:itemId/platforms/:platform | content:draft | Remove a platform from a post that has not gone out there. | |
| POST | /brands/:brandId/items/:itemId/discard | content:draft | Discard a post that has not gone out. | reason? |
| POST | /brands/:brandId/items/:itemId/legs/:platform/skip | content:draft | Skip one platform of a post that missed its window. | |
| POST | /brands/:brandId/posts/:recordId/retry-publish | content:draft | Try again to publish a post that failed. | |
| PATCH | /brands/:brandId/items/:itemId/captions | content:draft | Change the captions of a post that has not gone out. | captions |
| PATCH | /brands/:brandId/items/:itemId/new-ground | content:draft | Set the flag that decides whether the post waits for approval when auto-publish is on. Clearing it also needs tray:decide. | newGround |
| PATCH | /brands/:brandId/items/:itemId/media | content:draft | Replace the media of a post that has not gone out. | mediaRefs |
| GET | /brands/:brandId/insights | insights:read | Read post results for a brand. | query: since?, limit?, offset?, latest? |
| GET | /tray | tray:read | List the posts waiting for the owner. | |
| GET | /tray/held | tray:read | List the posts the owner has put on hold. | |
| POST | /tray/:id/decide | tray:decide | Approve, hold, edit or reject a post waiting for the owner. | decision, editDelta? |
| POST | /tray/:id/amend | tray:decide | Change a post waiting for the owner. | editDelta |
| GET | /books/:brandId | books:read | Read a brand’s usage figures. | |
| GET | /brands/:brandId/cooldowns | engine:read | Read a brand’s cooldowns. | |
| GET | /engine | engine:read | Read scheduler state. | |
| GET | /notifications | notifications:read | List notifications. |
MCP tools (29)
Endpoint: https://house.cassit.app/mcp, with the same Authorization header. Each tool needs the scope shown.
| Tool | Scope | What it does | Hints |
|---|---|---|---|
| list_brands | brands:read | List the brands the key can see. | read-only, idempotent |
| list_channels | brands:read | List a brand’s connected channels. | read-only, idempotent |
| list_items | content:read | List a brand’s posts. Each post lists, for each platform, whether it is waiting, publishing, scheduled, published or failed. | read-only, idempotent |
| read_insights | insights:read | Read post results for a brand. | read-only, idempotent |
| read_tray | tray:read | List the posts waiting for the owner. | read-only, idempotent |
| read_held | tray:read | List the posts the owner has put on hold. | read-only, idempotent |
| read_books | books:read | Read a brand’s usage figures. | read-only, idempotent |
| read_cooldowns | engine:read | Read a brand’s cooldowns. | read-only, idempotent |
| read_engine | engine:read | Read scheduler state. | read-only, idempotent |
| read_notifications | notifications:read | List notifications. | read-only, idempotent |
| create_brand | connect:write | Create a brand. | idempotent |
| connect_channel | connect:write | Add a channel to a brand. | idempotent |
| store_channel_token | connect:write | Store the access token for a channel. | destructive, idempotent |
| verify_channel | connect:write | Check that a channel’s token still works. | |
| connect_telegram_channel | connect:write | Connect a Telegram channel with a bot token. | idempotent |
| draft_content | content:draft | Ask for content for the listed platforms. | idempotent |
| compose_post | content:draft | Create a post from captions you supply. | idempotent |
| mine_ideas | content:draft | Ask for a list of post ideas. | idempotent |
| schedule_item | content:schedule | Set the time a post goes out. | idempotent |
| attach_platform_leg | content:draft | Add a platform to a post. | idempotent |
| detach_platform_leg | content:draft | Remove a platform from a post that has not gone out there. | destructive, idempotent |
| skip_platform_leg | content:draft | Skip one platform of a post that missed its window. | destructive, idempotent |
| retry_publish_leg | content:draft | Try again to publish a post that failed. | idempotent |
| discard_item | content:draft | Discard a post that has not gone out. | destructive, idempotent |
| edit_captions | content:draft | Change the captions of a post that has not gone out. | idempotent |
| set_new_ground | content:draft | Set the flag that decides whether the post waits for approval when auto-publish is on. Clearing it also needs tray:decide. | idempotent |
| replace_media | content:draft | Replace the media of a post that has not gone out. | idempotent |
| decide_tray | tray:decide | Approve, hold, edit or reject a post waiting for the owner. | destructive, idempotent |
| amend_tray | tray:decide | Change a post waiting for the owner. | idempotent |