{"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. The one exception is Hermoso’s own apps inside Canva, Adobe Express and Figma, where a person signs in to their own account and the token is theirs (lib/editor-apps-cors.mjs).\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 (https://app.hermoso.ai) on the MCP & CLI tab, under Terminal & API keys. 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 profiles, and `GET /v1/brands` lists them. A key is pinned to one, and `X-Hermoso-User: <profile-id>` overrides it per request. An id this key cannot act on is refused with a 404 `brand_not_found` rather than answered with empty results. 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## Long-running work\n\nA render takes longer than a request should. By default `POST /v1/tools/{name}` holds a render for up to 45 seconds and then hands back a job id inside a 200. Send `?wait=0` and it answers 202 with a job handle the moment the job is queued; poll `GET /v1/jobs/{id}` until `status` is `done` or `error`. Not waiting changes nothing about what the render costs. Never re-call the tool to check on a render: that starts a second one.\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."},"credits_needed":{"type":"number","description":"On `insufficient_credits` from a recast or an edit: what the request needs."},"balance":{"type":"number","description":"On `insufficient_credits` from a recast or an edit: what this workspace has."},"upgrade_url":{"type":"string","description":"On `real_face_needs_paid`: where to choose a paid plan."},"alternative":{"type":"string","description":"On `real_face_needs_paid`: the field that works on every plan instead (`person.describe`)."},"person_url":{"type":"string","description":"On a recast with `person.describe` whose recast step was refused after the person was made: the AI person, in your Library. Send it as `person.image_url` to retry without making a new one."}}},"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 profile’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 profile’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","email"],"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","description":"REQUIRED: a real mailbox for the human this account belongs to. A verification link is emailed there; the account works before it is clicked, and verifying only decides whether we may mail the address (receipts, low-credit alerts, replies to bug reports). It is NEVER the account identity: it cannot sign in to this account, and it can never link or merge this account with one that already uses the address. Hermoso synthetic addresses are refused (400 `email_invalid`)."}}},"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."}}},"ToolJob":{"type":"object","description":"A handle to a render that is queued or still running, answered with HTTP 202 when `wait` was sent.","properties":{"object":{"type":"string","enum":["tool_job"]},"tool":{"type":"string","description":"The tool that was called."},"job_id":{"type":"string"},"status":{"type":"string","description":"queued or running at the moment of the reply. This enum MAY GROW."},"poll":{"type":"string","description":"The path to poll: /v1/jobs/{id}. Also sent as the `Location` header."},"poll_url":{"type":"string","description":"The same, as an absolute URL."},"retry_after_seconds":{"type":"integer","description":"A sensible gap between polls. Also sent as `Retry-After`."},"text":{"type":"string","description":"The tool’s own sentence about the queued render."}}},"Job":{"type":"object","properties":{"object":{"type":"string","enum":["job"]},"id":{"type":"string"},"type":{"type":"string","description":"What kind of work it is: video, stitch, avatar, templatead, upscale, and so on. This set MAY GROW."},"status":{"type":"string","description":"queued, running, done or error. `done` and `error` are final. This enum MAY GROW: keep polling on a value you have not seen."},"progress":{"type":"number","description":"0 to 1, when the worker reports it."},"label":{"type":"string","nullable":true},"brand_id":{"type":"string","description":"The profile that started the job."},"created_at":{"type":"string","nullable":true,"description":"ISO-8601."},"updated_at":{"type":"string","nullable":true,"description":"ISO-8601. For a finished job, when it finished."},"run_at":{"type":"string","nullable":true,"description":"ISO-8601, for work scheduled to start later; null for work that starts immediately."},"url":{"type":"string","nullable":true,"description":"The finished file, once `status` is `done` and the job produced one. Always an absolute, publicly reachable URL."},"poster_url":{"type":"string","nullable":true,"description":"A still for a finished video, when there is one."},"model":{"type":"string","nullable":true,"description":"The model that rendered it."},"credits_used":{"type":"number","nullable":true},"error_message":{"type":"string","nullable":true,"description":"Why it failed, when `status` is `error`. Named `error_message` so it can never be mistaken for this API’s error envelope."},"cancelled":{"type":"boolean","description":"True when the job was stopped by `POST /v1/jobs/{id}/cancel` before it reached a model. Its `status` is `error` and nothing was charged."},"result":{"type":"object","nullable":true,"description":"The worker’s full result. PASSTHROUGH TIER: its shape is the job type’s own and improves with it, like a tool’s `data`. Build on the fields above; read this for detail."},"poll":{"type":"string","description":"This job’s own path."}}},"JobCancel":{"type":"object","properties":{"object":{"type":"string","enum":["job_cancel"]},"outcome":{"type":"string","description":"`cancelled`: it had not started, it is stopped and nothing was charged. `requested`: it is starting; it stops before it reaches a model if it can, and is charged nothing if it does. `too_late`: it has already reached the model, so it finishes, is charged as usual and lands in the Library. `finished`: it had already ended. This enum MAY GROW."},"message":{"type":"string","description":"A plain sentence for the person who asked to cancel."},"job":{"$ref":"#/components/schemas/Job"}}},"JobList":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Job"}},"running":{"type":"integer","description":"How many of the jobs matching this filter are running right now."},"has_more":{"type":"boolean"},"next_cursor":{"type":"string","nullable":true,"description":"Pass it back as `starting_after`."}}},"Brand":{"type":"object","properties":{"object":{"type":"string","enum":["brand"]},"id":{"type":"string","description":"The value `X-Hermoso-User` takes."},"name":{"type":"string","nullable":true},"is_default":{"type":"boolean","description":"The account’s anchor profile, whose id is the literal `default`. It is what a request with no `X-Hermoso-User` and an unpinned key acts on."},"current":{"type":"boolean","description":"The profile THIS request resolved to."},"shared":{"type":"boolean","description":"True for a profile owned by another account that you were invited into."},"owner_account_id":{"type":"string","nullable":true,"description":"Only on a shared profile: the value `X-Hermoso-Owner` takes."},"role":{"type":"string","description":"owner for your own profiles; your role on a shared one."},"headers":{"type":"object","description":"Exactly the headers that select this profile.","additionalProperties":{"type":"string"}}}},"BrandList":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Brand"}}}},"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."},"email":{"$ref":"#/components/schemas/ContactEmail","description":"The contact mailbox and its verification state. Present from the create call on."},"next":{"type":"string","description":"What to do now, in one sentence."}}},"ContactEmail":{"type":"object","properties":{"address":{"type":"string","nullable":true},"verified":{"type":"boolean"},"verified_at":{"type":"string","nullable":true,"description":"ISO-8601, or null while unverified."},"note":{"type":"string","description":"What the state means, in one sentence."}}},"GenerationCreate":{"type":"object","required":["type","prompt"],"properties":{"type":{"type":"string","enum":["image","video"],"description":"What to make. This enum MAY GROW."},"prompt":{"type":"string","description":"What the ad should show, in plain words."},"model":{"type":"string","description":"A model id from the live catalog (the `hermoso_capabilities` tool lists them with prices). Omit to let Hermoso pick; the quote then prices the model the server would pick."},"aspect_ratio":{"type":"string","description":"e.g. 1:1, 4:5, 9:16, 16:9. Each model lists the ratios it renders."},"image_size":{"type":"string","description":"Image only: a size tier the model lists (for example 1K, 2K)."},"duration_seconds":{"type":"integer","description":"Video only: one of the chosen model’s listed lengths."},"ref_image":{"type":"string","description":"Video only: a Hermoso-hosted image to animate (image-to-video)."},"ref_images":{"type":"array","items":{"type":"string"},"description":"Image only: up to 3 Hermoso-hosted images (a `POST /v1/media` or Library `url`) the render uses as its reference. When present the brand’s product photos are not attached for you; the quote prices exactly this."},"use_brand":{"type":"boolean","description":"Image only, default true: attach the profile’s own product photos (and its logo when the prompt asks for one) so the render shows the real product. The quote prices exactly what this attaches."}}},"GenerationQuote":{"type":"object","properties":{"object":{"type":"string","enum":["quote"]},"type":{"type":"string"},"credits":{"type":"number","nullable":true,"description":"What the render reserves. null when it would not start (see `refused`)."},"likely_credits":{"type":"number","nullable":true,"description":"Present when the hold covers an optional step (a label repair) that usually does not run: the likely charge, `credits` being the most it can be."},"label":{"type":"string","nullable":true,"description":"The model or pipeline that will render it."},"refused":{"type":"string","nullable":true,"description":"Why the render would not start. Nothing was charged."},"reference_images":{"type":"array","items":{"type":"string"},"description":"Image only: the pictures the render will use, exactly as it attaches them. With `use_brand` on these are the profile’s own saved product photos (up to three) and its logo when the prompt asks for one; empty when none is on file."},"reference_note":{"type":"string","nullable":true,"description":"Image only: a sentence about those pictures when there is one to say (a photo left out, a lineup, the logo)."}}},"CloneQuoteRequest":{"type":"object","required":["type","url"],"properties":{"type":{"type":"string","enum":["image","video"],"description":"image = a static ad image to clone; video = a TikTok, Reel, Facebook, X or YouTube link (or a direct video file). This enum MAY GROW."},"url":{"type":"string","description":"The https URL of the ad image (image) or of the post or video file (video)."}}},"CloneQuote":{"type":"object","properties":{"object":{"type":"string","enum":["quote"]},"type":{"type":"string","description":"clone_image or clone_video. This set MAY GROW."},"credits":{"type":"number","nullable":true,"description":"The sum of every step priced in advance (`flat` and `hold` steps, plus `estimate` steps). Steps with `credits: null` are billed on top at their real cost."},"steps":{"type":"array","items":{"$ref":"#/components/schemas/CloneQuoteStep"}},"refused":{"type":"string","nullable":true,"description":"Why the clone would not start. Nothing was charged."},"next":{"type":"string","description":"What runs it, in one sentence."}}},"CloneQuoteStep":{"type":"object","properties":{"step":{"type":"string","description":"read_layout, render, copy_and_check, plan or render_video. This set MAY GROW."},"label":{"type":"string","description":"The step in plain words."},"credits":{"type":"number","nullable":true,"description":"Its price, or null when it is billed at real cost or priced later."},"likely_credits":{"type":"number","nullable":true,"description":"For a `hold` that covers an optional step (a label repair): the likely charge."},"basis":{"type":"string","description":"flat, hold, estimate, metered or later. This set MAY GROW."}}},"RecastCreate":{"type":"object","required":["video","person"],"properties":{"video":{"type":"string","description":"The video the person goes into: a public https link to a video file, a TikTok, Reel, Facebook, X or YouTube post link (read once, through one lookup), or a Hermoso file URL (`POST /v1/media`, `GET /v1/library`, a job `url`)."},"person":{"$ref":"#/components/schemas/RecastPerson"},"voice":{"type":"string","description":"Optional. A NEW voice for the new person, keeping the original words and timing: `match` (a voice that fits them), `female`, `male`, or a stock voice name (Aria, Sarah, Charlotte, George, Brian, Charlie). Omit to keep the video's own sound. Never the original speaker's voice or a cloned one."},"language":{"type":"string","description":"Optional. The new person speaks this language instead (for example `Spanish`), with the mouth lip-synced to the new words. `voice` defaults to `match`."},"start":{"type":"number","description":"Optional. Where the recast window starts, in seconds. Default 0."},"end":{"type":"number","description":"Optional. Where it ends, in seconds. Default: the end of the video, up to 60 seconds after `start`. At least 3 seconds."},"instruction":{"type":"string","description":"Optional, in your own words: who in the video is replaced when there are several people (\"the man on the left\")."},"dry_run":{"type":"boolean","description":"true = return the exact price and the plan, and run nothing."},"callback_url":{"type":"string","description":"Optional https URL to POST the finished job to (the same as the `X-Hermoso-Callback-Url` header)."}}},"RecastPerson":{"type":"object","description":"Who goes into the video. Send exactly ONE of the three.","properties":{"creator_id":{"type":"string","description":"A saved creator's id or exact name, or a preset AI creator's id (the `list_creators` tool lists both). A saved photo of a real person needs a paid plan."},"image_url":{"type":"string","description":"A clear photo of the person (https or a Hermoso file URL). A photo of a real person needs a paid plan, and sending it confirms you have their permission; the confirmation is recorded. A portrait Hermoso made from words for this account counts as an AI person on every plan."},"describe":{"type":"string","description":"An AI person made from your words, on every plan: age, hair, style, vibe (\"a man in his 40s, short grey hair, navy sweater\"). Never a real or famous person by name. The person is made first (priced as its own step) and lands in your Library."}}},"RecastStep":{"type":"object","properties":{"step":{"type":"string","description":"`person` (making a described AI person) or `recast`. This set MAY GROW."},"label":{"type":"string","description":"The step in plain words."},"credits":{"type":"number","nullable":true,"description":"Its price: what the step reserves when it starts (`basis: hold`) or, once it has run, what it was charged (`basis: charged`)."},"likely_credits":{"type":"number","nullable":true,"description":"When the hold covers an optional leg that usually does not run: the likely charge."},"basis":{"type":"string","description":"`hold` or `charged`. This set MAY GROW."}}},"RecastQuote":{"type":"object","properties":{"object":{"type":"string","enum":["quote"]},"credits":{"type":"number","nullable":true,"description":"The whole price: the sum of `steps`. A real call reserves it and settles to the measured cost, never above it. null when it would not start (see `refused`)."},"likely_credits":{"type":"number","nullable":true},"refused":{"type":"string","nullable":true,"description":"Why it would not start. Nothing was charged."},"steps":{"type":"array","items":{"$ref":"#/components/schemas/RecastStep"}}}},"Recast":{"type":"object","properties":{"object":{"type":"string","enum":["recast"]},"dry_run":{"type":"boolean"},"status":{"type":"string","description":"`quoted` (a dry run), `refused` (a dry run that would not start), or the job's own status once queued: `queued`, `running`, `done`, `error`. This enum MAY GROW."},"quote":{"$ref":"#/components/schemas/RecastQuote"},"engine":{"type":"string","nullable":true,"description":"The model that will render it."},"window":{"type":"object","nullable":true,"properties":{"start":{"type":"number"},"end":{"type":"number"},"seconds":{"type":"number"}},"description":"The part of the video that is recast, in seconds."},"source_seconds":{"type":"number","nullable":true,"description":"The whole video's length."},"subject":{"type":"object","nullable":true,"properties":{"kind":{"type":"string","description":"`person`, or `object` when the picture shows no person (it then replaces the video's subject as an object)."},"name":{"type":"string","nullable":true}}},"voice":{"type":"object","nullable":true,"properties":{"voice":{"type":"string"},"language":{"type":"string","nullable":true},"lip_synced":{"type":"boolean"}}},"notes":{"type":"array","items":{"type":"string"},"description":"Plain sentences worth showing: a window that was cut, a recast done in passes and the cut second to watch, a face that is small in a shot."},"person_url":{"type":"string","nullable":true,"description":"With `person.describe` on a real call: the AI person that was made. It is in your Library; send it as `person.image_url` to reuse them."},"job_id":{"type":"string","nullable":true},"job":{"allOf":[{"$ref":"#/components/schemas/Job"}],"nullable":true,"description":"The queued job, as `GET /v1/jobs/{id}` answers it. Poll that until `done`; `url` is the video."}}},"EditCreate":{"type":"object","required":["video","instruction"],"properties":{"video":{"type":"string","description":"The clip to edit: a public https link to a video file or a social post, or a Hermoso file URL. 3 to 15 seconds."},"instruction":{"type":"string","description":"The ONE change, in plain words: \"change only the mug to red\", \"swap the bottle for our product\", \"make it nighttime\"."},"reference":{"type":"string","description":"Optional image (https or a Hermoso file URL) that anchors the material of what changes: a fabric, a finish, a colour swatch, the real product. Only its surface is used, never its framing or light."},"keep_audio":{"type":"boolean","description":"Default true: keep the clip's sound. false = silent."},"lighting":{"type":"string","enum":["auto","preserve","relight"],"description":"Default auto: keep the light unless the change needs new light (night, a lamp). preserve or relight forces it."},"preview_first_frame":{"type":"boolean","description":"true = edit ONE still of the clip first and return it with the clip's price. Nothing else runs."},"preview_still":{"type":"string","description":"The `preview.still_url` a preview returned: the clip is then rendered so the change looks the way it does in that still."},"preview_at":{"type":"number","description":"Which second to preview (default the first frame). Send the same value with `preview_still`."},"dry_run":{"type":"boolean","description":"true = return the exact price, and run nothing."},"callback_url":{"type":"string","description":"Optional https URL to POST the finished job to (the same as the `X-Hermoso-Callback-Url` header)."}}},"EditQuote":{"type":"object","properties":{"object":{"type":"string","enum":["quote"]},"credits":{"type":"number","nullable":true,"description":"What the clip edit reserves when it starts, from its own pricing. A real call settles to the measured cost, never above it. null when it would not start (see `refused`)."},"likely_credits":{"type":"number","nullable":true},"label":{"type":"string","nullable":true,"description":"The model or pipeline that will render it."},"refused":{"type":"string","nullable":true,"description":"Why it would not start. Nothing was charged."}}},"Edit":{"type":"object","properties":{"object":{"type":"string","enum":["edit"]},"dry_run":{"type":"boolean"},"mode":{"type":"string","enum":["clip","preview"],"description":"`preview` when `preview_first_frame` was sent. This enum MAY GROW."},"status":{"type":"string","description":"`quoted`, `refused`, `previewed`, or the job's own status once queued: `queued`, `running`, `done`, `error`. This enum MAY GROW."},"quote":{"$ref":"#/components/schemas/EditQuote"},"preview":{"type":"object","nullable":true,"properties":{"still_url":{"type":"string","nullable":true,"description":"The edited still (null on a dry run). Send it back as `preview_still`."},"still_credits":{"type":"number","nullable":true,"description":"What the still cost, or on a dry run what it reserves."},"still_refused":{"type":"string","nullable":true},"frame_at":{"type":"number","nullable":true,"description":"The second of the clip the still was taken from."},"note":{"type":"string","nullable":true}}},"job_id":{"type":"string","nullable":true},"job":{"allOf":[{"$ref":"#/components/schemas/Job"}],"nullable":true,"description":"The queued job, as `GET /v1/jobs/{id}` answers it."},"next":{"type":"string","description":"What to do now, in one sentence."}}},"Asset":{"type":"object","properties":{"object":{"type":"string","enum":["asset"]},"url":{"type":"string","description":"The durable public file."},"type":{"type":"string","description":"image or video. This set MAY GROW."},"poster_url":{"type":"string","nullable":true,"description":"A still for a video."},"model":{"type":"string","nullable":true},"created_at":{"type":"string","nullable":true,"description":"ISO-8601, when known."}}},"Model":{"type":"object","properties":{"object":{"type":"string","enum":["model"]},"id":{"type":"string","description":"Pass as `model` to POST /v1/generations."},"type":{"type":"string","description":"image or video."},"label":{"type":"string"},"featured":{"type":"boolean","description":"One of the models Hermoso recommends."},"default":{"type":"boolean","description":"The model Hermoso marks as its best pick for this type."},"aspect_ratios":{"type":"array","items":{"type":"string"}},"image_sizes":{"type":"array","nullable":true,"items":{"type":"string"}},"durations":{"type":"array","nullable":true,"items":{"type":"integer"},"description":"Video: the lengths, in seconds, this model renders in one pass."},"credits":{"type":"number","nullable":true,"description":"Image: the list price of one render. Video: null (see credits_by_duration)."},"credits_by_size":{"type":"object","nullable":true,"additionalProperties":{"type":"number"}},"credits_by_duration":{"type":"object","nullable":true,"additionalProperties":{"type":"number"}},"audio":{"type":"boolean","nullable":true,"description":"Video: renders its own sound."},"needs_reference":{"type":"boolean","description":"Only works on a picture or clip you supply."}}},"ModelList":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Model"}}}},"AssetList":{"type":"object","properties":{"object":{"type":"string","enum":["list"]},"data":{"type":"array","items":{"$ref":"#/components/schemas/Asset"}}}},"Binary":{"type":"string","format":"binary","description":"The file’s own bytes, with its own Content-Type."},"ContactEmailResend":{"type":"object","properties":{"email":{"type":"string","nullable":true,"description":"Omit to re-send to the current address. Give a different address to replace it (verification starts over)."}}}}},"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 profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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 — except a call that queued a job, whose replay is that job as it is now (what `GET /v1/jobs/{id}` answers), so a finished render replays as done with its file."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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, indeterminate. `indeterminate` means a restart interrupted the send and we cannot say whether it published."},{"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 profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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 profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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 profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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 profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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 — except a call that queued a job, whose replay is that job as it is now (what `GET /v1/jobs/{id}` answers), so a finished render replays as done with its file."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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 profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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 profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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 profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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":"callback_url","in":"query","required":false,"schema":{"type":"string"},"description":"The same as the `X-Hermoso-Callback-Url` header, for a client that cannot set headers. Prefer the header: a callback URL is a secret, and query strings are logged. A callback implies `wait=0` unless you send `wait` yourself."},{"name":"wait","in":"query","required":false,"schema":{"type":"string"},"description":"How long to hold this request while a render finishes. Only tools that render through the job queue are affected: video, avatars, template ads, post-production, and image generation (`generate_image` is queued as an image job whenever `wait` is sent, including `wait=0`). Every other tool answers as it always does. OMIT IT and nothing changes: a render is held for up to 45 seconds and then answered 200 with `data.stillRendering: true` and a `data.jobId`. `wait=0` (or `false`) answers 202 with a job handle the moment the job is queued, which is what a platform with a 30-second step limit wants. `wait=<seconds>` holds at most that long, capped at 45, and answers 202 if the render is not finished by then. Not waiting never changes what a render costs or whether it runs: the job is queued, checked against your balance and billed exactly as it is when you wait. Poll `GET /v1/jobs/{id}`."},{"name":"X-Hermoso-Callback-Url","in":"header","required":false,"schema":{"type":"string","format":"uri"},"description":"An https:// URL to POST the outcome to, for a platform that cannot poll (for example Zapier `z.generateCallbackUrl()`). When the call queued a job, Hermoso POSTs the job resource — exactly what `GET /v1/jobs/{id}` answers — once the job is `done` or `error`, with headers `Hermoso-Event: job.done | job.error` and `Hermoso-Delivery` (also sent as `Idempotency-Key`; drop a repeat). A 2xx ends delivery; a 4xx other than 408, 425 and 429 is final; anything else is retried for about 16 hours. When the call finished inside the request, its response body is POSTed too (`Hermoso-Event: tool.result`). The response header `Hermoso-Callback` says what happened: `registered`, `sent`, `limit-reached` or `not-registered`. Nothing about what runs or what it costs changes: the URL is attached after the job is queued. A refusal is the HTTP error and nothing is POSTed. Must be https on a public host name; a replay with the same `Idempotency-Key` and a new URL adds that URL to the same job."},{"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 — except a call that queued a job, whose replay is that job as it is now (what `GET /v1/jobs/{id}` answers), so a finished render replays as done with its file."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolCall"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResult"}}}},"202":{"description":"Only when `wait` was sent: the render is queued or still running. Poll `poll` (also sent as the `Location` header) until `status` is `done` or `error`. Do NOT call the tool again to check on it, that starts and bills a second render.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolJob"}}}},"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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/jobs":{"get":{"operationId":"listJobs","summary":"List jobs","description":"Renders and other long-running work for this profile, newest first. Cursor-paginated. A job belongs to the profile that started it (`brand_id`); send `X-Hermoso-User` to list another profile’s. Jobs are kept while they are useful to poll, not forever: a finished job ages out of this list, and its output lives on in the Library.","tags":["jobs"],"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 job 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: queued, running, done, error."},{"name":"type","in":"query","required":false,"schema":{"type":"string"},"description":"Filter to one job type, e.g. video, stitch, avatar, templatead."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobList"}}}},"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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/jobs/{id}":{"get":{"operationId":"getJob","summary":"Retrieve a job","description":"One job. Poll this until `status` is `done` or `error`; a few seconds between reads is plenty, and reads spend the read budget, not the write one. `url` is the finished file once there is one. An id that is not on your account is a 404, whether or not it exists somewhere else, and that answer is final: stop polling it and do not re-run the render on the strength of it.","tags":["jobs"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Job"}}}},"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"}}}},"404":{"description":"`error.code: job_not_found`: no job with that id on this account. Also `error.code: brand_not_found` when `X-Hermoso-User` names a profile this key cannot act on; `GET /v1/brands` lists the valid ids.","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/jobs/{id}/cancel":{"post":{"operationId":"cancelJob","summary":"Cancel an image or video job","description":"Stop an image or video generation. A job that has not started is stopped and charged nothing. A job that is starting stops before it reaches a model when it still can, and is charged nothing if it does. Once a model is rendering it, the render cannot be called back: it finishes, its credits are used and it lands in the Library, and `outcome` says so. Keep polling `GET /v1/jobs/{id}` for the final state; a stopped job ends `error` with `cancelled: true`. Safe to repeat.","tags":["jobs"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobCancel"}}}},"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"}}}},"404":{"description":"`error.code: job_not_found`: no job with that id on this account. Also `error.code: brand_not_found` when `X-Hermoso-User` names a profile this key cannot act on; `GET /v1/brands` lists the valid ids.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`error.code: not_cancellable`: only image and video generations are cancelled here.","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/brands":{"get":{"operationId":"listBrands","summary":"List profiles","description":"Every profile this key can act on (each is a business brand, a creator or a personal workspace): the account’s own, plus any another account shared with you. `id` is the value `X-Hermoso-User` takes. A shared profile also needs `X-Hermoso-Owner`, and each row’s `headers` spells out exactly what to send. `current` marks the profile THIS request resolved to. This is the one endpoint that ignores an unknown `X-Hermoso-User` instead of refusing it, because it is how you find a valid one.","tags":["brands"],"parameters":[{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BrandList"}}}},"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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/generations/quote":{"post":{"operationId":"quoteGeneration","summary":"Price a generation before running it","description":"The exact credits a generation with this body would reserve, from the render’s own pricing, with the brand’s product photos attached exactly as the real run attaches them. Free: nothing is queued, reserved or charged. The hold settles to the measured cost when the render finishes and never above it. `credits: null` with `refused` means the render would not start, and says why.","tags":["generations"],"parameters":[{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerationCreate"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerationQuote"}}}},"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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/clones/quote":{"post":{"operationId":"quoteClone","summary":"Price a clone before running it","description":"What cloning an ad for this profile will cost, step by step, before anything runs. `type: image` prices the static clone (`clone_static`): reading the layout (a flat charge) plus the image render’s own hold, with the brand’s product photo attached exactly as the clone attaches it; the copy writing and the final brand check are billed at their real model cost and are listed with `credits: null`. `type: video` prices the first step of a video clone (`clone_video`: watching the original and planning the storyboard) as the server’s own estimate; the render is priced exactly once the plan exists, by calling `render_ad` with `dryRun: true`. Free: nothing is read, reserved or charged.","tags":["clones"],"parameters":[{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CloneQuoteRequest"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CloneQuote"}}}},"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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/models":{"get":{"operationId":"listModels","summary":"List the image and video models","description":"The live model catalog this account can render with: ids, labels, the aspect ratios, sizes and lengths each takes, and its list price in credits. For the exact price of one render with your prompt and brand, ask `POST /v1/generations/quote`: brand product photos and video audio can change it. Models that only edit or only animate a supplied picture are marked `needs_reference`. The catalog grows: read it, never hard-code it.","tags":["models"],"parameters":[{"name":"type","in":"query","required":false,"schema":{"type":"string"},"description":"image or video (default both)."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelList"}}}},"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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/generations":{"post":{"operationId":"createGeneration","summary":"Generate an image or video","description":"Queue one image or one video render for this profile and answer 202 with the job. Poll `GET /v1/jobs/{id}` until `status` is `done` (its `url` is the file) or `error`. Billed exactly as the same render is anywhere in Hermoso: the credits are reserved when it starts and settle to the measured cost. A refusal (not enough credits, a prompt the model will not render) is an error in the standard envelope, with the server’s own sentence in `message`. Send an `Idempotency-Key` and a retry never starts a second render.","tags":["generations"],"parameters":[{"name":"callback_url","in":"query","required":false,"schema":{"type":"string"},"description":"The same as the `X-Hermoso-Callback-Url` header, for a client that cannot set headers. Prefer the header: a callback URL is a secret, and query strings are logged."},{"name":"X-Hermoso-Callback-Url","in":"header","required":false,"schema":{"type":"string","format":"uri"},"description":"An https:// URL to POST the outcome to, for a platform that cannot poll (for example Zapier `z.generateCallbackUrl()`). When the call queued a job, Hermoso POSTs the job resource — exactly what `GET /v1/jobs/{id}` answers — once the job is `done` or `error`, with headers `Hermoso-Event: job.done | job.error` and `Hermoso-Delivery` (also sent as `Idempotency-Key`; drop a repeat). A 2xx ends delivery; a 4xx other than 408, 425 and 429 is final; anything else is retried for about 16 hours. When the call finished inside the request, its response body is POSTed too (`Hermoso-Event: tool.result`). The response header `Hermoso-Callback` says what happened: `registered`, `sent`, `limit-reached` or `not-registered`. Nothing about what runs or what it costs changes: the URL is attached after the job is queued. A refusal is the HTTP error and nothing is POSTed. Must be https on a public host name; a replay with the same `Idempotency-Key` and a new URL adds that URL to the same job."},{"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 — except a call that queued a job, whose replay is that job as it is now (what `GET /v1/jobs/{id}` answers), so a finished render replays as done with its file."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GenerationCreate"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Job"}}}},"202":{"description":"Queued. Poll `poll` (also sent as `Location`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Job"}}}},"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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/recasts":{"post":{"operationId":"createRecast","summary":"Put a person into a video (motion transfer)","description":"Replace the person in a video with someone else: a saved or preset creator, a photo, or an AI person made from a description. 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. Optionally give them a new voice, or have them speak another language with the mouth lip-synced.\n\nPRICED UP FRONT. Send `\"dry_run\": true` and the response is the exact price: the credits the job reserves when it starts, from the job's own pricing, never an estimate. The real call reserves the same figure and settles to the measured cost, never above it. Nothing runs and nothing is charged on a dry run, except that reading a social post link uses its one lookup.\n\nASYNC. A real call answers `202` with the job inside and a `Location` to poll (`GET /v1/jobs/{id}`), usually a few minutes. Or name a `callback_url` and the finished job is POSTed to you.\n\nPEOPLE 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 (it 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, with a stable `error.code` and a plain sentence: `real_face_needs_paid`, `face_review_blocked`, `ref_unreadable`, `insufficient_credits`.","tags":["recasts"],"parameters":[{"name":"callback_url","in":"query","required":false,"schema":{"type":"string"},"description":"The same as the `X-Hermoso-Callback-Url` header or the body field, for a client that cannot set headers."},{"name":"X-Hermoso-Callback-Url","in":"header","required":false,"schema":{"type":"string","format":"uri"},"description":"An https:// URL to POST the outcome to, for a platform that cannot poll (for example Zapier `z.generateCallbackUrl()`). When the call queued a job, Hermoso POSTs the job resource — exactly what `GET /v1/jobs/{id}` answers — once the job is `done` or `error`, with headers `Hermoso-Event: job.done | job.error` and `Hermoso-Delivery` (also sent as `Idempotency-Key`; drop a repeat). A 2xx ends delivery; a 4xx other than 408, 425 and 429 is final; anything else is retried for about 16 hours. When the call finished inside the request, its response body is POSTed too (`Hermoso-Event: tool.result`). The response header `Hermoso-Callback` says what happened: `registered`, `sent`, `limit-reached` or `not-registered`. Nothing about what runs or what it costs changes: the URL is attached after the job is queued. A refusal is the HTTP error and nothing is POSTed. Must be https on a public host name; a replay with the same `Idempotency-Key` and a new URL adds that URL to the same job."},{"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 — except a call that queued a job, whose replay is that job as it is now (what `GET /v1/jobs/{id}` answers), so a finished render replays as done with its file."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecastCreate"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Recast"}}}},"202":{"description":"Queued. `job` is the job as it is now; poll `job.poll` (also sent as `Location`) until `status` is `done` (its `url` is the video) or `error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Recast"}}}},"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"}}}},"402":{"description":"`insufficient_credits`: the balance does not cover `credits_needed` (nothing was started). `real_face_needs_paid`: a photo of a real person needs a paid plan (`upgrade_url`), or use `person.describe`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`face_review_blocked`: the request was refused before anything ran (for example a public figure, a minor, or a sexual or violent scene). `ref_unreadable`: a picture or link could not be opened.","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"}}}}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl -sX POST https://app.hermoso.ai/v1/recasts \\\n  -H \"Authorization: Bearer hmk_...\" -H 'content-type: application/json' \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"video\": \"https://www.tiktok.com/@someone/video/7400000000000000000\",\n    \"person\": { \"describe\": \"a woman in her 30s, curly dark hair, denim jacket, warm smile\" },\n    \"dry_run\": true\n  }'"}],"security":[{"bearerAuth":[]}]}},"/v1/edits":{"post":{"operationId":"createEdit","summary":"Change one thing in a video","description":"Edit a clip from a plain instruction: \"change only the mug to red\", \"swap the bottle for our product\", \"make it night\". The motion, timing, framing and cut stay; the named thing changes, and the rest of the frame is held. A `reference` image anchors 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.\n\nPRICED UP FRONT. `\"dry_run\": true` returns the exact credits the edit reserves when it starts, from the job's own pricing. The real call reserves the same figure and settles to the measured cost, never above it.\n\nPREVIEW FIRST (optional). `\"preview_first_frame\": true` edits ONE still of the clip and returns it with the clip's price, and nothing else runs. Look at it, then send the same request with `preview_still` set to the still to render the clip matched to it. A preview is one image edit; with `dry_run` it is priced, not made.\n\nASYNC. A real clip call answers `202` with the job inside; poll `GET /v1/jobs/{id}` or name a `callback_url`.","tags":["edits"],"parameters":[{"name":"callback_url","in":"query","required":false,"schema":{"type":"string"},"description":"The same as the `X-Hermoso-Callback-Url` header or the body field, for a client that cannot set headers."},{"name":"X-Hermoso-Callback-Url","in":"header","required":false,"schema":{"type":"string","format":"uri"},"description":"An https:// URL to POST the outcome to, for a platform that cannot poll (for example Zapier `z.generateCallbackUrl()`). When the call queued a job, Hermoso POSTs the job resource — exactly what `GET /v1/jobs/{id}` answers — once the job is `done` or `error`, with headers `Hermoso-Event: job.done | job.error` and `Hermoso-Delivery` (also sent as `Idempotency-Key`; drop a repeat). A 2xx ends delivery; a 4xx other than 408, 425 and 429 is final; anything else is retried for about 16 hours. When the call finished inside the request, its response body is POSTed too (`Hermoso-Event: tool.result`). The response header `Hermoso-Callback` says what happened: `registered`, `sent`, `limit-reached` or `not-registered`. Nothing about what runs or what it costs changes: the URL is attached after the job is queued. A refusal is the HTTP error and nothing is POSTed. Must be https on a public host name; a replay with the same `Idempotency-Key` and a new URL adds that URL to the same job."},{"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 — except a call that queued a job, whose replay is that job as it is now (what `GET /v1/jobs/{id}` answers), so a finished render replays as done with its file."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EditCreate"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Edit"}}}},"202":{"description":"Queued. Poll `job.poll` (also sent as `Location`) until `status` is `done` (its `url` is the edited clip) or `error`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Edit"}}}},"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"}}}},"402":{"description":"`insufficient_credits`: the balance does not cover `credits_needed`. Nothing was started.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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"}}}}},"x-codeSamples":[{"lang":"Shell","label":"curl","source":"curl -sX POST https://app.hermoso.ai/v1/edits \\\n  -H \"Authorization: Bearer hmk_...\" -H 'content-type: application/json' \\\n  -H \"Idempotency-Key: $(uuidgen)\" \\\n  -d '{\n    \"video\": \"https://example.com/clip.mp4\",\n    \"instruction\": \"change only the mug to red\",\n    \"dry_run\": true\n  }'"}],"security":[{"bearerAuth":[]}]}},"/v1/library":{"get":{"operationId":"listLibrary","summary":"List the profile’s Library","description":"Every image and video this profile has made in Hermoso, newest first: the same Library the app shows. Each `url` is a durable public file; a video carries a `poster_url` still.","tags":["library"],"parameters":[{"name":"type","in":"query","required":false,"schema":{"type":"string"},"description":"image, video or all (default all)."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer"},"description":"1-60, default 30."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetList"}}}},"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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/content":{"get":{"operationId":"getMediaContent","summary":"Download a Hermoso file","description":"The bytes of a file Hermoso made or hosts (a Library `url`, a job `url`), streamed with its own `Content-Type`. Only Hermoso-hosted files are served; any other address is refused. For an app that has to hand raw bytes to its host (a design editor inserting the file) and cannot read our file host directly.","tags":["media"],"parameters":[{"name":"url","in":"query","required":false,"schema":{"type":"string"},"description":"A Hermoso-hosted file URL."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Binary"}}}},"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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":[]}},"/v1/account/email":{"get":{"operationId":"getContactEmail","summary":"Retrieve the contact email and whether it is verified","description":"The mailbox named at signup and whether its verification link has been clicked. The account works either way; a verified address is where receipts, low-credit alerts and replies to bug reports go. The address is NEVER the account identity and can never be used to sign in to, link, or merge accounts.","tags":["account"],"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 — except a call that queued a job, whose replay is that job as it is now (what `GET /v1/jobs/{id}` answers), so a finished render replays as done with its file."},{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactEmail"}}}},"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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/account/email/resend":{"post":{"operationId":"resendEmailVerification","summary":"Re-send the verification link, or set a different contact email","description":"With no body, re-sends the verification link to the current contact address (at most once every 10 minutes — 429 `verification_recently_sent` otherwise). With `email`, replaces the contact address and starts verification over for the new one. An address that is already the sign-in email of another Hermoso account cannot be verified here (409 `email_in_use` when the link is clicked) and this account stays exactly as it is.","tags":["account"],"parameters":[{"name":"X-Hermoso-User","in":"header","required":false,"schema":{"type":"string"},"description":"Which profile to act on, when the account holds more than one: an `id` from `GET /v1/brands`. Defaults to the workspace the key is pinned to. Membership is re-authorised on every request, and an id this key cannot act on is a 404 `brand_not_found`, never an empty result."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactEmailResend"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContactEmail"}}}},"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"}}}},"404":{"description":"`error.code: brand_not_found`: `X-Hermoso-User` (or the profile this key is pinned to) names a profile this key cannot act on. `GET /v1/brands` lists the valid ids.","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":[]}]}},"/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":[]}]}}}}