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
startandend, 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
{
"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
- Choose any video in your workspace media library. It can be uploaded or generated.
- Start a caption render with the video's
media_idand your timed cues. - Poll the returned
status_urluntil the job iscompletedorfailed. - Use
video.media_idto publish the new video, orvideo.urlto 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
| Field | Type | Required | Description |
|---|---|---|---|
media_id | uuid | Yes | Video media ID from the same workspace |
captions | array | Yes | 1–500 timed caption cues |
defaults | object | No | Style, 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
| Field | Type | Required | Limits and behavior |
|---|---|---|---|
start | number | Yes | Start time in seconds; must be ≥ 0 |
end | number | Yes | End time in seconds; must be after start and within the video duration |
text | string or span array | Yes | 1–500 characters; a span array contains 1–100 spans, joined without added spaces |
layer | integer | No | 0–50; higher captions draw on top when cues overlap |
style | object | No | Overrides default style fields for this cue |
position | object | No | Overrides default position fields for this cue |
animation | object | No | Overrides default animation fields for this cue |
highlight | object | No | Spoken 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.
| Setting | Default |
|---|---|
| Font | family: "Montserrat", size: 62, weight: "black" |
| Text | color: "#FFFFFF", opacity: 1, case: "uppercase" |
| Layout | align: "center", direction: "ltr", line_height: 1.08, max_width_pct: 82 |
| Text decoration | letter_spacing: 0, underline: false, strike: false |
| Stroke | color: "#000000", width: 4 |
| Shadow | color: "#000000", offset: 2, blur: 0.8 |
| Background | color: "#000000", opacity: 0, padding: 10, radius: 0 |
| Background border | color: "#000000", width: 0 |
| Position | anchor: "bottom", x_pct: 50, y_pct: 72 |
| Enter animation | type: "fade", duration_ms: 420 |
| Exit animation | type: "fade", duration_ms: 180 |
| Scale animation factors | from_scale: 0.8, to_scale: 0.8; used only with type: "scale" |
| Spoken highlight | None; 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
| Field | Type | Accepted values |
|---|---|---|
style.font.family | string | Inter, Arial, Roboto, Montserrat, Impact, Poppins, Open Sans, Noto Sans, Cairo, Noto Sans Arabic, Bebas Neue, Archivo Black |
style.font.size | integer | 10–320 video pixels. |
style.font.weight | string | regular, medium, bold, black |
style.text.color | string | Six-digit hex color, such as #FFFFFF |
style.text.opacity | number | 0–1; applies to the text fill, stroke, and shadow |
style.text.letter_spacing | number | -50–100 video pixels; negative values tighten text |
style.text.align | string | left, center, right |
style.text.direction | string | ltr (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_height | number | 0.8–2 |
style.text.case | string | original, uppercase, lowercase |
style.text.max_width_pct | number | 20–100 of the video width. A cap, not the box size. Shorter lines stay shorter. |
style.text.underline | boolean | Underline all cue text |
style.text.strike | boolean | Strike through all cue text |
style.stroke.color | string | Six-digit hex color |
style.stroke.width | number | 0–20 video pixels |
style.shadow.color | string | Six-digit hex color |
style.shadow.offset | number | 0–30 video pixels, down and right |
style.shadow.blur | number | 0–10; 0 is a hard edge |
style.background.color | string | Six-digit hex color |
style.background.opacity | number | 0–1 |
style.background.padding | number | 0–60 video pixels around the laid-out text |
style.background.radius | number | 0–80 video pixels |
style.background.border.color | string | Six-digit hex color |
style.background.border.width | number | 0–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.
| Result | Position |
|---|---|
| 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:
| Field | Type | Accepted values |
|---|---|---|
type | string | none, fade, slide-up, slide-down, slide-left, slide-right, scale |
duration_ms | integer | 0–2000 |
from_scale | number | Enter only; starting scale from 0.1–2, default 0.8 |
to_scale | number | Exit 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
| Problem | What to check |
|---|---|
| Caption is hidden by social UI | Move a bottom caption toward y_pct: 68–74 |
| Text wraps too early | Increase max_width_pct, reduce font size, or shorten the cue |
| Busy footage hurts readability | Add a background or increase stroke width |
| A cue is rejected | Confirm end > start, the cue is at least 0.01s, and end does not exceed the video duration |
| A highlighted word loses its style | Use a span array and keep the spaces inside adjacent span text |
| A spoken word never changes | Set highlight on the cue or defaults, and put highlight.start / highlight.end on the span, inside the cue |
Polling always returns 404 | Use 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
| Status | Meaning |
|---|---|
400 | Invalid body, missing source video, non-video media, unavailable storage path, or cue beyond the known duration |
401 | Invalid API key |
402 | Prior purchase required or insufficient credits |
403 | API key is missing the required scope |
404 | Render ID does not exist in this workspace |
429 | Rate limit exceeded; wait before retrying |
500 | Server 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.
| Tool | Required scope | Arguments |
|---|---|---|
powerpost_render_video_captions | videos:generate | media_id, captions, optional defaults and workspace_id |
powerpost_get_video_caption_render | videos:read | id (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.