Video Captions
Add timed, styled captions to a video.
Add timed captions to a video in your workspace media library. The result is saved as a new MP4.
Rendering requires a prior credit purchase.
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
}Rate limit: 30 requests per minute per API key.
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, and animation inherited by every cue |
defaults are deeply merged with the built-in defaults. A cue can then override individual fields without repeating the rest of the style.
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 after spans are joined |
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 |
Times are rendered at centisecond precision. Keep start and end at least 0.01 seconds apart.
Built-in defaults
If you send only cue timing and text, PowerPost uses these values:
| Group | Defaults |
|---|---|
| Font | Montserrat, size 62, weight black |
| Text | White, opacity 1, letter spacing 0, centered, uppercase, line height 1.08, max width 82% |
| Stroke | Black, width 4 |
| Shadow | Black, offset 2, blur 0.8 |
| Background | Transparent, padding 10, radius 0, no border |
| Position | Bottom anchor at x: 50%, y: 72% |
| Animation | Fade in 420ms, fade out 180ms |
Style fields, positioning, and text spans
Style fields
| Field | Type | Accepted values |
|---|---|---|
style.font.family | string | Inter, Arial, Roboto, Montserrat, Impact, Poppins, Open Sans, Noto Sans, Bebas Neue, Archivo Black |
style.font.size | integer | 10–320 video pixels. Two letters on 1080p often need 240–320. |
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.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.
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 absolute coordinates from 0 to 100. 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 spans. 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.
Get render status
GET /api/v1/videos/captions/renders/{id}
Scope: videos:read
Poll every 2–5 seconds until status is completed or failed. Rate limit: 120 requests per minute per API key.
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 |
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 |
MCP tools
These tools take the same JSON as the REST endpoints above, including defaults, per-cue style/position/animation, span arrays, and scale animations.
| Tool | What it does |
|---|---|
powerpost_render_video_captions | Starts the render and returns caption_render_id |
powerpost_get_video_caption_render | Poll until completed or failed; on success, use video.media_id |