# 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=,v1=.">, 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