Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Hermoso Ai Recasts API
version: '2026-08-26'
description: 'Operations tagged recasts across 2 of this provider''s published API definitions: app_hermoso_ai.json, app_hermoso_ai_rest.json. Each path carries the servers of the definition it was published in.'
servers:
- url: https://app.hermoso.ai
security:
- bearerAuth: []
tags:
- name: recasts
paths:
/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.
PRICED 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. It is an estimate: the real call reserves the same figure and is charged its actual cost, usually within a few percent of it. Nothing runs and nothing is charged on a dry run, except that reading a social post link uses its one lookup.
ASYNC. 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.
PEOPLE AND LIKENESS. A photo of a real person needs a paid plan, and sending one is your confirmation that you have that person''s permission (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: '`child_safety_blocked`: the request would put a child into sexual or sexualised content, so nothing ran (the only request Hermoso refuses itself; the video model''s own refusals are passed through in plain words). `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: []
servers:
- url: https://app.hermoso.ai
components:
schemas:
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.
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).
RecastQuote:
type: object
properties:
object:
type: string
enum:
- quote
credits:
type: number
nullable: true
description: 'The whole price: the sum of `steps`. An estimate: a real call reserves it and is charged its actual cost, usually within a few percent. 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'
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.'
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.'
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.
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.'
unknown_arguments:
type: array
items:
type: object
properties:
name:
type: string
description: The key that was sent.
did_you_mean:
type: string
nullable: true
description: An argument THIS tool declares that the key most likely meant, or null.
description: 'On `invalid_arguments` from POST /v1/tools/{name}: each key the tool does not take, with the argument it most likely meant. Nothing was run.'
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
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).
x-refined-from:
- app_hermoso_ai.json
- app_hermoso_ai_rest.json