Documentation overview

API & Integrations

Your own systems can do through the API what your team does in the product: ask the agent in plain English, run one tool, read your customers back out, send a service message, start a journey for one person, and run your loyalty program. Everything goes through one kind of credential – an API key from Settings → API keys (/settings/api) – scoped to exactly what it may do.

Keys are server-to-server credentials – never ship one in browser code, a mobile app, or anything a customer can inspect.

If you work with an AI agent such as Claude Code or Codex, point it at theaicmo.com/skill.md: it is this manual, written for the agent.

Keys and what they may do

Send the key in a header – either works on every endpoint:

text
Authorization: Bearer theaicmo_...
X-Api-Key: theaicmo_...

A key is shown once, when it is made. Every key can ask the agent and run tools. Tick anything else it needs when you create it:

On the formLets the key
Send service messagesSend receipts, resets and notices – POST /api/send
Trigger journeysStart a published journey for one person – POST /api/journeys/trigger
Loyalty programRead balances, list rewards, redeem – /api/loyalty/*
Read customer dataRead profiles and segment members – GET /api/customers/read – and let the agent use the tools that return customer or analytics data

A key allows 60 calls a minute and 1,000 a day. A 429 answer carries a Retry-After header with the seconds to wait.

Which brand a call is for

client_id names the brand. Your brands' ids are listed under Brand ids on the same Settings page, each with a copy button.

  • With one brand on the account you can leave client_id out.
  • With several, name one. The agent and tool endpoints answer 400 client_id_required with the list of brands to choose from, and the send endpoint answers 400 as well.
  • A journey trigger takes the brand from the journey.
  • A key acts for the brands its owner may edit – as the brand's owner, an editor on it, or an owner, admin or manager of its organization. Another account's brand is refused, and a brand where your role is view-only answers 403 read_only.
  • A key restricted to one brand acts only for that brand.

Ask in plain English

Scope: every key. One call, plain English, and the same agent that runs Chat in the product picks the tools, runs them in order and hands back what they produced. It works as the brand – its name, industry, audience, selling points, language and currency – and files what it makes in the brand's Library. This is the endpoint most integrations want.

bash
curl -X POST https://theaicmo.com/api/agent-api \
  -H "Authorization: Bearer theaicmo_..." \
  -H "Content-Type: application/json" \
  -d '{
    "message": "write three LinkedIn posts about our summer sale and a Google ad to match",
    "client_id": "<brand-id>"
  }'

The reply carries reply, the tools it ran, a results entry per tool with its own credits_used, any media produced, and the total credits_used. Pass the conversation_id you get back on the next call to continue the same conversation; it keeps its brand, and the agent can see what it made on the previous turn.

What it will not do. Through a key the agent creates and reads. It never publishes a post, sends a message, or changes a live ad account or a connected app – a person does that in the product, where the confirmation lives.

  • A turn stops at the first tool it may not run – one that publishes, or one that reads customer data on a key without Read customer data – and at a tool that fails. The reply then says success: false, with stopped and a message, and carries what ran before it.
  • credits_used counts the tools the turn ran. The agent's own reading and writing is metered on what it uses and shows under Settings → Usage.
  • A turn that makes images or video can take several minutes.

Run one tool

Scope: every key. When you know exactly which tool you need, name it. It runs the same way it runs in the product: keyword research, the backlinks analyzer and the Google Ads campaign builder read live search data, App Store Optimization reads the stores before it writes, images carry the brand's look, and the result is filed in the brand's Library.

bash
curl -X POST https://theaicmo.com/api/execute-tool-api \
  -H "Authorization: Bearer theaicmo_..." \
  -H "Content-Type: application/json" \
  -d '{
    "tool_id": "social-post-generator",
    "client_id": "<brand-id>",
    "idempotency_key": "summer-sale-linkedin-1",
    "parameters": {
      "prompt": "Launch of our cold brew subscription, first month half price",
      "platform": "linkedin",
      "variations": 2
    }
  }'
  • The brand fills what you leave out. Its name, audience, voice and the like come from the brand's profile, and the tool writes in the brand's language and currency. Send a field to override it.
  • Nothing is charged for a call that cannot run. A missing required field answers 400 invalid_parameters with the list of what is missing, before anything runs.
  • Retries are safe. Send an idempotency_key; a retry with the same key returns the first run's result and charges nothing.
  • A failure is refunded. A tool that runs and reports a failure answers 422 tool_failed, and its credits are returned.

The reply carries the output in result, plus credits_used, credits_remaining and library_item_id. Every tool, what it needs and a ready-made call for it are listed under Direct tool calls on Settings → API keys.

App Store Optimization

Tool idMakesRequiredThe brand fills
app-store-optimizationApp Store and Google Play listings for any market the stores cover: the current listing, store search positions, the apps above it and reviews read from the stores, then three options per store and market measured against each store's limits and checked against the brand's rules (demand only with its named source: Apple Ads search popularity or the brand's Play Console search terms when connected, an estimate of store searches for the United States in English, or Google web searches for the listing's country and language, which are interest on the web and not store searches; otherwise none)–productName

It takes apps (App Store links or ids, Google Play links or package names), stores, locales (en-GB style, any market the stores cover; at most 4 listings, stores × markets, per call) and terms (what the app should be found for). Searches are per language: with markets in more than one language, pass termsByLanguage ({"en": [...], "de": [...]}) and each listing uses its own language's list. Left out, apps is the app saved for the brand. It costs 15 credits.

bash
curl -X POST https://theaicmo.com/api/execute-tool-api \
  -H "Authorization: Bearer theaicmo_..." \
  -H "Content-Type: application/json" \
  -d '{
    "tool_id": "app-store-optimization",
    "client_id": "<brand-id>",
    "parameters": {
      "apps": ["https://apps.apple.com/gb/app/example/id123456789", "com.example.app"],
      "locales": ["en-GB"],
      "terms": ["budget planner", "expense tracker"]
    }
  }'

result.rawOutput is the result as JSON: demand.rows carry each figure's store, country, language, scale and source, and demand.sources the label to show with it; each option in listings[].options has guardrails, naming any brand rule it breaks (advisory: true for an industry advisory). The result is copy: nothing is changed in either store. A person sends a listing to App Store Connect or Google Play from the App Store Optimization page.

Read your customers back out

Scope: Read customer data. Your customer base is yours to read: profiles, the members of a saved segment, and everything changed since a timestamp so another system can stay in step. JSON by default, CSV with format=csv.

text
# one page of profiles for a brand
curl -H "X-Api-Key: theaicmo_..." \
  "https://theaicmo.com/api/customers/read?client_id=<brand-id>&limit=100"

# one person, by your own id or by e-mail
curl -H "X-Api-Key: theaicmo_..." \
  "https://theaicmo.com/api/customers/read?identifier=player-48213"
curl -H "X-Api-Key: theaicmo_..." \
  "https://theaicmo.com/api/customers/read?identifier=customer@example.com"

# a saved segment, page by page
curl -H "X-Api-Key: theaicmo_..." \
  "https://theaicmo.com/api/customers/read?segment_id=<segment-id>"

# a sync loop: everything changed since your last run
curl -H "X-Api-Key: theaicmo_..." \
  "https://theaicmo.com/api/customers/read?updated_since=2026-08-01T00:00:00Z"
  • With client_id you get that brand's contacts – whoever on your team added them – plus your contacts that carry no brand. Any role on the brand may read it.
  • Without client_id you get the whole account – every brand, and the contacts with no brand – if you are the account owner or an organization owner, admin or manager. A key restricted to one brand reads that brand. Anyone else is asked to name a brand (400 client_id_required, with the list of brands they can read).
  • Pages are 100 by default, 1000 at most. When has_more is true, pass next_cursor back as cursor for the next page – a segment or a sync of any size comes out in full that way, and people who share a timestamp (a whole import does) each come out exactly once. Always pass back what next_cursor gave you rather than building a cursor yourself.
  • A page can come back shorter than the limit, or empty, and not be the last one. Only has_more says the read is finished.
  • A CSV answer pages the same way, through the X-Has-More and X-Next-Cursor response headers.
  • Each profile carries registration: how the person registered with that profile's brand, or null – registered_at, registration_status (provisional for the first seven days, then frozen), registration_source and registration_channel (set once the record is frozen), and the affiliate_id, affiliate_tag, promo_code and declared_source your own system sent. A CSV answer has the same eight columns after client_id. If the records cannot be read the request answers 503 and you retry.
  • Contacts marked as test data are left out unless you pass include_test=true.
  • People erased under a GDPR request are never returned – on any brand. If that cannot be checked at the moment, the read answers 503 and you retry; an erasure is never undone by an export.

Send a service message

Scope: Send service messages. Receipts, password resets, order and account notices – messages a customer needs whether or not they signed up for marketing.

bash
curl -X POST https://theaicmo.com/api/send \
  -H "X-Api-Key: theaicmo_..." \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "email",
    "to": "customer@example.com",
    "subject": "Your order shipped",
    "html": "<p>It is on the way.</p>",
    "category": "delivery",
    "idempotency_key": "order-1042-shipped"
  }'

Parameters

FieldNotes
channelemail, sms, whatsapp, push, or inbox
toThe recipient – e-mail address, or E.164 phone for SMS and WhatsApp
subjectEmail only; optional when a library_item_id carries one
html / textInline content. For SMS and push, text is the message
library_item_idSend an email built in the platform instead of inline content – see below
external_idYour own customer id. It must be the same contact as to
categoryreceipt, order, delivery, payout, security, account, support, legal, other
client_idWhich brand sends – required when the account has several. A key may send for any brand its owner may edit
idempotency_keyExactly-once: the same key replays the original result, never a second send (also accepted as an Idempotency-Key header). A "result unknown" answer is kept too – see below
allow_unknowntrue sends to a recipient who is not a contact yet – see below
accentsSMS only: strip sends the text without accents (GSM-7, 160 characters a part), keep sends it as written (one accented letter makes it Unicode, 70 a part). Left out, the brand's default applies: without accents for a Czech or Slovak brand, as written for every other

WhatsApp takes an approved service template (with variables and language when it has them). Push takes title, url and routes (all, app or web). The website inbox takes headline, with cta_label, cta_url and days on screen (1–365, default 30).

Which sender. Email always leaves from your service sending identity. SMS and WhatsApp use your service sender when you have one, otherwise your marketing sender.

SMS accents. Without accents, every accented letter goes as its plain letter, € as EUR, and typographic quotes and dashes as plain ones; links, e-mail addresses and numbers are never changed. A successful SMS answers with the accents it used and its segments (parts).

Sends go to known customers by default. The address the message goes to must belong to a contact of the brand – whoever on your team added it – or to a contact with no brand; anyone else answers 403 unknown_recipient. An external_id sent with it must be that same contact, otherwise 403 recipient_mismatch. For flows where the send legitimately comes before the contact record – a brand-new customer's first receipt or one-time code – pass "allow_unknown": true for that one request:

bash
curl -X POST https://theaicmo.com/api/send \
  -H "X-Api-Key: theaicmo_..." \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "email",
    "to": "brand-new-customer@example.com",
    "subject": "Welcome – your account is ready",
    "html": "<p>Your login is ready.</p>",
    "allow_unknown": true,
    "idempotency_key": "signup-8812-welcome"
  }'

Blocked lists – including the account owner's – hard suppressions and GDPR erasures apply either way: an identity erased on request answers 410 identity_erased on every channel, override or not. If erasures cannot be checked at that moment, the send answers 503 erasure_check_unavailable and nothing goes out – retry it. If your blocked lists or opt-outs cannot be checked at that moment, the send answers 503 suppression_unavailable and nothing goes out – retry with the same idempotency_key. SMS answers the same way (it used to answer 502).

Service messages only. The content is checked, and marketing copy riding a service send comes back in warnings – the message still goes, and the warning is your early sight of it. Marketing goes out through journeys and campaigns in the product.

When the result is unknown. Every email, SMS, WhatsApp and push message goes out once. If the provider gives no clear answer – no answer in time, a connection that broke after the message was handed over, or a server error – the message may have been delivered, so the send answers 202 instead of an error:

json
{
  "channel": "sms",
  "stream": "transactional",
  "category": "security",
  "status": "unconfirmed",
  "code": "outcome_unknown",
  "message": "The SMS provider did not answer clearly, so this message may have been delivered. It was not sent again, and a retry with this idempotency_key gets this same answer and never sends it.",
  "provider_message": "Twilio 500"
}

It was not sent again, and a retry with the same idempotency_key gets this same 202 (marked "replayed": true) and never sends it. Treat it as possibly delivered: send a new message under a new key only when a second copy is acceptable, such as a fresh one-time code. A request that stopped on our side while it was handing the message over answers the same way when you retry after two minutes. A send that never left, or that the provider refused or asked to wait (429, 503), still answers 502 provider_error: nothing was delivered, so retry it with the same key. A push answers 422 no_device only when no device is registered for the person, which a retry cannot change.

Send an email from your library

Emails you built in the platform – the email designer, the campaign generator, journey drafts – live in your library and can be sent by reference instead of pasting HTML:

bash
curl -X POST https://theaicmo.com/api/send \
  -H "X-Api-Key: theaicmo_..." \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "email",
    "to": "customer@example.com",
    "library_item_id": "<the library item id>",
    "idempotency_key": "welcome-cust-8812"
  }'
  • The item's own subject line is used when it carries one; a subject in the request overrides it. Provide one or the other.
  • The item's preheader, when it has one, is delivered as the inbox preview line: hidden at the top of the HTML and personalised like the subject. If the item's HTML already opens with a hidden preview line, that line is kept and no second one is added.
  • Personalization renders for the recipient exactly as a journey send would render it – {{p.*}} tokens, {{brand.*}} tokens and survey links resolve to this person and your brand before delivery, in the item's own subject line as in its body. A subject you send yourself is used as written, except that {{brand.*}} tokens in it are filled, as they are in inline html and text.
  • Brand-locked. The item must belong to the same brand the send is for; anything else is refused with 403 wrong_profile.
  • Pass either library_item_id or inline html/text – not both.

The item id is shown on the item's detail panel in the library ("API id" – click to copy).

Trigger a journey

Scope: Trigger journeys. Start a published journey for one person from your own systems – "order shipped", "deposit made", "ticket resolved".

bash
curl -X POST https://theaicmo.com/api/journeys/trigger \
  -H "X-Api-Key: theaicmo_..." \
  -H "Content-Type: application/json" \
  -d '{
    "journey_id": "<the journey id>",
    "email": "customer@example.com",
    "event": { "order_number": "1042", "amount": 89.90 },
    "idempotency_key": "order-1042-shipped"
  }'

Name the person by email, external_id or both; someone who is not a contact yet is created – with no e-mail marketing consent ("Not granted") unless you send "consent": { "email": true } because your own system holds it. A Marketing journey sends a "Not granted" person no e-mail; a Service journey does. For someone already in your contacts the flag counts only when this "Not granted" is all that stops marketing to them (never over an unsubscribe or a withdrawn consent); leaving it out or sending false never changes anyone. Anything but an object is 400 invalid_consent. Event properties (up to 20) reach your message copy as {{ event.order_number }}. The journey's id and a ready-made call live on the journey's trigger settings in the builder – the full walkthrough is in Journeys.

Loyalty: balance, rewards, redemption

Scope: Loyalty program. Render points and tier inside your product, list the rewards shop, and redeem – full walkthrough in the Loyalty Program guide.

bash
curl -H "X-Api-Key: theaicmo_..." \
  "https://theaicmo.com/api/loyalty/balance?identity=customer@example.com"

A redemption spends the points and grants the reward in one step. The same idempotency_key returns the original redemption instead of spending again, and a redemption larger than the balance is refused with 402 – even two at the same moment can never spend more than the balance. A customer on one of your blacklists set to Blocks everything cannot redeem: the answer is 403 not_eligible, which never says which list, and GET /api/loyalty/rewards?identity=… gives the same answer before you show the shop. If that check cannot run, the answer is 503 suppression_unavailable and nothing is redeemed.

Errors

StatusMeaning
400A field is missing or invalid, or client_id is needed
401Missing, wrong, revoked or expired key
402Insufficient credits (agent, tools), the brand's credit budget is used up, or insufficient points (loyalty)
403The key lacks the scope, the brand is not one the key may act for (read_only for a view-only role), the call comes from an address a pinned key does not allow (ip_not_permitted), the recipient is not a known contact (unknown_recipient, recipient_mismatch – see allow_unknown), or the customer cannot redeem rewards (not_eligible, loyalty)
404Not found or not yours
409An idempotency key reused for a different message, a send or run still in progress, two identifiers that name different people, or a journey rule (paused, re-entry)
410Identity erased on request (GDPR) – cannot be messaged or enrolled
422No verified sending identity, nothing to send, or a tool reported a failure (refunded)
429Rate limited – honor Retry-After
502, 503Could not finish, or could not check – retry, with the same idempotency key (on /api/send, nothing was sent)

Error bodies are JSON with a stable code and a human message. On /api/send both sit inside error ({ "error": { "code", "message" } }); everywhere else error is the code itself. 202 outcome_unknown on /api/send is not an error: the message may have been delivered and is never sent again (see When the result is unknown).