hermosodocs

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, recasts and edits) are a small versioned contract that will not move under you. The tool passthrough at /v1/tools reaches all 906 tools, the same set an agent gets over MCP and the CLI — ad research, generation, publishing, twelve 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 on the MCP & CLI tab, under Terminal & API keys (or Settings → API keys, or straight there: app.hermoso.ai/?tab=api-keys). 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.

To take the next free slot in the brand's posting schedule instead of naming a time, send "use_queue": true in place of scheduled_at. Sending both is a 400, never a silent choice between them. timezone is the IANA zone the queue reads its slots in (for example America/New_York) and defaults to the brand's saved zone. A zone we cannot resolve is a 400 that names timezone, and nothing is scheduled: you asked for that zone, so you never get a slot in a different one without being told.

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.

Listing posts

GET /v1/posts lists scheduled and already-sent posts for this brand, newest first.

curl "https://app.hermoso.ai/v1/posts?status=scheduled&channel=linkedin&limit=20" \
  -H "Authorization: Bearer hmk_…"

{ "object": "list",
  "data": [ { "object": "post", "id": "…", "status": "scheduled", … }, … ],
  "has_more": true,
  "next_cursor": "…" }

Every filter is optional:

A cursor that names a post not in this list is a 400 with code: unknown_cursor, rather than a quiet restart from page one. That happens when the cursor came from a page with different filters, or from an older listing. Start the listing again.

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.

Recasts: put a person into a video

POST /v1/recasts replaces the person in a video with someone else. The new person performs every move, expression and mouth shape of the original, on the same beats and cuts, in the same set and light, and the original sound is kept. One call, priced before it runs, rendered in the background.

Who goes in is person, exactly one of three:

video is a public link to a video file, a TikTok, Reel, Facebook, X or YouTube post link (read once, through one lookup), or a Hermoso file URL from POST /v1/media. The whole video is recast, up to 60 seconds; start and end pick a window. Add voice to give the new person a new voice with the same words and timing, or language to have them speak another language with the mouth lip-synced.

Price it first. Send "dry_run": true and nothing runs. The answer is the exact price: what the job reserves when it starts, from the job's own pricing, step by step.

curl -sX POST https://app.hermoso.ai/v1/recasts \
  -H "Authorization: Bearer hmk_…" -H 'content-type: application/json' \
  -d '{
    "video": "https://www.tiktok.com/@someone/video/7400000000000000000",
    "person": { "describe": "a woman in her 30s, curly dark hair, denim jacket" },
    "dry_run": true
  }'

{ "object": "recast", "dry_run": true, "status": "quoted",
  "quote": { "object": "quote", "credits": …, "refused": null,
             "steps": [ { "step": "person", "credits": …, "basis": "hold" },
                        { "step": "recast", "credits": …, "basis": "hold" } ] },
  "engine": "…", "window": { "start": 0, "end": 9.4, "seconds": 9.4 },
  "notes": [], "job_id": null, "job": null }

Then run it. The same body without dry_run reserves that figure, answers 202 with the job inside and a Location to poll, and settles to the measured cost when it finishes, never above the quote. Send an Idempotency-Key and a retry returns the same job instead of starting a second render.

curl -sX POST https://app.hermoso.ai/v1/recasts \
  -H "Authorization: Bearer hmk_…" -H 'content-type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{ "video": "https://…/clip.mp4", "person": { "creator_id": "preset-maya" } }'

HTTP/1.1 202 Accepted
Location: /v1/jobs/job_abc123

{ "object": "recast", "status": "queued", "quote": { "credits": …, … },
  "job_id": "job_abc123", "job": { "object": "job", "id": "job_abc123", "status": "queued", … } }

Poll GET /v1/jobs/job_abc123 until status is done; its url is the video. If your platform cannot poll, add "callback_url": "https://…" to the body (or send the X-Hermoso-Callback-Url header) and the finished job is POSTed to you.

People and likeness. A photo of a real person needs a paid plan, and sending one is your confirmation that you have that person's permission; the confirmation is recorded. An AI person from describe, or a preset creator, works on every plan. A request that would be refused is refused before anything is charged, in the standard envelope with its own code: code: real_face_needs_paid (with an upgrade_url), code: face_review_blocked, code: ref_unreadable, or type: insufficient_credits (with credits_needed and balance).

