The Hermoso API
Build on Hermoso from your own backend, in any language, over plain HTTPS. No SDK and no MCP client: a bearer token and JSON. Server-side only.
curl https://app.hermoso.ai/v1/channels -H "Authorization: Bearer hmk_..."
Two tiers, and it is worth knowing which one you are standing on. The modelled resources below (posts, channels, media, credits) are a small versioned contract that will not move under you. The tool passthrough at /v1/tools reaches all 718 tools, the same set an agent gets over MCP and the CLI — ad research, generation, publishing, eleven ad platforms, analytics, files. Same key, same account, same spend. Neither is the lesser door; they make different promises, and both are stated plainly below.
Authentication
Send an agent key as a bearer token on every request:
Authorization: Bearer hmk_…
Create one in the app under Settings → Agents & API. Keys are hashed at rest and revocable, and one key can never mint another.
A third-party app acting on behalf of a Hermoso user runs the OAuth 2.0 authorization-code flow with PKCE. That flow mints one of these same keys, so there is one credential type and one set of guarantees behind both paths. One connection grants everything the account can do, and there are no scopes to negotiate.
Your agent can create its own account
An agent with no Hermoso account can provision one, get its own key and start publishing in the same session. POST /v1/signup is the only call here that takes no credential, because the credential is what it creates.
# 1. Start a signup.
curl -sX POST https://app.hermoso.ai/v1/signup \
-H 'content-type: application/json' \
-d '{"plan":"pro","period":"mo"}'
# -> { "id": "cs_...", "checkout_url": "https://checkout.stripe.com/...", "claim_token": "hsc_..." }
# 2. Pay at checkout_url. Store claim_token first: it is returned only in that response.
# 3. Claim it. Poll until status is "ready".
curl -sX POST https://app.hermoso.ai/v1/signup/cs_.../claim \
-H 'content-type: application/json' \
-d '{"claim_token":"hsc_..."}'
# -> { "status": "ready", "api_key": "hmk_...", "credits": 3000 }
That hmk_ key is the same credential everything else takes: /v1, the MCP server, the CLI.
Checkout is Stripe's own hosted page, so a browser-capable agent completes it unattended. Otherwise it is a one-click handoff: send checkout_url to whoever holds the card.
The agentic path takes a paid plan. Any of them. The free plan is for a person signing up at app.hermoso.ai, and asking for it here returns a refusal that says so. Nothing is created until the payment completes, so an unpaid signup leaves no account behind and charges nothing.
api_key is returned once. Re-claiming inside 24 hours mints a replacement and revokes the previous key, so a signup never leaves two live credentials behind.
Posts
One POST /v1/posts reaches every channel you name. Omit scheduled_at and it publishes now; include it and the post is queued.
curl -sX POST https://app.hermoso.ai/v1/posts \
-H "Authorization: Bearer hmk_…" \
-H 'content-type: application/json' \
-d '{
"channels": ["bluesky", "linkedin"],
"caption": "Shipping something today.",
"media": ["https://…/from-v1-media.jpg"],
"scheduled_at": "2026-09-01T15:00:00Z"
}'
A post fans out across channels independently, so results[] reports each channel's own outcome. The top-level status is one of:
scheduled | processing | published | partially_published | failed | indeterminate
A partial failure is normal, and it is reported as a partial rather than collapsed into a flat failed. failed is used only when we know nothing published. indeterminate means a restart interrupted the send and we cannot say whether it went out: check the channel before retrying. This set may grow, so tolerate a value you have not seen.
Everything a channel cannot do is refused when you create the post, not discovered hours later when it fires. A caption over a channel's limit, a visibility a channel cannot honour, a carousel a channel cannot carry: all of them come back as a 400 while you are still watching.
GET, PATCH and DELETE on /v1/posts/{id} read, reschedule and cancel. Send an Idempotency-Key header on any write and a retry is safe.
Channels
GET /v1/channels is the list to read rather than a list to hard-code. Channel ids are not a closed enum: the set grows as platform approvals land, and a new one is an addition rather than a new version of the API. Treat an id you have not seen before as valid.
Each row separates two things that look alike and are not. available is whether a channel can be connected on this deployment at all, which is false while a platform approval is outstanding. connected is whether this workspace has linked it. Collapsing the two would send you to build a connect flow that cannot succeed.
Linking an account needs a browser, because it means an OAuth consent screen and no consent screen can be completed headlessly. The API uses accounts that are already linked.
Media
POST /v1/media turns a file into a Hermoso-hosted URL that POST /v1/posts accepts. Two ways in, and exactly one per call: send the raw bytes as the request body with their own Content-Type, or send ?url= and we fetch it. Sending both is an error rather than a silent precedence, because a caller who sent both had two different files in mind.
Everything else: the tool passthrough
The resources above are the things worth modelling: you hard-code them, and we promise never to move them. Everything else Hermoso does — competitor research, image and video generation, publishing to any connected channel, campaigns on eleven ad platforms, analytics, Drive and OneDrive, brand and workspace management — is reachable over the same HTTPS with the same key.
# What can this key do? Grouped by area, searchable, cursor-paged.
curl "https://app.hermoso.ai/v1/tools?area=research" -H "Authorization: Bearer hmk_…"
# One tool, with the JSON Schema for its arguments.
curl https://app.hermoso.ai/v1/tools/search_meta_ads -H "Authorization: Bearer hmk_…"
# Run it. The body IS the argument object — there is no envelope.
curl -sX POST https://app.hermoso.ai/v1/tools/search_meta_ads \
-H "Authorization: Bearer hmk_…" -H 'content-type: application/json' \
-d '{"query":"running shoes"}'
The reply carries both halves of the answer, because this one endpoint serves two audiences: text is the sentence a model would read, and data is the structured result an integration wants. A tool that refuses answers with an HTTP error in the standard envelope, never a 200 with a failure buried inside it.
Reading the roster
GET /v1/tools is built for browsing as much as for introspection. Every row carries a human-readable description, the area it belongs to, whether it is read-only, which account it needs linked, and its JSON Schema. The response also lists the areas with a count, so one call shows you the whole map before you page through it.
{ "object": "list",
"areas": [ { "id": "research", "description": "Ad spy and competitor research…", "tool_count": 18 }, … ],
"data": [ { "name": "search_meta_ads", "area": "research", "read_only": true,
"requires_connector": null, "input_schema": { … } }, … ] }
Filter with ?area=, search with ?search=, page with ?limit= and ?starting_after=. An unknown area or a stale cursor is refused by name rather than answered with an empty page, because an empty page reads as "this account has nothing here" and that is a different, wrong answer.
Why a tool might not be listed
A tool whose third-party account this workspace has not linked is withheld from the index: it could only ever answer "not connected", so listing it is noise. Calling it anyway gets a 401 carrying connector, naming the account and the one-click link to link it. The tool is still described at GET /v1/tools/{name}, with connector_connected: false — so "what would I have to connect to do this?" is answerable without connecting anything first.
If we cannot read your connections at that moment, nothing is withheld and connector_connected is null. A failed read is not an empty one, and we will not tell you an account is unlinked when we simply could not check.
Credits, limits and retries
A tool costs exactly what it costs anywhere else. Research and generation spend credits; reading and publishing to an account you have already linked do not. Out of credits is a 402 with type: insufficient_credits, which you can branch on to top up.
Read-only tools are metered on the read budget even though calling one is a POST; everything else is metered as a write. Each row's rate_class tells you which. Send an Idempotency-Key on anything that spends or publishes and a retry is safe — keys are scoped per tool, so reusing one across two different tools does not replay the first one's answer at the second.
What we promise here, and what we do not
The envelope is stable under the same promise as the rest of /v1: the routes, the authentication, the error shape, the rate-limit headers, the idempotency semantics and this discovery document.
The tools inside it are not frozen. Their arguments and results improve as the tools do, continuously and without a version bump — an enum gains a member, a description sharpens, a new field appears when a platform starts requiring one. We are not going to stop improving them to keep a generated client frozen, and pretending otherwise would make the promise above worth nothing.
So read the roster rather than pinning it. GET /v1/tools is a live read and GET /v1/tools/{name} is the current schema for one tool. If you generate code, regenerate it; if you validate arguments, validate against what the API says today.
One tool is deliberately not here. enable_tools widens an MCP session's tool list mid-conversation. /v1/tools lists everything this workspace can reach and every listed tool is callable immediately, so there is no roster to widen. Calling it says exactly that rather than returning a bare 404.
Workspaces
An account can hold several brands, which is how an agency runs several clients through one connection. A key is pinned to one, and X-Hermoso-User: <workspace-id> overrides that per request.
The workspace is never read from a request body. Membership is re-authorised on the server on every single request, so access that was revoked stops working immediately, and a body naming a workspace is refused by name rather than ignored.
Errors
Every failure has one shape:
{
"error": { "type": "…", "code": "…", "message": "…" },
"request_id": "…"
}
Branch on type, log code, show message, quote request_id to support. New codes land inside an existing type wherever possible so your branch keeps working, and a client should still fall through gracefully on a type it does not recognise.
Connector failures are worth knowing about specifically: a 401 carrying a connector field means that account is not linked, and a 401 without one means your own key is bad. See Errors.
Rate limits
300 reads and 60 writes per minute, per key. Two keys have two budgets. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, and a 429 also carries Retry-After in seconds.
Publishing and scheduling cost no Hermoso credits. Generation does, and GET /v1/credits reads the balance.
Why there is no browser support
We send no CORS headers, for any origin, including our own. An API key carries full account authority, and a key shipped to a browser is a key you have published. Call /v1 from your backend.
A preflight gets an explicit 405 naming the reason, rather than an opaque browser error nobody can act on.
Compatibility
Additions land inside /v1 without notice, and enums grow. Nothing is removed, retyped or given a new meaning inside a version. So: ignore fields you do not recognise, and tolerate enum values you have not seen.
That promise covers the modelled resources — posts, channels, media, credits — and it deliberately stops at the tool passthrough, where the envelope is stable and the tools inside it keep improving. Stretching it over both would have made it untrue somewhere, and a promise that is untrue somewhere is worth nothing everywhere.
The promise itself lives in one place and is returned in full on GET /v1, alongside the current contract version. Read it there rather than from a copy. That version is a date, and it also rides on every response as X-Hermoso-Api-Version.
One thing it is worth being blunt about: the /api/* routes this app uses internally are not part of any contract. They are shaped for our own web client, they change without notice, and they are not documented. Do not build on them.
The spec
app.hermoso.ai/openapi.json is the live OpenAPI document, generated from the same table the router is mounted from. It cannot describe an endpoint that does not exist, and it cannot miss one that does. Point a code generator at it.
A static copy is served beside these docs at /docs/reference/openapi.json so a generator can fetch prose and spec from one origin. The live copy is the authoritative one.
Next
- Which surface should you use?. The API, MCP, the CLI and the app, and what each one is best at.
- MCP server. The same 718 tools, loaded into a client as a manifest an agent can choose from.
- Errors. The one response shape, in more detail.