PowerPost APIv1
PowerPost APIv1
DashboardAPI keysPowerPost API
QuickstartAuthenticationWorkspacesErrors & Rate Limits
Input TypesPost TypesCreditsResearch Modes
Generate CaptionsGenerate ImagesPublish and ScheduleWebhooksMCP Server

Content

Generate ContentGenerations

Media

Upload MediaGenerate ImagesGenerate VideosVideo CaptionsAudio GenerationVideo AudioVideo Stitching

Publishing & planning

PostsPublish & SchedulePost ItemsAnalyticsCalendar

Account

Get Credits
Changelog
API reference

Video Captions

Add timed, styled captions to a video.

Burn text into a video in your workspace media library. You supply the text and its timing; PowerPost renders it into a new MP4 and keeps the original video.

This endpoint does not transcribe audio, detect word timings, or generate subtitle files. Captions become part of the picture and cannot be switched off in a player. To write social post text instead, use Generate captions.

Before you start

  • Upload or generate a video and keep its media ID. A generation ID, render ID, or external video URL cannot be used as media_id.
  • Prepare timed cues: each cue is a piece of text shown between start and end, in seconds from the beginning of the video.
  • Use the same workspace for the source video, render request, and status requests.

Rendering requires a prior credit purchase and sufficient credits. Each new render has a flat credit charge, deducted at start. Failed renders are refunded; the refund may appear after the failed status. See Credits.

Examples

A bold sentence with one emphasized word.

Caption cues

This video uses the production renderer. Select a cue to seek to its start time.

View request JSON
Replace `media_id` with the video media ID.
{
  "media_id": "11111111-1111-4111-8111-111111111111",
  "defaults": {
    "position": {
      "anchor": "bottom",
      "x_pct": 50,
      "y_pct": 72
    },
    "style": {
      "font": {
        "family": "Montserrat",
        "size": 68,
        "weight": "black"
      },
      "text": {
        "color": "#FFFFFF",
        "align": "center",
        "line_height": 1.05,
        "case": "original",
        "max_width_pct": 82
      },
      "stroke": {
        "color": "#000000",
        "width": 5
      },
      "shadow": {
        "color": "#000000",
        "offset": 3,
        "blur": 0.8
      },
      "background": {
        "opacity": 0
      }
    },
    "animation": {
      "enter": {
        "type": "slide-up",
        "duration_ms": 140
      },
      "exit": {
        "type": "fade",
        "duration_ms": 80
      }
    }
  },
  "captions": [
    {
      "start": 0,
      "end": 1.5,
      "text": [
        {
          "text": "Use the "
        },
        {
          "text": "NORTH",
          "style": {
            "text": {
              "color": "#FFE66D"
            },
            "font": {
              "size": 82
            }
          }
        },
        {
          "text": " entrance"
        }
      ]
    },
    {
      "start": 1.5,
      "end": 3.2,
      "text": [
        {
          "text": "The market opens at "
        },
        {
          "text": "EIGHT",
          "style": {
            "text": {
              "color": "#7DD3FC"
            },
            "font": {
              "size": 82
            }
          }
        }
      ]
    },
    {
      "start": 3.2,
      "end": 5,
      "text": [
        {
          "text": "Coffee is on the "
        },
        {
          "text": "SECOND",
          "style": {
            "text": {
              "color": "#F9A8D4"
            },
            "font": {
              "size": 82
            }
          }
        },
        {
          "text": " floor"
        }
      ]
    }
  ]
}

Workflow

  1. Choose any video in your workspace media library. It can be uploaded or generated.
  2. Start a caption render with the video's media_id and your timed cues.
  3. Poll the returned status_url until the job is completed or failed.
  4. Use video.media_id to publish the new video, or video.url to preview it.

Required scopes: videos:generate to render and videos:read to check status.

Render captions

POST /api/v1/videos/captions/render

Styling is optional. If omitted, the built-in defaults are used.

# Start the render
curl -X POST https://powerpost.ai/api/v1/videos/captions/render \
  -H "x-api-key: pp_live_sk_YOUR_KEY" \
  -H "X-Workspace-Id: YOUR_WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "media_id": "YOUR_VIDEO_MEDIA_ID",
    "captions": [
      { "start": 0, "end": 1.8, "text": "Meet at the station at eight" },
      { "start": 1.8, "end": 3.8, "text": "The north entrance is still open" }
    ]
  }'

