Make videos from your scripts, automation tools and AI agents
The GhostShorts API creates the same videos as the app, from your own scripts, automation tools and AI agents. API keys use your plan's videos and credits; there is no separate API plan. Create a key at Settings, API keys. Keys are shown once, so store yours safely. Working with an AI agent? Copy the guide below and paste it into the chat: it has the auth, the flow, every template and every error code, generated from the API itself.
Base URL https://api.ghostshorts.com. Send your key as Authorization: Bearer gs_live_....
# 1. Create a key at https://ghostshorts.com/app/settings/api-keys, then:
export GS_KEY=gs_live_...
curl https://api.ghostshorts.com/v1/me -H "Authorization: Bearer $GS_KEY"# 2. Create a video (answers 202 with an id; the video is made in the background)
curl -X POST https://api.ghostshorts.com/v1/videos \
-H "Authorization: Bearer $GS_KEY" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: my-first-video-001' \
-d '{"template":"article_video","inputs":{"source":{"kind":"url","url":"https://example.com/my-post"}}}'# 3. Check on it (or pass webhookUrl when creating). downloadUrl appears when status is "completed".
curl https://api.ghostshorts.com/v1/videos/VIDEO_ID -H "Authorization: Bearer $GS_KEY"A video usually finishes in about a minute. Poll every 20 seconds or so, or pass webhookUrl when creating. The downloadUrl needs no key and works for 7 days, so anyone you share it with can download the file.
Each template's full input schema and defaults are at api.ghostshorts.com/v1/templates, and the voices, backgrounds and music to pick from at api.ghostshorts.com/v1/options. Both are public: no key needed. Check a cost before you spend it with POST /v1/estimate.
Every video is a 1080x1920 (9:16) MP4. Each template below has its own plain-text guide with the full input list, a complete example request and response, credits and errors.
article_video)quiz_video)reddit_story)fake_text_message)article_video# GhostShorts API: Article to Video (article_video)
Turn an article, blog post or newsletter (URL or pasted text) into a narrated 9:16 short with word-synced captions over a looping background.
Output: one 1080x1920 (9:16) MP4.
This guide: https://api.ghostshorts.com/v1/templates/article_video.txt All templates: https://api.ghostshorts.com/v1/llms.txt
## Base URL and auth
Base URL: https://api.ghostshorts.com
Auth: send your API key on every request as the header "Authorization: Bearer gs_live_..."
Create a key at https://ghostshorts.com/app/settings/api-keys (the key is shown once). Keys use your plan's videos and credits; there is no separate API plan. Plans: https://ghostshorts.com/pricing
## Flow
1. Optional and free: POST /v1/estimate with the same {"template", "inputs"} body returns the cost without spending anything.
2. Optional and free (0 credits): POST /v1/scripts with {"template": "article_video", "inputs": {...}} writes the script and returns it with a "projectId". Check it, then send that projectId on POST /v1/videos so it is reused instead of written again.
3. POST /v1/videos with {"template": "article_video", "inputs": {...}} (optional "webhookUrl", optional "projectId" from the free step). It answers 202 with the video object; keep its "id".
4. Poll GET /v1/videos/{id} every 10-20 s until "status" is "completed" or "failed" (or "cancelled" if you cancelled it). In between you may see queued, preparing_media, generating_script, narrating and rendering. A queued video can be cancelled with POST /v1/videos/{id}/cancel.
5. On "completed", "downloadUrl" is a public link (no key needed) valid for 7 days. Give it to the user exactly as returned. On "failed", "error" is { code, message }.
Webhook (optional): pass "webhookUrl" (public https) and we POST { id, type, createdAt, data } to it, where type is video.completed or video.failed and data is the video object. Deliveries carry the header X-GhostShorts-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with webhooks.signingSecret from GET /v1/me minus its whsec_ prefix. Verification code: https://api.ghostshorts.com/v1/llms.txt
Idempotency: send an "Idempotency-Key" header (any unique string up to 255 characters) on every POST. Retrying with the same key and body within 24 hours replays the first answer, so nothing is created or charged twice.
## Inputs (the "inputs" object for template "article_video")
Fields marked optional may be omitted; the default shown is applied. Unknown fields are refused with 400 INVALID_INPUT.
- source (object, required, one of 2 shapes below): Where the article comes from: a URL we fetch, or the text itself.
when source.kind is url:
- source.kind (string, required, always url)
- source.url (string, required, URL): Public article, blog or newsletter URL.
when source.kind is text:
- source.kind (string, required, always text)
- source.text (string, required, 1-60000 chars): Pasted article text. The script step needs 90 to 4000 words.
- length (string, optional, default standard, one of [short, standard, deep]): short ~30s, standard ~60s, deep ~90s.
- tone (string, optional, default explainer, one of [explainer, punchy, authority])
- voiceId (string, optional, default onwK4e9ZLuTAKqWW03F9, one of [onwK4e9ZLuTAKqWW03F9, JBFqnCBsd6RMkjVDRZzb, EXAVITQu4vr4xnSDxMaL, pNInz6obpgDQGcFmaJgB, LcfcDJNUP1GQjkzn1xUU, TxGEqnHWrfWFTfGW9XjX]): Narrator voice. See GET /v1/options voices tagged article_video.
- captionPresetId (string, optional, one of 17 ids listed with names at GET /v1/options): Caption style. See GET /v1/options captionPresets.
- silent (boolean, optional, default false): Render with no narration. Refused unless explicitly true.
## Example request
Set GS_KEY to your key first (export GS_KEY=gs_live_...).
curl -X POST https://api.ghostshorts.com/v1/videos \
-H "Authorization: Bearer $GS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: article-video-example-001" \
--data-binary @- <<'JSON'
{
"template": "article_video",
"inputs": {
"source": {
"kind": "url",
"url": "https://paulgraham.com/google.html"
},
"length": "standard",
"tone": "explainer"
}
}
JSON
Response: 202 Accepted, header Location: /v1/videos/cmuha1rt0000108l4a7kq2x9d
{
"id": "cmuha1rt0000108l4a7kq2x9d",
"template": "article_video",
"status": "queued",
"stage": "Queued",
"progress": null,
"projectId": null,
"exportId": null,
"downloadUrl": null,
"downloadExpiresAt": null,
"error": null,
"creditsCharged": null,
"estimatedSecondsRemaining": 90,
"createdAt": "2026-09-24T15:00:00.000Z",
"completedAt": null,
"cutAtMs": null,
"notices": [
"This script is an AI first draft of your article. Read it against your source before you publish; check any numbers, names and dates, and keep the claims yours.",
"Works best on your own posts and any public page. Paywalled or login-only pages will not import; paste the text instead."
]
}
Once completed, GET /v1/videos/cmuha1rt0000108l4a7kq2x9d answers (example values):
{
"id": "cmuha1rt0000108l4a7kq2x9d",
"template": "article_video",
"status": "completed",
"stage": "Done",
"progress": 100,
"projectId": "cmuha1rt9000208l4f3bn6c1e",
"exportId": "cmuha2b4x000308l4d9ye7h0s",
"downloadUrl": "https://api.ghostshorts.com/v1/d/Zq3hR8uXk2mV7pLw4sNf9cJt1bYe6aGd0oKi5HrQyUE",
"downloadExpiresAt": "2026-10-01T15:01:04.000Z",
"error": null,
"creditsCharged": {
"narration": 0,
"render": 0,
"videos": 1
},
"estimatedSecondsRemaining": null,
"createdAt": "2026-09-24T15:00:00.000Z",
"completedAt": "2026-09-24T15:01:04.000Z",
"cutAtMs": null,
"notices": [
"This script is an AI first draft of your article. Read it against your source before you publish; check any numbers, names and dates, and keep the claims yours.",
"Works best on your own posts and any public page. Paywalled or login-only pages will not import; paste the text instead."
]
}
## Credits
- narration: 1 credit per minute of narration (legacy plans); included on current plans within fair use
- render: 1 video from the monthly allowance (current plans); export counter on legacy plans
Plan and balance are checked before anything runs, so a 402 refusal costs nothing. creditsCharged on the video says what was actually charged.
## Notes
- The script is condensed from the source and must stay faithful to it: every figure, quote and named person in the script must appear in the source.
- Source text must be 90 to 4000 words after extraction.
- Background footage is chosen server-side.
## Errors
Errors are JSON with Content-Type application/problem+json: { type, title, status, detail, code }. Branch on "code". 400 INVALID_INPUT adds "errors": [{ path, message, code }]. Every 402 adds "upsell" and "plansUrl" (https://ghostshorts.com/pricing). A 429 carries Retry-After. Full reference: https://ghostshorts.com/developers/errors
- 401 MISSING_API_KEY: No Authorization: Bearer header was sent.
- 401 INVALID_API_KEY: The key is unknown or has been revoked. Create a new one in Settings.
- 402 SUBSCRIPTION_REQUIRED: This step needs an active plan. The response carries plansUrl.
- 402 INSUFFICIENT_CREDITS: Not enough credits for this step. Top up or wait for your plan to renew.
- 402 VIDEO_ALLOWANCE_EXHAUSTED: This month's video allowance is used up.
- 402 SOFT_CAP_REACHED: A fair-use limit for this period was reached.
- 400 INVALID_INPUT: The body does not match the schema. The errors array names each field. Also sent as 413 for a body over 2 MB.
- 400 INVALID_WEBHOOK_URL: webhookUrl must be a public https URL.
- 404 NOT_FOUND: No such endpoint, or no such video, project or download link on this account.
- 409 IDEMPOTENCY_IN_PROGRESS: A request with this Idempotency-Key is still running. Retry after Retry-After.
- 422 IDEMPOTENCY_KEY_REUSED: This Idempotency-Key was already used with a different body. Use a new key for a new request.
- 429 RATE_LIMITED: Too many requests. Wait for Retry-After seconds.
- 429 CONCURRENCY_LIMIT: Three videos are already in progress on this account. Retry after one finishes.
- 503 SERVICE_UNAVAILABLE: The video queue is briefly unavailable. Nothing was charged; retry shortly.
- 400 NARRATION_REQUIRED: The video cannot be rendered without narration.
- 400 SAFETY_REJECTED: The content was refused by our safety checks. Change the topic or text.
A video that fails has "status": "failed" and "error": { code, message }; error.message always says what happened. Codes for this template:
- BACKGROUND_UNAVAILABLE: The background video or category you picked has no usable footage. Pick one from GET /v1/options.
- SCRIPT_FAILED: The script could not be written.
- INGEST_FAILED: The article at your URL could not be read. Paste the text instead.
- SOURCE_TOO_SHORT: The article is too short to make a script from.
- NARRATION_FAILED: The narration could not be recorded.
- RENDER_FAILED: The render failed. Create the video again.
- INTERRUPTED: Processing was interrupted. Create the video again; contact support if narration was charged.
- CANCELLED: You cancelled the video while it was queued.
## Timing
Renders usually take about a minute; own-media imports add about 30 seconds the first time; the first attempt occasionally takes several minutes before the automatic retry succeeds.
Machine-readable: https://api.ghostshorts.com/v1/openapi.json Templates: https://api.ghostshorts.com/v1/templates Options (voices, backgrounds, music, caption presets): https://api.ghostshorts.com/v1/options
An article, blog post or newsletter (URL or pasted text) turned into a narrated short with captions.
quiz_video# GhostShorts API: AI Quiz Video (quiz_video)
A multiple-choice quiz short: question card, countdown, answer reveal with a fact line, narrated, over a looping background.
Output: one 1080x1920 (9:16) MP4.
This guide: https://api.ghostshorts.com/v1/templates/quiz_video.txt All templates: https://api.ghostshorts.com/v1/llms.txt
## Base URL and auth
Base URL: https://api.ghostshorts.com
Auth: send your API key on every request as the header "Authorization: Bearer gs_live_..."
Create a key at https://ghostshorts.com/app/settings/api-keys (the key is shown once). Keys use your plan's videos and credits; there is no separate API plan. Plans: https://ghostshorts.com/pricing
## Flow
1. Optional and free: POST /v1/estimate with the same {"template", "inputs"} body returns the cost without spending anything.
2. Optional and free (0 credits): POST /v1/scripts with {"template": "quiz_video", "inputs": {...}} writes the question pack and returns it with a "projectId". Check it, then send that projectId on POST /v1/videos so it is reused instead of written again.
3. POST /v1/videos with {"template": "quiz_video", "inputs": {...}} (optional "webhookUrl", optional "projectId" from the free step). It answers 202 with the video object; keep its "id".
4. Poll GET /v1/videos/{id} every 10-20 s until "status" is "completed" or "failed" (or "cancelled" if you cancelled it). In between you may see queued, preparing_media, generating_script, narrating and rendering. A queued video can be cancelled with POST /v1/videos/{id}/cancel.
5. On "completed", "downloadUrl" is a public link (no key needed) valid for 7 days. Give it to the user exactly as returned. On "failed", "error" is { code, message }.
Webhook (optional): pass "webhookUrl" (public https) and we POST { id, type, createdAt, data } to it, where type is video.completed or video.failed and data is the video object. Deliveries carry the header X-GhostShorts-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with webhooks.signingSecret from GET /v1/me minus its whsec_ prefix. Verification code: https://api.ghostshorts.com/v1/llms.txt
Idempotency: send an "Idempotency-Key" header (any unique string up to 255 characters) on every POST. Retrying with the same key and body within 24 hours replays the first answer, so nothing is created or charged twice.
## Inputs (the "inputs" object for template "quiz_video")
Fields marked optional may be omitted; the default shown is applied. Unknown fields are refused with 400 INVALID_INPUT.
- topic (string, required, 2-200 chars): What the quiz is about, e.g. "the Roman Empire".
- difficulty (string, optional, default medium, one of [easy, medium, hard])
- questionCount (number, optional, default 5, one of [5, 7, 10])
- categoryId (string, optional, one of [general-knowledge, geography, history, science, movies-tv, music, sports, food-drink, video-games, logos-brands]): Optional steer for the generator.
- videoTitle (string, optional, 1-48 chars): Title on the intro card.
- voiceId (string, optional, default LcfcDJNUP1GQjkzn1xUU, one of [LcfcDJNUP1GQjkzn1xUU, EXAVITQu4vr4xnSDxMaL, pNInz6obpgDQGcFmaJgB, TxGEqnHWrfWFTfGW9XjX, JBFqnCBsd6RMkjVDRZzb, onwK4e9ZLuTAKqWW03F9]): Narrator voice. See GET /v1/options voices tagged quiz_video.
- theme (string, optional, default neon, one of [neon, sunset, classic])
- font (string, optional, default anton, one of [anton, bangers, bebas-neue, poppins])
- countdownSeconds (number, optional, default 5, one of [3, 5, 7])
- captionsEnabled (boolean, optional, default false)
- silent (boolean, optional, default false): Render with no narration. Refused unless explicitly true.
## Example request
Set GS_KEY to your key first (export GS_KEY=gs_live_...).
curl -X POST https://api.ghostshorts.com/v1/videos \
-H "Authorization: Bearer $GS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: quiz-video-example-001" \
--data-binary @- <<'JSON'
{
"template": "quiz_video",
"inputs": {
"topic": "The Roman Empire",
"difficulty": "medium",
"questionCount": 5,
"videoTitle": "Roman Empire Quiz",
"theme": "neon"
}
}
JSON
Response: 202 Accepted, header Location: /v1/videos/cmuhb7qk2000408l4c1dv5n8p
{
"id": "cmuhb7qk2000408l4c1dv5n8p",
"template": "quiz_video",
"status": "queued",
"stage": "Queued",
"progress": null,
"projectId": null,
"exportId": null,
"downloadUrl": null,
"downloadExpiresAt": null,
"error": null,
"creditsCharged": null,
"estimatedSecondsRemaining": 90,
"createdAt": "2026-09-24T15:00:00.000Z",
"completedAt": null,
"cutAtMs": null,
"notices": [
"Answers are AI-generated. Check the facts before you publish; you are the one whose channel says it."
]
}
Once completed, GET /v1/videos/cmuhb7qk2000408l4c1dv5n8p answers (example values):
{
"id": "cmuhb7qk2000408l4c1dv5n8p",
"template": "quiz_video",
"status": "completed",
"stage": "Done",
"progress": 100,
"projectId": "cmuhb7qkb000508l4h6tz2w4r",
"exportId": "cmuhb8f1m000608l4e0xs9k3u",
"downloadUrl": "https://api.ghostshorts.com/v1/d/pT6vJ1cW9xE4kQ7nB2yH5rL8mD3fA0sGuZiOeVqNwKj",
"downloadExpiresAt": "2026-10-01T15:01:12.000Z",
"error": null,
"creditsCharged": {
"narration": 0,
"render": 0,
"videos": 1
},
"estimatedSecondsRemaining": null,
"createdAt": "2026-09-24T15:00:00.000Z",
"completedAt": "2026-09-24T15:01:12.000Z",
"cutAtMs": null,
"notices": [
"Answers are AI-generated. Check the facts before you publish; you are the one whose channel says it."
]
}
## Credits
- narration: 1 credit per minute of narration across the whole pack (legacy plans); included on current plans within fair use
- render: 1 video from the monthly allowance (current plans); export counter on legacy plans
Plan and balance are checked before anything runs, so a 402 refusal costs nothing. creditsCharged on the video says what was actually charged.
## Notes
- Get the pack free first with POST /v1/scripts (template quiz_video), check the answers, then pass its projectId to POST /v1/videos.
- Questions are AI-generated. Check answers before publishing, especially dates and award years.
- Topics that target a group of people are refused.
## Errors
Errors are JSON with Content-Type application/problem+json: { type, title, status, detail, code }. Branch on "code". 400 INVALID_INPUT adds "errors": [{ path, message, code }]. Every 402 adds "upsell" and "plansUrl" (https://ghostshorts.com/pricing). A 429 carries Retry-After. Full reference: https://ghostshorts.com/developers/errors
- 401 MISSING_API_KEY: No Authorization: Bearer header was sent.
- 401 INVALID_API_KEY: The key is unknown or has been revoked. Create a new one in Settings.
- 402 SUBSCRIPTION_REQUIRED: This step needs an active plan. The response carries plansUrl.
- 402 INSUFFICIENT_CREDITS: Not enough credits for this step. Top up or wait for your plan to renew.
- 402 VIDEO_ALLOWANCE_EXHAUSTED: This month's video allowance is used up.
- 402 SOFT_CAP_REACHED: A fair-use limit for this period was reached.
- 400 INVALID_INPUT: The body does not match the schema. The errors array names each field. Also sent as 413 for a body over 2 MB.
- 400 INVALID_WEBHOOK_URL: webhookUrl must be a public https URL.
- 404 NOT_FOUND: No such endpoint, or no such video, project or download link on this account.
- 409 IDEMPOTENCY_IN_PROGRESS: A request with this Idempotency-Key is still running. Retry after Retry-After.
- 422 IDEMPOTENCY_KEY_REUSED: This Idempotency-Key was already used with a different body. Use a new key for a new request.
- 429 RATE_LIMITED: Too many requests. Wait for Retry-After seconds.
- 429 CONCURRENCY_LIMIT: Three videos are already in progress on this account. Retry after one finishes.
- 503 SERVICE_UNAVAILABLE: The video queue is briefly unavailable. Nothing was charged; retry shortly.
- 400 NARRATION_REQUIRED: The video cannot be rendered without narration.
- 400 SAFETY_REJECTED: The content was refused by our safety checks. Change the topic or text.
A video that fails has "status": "failed" and "error": { code, message }; error.message always says what happened. Codes for this template:
- BACKGROUND_UNAVAILABLE: The background video or category you picked has no usable footage. Pick one from GET /v1/options.
- PACK_FAILED: The quiz questions could not be written.
- NARRATION_FAILED: The narration could not be recorded.
- RENDER_FAILED: The render failed. Create the video again.
- INTERRUPTED: Processing was interrupted. Create the video again; contact support if narration was charged.
- CANCELLED: You cancelled the video while it was queued.
## Timing
Renders usually take about a minute; own-media imports add about 30 seconds the first time; the first attempt occasionally takes several minutes before the automatic retry succeeds.
Machine-readable: https://api.ghostshorts.com/v1/openapi.json Templates: https://api.ghostshorts.com/v1/templates Options (voices, backgrounds, music, caption presets): https://api.ghostshorts.com/v1/options
A multiple-choice quiz short: question, countdown, answer reveal with a fact line, narrated.
reddit_story# GhostShorts API: Reddit Story (reddit_story)
Your own Reddit-style story (title plus 800 to 5000 characters of text, written by you: nothing is generated for it), narrated by an AI voice with word-synced captions over gameplay footage, with a Reddit title card. Narration is mandatory: there is no silent option. Free first: POST /v1/previews returns a ~10 second narration teaser for 0 credits.
Output: one 1080x1920 (9:16) MP4.
This guide: https://api.ghostshorts.com/v1/templates/reddit_story.txt All templates: https://api.ghostshorts.com/v1/llms.txt
## Base URL and auth
Base URL: https://api.ghostshorts.com
Auth: send your API key on every request as the header "Authorization: Bearer gs_live_..."
Create a key at https://ghostshorts.com/app/settings/api-keys (the key is shown once). Keys use your plan's videos and credits; there is no separate API plan. Plans: https://ghostshorts.com/pricing
## Flow
1. Optional and free: POST /v1/estimate with the same {"template", "inputs"} body returns the cost without spending anything.
2. Optional and free (0 credits): POST /v1/previews with {"template": "reddit_story", "inputs": {"title": "...", "text": "..."}} returns a ~10 second narration teaser ("audioUrl") and a "projectId". You may send that projectId on POST /v1/videos; the full story is always narrated there (the teaser is never reused).
3. POST /v1/videos with {"template": "reddit_story", "inputs": {...}} (optional "webhookUrl", optional "projectId" from the free step). It answers 202 with the video object; keep its "id".
4. Poll GET /v1/videos/{id} every 10-20 s until "status" is "completed" or "failed" (or "cancelled" if you cancelled it). In between you may see queued, preparing_media, generating_script, narrating and rendering. A queued video can be cancelled with POST /v1/videos/{id}/cancel.
5. On "completed", "downloadUrl" is a public link (no key needed) valid for 7 days. Give it to the user exactly as returned. On "failed", "error" is { code, message }.
Webhook (optional): pass "webhookUrl" (public https) and we POST { id, type, createdAt, data } to it, where type is video.completed or video.failed and data is the video object. Deliveries carry the header X-GhostShorts-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with webhooks.signingSecret from GET /v1/me minus its whsec_ prefix. Verification code: https://api.ghostshorts.com/v1/llms.txt
Idempotency: send an "Idempotency-Key" header (any unique string up to 255 characters) on every POST. Retrying with the same key and body within 24 hours replays the first answer, so nothing is created or charged twice.
## Inputs (the "inputs" object for template "reddit_story")
Fields marked optional may be omitted; the default shown is applied. Unknown fields are refused with 400 INVALID_INPUT.
- title (string, required, 1-200 chars): Post title, narrated first and shown on the title card.
- text (string, required, 800-5000 chars): The story body, 800 to 5000 characters.
- author (string, optional, default Anonymous, max 32 chars)
- voiceId (string, optional, default pNInz6obpgDQGcFmaJgB, one of 47 ids listed with names at GET /v1/options): Narrator voice. See GET /v1/options voices tagged reddit_story.
- language (string, optional, default en, 2-5 chars): Narration language (ISO 639-1).
- backgroundVideoAssetId (string, optional): A background video id from GET /v1/options backgrounds.assets. Mutually exclusive with backgroundCategory.
- backgroundCategory (string, optional, one of [Minecraft, Mario Kart, GTA V, Subway Surfers, Satisfying, Backrooms, Roblox]): Pick any background from this category. Mutually exclusive with backgroundVideoAssetId.
- backgroundVideoUrl (string, optional, 1-2048 chars): Your own footage by public https URL. Video is converted for rendering (H.264 baseline, 1080x1920, cropped to fill, silent); files up to 300 MB / 10 min. Mutually exclusive with backgroundVideoAssetId and backgroundCategory.
- backgroundAudioAssetId (string, optional): Background music id from GET /v1/options music.
- backgroundAudioUrl (string, optional, 1-2048 chars): Your own track by public https URL. Converted to MP3 for rendering; files up to 30 MB / 10 min. Mutually exclusive with backgroundAudioAssetId.
- backgroundAudioVolume (integer, optional, default 30, 0-100)
- captionPresetId (string, optional, default hormozi, one of 17 ids listed with names at GET /v1/options)
- captionPosition (string, optional, default middle, one of [top, middle, bottom])
- captionsEnabled (boolean, optional, default true)
- titleCardTheme (string, optional, default dark, one of [dark, light])
- voiceSpeed (number, optional, default 1, 0.85-1.25)
- stability (number, optional, default 50, 0-100)
- similarity (number, optional, default 75, 0-100)
- pitch (number, optional, default 1, 0.5-2)
## Example request
Set GS_KEY to your key first (export GS_KEY=gs_live_...).
curl -X POST https://api.ghostshorts.com/v1/videos \
-H "Authorization: Bearer $GS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reddit-story-example-001" \
--data-binary @- <<'JSON'
{
"template": "reddit_story",
"inputs": {
"title": "AITA for refusing to switch seats so a couple could sit together on a 6 hour flight?",
"text": "I (29F) booked this flight three months ago and paid extra for an exit row window seat, because I am tall and my knees hate me on long flights. Last week I boarded, sat down, and a few minutes later a couple stopped at my row. The man asked if I would swap with his girlfriend so they could sit together. Her seat was a middle seat eleven rows back, right next to the bathrooms. I said sorry, but no, I paid for this seat on purpose. He said it was only six hours and that I was being selfish. His girlfriend did not say a word, she just stared at me. A flight attendant came over, checked our boarding passes, and told them the seats were assigned and I did not have to move. They sat apart, and he kept turning around to glare at me for most of the flight. When we landed, the woman next to me said I should have just moved because it would have been the nice thing to do. Now my sister, who heard the whole story over dinner, says I was technically right but still kind of a jerk. So, AITA?",
"author": "throwaway_flyer",
"backgroundCategory": "Minecraft",
"captionPosition": "middle"
}
}
JSON
Response: 202 Accepted, header Location: /v1/videos/cmuhc3wd5000708l4b8gm1q6t
{
"id": "cmuhc3wd5000708l4b8gm1q6t",
"template": "reddit_story",
"status": "queued",
"stage": "Queued",
"progress": null,
"projectId": null,
"exportId": null,
"downloadUrl": null,
"downloadExpiresAt": null,
"error": null,
"creditsCharged": null,
"estimatedSecondsRemaining": 90,
"createdAt": "2026-09-24T15:00:00.000Z",
"completedAt": null,
"cutAtMs": null
}
Once completed, GET /v1/videos/cmuhc3wd5000708l4b8gm1q6t answers (example values):
{
"id": "cmuhc3wd5000708l4b8gm1q6t",
"template": "reddit_story",
"status": "completed",
"stage": "Done",
"progress": 100,
"projectId": "cmuhc3wde000808l4g2rk7y5v",
"exportId": "cmuhc4n9h000908l4a5fp3j0w",
"downloadUrl": "https://api.ghostshorts.com/v1/d/Hn4sX8bQ2vK6yT0rM3wC7gF1jL5dP9aZeUoIiBtNcRq",
"downloadExpiresAt": "2026-10-01T15:01:31.000Z",
"error": null,
"creditsCharged": {
"narration": 0,
"render": 0,
"videos": 1
},
"estimatedSecondsRemaining": null,
"createdAt": "2026-09-24T15:00:00.000Z",
"completedAt": "2026-09-24T15:01:31.000Z",
"cutAtMs": null
}
## Credits
- narration: 1 credit per minute of narration, estimated at 15 characters per second (legacy plans); included on current plans within fair use
- render: 1 video from the monthly allowance (current plans); export counter on legacy plans
Plan and balance are checked before anything runs, so a 402 refusal costs nothing. creditsCharged on the video says what was actually charged.
## Notes
- The story text is yours; nothing is generated for it.
- With no background field set, the Subway Surfers default background is used.
- Own media by URL is fetched before anything is charged. The same URL is reused for 30 days without re-fetching; up to 20 new URLs per day. Failures: MEDIA_FETCH_FAILED, MEDIA_INVALID, MEDIA_TOO_LONG, MEDIA_QUOTA_EXCEEDED.
- Narration always runs on POST /v1/videos, even for a projectId from POST /v1/previews: the teaser is never reused as the narration.
## Errors
Errors are JSON with Content-Type application/problem+json: { type, title, status, detail, code }. Branch on "code". 400 INVALID_INPUT adds "errors": [{ path, message, code }]. Every 402 adds "upsell" and "plansUrl" (https://ghostshorts.com/pricing). A 429 carries Retry-After. Full reference: https://ghostshorts.com/developers/errors
- 401 MISSING_API_KEY: No Authorization: Bearer header was sent.
- 401 INVALID_API_KEY: The key is unknown or has been revoked. Create a new one in Settings.
- 402 SUBSCRIPTION_REQUIRED: This step needs an active plan. The response carries plansUrl.
- 402 INSUFFICIENT_CREDITS: Not enough credits for this step. Top up or wait for your plan to renew.
- 402 VIDEO_ALLOWANCE_EXHAUSTED: This month's video allowance is used up.
- 402 SOFT_CAP_REACHED: A fair-use limit for this period was reached.
- 400 INVALID_INPUT: The body does not match the schema. The errors array names each field. Also sent as 413 for a body over 2 MB.
- 400 INVALID_WEBHOOK_URL: webhookUrl must be a public https URL.
- 404 NOT_FOUND: No such endpoint, or no such video, project or download link on this account.
- 409 IDEMPOTENCY_IN_PROGRESS: A request with this Idempotency-Key is still running. Retry after Retry-After.
- 422 IDEMPOTENCY_KEY_REUSED: This Idempotency-Key was already used with a different body. Use a new key for a new request.
- 429 RATE_LIMITED: Too many requests. Wait for Retry-After seconds.
- 429 CONCURRENCY_LIMIT: Three videos are already in progress on this account. Retry after one finishes.
- 503 SERVICE_UNAVAILABLE: The video queue is briefly unavailable. Nothing was charged; retry shortly.
A video that fails has "status": "failed" and "error": { code, message }; error.message always says what happened. Codes for this template:
- MEDIA_FETCH_FAILED: Your background or music URL could not be downloaded: not public https, not reachable, refused, or larger than 300 MB (video) / 30 MB (audio).
- MEDIA_INVALID: The file at your URL is not a usable video or audio file.
- MEDIA_TOO_LONG: The file at your URL is longer than 10 minutes.
- MEDIA_QUOTA_EXCEEDED: More than 20 new media URLs today. Reuse a URL (free for 30 days) or wait.
- BACKGROUND_UNAVAILABLE: The background video or category you picked has no usable footage. Pick one from GET /v1/options.
- MUSIC_UNAVAILABLE: The music id is not in the library. Pick one from GET /v1/options music.
- VOICE_TTS_FAILED: The voice provider failed while recording.
- RENDER_FAILED: The render failed. Create the video again.
- INTERRUPTED: Processing was interrupted. Create the video again; contact support if narration was charged.
- CANCELLED: You cancelled the video while it was queued.
## Timing
Renders usually take about a minute; own-media imports add about 30 seconds the first time; the first attempt occasionally takes several minutes before the automatic retry succeeds.
Machine-readable: https://api.ghostshorts.com/v1/openapi.json Templates: https://api.ghostshorts.com/v1/templates Options (voices, backgrounds, music, caption presets): https://api.ghostshorts.com/v1/options
Your own Reddit-style story narrated over gameplay footage, with a title card and captions.
fake_text_message# GhostShorts API: Text Messages (fake_text_message)
Your own chat conversation (written by you: nothing is generated for it) rendered as an iMessage, WhatsApp, Instagram or Tinder chat over gameplay footage, with each message voiced by an AI voice for "me" and one for "them". Returns one MP4; pass cutAfterMessage to get the exact millisecond where the next message appears, for splicing.
Output: one 1080x1920 (9:16) MP4.
This guide: https://api.ghostshorts.com/v1/templates/fake_text_message.txt All templates: https://api.ghostshorts.com/v1/llms.txt
## Base URL and auth
Base URL: https://api.ghostshorts.com
Auth: send your API key on every request as the header "Authorization: Bearer gs_live_..."
Create a key at https://ghostshorts.com/app/settings/api-keys (the key is shown once). Keys use your plan's videos and credits; there is no separate API plan. Plans: https://ghostshorts.com/pricing
## Flow
1. Optional and free: POST /v1/estimate with the same {"template", "inputs"} body returns the cost without spending anything.
2. POST /v1/videos with {"template": "fake_text_message", "inputs": {...}} (optional "webhookUrl"). It answers 202 with the video object; keep its "id".
3. Poll GET /v1/videos/{id} every 10-20 s until "status" is "completed" or "failed" (or "cancelled" if you cancelled it). In between you may see queued, preparing_media, generating_script, narrating and rendering. A queued video can be cancelled with POST /v1/videos/{id}/cancel.
4. On "completed", "downloadUrl" is a public link (no key needed) valid for 7 days. Give it to the user exactly as returned. On "failed", "error" is { code, message }.
This template has no free preview step.
Webhook (optional): pass "webhookUrl" (public https) and we POST { id, type, createdAt, data } to it, where type is video.completed or video.failed and data is the video object. Deliveries carry the header X-GhostShorts-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, keyed with webhooks.signingSecret from GET /v1/me minus its whsec_ prefix. Verification code: https://api.ghostshorts.com/v1/llms.txt
Idempotency: send an "Idempotency-Key" header (any unique string up to 255 characters) on every POST. Retrying with the same key and body within 24 hours replays the first answer, so nothing is created or charged twice.
## Inputs (the "inputs" object for template "fake_text_message")
Fields marked optional may be omitted; the default shown is applied. Unknown fields are refused with 400 INVALID_INPUT.
- platform (string, optional, default imessage, one of [imessage, whatsapp, instagram, tinder, ios]): Chat skin. "ios" is accepted as an alias of "imessage".
- theme (string, optional, default dark, one of [dark, light]): Light or dark chat skin.
- contact (object, required)
- contact.name (string, required, 1-40 chars): The contact shown in the chat header.
- contact.avatarAssetId (string, optional): An image asset you own (or a system image) for the contact avatar.
- messages (array of object, required, 1-80 items): The conversation, in order.
- messages[].sender (string, required, one of [me, them]): "me" is the phone owner (right-hand bubbles); "them" is the contact.
- messages[].text (string, optional, 1-500 chars): The message text. Send text or imageAssetId, not both.
- messages[].imageAssetId (string, optional): An image asset you own (or a system image) shown as a photo message, held 3 seconds.
- messages[].delayMs (integer, optional, 0-10000): Gap in milliseconds before this message appears. Defaults to the pacing preset.
- messages[].timestampLabel (string, optional, 1-40 chars): Timestamp row shown above this message, e.g. "Today 8:12 PM".
- messages[].gapLabel (string, optional, 1-40 chars): A time-gap row shown before this message, e.g. "4 days later". Fixed at voice-generation time (900 ms).
- messages[].receipt (string, optional, 1-30 chars): Receipt label under this message, free text, e.g. "Read 8:14 PM" or "Delivered".
- messages[].typingIndicatorBefore (boolean, optional): Show a typing bubble before this message (at least 800 ms; fixed at voice-generation time).
- pacing (string, optional, default normal, one of [fast, normal, slow]): Default gap for messages without delayMs: fast 0 ms, normal 0 ms, slow 1000 ms.
- voices (object, optional)
- voices.enabled (boolean, optional, default true): Must be true: every Text Messages video is voiced (the bubble timing comes from the voice clips).
- voices.me (string, optional, default pNInz6obpgDQGcFmaJgB, one of 47 ids listed with names at GET /v1/options): Voice for "me". See GET /v1/options voices tagged fake_text_message.
- voices.them (string, optional, default EXAVITQu4vr4xnSDxMaL, one of 47 ids listed with names at GET /v1/options): Voice for "them".
- background (object, optional)
- background.category (string, optional, one of [Minecraft, Mario Kart, GTA V, Subway Surfers, Satisfying, Backrooms, Roblox]): Pick any background from this category (GET /v1/options backgrounds.categories).
- background.assetId (string, optional): A background video id from GET /v1/options backgrounds.assets, or your own upload.
- background.ownUrl (string, optional, 1-2048 chars): Your own footage by public https URL. Video is converted for rendering (H.264 baseline, 1080x1920, cropped to fill, silent); files up to 300 MB / 10 min.
- music (object, optional)
- music.assetId (string, optional): Background music id from GET /v1/options music. Omit for none.
- music.ownUrl (string, optional, 1-2048 chars): Your own track by public https URL. Converted to MP3 for rendering; files up to 30 MB / 10 min.
- music.volume (integer, optional, 0-100): 0-100. Default 30.
- captions (array of object, optional, max 20 items): Text captions pinned to messages, up to 20.
- captions[].afterMessage (integer, required, min 1): 1-based number of the message this caption follows (at most the number of messages).
- captions[].text (string, required, 1-120 chars): Caption text.
- captions[].delayMs (integer, optional, default 0, 0-10000): Milliseconds after that message appears. Default 0.
- captions[].position (string, optional, default below_card, one of [below_card, above_card]): Default below_card.
- hookText (object, optional): Hook shown before the conversation.
- hookText.text (string, required, 1-120 chars): The hook line.
- hookText.seconds (number, optional, default 2, 0.5-10): How long the hook holds before the chat starts. Default 2.
- endCard (object, optional): End card shown after the last message.
- endCard.line1 (string, required, 1-80 chars)
- endCard.line2 (string, optional, 1-80 chars)
- endCard.seconds (number, optional, default 2.5, 0.5-10): How long the end card holds. Default 2.5.
- sfx (object, optional): Message sounds, each default false. Honoured, but silent until the sound library is uploaded.
- sfx.send (boolean, optional, default false)
- sfx.receive (boolean, optional, default false)
- sfx.typing (boolean, optional, default false)
- layout (object, optional): Chat card layout.
- layout.style (string, optional, default card_over_background, one of [card_over_background]): The chat card over the background video.
- layout.cardWidthPct (integer, optional, default 80, 40-100): Card width as a percentage of the frame. Default 80.
- layout.cardHeight (string, optional, default fit, one of [full, fit]): fit (default): the card grows with the conversation. full: fixed-height card, the wizard's legacy look.
- cutAfterMessage (integer, optional, 1-80): 1-based message number. The video reports cutAtMs: where message N+1 appears (or the end of the video when N is the last message), so you can split the MP4 there.
## Example request
Set GS_KEY to your key first (export GS_KEY=gs_live_...).
curl -X POST https://api.ghostshorts.com/v1/videos \
-H "Authorization: Bearer $GS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: fake-text-message-example-001" \
--data-binary @- <<'JSON'
{
"template": "fake_text_message",
"inputs": {
"platform": "imessage",
"theme": "light",
"contact": {
"name": "Maya"
},
"hookText": {
"text": "i texted \"haha yeah\". convo died. this line fixed it.",
"seconds": 2
},
"messages": [
{
"sender": "them",
"text": "ok that hike shot is unreal, where even was that",
"timestampLabel": "Today 8:12 PM"
},
{
"sender": "me",
"text": "north shore trail. went up with my brother"
},
{
"sender": "them",
"text": "jealous. I keep saying I'll do a real hike this fall"
},
{
"sender": "me",
"text": "you should. october up there is the best"
},
{
"sender": "them",
"text": "ok convince me. would you go again if I came?"
},
{
"sender": "me",
"text": "haha yeah",
"receipt": "Read 8:14 PM"
},
{
"sender": "me",
"text": "you come with me and I'll make sure there's a good view waiting at the end",
"gapLabel": "4 days later",
"receipt": "Delivered"
},
{
"sender": "them",
"text": "a good view is a strong offer. when are we going?",
"typingIndicatorBefore": true
}
],
"captions": [
{
"afterMessage": 8,
"text": "a question back. we're in.",
"delayMs": 300,
"position": "below_card"
}
],
"endCard": {
"line1": "That is how you revive a dead chat.",
"line2": "Save this for later.",
"seconds": 2.5
},
"sfx": {
"send": false,
"receive": false,
"typing": false
},
"layout": {
"style": "card_over_background",
"cardWidthPct": 78,
"cardHeight": "fit"
},
"background": {
"category": "Subway Surfers"
},
"pacing": "normal",
"cutAfterMessage": 6
},
"webhookUrl": "https://example.com/ghostshorts/webhook"
}
JSON
Response: 202 Accepted, header Location: /v1/videos/cmuhd9xa1001008l4c6hn2r7b
{
"id": "cmuhd9xa1001008l4c6hn2r7b",
"template": "fake_text_message",
"status": "queued",
"stage": "Queued",
"progress": null,
"projectId": null,
"exportId": null,
"downloadUrl": null,
"downloadExpiresAt": null,
"error": null,
"creditsCharged": null,
"estimatedSecondsRemaining": 90,
"createdAt": "2026-09-24T15:00:00.000Z",
"completedAt": null,
"cutAtMs": null
}
Once completed, GET /v1/videos/cmuhd9xa1001008l4c6hn2r7b answers (example values):
{
"id": "cmuhd9xa1001008l4c6hn2r7b",
"template": "fake_text_message",
"status": "completed",
"stage": "Done",
"progress": 100,
"projectId": "cmuhd9xaa001108l4f4jw8m3d",
"exportId": "cmuhdaq5t001208l4b0kc6s1g",
"downloadUrl": "https://api.ghostshorts.com/v1/d/Wd2kF7nR0tY5qA9mC4xL8hV3bJ6gS1pEuZoIeNiKrTs",
"downloadExpiresAt": "2026-10-01T15:01:08.000Z",
"error": null,
"creditsCharged": {
"narration": 0,
"render": 0,
"videos": 1
},
"estimatedSecondsRemaining": null,
"createdAt": "2026-09-24T15:00:00.000Z",
"completedAt": "2026-09-24T15:01:08.000Z",
"cutAtMs": 14267
}
## Credits
- voices: 1 credit per minute of voiced text, estimated at 15 characters per second (legacy plans); included on current plans within fair use
- render: 1 video from the monthly allowance (current plans); export counter on legacy plans
Plan and balance are checked before anything runs, so a 402 refusal costs nothing. creditsCharged on the video says what was actually charged.
## Notes
- The conversation is yours; nothing is generated for it.
- Each message stays on screen for the length of its voice clip; delayMs (or pacing) adds a gap before it.
- With no background fields, the Subway Surfers default background is used.
- Own media by URL is fetched before anything is charged. The same URL is reused for 30 days without re-fetching; up to 20 new URLs per day. Failures: MEDIA_FETCH_FAILED, MEDIA_INVALID, MEDIA_TOO_LONG, MEDIA_QUOTA_EXCEEDED.
- Gap rows and typing bubbles are timed into the narration when voices are generated, so they are fixed for the video.
- sfx toggles are honoured but play nothing until the sound library is uploaded.
- hookText, endCard, captions, sfx and layout are objects; hookText is { text, seconds? }, not a bare string.
- cutAtMs on the video is null until the render is queued, and null when cutAfterMessage was not sent.
## Errors
Errors are JSON with Content-Type application/problem+json: { type, title, status, detail, code }. Branch on "code". 400 INVALID_INPUT adds "errors": [{ path, message, code }]. Every 402 adds "upsell" and "plansUrl" (https://ghostshorts.com/pricing). A 429 carries Retry-After. Full reference: https://ghostshorts.com/developers/errors
- 401 MISSING_API_KEY: No Authorization: Bearer header was sent.
- 401 INVALID_API_KEY: The key is unknown or has been revoked. Create a new one in Settings.
- 402 SUBSCRIPTION_REQUIRED: This step needs an active plan. The response carries plansUrl.
- 402 INSUFFICIENT_CREDITS: Not enough credits for this step. Top up or wait for your plan to renew.
- 402 VIDEO_ALLOWANCE_EXHAUSTED: This month's video allowance is used up.
- 402 SOFT_CAP_REACHED: A fair-use limit for this period was reached.
- 400 INVALID_INPUT: The body does not match the schema. The errors array names each field. Also sent as 413 for a body over 2 MB.
- 400 INVALID_WEBHOOK_URL: webhookUrl must be a public https URL.
- 404 NOT_FOUND: No such endpoint, or no such video, project or download link on this account.
- 409 IDEMPOTENCY_IN_PROGRESS: A request with this Idempotency-Key is still running. Retry after Retry-After.
- 422 IDEMPOTENCY_KEY_REUSED: This Idempotency-Key was already used with a different body. Use a new key for a new request.
- 429 RATE_LIMITED: Too many requests. Wait for Retry-After seconds.
- 429 CONCURRENCY_LIMIT: Three videos are already in progress on this account. Retry after one finishes.
- 503 SERVICE_UNAVAILABLE: The video queue is briefly unavailable. Nothing was charged; retry shortly.
A video that fails has "status": "failed" and "error": { code, message }; error.message always says what happened. Codes for this template:
- MEDIA_FETCH_FAILED: Your background or music URL could not be downloaded: not public https, not reachable, refused, or larger than 300 MB (video) / 30 MB (audio).
- MEDIA_INVALID: The file at your URL is not a usable video or audio file.
- MEDIA_TOO_LONG: The file at your URL is longer than 10 minutes.
- MEDIA_QUOTA_EXCEEDED: More than 20 new media URLs today. Reuse a URL (free for 30 days) or wait.
- BACKGROUND_UNAVAILABLE: The background video or category you picked has no usable footage. Pick one from GET /v1/options.
- MUSIC_UNAVAILABLE: The music id is not in the library. Pick one from GET /v1/options music.
- IMAGE_UNAVAILABLE: An avatar or photo id is not an image you can use.
- VOICE_TTS_FAILED: The voice provider failed while recording.
- RENDER_FAILED: The render failed. Create the video again.
- INTERRUPTED: Processing was interrupted. Create the video again; contact support if narration was charged.
- CANCELLED: You cancelled the video while it was queued.
## Timing
Renders usually take about a minute; own-media imports add about 30 seconds the first time; the first attempt occasionally takes several minutes before the automatic retry succeeds.
Machine-readable: https://api.ghostshorts.com/v1/openapi.json Templates: https://api.ghostshorts.com/v1/templates Options (voices, backgrounds, music, caption presets): https://api.ghostshorts.com/v1/options
Your own chat conversation as an iMessage, WhatsApp, Instagram or Tinder chat, with a voice per side.
Pass your own background footage or music by public https URL instead of a library pick. On Text Messages that is background.ownUrl and music.ownUrl; on Reddit Story, backgroundVideoUrl and backgroundAudioUrl.
Pass webhookUrl (public https) on POST /v1/videos. We POST JSON { id, type, createdAt, data } where type is video.completed or video.failed and data is the same video object GET /v1/videos/{id} returns. Answer with any 2xx. A failed delivery is retried 3 times.
Every delivery is signed: X-GhostShorts-Signature: t=<unix>,v1=<hex>, where v1 is HMAC-SHA256 over "<t>.<body>"keyed with your account's signing secret (webhooks.signingSecret on GET /v1/me, without its whsec_ prefix). In Node:
const crypto = require('crypto');
// rawBody: the request body as received (string), before JSON.parse
function verifyGhostShorts(rawBody, header, signingSecret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto.createHmac('sha256', signingSecret.replace(/^whsec_/, '')).update(parts.t + '.' + rawBody).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && expected.length === (parts.v1 || '').length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Your current limits are on GET /v1/me under limits, and each response carries X-RateLimit-* headers. A 429 carries Retry-After.
Send an Idempotency-Key header on every POST, so a retry after a timeout never makes a second video. For 24 hours:
Idempotent-Replayed: true. For POST /v1/videos the replay shows the video as it is now, with its current status and download link. Nothing is created or charged twice.IDEMPOTENCY_KEY_REUSED.IDEMPOTENCY_IN_PROGRESS.Errors are RFC 9457 problem JSON (application/problem+json): { type, title, status, detail, code }, plus an errors array on INVALID_INPUT and upsell + plansUrl on every 402. Branch on code.
| Code | HTTP | What it means |
|---|---|---|
MISSING_API_KEY | 401 | No Authorization: Bearer header was sent. |
INVALID_API_KEY | 401 | The key is unknown or has been revoked. Create a new one in Settings. |
UNSUPPORTED_TOKEN | 401 | The bearer token is not a GhostShorts API key (gs_live_...). |
UNAUTHENTICATED | 401 | The request reached an endpoint without an authenticated account. Send your API key. |
SUBSCRIPTION_REQUIRED | 402 | This step needs an active plan. The response carries plansUrl. |
INSUFFICIENT_CREDITS | 402 | Not enough credits for this step. Top up or wait for your plan to renew. |
INSUFFICIENT_AI_CREDITS | 402 | Not enough AI credits for this step. |
VIDEO_ALLOWANCE_EXHAUSTED | 402 | This month's video allowance is used up. |
CLIPPING_ALLOWANCE_EXHAUSTED | 402 | This month's clipping minutes are used up. |
SOFT_CAP_REACHED | 402 | A fair-use limit for this period was reached. |
INVALID_INPUT | 400 | The body does not match the schema. The errors array names each field. Also sent as 413 for a body over 2 MB. |
INVALID_VOICE | 400 | That voice id is not available for this template. See GET /v1/options voices. |
UNKNOWN_TEMPLATE | 400 | No template with that id. See GET /v1/templates. |
TEMPLATE_NOT_AVAILABLE | 400 | The template has no such step through the API (for example POST /v1/scripts for reddit_story). The detail says what to call instead. |
NARRATION_REQUIRED | 400 | The video cannot be rendered without narration. |
INVALID_WEBHOOK_URL | 400 | webhookUrl must be a public https URL. |
SAFETY_REJECTED | 400 | The content was refused by our safety checks. Change the topic or text. |
INVALID_IDEMPOTENCY_KEY | 400 | The Idempotency-Key header is empty or longer than 255 characters. |
NOT_FOUND | 404 | No such endpoint, or no such video, project or download link on this account. |
IDEMPOTENCY_IN_PROGRESS | 409 | A request with this Idempotency-Key is still running. Retry after Retry-After. |
CANNOT_CANCEL | 409 | Only a queued video can be cancelled. |
LINK_EXPIRED | 410 | The download link is past its 7 days. GET /v1/videos/{id} again for a fresh one. |
IDEMPOTENCY_KEY_REUSED | 422 | This Idempotency-Key was already used with a different body. Use a new key for a new request. |
RATE_LIMITED | 429 | Too many requests. Wait for Retry-After seconds. |
CONCURRENCY_LIMIT | 429 | Three videos are already in progress on this account. Retry after one finishes. |
INTERNAL_ERROR | 500 | Something failed on our side. Retry; the same Idempotency-Key is safe to reuse. |
RATE_LIMITER_UNAVAILABLE | 503 | Rate limiting is briefly unavailable, so the request was refused. Retry shortly. |
SERVICE_UNAVAILABLE | 503 | The video queue is briefly unavailable. Nothing was charged; retry shortly. |
When a video ends as failed or cancelled, error.code on the video (and in the video.failed webhook) is one of these. error.message always says what happened.
| Code | What it means |
|---|---|
MEDIA_FETCH_FAILED | Your background or music URL could not be downloaded: not public https, not reachable, refused, or larger than 300 MB (video) / 30 MB (audio). |
MEDIA_INVALID | The file at your URL is not a usable video or audio file. |
MEDIA_TOO_LONG | The file at your URL is longer than 10 minutes. |
MEDIA_QUOTA_EXCEEDED | More than 20 new media URLs today. Reuse a URL (free for 30 days) or wait. |
BACKGROUND_UNAVAILABLE | The background video or category you picked has no usable footage. Pick one from GET /v1/options. |
MUSIC_UNAVAILABLE | The music id is not in the library. Pick one from GET /v1/options music. |
IMAGE_UNAVAILABLE | An avatar or photo id is not an image you can use. |
SCRIPT_FAILED | The script could not be written. |
PACK_FAILED | The quiz questions could not be written. |
INGEST_FAILED | The article at your URL could not be read. Paste the text instead. |
SOURCE_TOO_SHORT | The article is too short to make a script from. |
NARRATION_FAILED | The narration could not be recorded. |
VOICE_TTS_FAILED | The voice provider failed while recording. |
RENDER_FAILED | The render failed. Create the video again. |
INTERRUPTED | Processing was interrupted. Create the video again; contact support if narration was charged. |
CANCELLED | You cancelled the video while it was queued. |
The full OpenAPI 3.1 document, with every endpoint and schema, is at api.ghostshorts.com/v1/openapi.json. No key needed.
Stable plain-text URLs an agent can fetch directly. No key needed; they are generated from the API's own template registry, so they always match what the API accepts.
https://api.ghostshorts.com/v1/llms.txt The whole API in one plain-text guide.https://api.ghostshorts.com/v1/templates/article_video.txt Article to Video: inputs, a full example, credits, errors.https://api.ghostshorts.com/v1/templates/quiz_video.txt AI Quiz Video: inputs, a full example, credits, errors.https://api.ghostshorts.com/v1/templates/reddit_story.txt Reddit Story: inputs, a full example, credits, errors.https://api.ghostshorts.com/v1/templates/fake_text_message.txt Text Messages: inputs, a full example, credits, errors.https://ghostshorts.com/developers/llms.txt The same guide, served from this site.