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

SurfaceURL
RESThttps://house.cassit.app/api/v1
MCPhttps://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.

ScopeAllows*
brands:readRead brands and their channels.covered
content:readRead a brand’s posts and their state.covered
content:draftCreate and change posts that have not gone out.covered
content:scheduleSet the time a post goes out.covered
insights:readRead post results.covered
books:readRead per-brand usage figures.covered
engine:readRead scheduler state and cooldowns.covered
tray:readRead the posts waiting for the owner.covered
notifications:readRead notifications.covered
tray:decideApprove, hold or amend a post waiting for the owner.name it
connect:writeCreate 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 422 with idempotency_key_reused_with_different_request.
  • The same key while the first call is still running gets 409 with request_in_flight and Retry-After: 2.
  • A 5xx response is not kept, so you can retry with the same key. Any other response, a 4xx included, 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"}
StatusErrorMeaning
400bad_requestThe body is not valid JSON.
400idempotency_key_requiredA call that changes data had no Idempotency-Key.
401unauthorizedThe key is missing or not valid.
403forbiddenThe key lacks the scope. The hint names it.
404not_foundThe route does not exist.
409request_in_flightThe first call with this key is still running.
413payload_too_largeThe request body is too large.
415unsupported_media_typeThe content type is not accepted.
422caption_rejectedA caption was refused. The detail says why.
422idempotency_key_reused_with_different_requestThe key was used for another request.
429rate_limitedToo many requests. See Fair use.
500internal_errorThe 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.

MethodPathScopeWhat it doesBody or query
GET/brandsbrands:readList the brands the key can see.
POST/brandsconnect:writeCreate a brand.name, timezone?
POST/brands/:brandId/renameconnect:writeRename a brand.name
GET/brands/:brandId/channelsbrands:readList a brand’s connected channels.
POST/brands/:brandId/channelsconnect:writeAdd a channel to a brand.platform, transportKind, accountRef
PUT/channels/:channelId/tokenconnect:writeStore the access token for a channel.token, kind, expiresAt?, scopes?
POST/channels/:channelId/verifyconnect:writeCheck that a channel’s token still works.
POST/brands/:brandId/channels/telegram/connectconnect:writeConnect a Telegram channel with a bot token.botToken, chatIdentifier
POST/brands/:brandId/draftscontent:draftAsk for content for the listed platforms.platforms[], format?, subjectHint?
POST/brands/:brandId/ideascontent:draftAsk for a list of post ideas.count?, format?, explore?
POST/brands/:brandId/ingestcontent:draftUpload a media file with a caption as a post.multipart: file, caption?, publishAt?, platforms?, subject?, register?
POST/brands/:brandId/media/uploadcontent:draftUpload a media file for later use in a post.multipart: file
GET/brands/:brandId/itemscontent:readList 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/itemscontent:draftCreate a post from captions you supply.captions, mediaRefs?, subject?, format?, composition?, publishAt?
POST/brands/:brandId/items/:itemId/schedulecontent:scheduleSet the time a post goes out.at
POST/brands/:brandId/items/:itemId/platformscontent:draftAdd a platform to a post.platform, caption, publishAt?
DELETE/brands/:brandId/items/:itemId/platforms/:platformcontent:draftRemove a platform from a post that has not gone out there.
POST/brands/:brandId/items/:itemId/discardcontent:draftDiscard a post that has not gone out.reason?
POST/brands/:brandId/items/:itemId/legs/:platform/skipcontent:draftSkip one platform of a post that missed its window.
POST/brands/:brandId/posts/:recordId/retry-publishcontent:draftTry again to publish a post that failed.
PATCH/brands/:brandId/items/:itemId/captionscontent:draftChange the captions of a post that has not gone out.captions
PATCH/brands/:brandId/items/:itemId/new-groundcontent:draftSet 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/mediacontent:draftReplace the media of a post that has not gone out.mediaRefs
GET/brands/:brandId/insightsinsights:readRead post results for a brand.query: since?, limit?, offset?, latest?
GET/traytray:readList the posts waiting for the owner.
GET/tray/heldtray:readList the posts the owner has put on hold.
POST/tray/:id/decidetray:decideApprove, hold, edit or reject a post waiting for the owner.decision, editDelta?
POST/tray/:id/amendtray:decideChange a post waiting for the owner.editDelta
GET/books/:brandIdbooks:readRead a brand’s usage figures.
GET/brands/:brandId/cooldownsengine:readRead a brand’s cooldowns.
GET/engineengine:readRead scheduler state.
GET/notificationsnotifications:readList notifications.

MCP tools (29)

Endpoint: https://house.cassit.app/mcp, with the same Authorization header. Each tool needs the scope shown.

ToolScopeWhat it doesHints
list_brandsbrands:readList the brands the key can see.read-only, idempotent
list_channelsbrands:readList a brand’s connected channels.read-only, idempotent
list_itemscontent:readList a brand’s posts. Each post lists, for each platform, whether it is waiting, publishing, scheduled, published or failed.read-only, idempotent
read_insightsinsights:readRead post results for a brand.read-only, idempotent
read_traytray:readList the posts waiting for the owner.read-only, idempotent
read_heldtray:readList the posts the owner has put on hold.read-only, idempotent
read_booksbooks:readRead a brand’s usage figures.read-only, idempotent
read_cooldownsengine:readRead a brand’s cooldowns.read-only, idempotent
read_engineengine:readRead scheduler state.read-only, idempotent
read_notificationsnotifications:readList notifications.read-only, idempotent
create_brandconnect:writeCreate a brand.idempotent
connect_channelconnect:writeAdd a channel to a brand.idempotent
store_channel_tokenconnect:writeStore the access token for a channel.destructive, idempotent
verify_channelconnect:writeCheck that a channel’s token still works.
connect_telegram_channelconnect:writeConnect a Telegram channel with a bot token.idempotent
draft_contentcontent:draftAsk for content for the listed platforms.idempotent
compose_postcontent:draftCreate a post from captions you supply.idempotent
mine_ideascontent:draftAsk for a list of post ideas.idempotent
schedule_itemcontent:scheduleSet the time a post goes out.idempotent
attach_platform_legcontent:draftAdd a platform to a post.idempotent
detach_platform_legcontent:draftRemove a platform from a post that has not gone out there.destructive, idempotent
skip_platform_legcontent:draftSkip one platform of a post that missed its window.destructive, idempotent
retry_publish_legcontent:draftTry again to publish a post that failed.idempotent
discard_itemcontent:draftDiscard a post that has not gone out.destructive, idempotent
edit_captionscontent:draftChange the captions of a post that has not gone out.idempotent
set_new_groundcontent:draftSet the flag that decides whether the post waits for approval when auto-publish is on. Clearing it also needs tray:decide.idempotent
replace_mediacontent:draftReplace the media of a post that has not gone out.idempotent
decide_traytray:decideApprove, hold, edit or reject a post waiting for the owner.destructive, idempotent
amend_traytray:decideChange a post waiting for the owner.idempotent