Edits: change one thing in a video

POST /v1/edits changes one thing in a clip from a plain instruction: "change only the mug to red", "swap the bottle for our product", "make it nighttime". The motion, timing, framing and cut stay, and the rest of the frame is held. Add reference, an image, to anchor the material of what changes: a fabric, a finish, the real product. One change per call holds best, on clips of 3 to 15 seconds.

curl -sX POST https://app.hermoso.ai/v1/edits \
  -H "Authorization: Bearer hmk_…" -H 'content-type: application/json' \
  -d '{ "video": "https://…/clip.mp4", "instruction": "change only the mug to red", "dry_run": true }'

{ "object": "edit", "dry_run": true, "mode": "clip", "status": "quoted",
  "quote": { "object": "quote", "credits": …, "label": "…", "refused": null },
  "job_id": null, "job": null, "next": "Send the same request without dry_run to render it." }

Without dry_run it reserves that figure and answers 202 with the job, exactly like a recast.

See it on one still first. "preview_first_frame": true edits one still of the clip and returns it beside the clip's price, and nothing else runs. If you like it, send the same request with preview_still set to preview.still_url and the clip is rendered so the change matches the still. The still is one image edit; with dry_run it is priced, not made.

curl -sX POST https://app.hermoso.ai/v1/edits \
  -H "Authorization: Bearer hmk_…" -H 'content-type: application/json' \
  -d '{ "video": "https://…/clip.mp4", "instruction": "change only the mug to red", "preview_first_frame": true }'

{ "object": "edit", "mode": "preview", "status": "previewed",
  "quote": { "credits": …, … },
  "preview": { "still_url": "https://assets.hermoso.ai/…/still.jpg", "still_credits": …, "frame_at": 0.1 },
  "next": "…send the same request again without preview_first_frame and with preview_still set to preview.still_url…" }

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 twelve 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.

Renders take longer than a request: wait and jobs

A video render runs for one to three minutes. By default a tool that renders holds your request for up to 45 seconds. If the render is done by then you get it in the 200; if not, the 200 carries data.stillRendering: true and a data.jobId. That default is unchanged and will stay that way.

If your platform caps a step at 30 seconds (most automation and copilot platforms do), send ?wait=0. The call answers 202 as soon as the render is queued, with a job handle:

curl -sX POST "https://app.hermoso.ai/v1/tools/generate_video?wait=0" \
  -H "Authorization: Bearer hmk_…" -H 'content-type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"prompt":"a slow push-in on a ceramic mug, morning light"}'

HTTP/1.1 202 Accepted
Location: /v1/jobs/job_abc123
Retry-After: 5

{ "object": "tool_job", "tool": "generate_video", "job_id": "job_abc123",
  "status": "queued", "poll": "/v1/jobs/job_abc123",
  "poll_url": "https://app.hermoso.ai/v1/jobs/job_abc123", "retry_after_seconds": 5 }

wait also takes a number of seconds, capped at 45: the call is held at most that long and answers 202 if the render is not finished. Prefer: respond-async means the same as wait=0.

The tools that render through the job queue are video, avatars, template ads, post-production and image generation. generate_image goes through the queue whenever wait is sent, so wait=0 answers it with a 202 and a job handle exactly as it does a video. Tools that do not render through the job queue ignore wait and answer as they always do.

Not waiting changes nothing about the render. It is queued through the same spend gate, checked against your balance before it starts and billed when it finishes, exactly as it is when you wait. A render you cannot afford is still a 402, not a 202.

Poll the job, never the tool. Calling the tool again to see how a render is going starts a second render and bills it. Send an Idempotency-Key and a retried call returns the same 202 and the same job.

Jobs

GET /v1/jobs/{id} reads one job. Poll it every few seconds until status is done or error. Polling is a read, so it spends the read budget.

curl https://app.hermoso.ai/v1/jobs/job_abc123 -H "Authorization: Bearer hmk_…"