# Poll using the caption_render_id from the response
curl https://powerpost.ai/api/v1/videos/captions/renders/CAPTION_RENDER_ID \
  -H "x-api-key: pp_live_sk_YOUR_KEY" \
  -H "X-Workspace-Id: YOUR_WORKSPACE_ID"
Start response
{
  "caption_render_id": "22222222-2222-4222-8222-222222222222",
  "status": "processing",
  "source_media_id": "11111111-1111-4111-8111-111111111111",
  "status_url": "/api/v1/videos/captions/renders/22222222-2222-4222-8222-222222222222",
  "created_at": "2026-08-21T10:00:00Z",
  "remaining_credits": 42
}

A successful start means the job was accepted, not that the video is ready. status_url is a relative path; prepend https://powerpost.ai when calling it.

The examples poll every two seconds and stop on HTTP errors. In production, back off on 429 (respect Retry-After) and transient status-request failures. Set a polling deadline and save caption_render_id so you can resume checking later. A client timeout does not cancel the render.

Each POST creates a new, chargeable render; identical requests are not deduplicated. Once you have a render ID, poll that job instead of submitting it again.

Request reference

Top-level fields

FieldTypeRequiredDescription
media_iduuidYesVideo media ID from the same workspace
captionsarrayYes1–500 timed caption cues
defaultsobjectNoStyle, position, animation, and spoken highlight inherited by every cue

Settings are applied in order: built-in defaults → request defaults → individual cue → span style → active span highlight. Only supplied fields override inherited fields. Span backgrounds are independent of the cue background.

For example, defaults.style.font.size: 72 changes the size for all cues; captions[0].style.text.color: "#FFE66D" changes only the first cue's color. Keep nested objects as shown in the examples; dotted table names are field paths, not literal JSON keys. Unknown fields are rejected, including unsupported style fields on spans or highlights.

Caption cues

FieldTypeRequiredLimits and behavior
startnumberYesStart time in seconds; must be ≥ 0
endnumberYesEnd time in seconds; must be after start and within the video duration
textstring or span arrayYes1–500 characters; a span array contains 1–100 spans, joined without added spaces
layerintegerNo0–50; higher captions draw on top when cues overlap
styleobjectNoOverrides default style fields for this cue
positionobjectNoOverrides default position fields for this cue
animationobjectNoOverrides default animation fields for this cue
highlightobjectNoSpoken color, pill, and in-place scale. A span window decides when it applies

All cue and highlight times are absolute seconds from the beginning of the video, not offsets from the cue. For a cue beginning at 10 seconds, a word spoken 0.4 seconds later needs highlight.start: 10.4.

Times round to centiseconds (0.01 seconds); start and end must remain distinct after rounding. Use two decimal places and keep them at least 0.01 seconds apart. Animation durations use milliseconds instead.

Cues may overlap and do not need to be sorted. Use layer to control stacking; overlapping cues are not automatically moved apart. Highlight windows within a single cue cannot overlap.

Built-in defaults

With only cue timing and text, the result is white uppercase Montserrat Black text, with a black outline and shadow, centered near the bottom, fading in and out.

SettingDefault
Fontfamily: "Montserrat", size: 62, weight: "black"
Textcolor: "#FFFFFF", opacity: 1, case: "uppercase"
Layoutalign: "center", direction: "ltr", line_height: 1.08, max_width_pct: 82
Text decorationletter_spacing: 0, underline: false, strike: false
Strokecolor: "#000000", width: 4
Shadowcolor: "#000000", offset: 2, blur: 0.8
Backgroundcolor: "#000000", opacity: 0, padding: 10, radius: 0
Background bordercolor: "#000000", width: 0
Positionanchor: "bottom", x_pct: 50, y_pct: 72
Enter animationtype: "fade", duration_ms: 420
Exit animationtype: "fade", duration_ms: 180
Scale animation factorsfrom_scale: 0.8, to_scale: 0.8; used only with type: "scale"
Spoken highlightNone; when configured, scale defaults to 1

To preserve your capitalization, set defaults.style.text.case to "original". Set stroke width or shadow offset to 0 to remove those effects. A background needs opacity greater than 0 to be visible; setting its color alone does not show it.

For captions without fades, set both defaults.animation.enter.type and defaults.animation.exit.type to "none".

