curl -X POST "https://api.hooked.so/v1/character/create" \
-H "x-api-key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{ "name": "Mia", "description": "Late 20s, red curly hair, denim jacket", "voiceProfile": "Warm, slightly husky, speaks fast", "generatePortrait": true }'
const response = await fetch("https://api.hooked.so/v1/character/create", {
method: "POST",
headers: { "x-api-key": process.env.HOOKED_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
name: "Mia",
description: "Late 20s, red curly hair, denim jacket",
voiceProfile: "Warm, slightly husky, speaks fast",
generatePortrait: true,
}),
});
const { data: character } = await response.json();
// 202: poll GET /v1/character/{id} until imageStatus is COMPLETED
console.log(character.id, character.imageStatus);
import os
import requests
response = requests.post(
"https://api.hooked.so/v1/character/create",
headers={"x-api-key": os.environ["HOOKED_API_KEY"]},
json={
"name": "Mia",
"description": "Late 20s, red curly hair, denim jacket",
"voiceProfile": "Warm, slightly husky, speaks fast",
"generatePortrait": True,
},
)
character = response.json()["data"]
print(character["id"], character["imageStatus"])
{
"success": true,
"message": "Character created; its photo is processing",
"data": {
"id": "cm4xa1b2c0001cd34ef56gh78",
"name": "Mia",
"description": "Late 20s, red curly hair, denim jacket",
"voiceProfile": "Warm, slightly husky, speaks fast",
"presetId": "cinematic",
"imageUrl": null,
"imageStatus": "PROCESSING",
"createdAt": "2026-09-30T18:00:00.000Z",
"creditsCharged": 4
}
}
{
"success": false,
"message": "description: Required to generate a portrait"
}
Create Character
Save a Cinematic character, with a photo from your library, a URL or a generated portrait
curl -X POST "https://api.hooked.so/v1/character/create" \
-H "x-api-key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{ "name": "Mia", "description": "Late 20s, red curly hair, denim jacket", "voiceProfile": "Warm, slightly husky, speaks fast", "generatePortrait": true }'
const response = await fetch("https://api.hooked.so/v1/character/create", {
method: "POST",
headers: { "x-api-key": process.env.HOOKED_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
name: "Mia",
description: "Late 20s, red curly hair, denim jacket",
voiceProfile: "Warm, slightly husky, speaks fast",
generatePortrait: true,
}),
});
const { data: character } = await response.json();
// 202: poll GET /v1/character/{id} until imageStatus is COMPLETED
console.log(character.id, character.imageStatus);
import os
import requests
response = requests.post(
"https://api.hooked.so/v1/character/create",
headers={"x-api-key": os.environ["HOOKED_API_KEY"]},
json={
"name": "Mia",
"description": "Late 20s, red curly hair, denim jacket",
"voiceProfile": "Warm, slightly husky, speaks fast",
"generatePortrait": True,
},
)
character = response.json()["data"]
print(character["id"], character["imageStatus"])
{
"success": true,
"message": "Character created; its photo is processing",
"data": {
"id": "cm4xa1b2c0001cd34ef56gh78",
"name": "Mia",
"description": "Late 20s, red curly hair, denim jacket",
"voiceProfile": "Warm, slightly husky, speaks fast",
"presetId": "cinematic",
"imageUrl": null,
"imageStatus": "PROCESSING",
"createdAt": "2026-09-30T18:00:00.000Z",
"creditsCharged": 4
}
}
{
"success": false,
"message": "description: Required to generate a portrait"
}
Overview
Saves a character to your team, the same as Studio → Characters → New character in the dashboard. Pass itsid in characterIds of Create Cinematic Video to cast it.
A character has a name (how the story calls it, up to 60 characters), a visual description, a voiceProfile (how it sounds, in words) and a presetId (the visual style of a generated portrait, cinematic by default; any id of the visual-styles catalog or your own custom-<id>). Only name is required.
The photo
Send at most one of these. Without any, the character has no photo, and Cinematic draws a portrait for it when it is cast.| Field | What happens | Cost |
|---|---|---|
mediaId | An image of your media library, already COMPLETED, becomes the photo. | Free |
imageUrl | A public https JPEG, PNG, WEBP or GIF is imported into your library after the response (like Import Media). | Free, counts against your storage |
generatePortrait: true | A portrait is generated from description in the character’s style, after the response. Pick the model with imageModel (an image-models catalog id, gpt_image_2 by default). | One image at that model: 4 credits with the default |
imageUrl or generatePortrait the answer is 202 and imageStatus is PROCESSING. Poll Get Character until it is COMPLETED (imageUrl is set) or FAILED. A failed portrait gives its credits back.
402 missing_credentials, nothing saved), and a managed team needs a plan and the credits for one image (otherwise 402, nothing saved).curl -X POST "https://api.hooked.so/v1/character/create" \
-H "x-api-key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{ "name": "Mia", "description": "Late 20s, red curly hair, denim jacket", "voiceProfile": "Warm, slightly husky, speaks fast", "generatePortrait": true }'
const response = await fetch("https://api.hooked.so/v1/character/create", {
method: "POST",
headers: { "x-api-key": process.env.HOOKED_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({
name: "Mia",
description: "Late 20s, red curly hair, denim jacket",
voiceProfile: "Warm, slightly husky, speaks fast",
generatePortrait: true,
}),
});
const { data: character } = await response.json();
// 202: poll GET /v1/character/{id} until imageStatus is COMPLETED
console.log(character.id, character.imageStatus);
import os
import requests
response = requests.post(
"https://api.hooked.so/v1/character/create",
headers={"x-api-key": os.environ["HOOKED_API_KEY"]},
json={
"name": "Mia",
"description": "Late 20s, red curly hair, denim jacket",
"voiceProfile": "Warm, slightly husky, speaks fast",
"generatePortrait": True,
},
)
character = response.json()["data"]
print(character["id"], character["imageStatus"])
{
"success": true,
"message": "Character created; its photo is processing",
"data": {
"id": "cm4xa1b2c0001cd34ef56gh78",
"name": "Mia",
"description": "Late 20s, red curly hair, denim jacket",
"voiceProfile": "Warm, slightly husky, speaks fast",
"presetId": "cinematic",
"imageUrl": null,
"imageStatus": "PROCESSING",
"createdAt": "2026-09-30T18:00:00.000Z",
"creditsCharged": 4
}
}
{
"success": false,
"message": "description: Required to generate a portrait"
}
Errors
| Status | Message | Cause |
|---|---|---|
| 400 | Invalid JSON body | The body is not JSON. |
| 400 | name: Required | No name, or an empty one. Longer than 60 characters is refused too. |
| 400 | presetId: Invalid preset "…" | Not a visual-styles id, or another team’s custom style. |
| 400 | mediaId: Pass only one of mediaId, imageUrl or generatePortrait | More than one photo source. |
| 400 | mediaId: Image "…" not found in your library | Not an image of your library (another team’s, a video, a missing or malformed id). |
| 400 | mediaId: Media "…" is not ready (status PROCESSING) | The image is still being imported or uploaded. |
| 400 | imageUrl: Must be a public https URL | Not a URL, not https, or a local or private address. |
| 400 | description: Required to generate a portrait | generatePortrait without a description. |
| 400 | imageModel: Invalid image model "…" | Not an image-models id. imageModel without generatePortrait is refused too. |
| 401 | code: "not_authenticated" | Missing or invalid x-api-key. |
| 402 | missing_credentials, subscription_required or INSUFFICIENT_CREDITS | Only with generatePortrait: no OpenRouter key (BYOK), no plan, or not enough credits for the portrait. Nothing was saved. |
| 403 | code: "entitlement_required" | Your team does not have the Hooked app product. |
imageStatus becomes FAILED. Set another photo with Update Character.Authorizations
Headers
Makes the request safe to retry. 1-255 printable ASCII characters, one per operation (your job id, or a UUID you store), reused on every retry. Within 24 hours the same key with the same body answers with the first response and the header Idempotent-Replayed: true, without running or charging again. Keys are scoped to your team and the endpoint. 5xx answers and refusals before anything ran (401, 402, 403, 409, 429) are not kept. See Idempotency.
1 - 255^[\x20-\x7E]+$Body
How the story calls the character (and its @mention).
60What the character looks like. Required with generatePortrait.
2000How the character sounds, in words.
500Visual style of a generated portrait: a visual-styles catalog id or your custom-<id>. Default cinematic.
An image of your media library (from List Media or an import), COMPLETED. Free.
A public https JPEG, PNG, WEBP or GIF. It is imported into your library after the response (free, counted against your storage); imageStatus is PROCESSING until then.
true generates a portrait from description in the character's style, after the response. Charged one image at imageModel (4 credits with the default); given back if it fails.
Image model for generatePortrait (an image-models catalog id). Default gpt_image_2.