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:
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 form | Lets the key |
|---|---|
| Send service messages | Send receipts, resets and notices – POST /api/send |
| Trigger journeys | Start a published journey for one person – POST /api/journeys/trigger |
| Loyalty program | Read balances, list rewards, redeem – /api/loyalty/* |
| Read customer data | Read 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_idout. - With several, name one. The agent and tool endpoints answer
400 client_id_requiredwith the list of brands to choose from, and the send endpoint answers400as 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.
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, withstoppedand amessage, and carries what ran before it. credits_usedcounts 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.
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_parameterswith 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 id | Makes | Required | The brand fills |
|---|---|---|---|
app-store-optimization | App 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.
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.
# 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_idyou 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_idyou 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_moreis true, passnext_cursorback ascursorfor 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 whatnext_cursorgave 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_moresays the read is finished. - A CSV answer pages the same way, through the
X-Has-MoreandX-Next-Cursorresponse headers. - Each profile carries
registration: how the person registered with that profile's brand, ornull–registered_at,registration_status(provisionalfor the first seven days, thenfrozen),registration_sourceandregistration_channel(set once the record is frozen), and theaffiliate_id,affiliate_tag,promo_codeanddeclared_sourceyour own system sent. A CSV answer has the same eight columns afterclient_id. If the records cannot be read the request answers503and 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
503and 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.
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
| Field | Notes |
|---|---|
channel | email, sms, whatsapp, push, or inbox |
to | The recipient – e-mail address, or E.164 phone for SMS and WhatsApp |
subject | Email only; optional when a library_item_id carries one |
html / text | Inline content. For SMS and push, text is the message |
library_item_id | Send an email built in the platform instead of inline content – see below |
external_id | Your own customer id. It must be the same contact as to |
category | receipt, order, delivery, payout, security, account, support, legal, other |
client_id | Which brand sends – required when the account has several. A key may send for any brand its owner may edit |
idempotency_key | Exactly-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_unknown | true sends to a recipient who is not a contact yet – see below |
accents | SMS 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:
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:
{
"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:
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
subjectin 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. Asubjectyou send yourself is used as written, except that{{brand.*}}tokens in it are filled, as they are in inlinehtmlandtext. - 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_idor inlinehtml/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".
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.
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
| Status | Meaning |
|---|---|
| 400 | A field is missing or invalid, or client_id is needed |
| 401 | Missing, wrong, revoked or expired key |
| 402 | Insufficient credits (agent, tools), the brand's credit budget is used up, or insufficient points (loyalty) |
| 403 | The 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) |
| 404 | Not found or not yours |
| 409 | An 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) |
| 410 | Identity erased on request (GDPR) – cannot be messaged or enrolled |
| 422 | No verified sending identity, nothing to send, or a tool reported a failure (refunded) |
| 429 | Rate limited – honor Retry-After |
| 502, 503 | Could 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).