Style fields

FieldTypeAccepted values
style.font.familystringInter, Arial, Roboto, Montserrat, Impact, Poppins, Open Sans, Noto Sans, Cairo, Noto Sans Arabic, Bebas Neue, Archivo Black
style.font.sizeinteger10–320 video pixels.
style.font.weightstringregular, medium, bold, black
style.text.colorstringSix-digit hex color, such as #FFFFFF
style.text.opacitynumber0–1; applies to the text fill, stroke, and shadow
style.text.letter_spacingnumber-50–100 video pixels; negative values tighten text
style.text.alignstringleft, center, right
style.text.directionstringltr (default) or rtl. Set rtl for Arabic or Hebrew so colored spans and trailing . / ؟ stay in reading order. Spans inherit the cue; they cannot set this.
style.text.line_heightnumber0.8–2
style.text.casestringoriginal, uppercase, lowercase
style.text.max_width_pctnumber20–100 of the video width. A cap, not the box size. Shorter lines stay shorter.
style.text.underlinebooleanUnderline all cue text
style.text.strikebooleanStrike through all cue text
style.stroke.colorstringSix-digit hex color
style.stroke.widthnumber0–20 video pixels
style.shadow.colorstringSix-digit hex color
style.shadow.offsetnumber0–30 video pixels, down and right
style.shadow.blurnumber0–10; 0 is a hard edge
style.background.colorstringSix-digit hex color
style.background.opacitynumber0–1
style.background.paddingnumber0–60 video pixels around the laid-out text
style.background.radiusnumber0–80 video pixels
style.background.border.colorstringSix-digit hex color
style.background.border.widthnumber0–30 video pixels

The cue background fits the wrapped text plus padding and border. Raise max_width_pct to keep a long line together; the plate stays as wide as that line.

For Arabic captions, select Cairo or Noto Sans Arabic, and set direction to rtl. Both fonts include regular, medium, bold, and black weights. For example:

{
  "defaults": {
    "style": {
      "font": { "family": "Cairo", "weight": "bold" },
      "text": { "case": "original", "letter_spacing": 0, "align": "right", "direction": "rtl" }
    }
  }
}

Font size and spacing are measured against the actual video frame. A size of 64 therefore looks proportionally smaller on a 4K frame than on a 1080p frame.

Position

x_pct and y_pct are percentages from 0 to 100, measured from the left and top edges of the video. anchor selects which point on the caption box attaches to that coordinate.

ResultPosition
Exact center{ "anchor": "center", "x_pct": 50, "y_pct": 50 }
Centered near bottom{ "anchor": "bottom", "x_pct": 50, "y_pct": 72 }
Top-left inset{ "anchor": "top-left", "x_pct": 8, "y_pct": 8 }
Bottom-right inset{ "anchor": "bottom-right", "x_pct": 92, "y_pct": 92 }

Available anchors: top-left, top, top-right, left, center, right, bottom-left, bottom, bottom-right.

For Reels, Shorts, and TikTok, start near y_pct: 72 instead of the physical bottom. This leaves room for platform controls and descriptions.

Animation

Both animation.enter and animation.exit accept:

FieldTypeAccepted values
typestringnone, fade, slide-up, slide-down, slide-left, slide-right, scale
duration_msinteger0–2000
from_scalenumberEnter only; starting scale from 0.1–2, default 0.8
to_scalenumberExit only; ending scale from 0.1–2, default 0.8

If enter plus exit is longer than the cue, both durations shrink to fit.

Slide animations travel one resolved line height.

Scale animations transform the entire caption around position.anchor. Enter animations move from from_scale to normal size (1); exit animations move from normal size to to_scale. Scaling is uniform, linear, and includes every line, text span, stroke, shadow, background, and border.

{
  "animation": {
    "enter": { "type": "scale", "from_scale": 0.6, "duration_ms": 220 },
    "exit": { "type": "scale", "to_scale": 0.8, "duration_ms": 180 }
  }
}

Highlight individual words

Instead of a string, set text to an array of 1–100 spans. Spans are joined exactly as supplied: include spaces in the span text, such as "Open the " followed by "SETTINGS". Each span inherits the cue's text styling and can override font, color, opacity, letter spacing, underline, strike-through, stroke, or shadow. A span can also draw its own background.