{ "object": "job", "id": "job_abc123", "type": "video", "status": "done", "progress": 1,
  "label": "Mug push-in", "brand_id": "default",
  "created_at": "2026-09-20T14:02:11.000Z", "updated_at": "2026-09-20T14:04:40.000Z",
  "run_at": null,
  "url": "https://assets.hermoso.ai/…/clip.mp4", "poster_url": "https://assets.hermoso.ai/…/clip.jpg",
  "model": "…", "credits_used": …,"error_message": null,
  "result": { … }, "poll": "/v1/jobs/job_abc123" }

label is the short human-readable name the job was given, or null. run_at is set only for work scheduled to start later (a scheduled post, for example) and is null for work that starts immediately.

The fields above are modelled and covered by the compatibility promise. result is the worker's own full payload and sits in the passthrough tier: read it for detail, build on the fields beside it. url is always an absolute, publicly reachable URL. A failed job reports why in error_message, named that way so it can never be mistaken for the error envelope.

GET /v1/jobs lists this brand's jobs, newest first, with ?status=, ?type=, ?limit= and ?starting_after=. Jobs are kept while they are useful to poll, not for ever: a finished job ages out of the list and its output stays in the Library.

An id that is not on your account is a 404 with code: job_not_found, whether or not it exists somewhere else. That answer is final. Stop polling, and do not re-run the render because of it.

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: <brand-id> overrides that per request.

curl https://app.hermoso.ai/v1/brands -H "Authorization: Bearer hmk_…"

{ "object": "list", "data": [
  { "object": "brand", "id": "default", "name": "Acme", "is_default": true, "current": true,
    "shared": false, "owner_account_id": null, "role": "owner",
    "headers": { "X-Hermoso-User": "default" } },
  { "object": "brand", "id": "pmabc123", "name": "Acme Kids", "is_default": false, "current": false,
    "shared": false, "owner_account_id": null, "role": "owner",
    "headers": { "X-Hermoso-User": "pmabc123" } },
  { "object": "brand", "id": "pmxyz789", "name": "Client Co", "is_default": false, "current": false,
    "shared": true, "owner_account_id": "acct_…", "role": "member",
    "headers": { "X-Hermoso-User": "pmxyz789", "X-Hermoso-Owner": "acct_…" } } ] }

GET /v1/brands lists every brand the key can act on, including brands another account shared with you. The anchor brand's id is the literal default. A shared brand needs a second header, X-Hermoso-Owner, and each row's headers spells out exactly what to send.

owner_account_id is set only on a shared brand: it is the account that owns it, and it is the value X-Hermoso-Owner takes. On a brand of your own it is null. role is owner on your own brands, and your role in the owner's team (for example member or admin) on a shared one.

A brand id the key cannot act on is a 404 with code: brand_not_found on every endpoint, never a 200 with empty lists. An empty list would say "this brand has nothing", which is a different answer from "this brand does not exist", and a typo should not read as a quiet brand.

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.

Credits

GET /v1/credits reads the balance this key spends from.

curl https://app.hermoso.ai/v1/credits -H "Authorization: Bearer hmk_…"

{ "object": "credits", "balance": 2840, "plan": "pro", "shared_workspace": false }

balance is the credits available now. plan is the plan id, and may be null. shared_workspace is true when the key is acting inside a brand another account owns: the balance and plan are then the owner's, because that pool is what your renders spend. A balance we cannot read is a 503 with code: balance_read_failed, never a zero.

Account contact email

GET /v1/account/email reads the contact address on the account and whether it has been verified.

curl https://app.hermoso.ai/v1/account/email -H "Authorization: Bearer hmk_…"

{ "object": "contact_email", "address": "ops@example.com", "verified": false, "verified_at": null,
  "note": "…one sentence on what this state means…" }

The account works whether or not the address is verified. A verified address is where receipts, low-credit alerts and replies to bug reports go. It is never the account's identity: it cannot be used to sign in, or to link or merge accounts.

POST /v1/account/email/resend with no body sends the verification link again, at most once every 10 minutes (a 429 with code: verification_recently_sent otherwise). With {"email": "…"} it replaces the address and starts verification over. It answers with the same shape as the read. In a brand another account shared with you, the address belongs to the owner, so a resend is a 403 with code: shared_workspace.

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, recasts and edits — 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.

The same document is served at app.hermoso.ai/v1/openapi.json, for tools that look for it under the API's own path. Neither needs a key.

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