{"openapi":"3.1.0","info":{"title":"Hermoso API","version":"2026-08-26","summary":"Publish, schedule and manage social content from your own backend.","description":"The Hermoso public API. Server-side only — it sends no CORS headers and cannot be called from browser JavaScript, because an API key carries full account authority and a key shipped to a browser is a key you have published.\n\n## Compatibility\n\nADD FREELY, NEVER BREAK. Inside `/v1` we add endpoints, optional request fields and response fields without notice, and enums grow — a new publishing channel is an addition, not a version. We never remove a field, change its type or meaning, change a default, or change the status code of an existing condition. Anything that cannot obey that gets a `/v2`, and `/v1` then keeps serving for at least 12 months carrying `Deprecation` and `Sunset` headers (RFC 8594).\n\nSo: **ignore fields you do not recognise, and tolerate enum values you have not seen.** Those two habits are what make the promise worth anything.\n\nThe `/api/*` routes this app uses internally are NOT part of any contract. They change without notice and are not documented here. Do not call them. **One exception, and it is listed in `paths` below:** `POST /api/billing/machine-payment`. The Machine Payments Protocol discovers a payable endpoint by reading `x-payment-info` out of an OpenAPI document, so an endpoint that is not in this document cannot be found by a wallet at all. It is not in `/v1` because a `402` challenge has to be a raw response carrying `WWW-Authenticate`, and every `/v1` handler returns an object this API wraps in its own envelope. Treat that one path as covered by the same never-break promise as `/v1`.\n\n## Two tiers, both first-class\n\n`/v1/tools` is FULL PARITY with the Hermoso toolset, and it EVOLVES WITH IT. Two different promises live in this API, and it is worth knowing which one you are standing on.\n\n**The modelled resources** — `/v1/posts`, `/v1/channels`, `/v1/media`, `/v1/credits` — obey the compatibility promise above in full. Add freely, never break. Build a product on them.\n\n**The tool passthrough** — `/v1/tools` and `/v1/tools/{name}` — reaches everything else Hermoso does: research, generation, publishing, paid campaigns on every connected ad platform, analytics, files, workspace. Its ENVELOPE is stable under the same promise: the routes, the authentication, the error shape, the rate-limit headers, the idempotency semantics and this document. THE TOOLS INSIDE IT ARE NOT FROZEN — their arguments and results improve as the tools do, continuously and without a version bump.\n\nSo read the roster rather than pinning it: `GET /v1/tools` is a live read, filterable by area and searchable, and `GET /v1/tools/{name}` is the current schema for one tool. Neither tier is the lesser door — pick per job.\n\n## Authentication\n\n`Authorization: Bearer hmk_...`. Create a key in the app under Settings, Agents & API. Keys are hashed at rest and revocable, and one key can never mint another.\n\nA third-party app acting on behalf of a Hermoso user runs the OAuth 2.0 authorization-code flow with PKCE (`/.well-known/oauth-authorization-server`; dynamic client registration is supported). That flow MINTS one of these same keys, so there is one credential type and one set of guarantees, not two. One connection grants everything the account can do — there is no scope selection to negotiate.\n\n## Workspaces\n\nAn account can hold several brand workspaces. A key is pinned to one, and `X-Hermoso-User: <workspace-id>` overrides it per request. The workspace is NEVER read from a request body — membership is re-authorised on every single request, so a body field naming a workspace is refused rather than honoured.\n\n## Rate limits\n\nPer API key: 300 reads and 60 writes per minute. Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`; a 429 also carries `Retry-After`. Two keys on one account get two independent budgets."},"servers":[{"url":"https://app.hermoso.ai"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"hmk_...","description":"A Hermoso API key, or an access token from the OAuth authorization-code flow (which is the same thing)."}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["type","code","message"],"properties":{"type":{"type":"string","enum":["authentication_error","permission_error","invalid_request_error","connector_error","rate_limit_error","insufficient_credits","api_error"],"description":"The coarse family — branch on this. New CODES land inside an existing type wherever possible so that branch keeps working; the enum is published for code generation, and a client should still fall through gracefully on a member it does not recognise."},"code":{"type":"string","description":"The specific reason. Log this. New values may appear at any time."},"message":{"type":"string"},"param":{"type":"string","description":"The offending field, when there is one."},"connector":{"type":"string","description":"Present when the reason is a channel that is not connected. Reconnect in the app — an OAuth consent screen cannot be completed headlessly."}}},"request_id":{"type":"string","nullable":true}}},"Post":{"type":"object","properties":{"object":{"type":"string","enum":["post"]},"id":{"type":"string"},"status":{"type":"string","description":"scheduled | processing | published | partially_published | failed | indeterminate. THIS SET MAY GROW — tolerate an unknown value. `indeterminate` means a restart interrupted the send and we cannot say whether it published: check the channel rather than assuming either way. `failed` is only ever used when we know nothing published."},"scheduled_at":{"type":"string","format":"date-time","nullable":true},"created_at":{"type":"string","format":"date-time","nullable":true},"channels":{"type":"array","items":{"type":"string"}},"caption":{"type":"string"},"title":{"type":"string"},"title_effective":{"type":"string","description":"The headline actually going out — derived from the caption when you named none."},"link":{"type":"string"},"media":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string"},"url":{"type":"string"}}}},"captions_by_channel":{"type":"object","nullable":true,"additionalProperties":{"type":"string"}},"visibility":{"type":"string"},"visibility_by_channel":{"type":"object","nullable":true,"additionalProperties":{"type":"string"}},"targets":{"type":"object","additionalProperties":true,"description":"Which account this goes to, per channel (Page, board, LinkedIn Page, Business listing). Unset means the connection’s default."},"options":{"type":"object","additionalProperties":true},"results":{"type":"array","nullable":true,"description":"Per-channel outcome once fired. A partial failure is normal and is reported here — the top-level status then says `partially_published`, never a flat `failed`.","items":{"type":"object","properties":{"channel":{"type":"string"},"ok":{"type":"boolean"},"url":{"type":"string","nullable":true},"id":{"type":"string","nullable":true},"error":{"type":"string","nullable":true}}}},"retry_of":{"type":"string","nullable":true},"status_detail":{"type":"string","nullable":true,"description":"Present when the status alone would mislead. Set for `indeterminate`."},"content_lost":{"type":"boolean"}}},"PostList":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Post"}},"has_more":{"type":"boolean"},"next_cursor":{"type":"string","nullable":true}}},"PostCreate":{"type":"object","required":["channels"],"properties":{"channels":{"type":"array","items":{"type":"string"},"description":"One or more channel ids from GET /v1/channels. DELIBERATELY NOT AN ENUM — the set is derived from what the product can publish to, and it GROWS as platform approvals land. Read it from GET /v1/channels rather than hard-coding it, and treat an id you have not seen as valid."},"caption":{"type":"string","description":"The post text. Per-channel length limits are enforced at create time, not discovered at publish time."},"title":{"type":"string","description":"The headline Pinterest and YouTube each require. Derived from the caption when omitted."},"link":{"type":"string"},"media":{"type":"array","description":"Ordered. One item is a single post; two or more are a carousel, in the order given. Each item is a Hermoso-hosted URL from POST /v1/media, as a bare string or as {url, type}.","items":{"oneOf":[{"type":"string"},{"type":"object","properties":{"url":{"type":"string"},"type":{"type":"string","enum":["image","video"]}}}]}},"scheduled_at":{"type":"string","format":"date-time","description":"Omit to publish immediately. Otherwise ISO-8601, at least 60 seconds out and within the documented horizon."},"use_queue":{"type":"boolean","description":"Take the next free slot in this brand’s posting schedule instead of naming a time. Mutually exclusive with scheduled_at — sending both is an error, never a silent precedence."},"timezone":{"type":"string","description":"IANA zone for use_queue. Defaults to the brand’s saved zone."},"visibility":{"type":"string","description":"public (default) | unlisted | private | draft. Refused at create time where a channel cannot honour it, never silently downgraded at publish time."},"visibility_by_channel":{"type":"object","additionalProperties":{"type":"string"}},"captions_by_channel":{"type":"object","additionalProperties":{"type":"string"},"description":"Override the caption for one channel."},"targets":{"type":"object","additionalProperties":true,"description":"Which account to post to, where the connection holds more than one. Omit for the default. GET /v1/channels names the field each channel uses."},"options":{"type":"object","additionalProperties":true,"description":"Per-channel publish metadata (YouTube description and tags, X thread parts, Instagram alt text, and so on)."}}},"PostUpdate":{"type":"object","description":"Any subset of PostCreate. Omitted means unchanged; an explicit empty value clears.","additionalProperties":true},"Deleted":{"type":"object","properties":{"object":{"type":"string","enum":["post"]},"id":{"type":"string"},"deleted":{"type":"boolean"}}},"Channel":{"type":"object","properties":{"object":{"type":"string","enum":["channel"]},"id":{"type":"string"},"name":{"type":"string"},"connector":{"type":"string","description":"The connection this channel is reached through. Several channels can share one — Facebook and Instagram both ride the Meta connection."},"available":{"type":"boolean","description":"Whether this channel can be connected AT ALL on this deployment. False while a platform approval is outstanding — a different answer from \"you have not connected it yet\", and collapsing the two would send you to build a connect flow that cannot succeed."},"unavailable_reason":{"type":"string","nullable":true,"description":"Why, when `available` is false."},"connected":{"type":"boolean"},"needs_reconnect":{"type":"boolean","description":"A row exists but holds no usable credential. Reconnect in the app; an OAuth consent screen cannot be completed headlessly."},"account":{"type":"string","nullable":true,"description":"Which account is connected, where the provider tells us."},"visibilities":{"type":"array","items":{"type":"string"}},"visibility_notes":{"type":"string","nullable":true},"carousel":{"type":"object","nullable":true,"properties":{"min":{"type":"integer"},"max":{"type":"integer","nullable":true,"description":"The vendor’s own cap. null means the vendor publishes no number — render that as \"no documented limit\", never as unlimited and never as an invented cap."},"effective_max":{"type":"integer"},"video":{"type":"boolean"}}},"carousel_unsupported_reason":{"type":"string","nullable":true},"caption_max":{"type":"integer","nullable":true},"target_field":{"type":"string","nullable":true,"description":"The key inside `targets` that names WHICH account on this channel to post to."}}},"ChannelList":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Channel"}}}},"Media":{"type":"object","properties":{"object":{"type":"string","enum":["media"]},"url":{"type":"string","description":"A durable Hermoso-hosted URL. Pass it straight into POST /v1/posts."},"type":{"type":"string","description":"image | video | audio | document."},"bytes":{"type":"integer"},"name":{"type":"string","nullable":true}}},"Credits":{"type":"object","properties":{"object":{"type":"string","enum":["credits"]},"balance":{"type":"number","description":"Hermoso credits available to this workspace."},"plan":{"type":"string","nullable":true},"shared_workspace":{"type":"boolean","description":"True when this key is acting inside a workspace owned by another account — the balance is then the owner’s pool, which is what your renders spend."}}},"SignupCreate":{"type":"object","required":["plan"],"properties":{"plan":{"type":"string","description":"A PAID plan id — see GET /v1/signup/plans-equivalent on hermoso.ai/pricing, currently starter, pro or studio. The free plan is refused here by name: this path provisions an account only against a completed payment."},"period":{"type":"string","enum":["mo","yr"],"description":"Billing cadence. Defaults to `mo`."},"email":{"type":"string","nullable":true,"description":"OPTIONAL, and only ever a convenience: it pre-fills the Stripe checkout so the payer does not retype it. It is NOT used as the account identity and is never trusted as proof of anything — Stripe collects an email for the receipt but does not verify it, and an unverified address written into an account identity is an account-takeover vector. The provisioned account carries a synthetic identity instead."}}},"SignupClaim":{"type":"object","required":["claim_token"],"properties":{"claim_token":{"type":"string","description":"The token returned exactly once by POST /v1/signup."}}},"Tool":{"type":"object","properties":{"object":{"type":"string","enum":["tool"]},"name":{"type":"string","description":"Call it at POST /v1/tools/{name}."},"title":{"type":"string"},"area":{"type":"string","nullable":true,"description":"Which area of the product it belongs to. THIS SET MAY GROW — tolerate a member you have not seen."},"description":{"type":"string","description":"What it does, written for whoever is choosing: a person reading the index or a model reading the roster."},"read_only":{"type":"boolean","description":"It reads and changes nothing. Read-only tools are metered on the READ rate-limit budget."},"destructive":{"type":"boolean","description":"It can remove or overwrite something. Destructive tools carry their own confirmation arguments — read the schema."},"idempotent":{"type":"boolean","description":"Calling it twice with the same arguments has the same effect as calling it once. Independent of `Idempotency-Key`, which protects a retry regardless."},"requires_connector":{"type":"string","nullable":true,"description":"The third-party account this tool needs linked, or null when it needs none. Linking is a browser flow: an OAuth consent screen cannot be completed headlessly."},"connector_connected":{"type":"boolean","nullable":true,"description":"Whether that connector is linked in this workspace. NULL means the connections could not be read, which is NOT the same as \"no\" — never render it as a refusal."},"rate_class":{"type":"string","enum":["read","write"],"description":"Which rate-limit budget a call to it spends."},"invoke":{"type":"object","properties":{"method":{"type":"string"},"path":{"type":"string"}}},"input_schema":{"type":"object","description":"JSON Schema for the request body of POST /v1/tools/{name}. The body IS the argument object."}}},"ToolList":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"areas":{"type":"array","description":"Every area holding at least one tool available to this workspace, so one call shows the whole map before you page through it.","items":{"type":"object","properties":{"object":{"type":"string","enum":["tool_area"]},"id":{"type":"string"},"description":{"type":"string"},"tool_count":{"type":"integer"}}}},"data":{"type":"array","items":{"$ref":"#/components/schemas/Tool"}},"total":{"type":"integer","description":"Tools matching this filter, across all pages."},"has_more":{"type":"boolean"},"next_cursor":{"type":"string","nullable":true,"description":"Pass it back as `starting_after`."},"limit":{"type":"integer"}}},"ToolCall":{"type":"object","description":"The TOOL’S OWN arguments, as a flat object. There is no wrapper: to call a tool that takes `query`, send `{\"query\":\"...\"}`. Read the shape from GET /v1/tools/{name}.","additionalProperties":true},"ToolResult":{"type":"object","properties":{"object":{"type":"string","enum":["tool_result"]},"tool":{"type":"string"},"text":{"type":"string","description":"The tool’s answer as a sentence — what a model would read. Always present."},"data":{"type":"object","nullable":true,"description":"The structured result, when the tool produces one; null when its answer is only prose. Its shape is the tool’s own and moves with the tool."}}},"Signup":{"type":"object","properties":{"object":{"type":"string","enum":["signup"]},"id":{"type":"string","description":"Pass this to POST /v1/signup/{id}/claim."},"status":{"type":"string","enum":["awaiting_payment","ready"],"description":"This enum MAY GROW — tolerate a member you have not seen."},"plan":{"type":"string"},"period":{"type":"string","enum":["mo","yr"]},"checkout_url":{"type":"string","nullable":true,"description":"Stripe’s hosted checkout. Present while payment is outstanding. A card is entered there, never here."},"expires_at":{"type":"string","nullable":true,"description":"ISO-8601. When the checkout link stops working. Nothing is created and nothing is charged if it lapses."},"claim_token":{"type":"string","nullable":true,"description":"Returned ONCE, by the create call. Store it before doing anything else — it is the only proof this signup is yours."},"api_key":{"type":"string","nullable":true,"description":"Returned ONCE, on the claim that provisions the account (or on a re-claim inside 24h, which revokes the previous key). Stored only as a hash — it can never be re-read."},"account_id":{"type":"string","nullable":true},"credits":{"type":"number","nullable":true,"description":"The plan’s credits, already granted."},"next":{"type":"string","description":"What to do now, in one sentence."}}}}},"paths":{"/v1/channels":{"get":{"operationId":"listChannels","summary":"List publishing channels","description":"Every channel Hermoso can publish to, whether this workspace has it connected, and what each supports (visibilities, carousel limits, caption length). A channel that is NOT connected is still listed, with `connected: false` — \"we do not support that\" and \"you have not connected it\" are different answers and a client must be able to tell them apart.","tags":["channels"],"parameters":[{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which brand workspace to act on, when the account holds more than one. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelList"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]}]}},"/v1/posts":{"post":{"operationId":"createPost","summary":"Create a post","description":"Publish now, or schedule for later. Omit `scheduled_at` to send immediately; pass an ISO-8601 timestamp to queue it. A post fans out across every channel in `channels` INDEPENDENTLY — one channel failing does not stop the others, and `results[]` reports each. Everything that can be refused is refused HERE, while you still hold the request: an unknown channel, a visibility the channel cannot honour, a caption over a channel limit, a carousel a channel cannot carry. Nothing is ever silently downgraded at publish time.","tags":["posts"],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string"},"description":"Send a unique value to make this call safe to retry. A replay returns the original response with `Idempotency-Replayed: true`, for 24 hours."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which brand workspace to act on, when the account holds more than one. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostCreate"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]}]},"get":{"operationId":"listPosts","summary":"List posts","description":"Scheduled and already-fired posts for this workspace, newest first. Cursor-paginated.","tags":["posts"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer"},"description":"1-100, default 25."},{"name":"starting_after","in":"query","required":false,"schema":{"type":"string"},"description":"A post id from a previous page; returns the page after it. An unknown cursor is an error, never a silent restart."},{"name":"status","in":"query","required":false,"schema":{"type":"string"},"description":"Filter: scheduled, processing, published, partially_published, failed."},{"name":"channel","in":"query","required":false,"schema":{"type":"string"},"description":"Filter to posts that include this channel."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which brand workspace to act on, when the account holds more than one. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostList"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]}]}},"/v1/posts/{id}":{"get":{"operationId":"getPost","summary":"Retrieve a post","description":"One post, including per-channel results once it has fired.","tags":["posts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which brand workspace to act on, when the account holds more than one. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]}]},"patch":{"operationId":"updatePost","summary":"Update a scheduled post","description":"Only while it is still `scheduled`. An omitted field is left alone; an explicit empty value clears it. The edit re-runs the SAME validation a create does — it is not a looser path.","tags":["posts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which brand workspace to act on, when the account holds more than one. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostUpdate"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Post"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]}]},"delete":{"operationId":"deletePost","summary":"Cancel a scheduled post","description":"Only while it is still `scheduled`. A post that has already fired cannot be cancelled — it exists on the channel now, and removing it there is that channel’s own delete.","tags":["posts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which brand workspace to act on, when the account holds more than one. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Deleted"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]}]}},"/v1/media":{"post":{"operationId":"createMedia","summary":"Upload media","description":"Turn a file into a Hermoso-hosted URL that `POST /v1/posts` accepts. Two ways in, and exactly ONE per call: send the file’s raw bytes as the request body with its own `Content-Type` (name it with `X-File-Name`), or send `?url=` and we fetch it. Sending both is an error rather than a silent precedence — a caller who sent both has two different files in mind.","tags":["media"],"parameters":[{"name":"url","in":"query","required":false,"schema":{"type":"string"},"description":"A public https URL to fetch instead of sending bytes."},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string"},"description":"Send a unique value to make this call safe to retry. A replay returns the original response with `Idempotency-Replayed: true`, for 24 hours."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which brand workspace to act on, when the account holds more than one. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request."}],"requestBody":{"required":false,"content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Media"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]}]}},"/v1/credits":{"get":{"operationId":"getCredits","summary":"Retrieve the credit balance","description":"This workspace’s Hermoso credit balance and plan. Publishing and scheduling cost no credits; generation does.","tags":["credits"],"parameters":[{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which brand workspace to act on, when the account holds more than one. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Credits"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]}]}},"/v1/tools":{"get":{"operationId":"listTools","summary":"List the callable tools","description":"Everything this workspace can do over HTTP, grouped by area, each with its human-readable description and its JSON Schema. This is the browsable index: filter by `area`, search by substring, page with a cursor.\n\nREAD IT, DO NOT HARD-CODE IT. The set grows continuously and a tool absent here is not a missing capability — a tool whose connector this workspace has not linked is withheld, because it could only ever answer \"not connected\". `requires_connector` on every row names what to link.\n\nCONTRACT TIER: this endpoint's SHAPE is stable under the same promise as everything else in `/v1`. The TOOLS inside it are not frozen — their arguments improve as the tools do. Treat the roster as live data.","tags":["tools"],"parameters":[{"name":"area","in":"query","required":false,"schema":{"type":"string"},"description":"Filter to one area. `areas[]` in the response lists them with a description and a count."},{"name":"search","in":"query","required":false,"schema":{"type":"string"},"description":"Case-insensitive substring over name, title and description."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer"},"description":"1-200, default 25. Each row carries a JSON Schema, so a large page is a large response."},{"name":"starting_after","in":"query","required":false,"schema":{"type":"string"},"description":"A tool name from a previous page; returns the page after it. An unknown cursor is an error, never a silent restart."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which brand workspace to act on, when the account holds more than one. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolList"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]}]}},"/v1/tools/{name}":{"get":{"operationId":"getTool","summary":"Retrieve one tool","description":"One tool: what it does, which area it belongs to, whether it is read-only, which connector it needs, and the JSON Schema for its arguments. A tool withheld from the index because its connector is not linked is still described HERE, with `connector_connected: false` — so \"what would I have to link to use this?\" is answerable without linking anything first.","tags":["tools"],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string"}},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which brand workspace to act on, when the account holds more than one. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Tool"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]}]},"post":{"operationId":"callTool","summary":"Call a tool","description":"Invoke a tool with a JSON body of its arguments and get its result. The body IS the argument object — there is no envelope to wrap it in.\n\nThe same handler the MCP server and the CLI run, on the same credential, through the same spend gate: a tool that costs credits costs the same credits here, and one that publishes publishes to the same connected account. Send an `Idempotency-Key` on anything that spends or publishes and a retry is safe.\n\nThe reply carries BOTH halves of the tool's answer: `text` is the sentence (what an agent reads) and `data` is the structured result (what an integration reads). A tool that refuses answers with an HTTP error in the standard envelope, never a 200 with a failure inside it.\n\nCONTRACT TIER: the envelope here — the route, the auth, the errors, the rate-limit headers, the idempotency semantics — is stable. A tool's own arguments are not frozen: they improve with the tool. Read `GET /v1/tools/{name}` rather than pinning a schema.","tags":["tools"],"parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string"}},{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string"},"description":"Send a unique value to make this call safe to retry. A replay returns the original response with `Idempotency-Replayed: true`, for 24 hours."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which brand workspace to act on, when the account holds more than one. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCall"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResult"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]}]}},"/v1/signup":{"post":{"operationId":"createSignup","summary":"Start an agentic signup","description":"Create a Hermoso account programmatically, with no human at a browser and no existing credential — the one call in this API that needs no key, because the key is what it creates. Returns a Stripe Checkout URL and a one-time `claim_token`.\n\nAGENTIC SIGNUP REQUIRES A PAID PLAN. Nothing is created here: no account, no credits, no row. An account exists only once Stripe reports the payment completed, at which point `POST /v1/signup/{id}/claim` provisions it and returns your API key. The free plan is not available on this path — it is available to a person signing up at https://app.hermoso.ai.\n\nA card is entered on Stripe’s own hosted page, never here, so the checkout step itself needs a browser: hand `checkout_url` to whoever is paying. `claim_token` is returned exactly once and is the only proof this signup is yours — a checkout session id alone is not enough, because session ids travel in URLs.","tags":["signup"],"parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupCreate"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Signup"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[]}},"/v1/signup/{id}/claim":{"post":{"operationId":"claimSignup","summary":"Claim a paid signup (poll until ready)","description":"Poll this until `status` is `ready`. While payment is outstanding it answers `awaiting_payment` and creates nothing. Once Stripe reports the payment completed it provisions the account, applies the plan and its credits, and returns `api_key` — ONCE.\n\nThe key is stored only as a hash and can never be re-read. If you lose the response, re-claiming inside 24 hours mints a replacement and revokes the previous key, so a signup never leaves two live credentials behind. After that window there is no self-serve recovery: an agentic account has a synthetic identity and no mailbox to send a link to. Billing is always recoverable regardless — the payer receives Stripe’s receipts and can manage or cancel the subscription from Stripe’s own portal.","tags":["signup"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The `id` from POST /v1/signup (a Stripe Checkout session id)."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupClaim"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Signup"}}}},"400":{"description":"Invalid request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key — or a channel that is not connected, in which case `error.connector` names it and `error.type` is `connector_error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"Rate limited. `Retry-After` says how long to wait.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[]}},"/api/billing/machine-payment":{"post":{"operationId":"buyCreditsWithMachinePayment","summary":"Buy a credit pack with a machine payment","description":"Top up credits with no human and no card entry, for an agent that holds its own payment credential. Name a credit pack and this answers `402` with an MPP challenge; authorise it and retry, and the pack is granted against the same account and the same credit meter a Stripe Checkout purchase would have credited.\n\nThe amount is taken from the pack catalog and never from the request. `GET /api/billing/config` carries a `machinePayments` block with the pack ids, prices, per-credit rates and whether this lane is enabled on this server.\n\nIf you do NOT hold a payment credential — which is still the common case — use `POST /api/billing/checkout-link` instead. It mints a Stripe Checkout URL for a person to pay on.","tags":["billing"],"x-payment-info":{"offers":[{"amount":null,"currency":"usd","description":"A Hermoso credit pack. The charge is the catalog price of the pack named in the request body.","intent":"charge","method":"stripe"}]},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["packId"],"properties":{"packId":{"type":"string","description":"A pack id from GET /api/billing/config, e.g. pack-1k."}}}}}},"responses":{"200":{"description":"Paid and credited. The response carries a `Payment-Receipt` header.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object"}}}}}},"400":{"description":"Unknown pack, or a pack below the payment floor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Missing, invalid or revoked key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"402":{"description":"Payment required. The `WWW-Authenticate` header carries the MPP challenge; authorise it and retry the same request with the credential."},"501":{"description":"Machine payments are not enabled on this server. The body names the reason and points at the Checkout path.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"security":[{"bearerAuth":[]}]}}}}