{
  "start": 0,
  "end": 2.2,
  "text": [
    { "text": "Open the " },
    {
      "text": "SETTINGS",
      "style": {
        "font": { "size": 84 },
        "text": { "color": "#111111" },
        "background": { "color": "#FFE66D", "opacity": 1, "padding": 8, "radius": 4 }
      }
    }
  ]
}

Span styles accept font, text.color, text.opacity, text.letter_spacing, text.underline, text.strike, stroke, shadow, and background. A span background accepts the same color, opacity, padding, radius, and border fields as a cue background. It is independent of the cue background and does not change text spacing or wrapping. The box is the visible glyphs plus padding, border, and stroke, so an outline stays inside the chip. Leading and trailing whitespace still advances the text but is excluded from the visible background. If the span wraps, each line receives its own box.

A span's text opacity overrides the cue opacity rather than multiplying it. Letter spacing is measured in video pixels and applies after each character, including the final character's positioning advance and styled-span boundaries. Keep position, animation, line height, case, and max width on the cue or in defaults. Span style stays on for the whole cue.

Highlight words while they are spoken

highlight is the look a word uses while it is spoken. Set it on the cue or in defaults. Put highlight.start and highlight.end on each span that should change. Those times use the same clock as the cue. A span with no window keeps its regular style.

The usual look is a fill-color change. The other words keep the cue stroke. highlight.scale is 1–2, default 1. A value near 1.06 is a small pop. The letters grow around their own center, the gap stays the same width, and the size eases up at the start of the window and then holds. Larger values may overlap adjacent words because spacing stays fixed. While the cue itself is sliding or scaling, the spoken color still applies and the size change waits.

highlight.style also accepts text.opacity, stroke, shadow, and background. A background is a chip behind that one word. With a visible chip, omitting highlight.style.stroke drops the outline for that window, and omitting highlight.style.shadow drops the inherited shadow. These overrides are independent: setting a stroke does not keep the shadow. Font, letter spacing, underline, and strike stay on the cue or the span, so the other words do not move.

Each window must sit inside its cue, have distinct start and end times after centisecond rounding, and stay clear of other windows in that cue. Use a duration of at least 0.01 seconds. Touching endpoints are fine.

{
  "start": 0,
  "end": 2.2,
  "highlight": {
    "style": { "text": { "color": "#FFE66D" } },
    "scale": 1.06
  },
  "text": [
    { "text": "Meet ", "highlight": { "start": 0, "end": 0.35 } },
    { "text": "at ", "highlight": { "start": 0.35, "end": 0.55 } },
    { "text": "the ", "highlight": { "start": 0.55, "end": 0.7 } },
    { "text": "station", "highlight": { "start": 0.7, "end": 1.3 } }
  ]
}

"Meet" turns yellow from 0 to 0.35, then returns to the cue color. "station" does the same from 0.7 to 1.3. The same highlight object can live on defaults when every cue shares it. Without a configured highlight look and span windows, no spoken-word effect appears. PowerPost does not infer these windows from the audio.

Get render status

GET /api/v1/videos/captions/renders/{id}

Scope: videos:read

Poll until status is completed or failed. processing covers both waiting and rendering; no progress percentage is returned. A failed render is returned as a successful HTTP status lookup with status: "failed" and an error object. Always check the job status as well as the HTTP status. Polling too quickly returns 429.

Processing response
{
  "caption_render_id": "22222222-2222-4222-8222-222222222222",
  "status": "processing",
  "source_media_id": "11111111-1111-4111-8111-111111111111",
  "output_media_id": null,
  "error": null,
  "created_at": "2026-08-21T10:00:00Z",
  "updated_at": "2026-08-21T10:00:00Z",
  "video": null
}
Completed response
{
  "caption_render_id": "22222222-2222-4222-8222-222222222222",
  "status": "completed",
  "source_media_id": "11111111-1111-4111-8111-111111111111",
  "output_media_id": "33333333-3333-4333-8333-333333333333",
  "error": null,
  "created_at": "2026-08-21T10:00:00Z",
  "updated_at": "2026-08-21T10:02:00Z",
  "video": {
    "media_id": "33333333-3333-4333-8333-333333333333",
    "url": "https://signed-storage-url.example/output.mp4",
    "thumbnail_url": "https://signed-storage-url.example/output-thumb.jpg",
    "file_name": "source-captions-2026-08-21T10-02-00Z.mp4",
    "file_size": 12345,
    "mime_type": "video/mp4",
    "width": 1080,
    "height": 1920,
    "duration": 5,
    "created_at": "2026-08-21T10:02:00Z"
  }
}

The returned url is signed and temporary. Store the durable video.media_id and request a fresh media URL when needed. source_media_id can be null if the original video was deleted after the job was created.

Failed response
{
  "caption_render_id": "22222222-2222-4222-8222-222222222222",
  "status": "failed",
  "source_media_id": "11111111-1111-4111-8111-111111111111",
  "output_media_id": null,
  "error": {
    "code": "VIDEO_CAPTION_RENDER_FAILED",
    "message": "Caption rendering failed"
  },
  "created_at": "2026-08-21T10:00:00Z",
  "updated_at": "2026-08-21T10:02:00Z",
  "video": null
}

Common problems

ProblemWhat to check
Caption is hidden by social UIMove a bottom caption toward y_pct: 68–74
Text wraps too earlyIncrease max_width_pct, reduce font size, or shorten the cue
Busy footage hurts readabilityAdd a background or increase stroke width
A cue is rejectedConfirm end > start, the cue is at least 0.01s, and end does not exceed the video duration
A highlighted word loses its styleUse a span array and keep the spaces inside adjacent span text
A spoken word never changesSet highlight on the cue or defaults, and put highlight.start / highlight.end on the span, inside the cue
Polling always returns 404Use the same workspace that created the render

When the stored source duration is available, a cue beyond the video duration returns 400. If bad media metadata prevents early validation, the background render can fail instead.

HTTP errors

StatusMeaning
400Invalid body, missing source video, non-video media, unavailable storage path, or cue beyond the known duration
401Invalid API key
402Prior purchase required or insufficient credits
403API key is missing the required scope
404Render ID does not exist in this workspace
429Rate limit exceeded; wait before retrying
500Server or job setup error

MCP tools

Connect using the MCP setup guide. The render tool accepts the REST request body as its top-level arguments, plus optional workspace_id. Do not wrap it in a body object.

ToolRequired scopeArguments
powerpost_render_video_captionsvideos:generatemedia_id, captions, optional defaults and workspace_id
powerpost_get_video_caption_rendervideos:readid (the returned caption_render_id), optional workspace_id

Call powerpost_render_video_captions with:

{
  "workspace_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
  "media_id": "11111111-1111-4111-8111-111111111111",
  "defaults": {
    "style": { "text": { "case": "original" } }
  },
  "captions": [
    { "start": 0, "end": 1.8, "text": "Meet at the station at eight" }
  ]
}

Then call powerpost_get_video_caption_render with the returned render ID:

{
  "workspace_id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
  "id": "22222222-2222-4222-8222-222222222222"
}

Repeat the status call until completed or failed. On success, use video.media_id as the captioned video in subsequent tools. The original media_id still points to the unmodified source. You may omit workspace_id when X-Workspace-Id is configured on the connection.

The same defaults, timing rules, validation, charges, and rendering limitations apply to REST and MCP. The MCP tool does not transcribe speech or supply word timings.

Output and limitations

  • The output is an H.264 MP4 with captions permanently burned in. It is re-encoded, so file size and quality can differ from the source.
  • The first audio track is retained, if present. AAC is copied; other audio codecs are converted to AAC. Additional audio tracks, source subtitles, metadata, and chapters are not carried over.
  • Odd frame dimensions are padded to even dimensions for H.264 compatibility.
  • A successful request validates the input but does not guarantee rendering will succeed. Unplayable media or processing limits can still produce a failed job.
  • To change the text or styling, submit a new render using the original video. Rendering the captioned output adds another layer of captions over the existing ones.

Next steps

  • Upload media
  • Generate a video
  • Create a post with the captioned video
  • Publish or schedule

Generate Videos

Create AI-generated videos from text prompts, source images, or existing captions.

Audio Generation

Generate speech, sound effects, ambience, and music.

On this page

Before you startExamplesWorkflowRender captionsRequest referenceTop-level fieldsCaption cuesBuilt-in defaultsStyle fieldsPositionAnimationHighlight individual wordsHighlight words while they are spokenGet render statusCommon problemsHTTP errorsMCP toolsOutput and limitationsNext steps