# Reel Forge API Documentation > Complete reference for the Reel Forge video processing API. Base URL: https://api.reelforger.com # Reel Forge Public API The public API is built for one core workflow: submit a render manifest and poll job status until your final video URL is ready. **Free trial:** New accounts receive 50 free API credits to test the service before purchasing. Sign up at [reelforger.com](https://reelforger.com) to get started. ## Base URL - Production: `https://api.reelforger.com` ## Request Format The render endpoint expects the manifest at the **top level** of the JSON body. Include `version`, `output`, `assets`, and `composition` as direct keys. Do not nest them under a `manifest` key. Optional fields like `webhook_url` and `idempotency_key` are also top-level. ## Public Workflow 1. Call `POST /v1/videos/render` with your manifest. 2. Receive a `job_id` immediately. 3. Poll `GET /v1/jobs/:jobId` until `status` becomes `completed` or `failed`. 4. Read `output_url` when completed. ## Asynchronous Rendering Model Rendering runs in a queue-backed worker. The submit endpoint is non-blocking and does not wait for video processing to finish. --- ## Authentication All public API requests use API key authentication. Use this header on every request: ```bash Authorization: Bearer ``` ## Getting an API Key 1. Sign up at the [Reel Forge dashboard](https://reelforger.com) (new accounts receive 50 free credits). 2. Go to `API Keys`. 3. Create a new key and copy it immediately. 4. Store it in your backend/server environment variables. > Never embed API keys in frontend browser code. --- ## Working with Video Video layers are the foundation of most Reel Forge compositions. They allow you to place video assets onto the timeline, optionally trimming them, and controlling how they scale within your composition. ### Basic Video Object A basic video layer requires an `id`, `type`, `asset_id`, and `time` positioning. **When is `time` required?** For video (and audio) layers, `time` is required unless `composition.auto_stitch` is `true`—in that case, timing is derived automatically from clip order. ```json { "id": "main-video", "type": "video", "asset_id": "asset-video-1", "time": { "start_seconds": 0, "duration_seconds": 5 } } ``` ### Auto-Stitching & Arrays If you don't want to calculate exact timeline mathematics (`start_seconds`), Reel Forge supports `auto_stitch`. When enabled on the root `composition` object, you can completely omit the `time` property from your video layers. Reel Forge will automatically play them back-to-back in the order they appear in the `timeline` array. ```json { "composition": { "auto_stitch": true, "timeline": [ { "id": "clip-1", "type": "video", "asset_id": "asset-video-1" }, { "id": "clip-2", "type": "video", "asset_id": "asset-video-2" } ] } } ``` ### Z-Index Stacking By default, layers are drawn back-to-front based on their order in the `timeline` array. The first layer is the bottom-most background, and the last layer is on top. You can explicitly override this by providing a `z_index` inside the `layout` object. ```json { "id": "foreground-video", "type": "video", "asset_id": "asset-video-1", "layout": { "z_index": 100 } } ``` ### Background Mode & Letterboxing When you place a video with a different aspect ratio than your composition (e.g., a 16:9 landscape video into a 9:16 portrait composition), Reel Forge must pad the empty space. You can control this using the `background_mode` property: * `"blurred"` (Default): Creates a cinematic blurred and zoomed version of your video to fill the background. * `"solid"`: Fills the empty space with a solid black background. * `"transparent"`: Leaves the empty space transparent, allowing layers beneath it (lower z-index) to show through. ```json { "id": "landscape-clip", "type": "video", "asset_id": "asset-landscape-video", "background_mode": "blurred", "layout": { "fit": "contain" } } ``` ### The Full Video Request Example Here is a complete payload placing two videos sequentially using explicit timings, with the first video using a solid background mode. ```bash curl -X POST https://api.reelforger.com/v1/videos/render \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "asset-1", "type": "video", "url": "https://example.com/video1.mp4" }, { "id": "asset-2", "type": "video", "url": "https://example.com/video2.mp4" } ], "composition": { "timeline": [ { "id": "layer-1", "type": "video", "asset_id": "asset-1", "background_mode": "solid", "time": { "start_seconds": 0, "duration_seconds": 5 } }, { "id": "layer-2", "type": "video", "asset_id": "asset-2", "time": { "start_seconds": 5, "duration_seconds": 5 } } ] } }' ``` --- ## Working with Audio Audio layers function similarly to video layers but never appear visually on the canvas. They are entirely dedicated to the auditory mix. ### Basic Audio Object A basic audio layer requires an `asset_id` and a `time` definition. If the duration requested is longer than the source audio, the audio will simply end early unless you enable looping. ```json { "id": "bg-music", "type": "audio", "asset_id": "asset-music-1", "time": { "start_seconds": 0, "duration_seconds": 15 } } ``` ### Volume Control You can control the mix of your audio layer using the `volume` property inside `media_settings`. Volume is a multiplier: `1.0` is original volume, `0.5` is 50% volume, and `0.0` is muted. ```json { "id": "voiceover", "type": "audio", "asset_id": "asset-vo-1", "media_settings": { "volume": 0.8 } } ``` ### Looping Audio If you have a short music track (e.g., a 10-second loop) but your video composition is 60 seconds long, you can enable `loop` in the `media_settings`. The audio will repeat seamlessly until it reaches the layer's `duration_seconds`. ```json { "id": "looping-beat", "type": "audio", "asset_id": "asset-beat-1", "time": { "start_seconds": 0, "duration_seconds": 60 }, "media_settings": { "loop": true, "volume": 0.2 } } ``` ### Fades and Crossfades You can explicitly add fade-in and fade-out animations to audio layers using `fade_in_seconds` and `fade_out_seconds`. Reel Forge will automatically ramp the volume smoothly. ```json { "id": "fading-music", "type": "audio", "asset_id": "asset-music-1", "time": { "start_seconds": 0, "duration_seconds": 10 }, "media_settings": { "fade_in_seconds": 2.0, "fade_out_seconds": 3.0 } } ``` ### The Full Audio Request Example This example demonstrates how to add a quiet, looping background track to a 10-second composition. ```bash curl -X POST https://api.reelforger.com/v1/videos/render \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "asset-music", "type": "audio", "url": "https://example.com/looping-beat.mp3" } ], "composition": { "timeline": [ { "id": "bgm-layer", "type": "audio", "asset_id": "asset-music", "time": { "start_seconds": 0, "duration_seconds": 10 }, "media_settings": { "volume": 0.1, "loop": true, "fade_in_seconds": 1.0, "fade_out_seconds": 1.0 } } ] } }' ``` --- ## Working with Images Image layers allow you to render static `.jpg`, `.png`, or `.webp` files over time. They are incredibly powerful when combined with the `layout` object for Picture-in-Picture, logos, or watermarks. ### Basic Image Object An image layer needs an `asset_id` and a `time` definition. Unlike a video or audio file, an image inherently has no duration, so you **must** supply `duration_seconds`. ```json { "id": "watermark-logo", "type": "image", "asset_id": "asset-logo-1", "time": { "start_seconds": 0, "duration_seconds": 60 } } ``` ### Spatial Coordinates (`layout`) To position an image, supply a `layout` object. The `layout` allows you to define standard CSS-like spatial bounds. * `x`: Horizontal position (e.g., `"10%"`, `"20px"`). Maps to the CSS `left` property. * `y`: Vertical position (e.g., `"10%"`, `"20px"`). Maps to the CSS `top` property. * `width`: The width of the bounding box. * `height`: The height of the bounding box. * `fit`: How the image fits inside its bounding box (`"cover"` or `"contain"`). ```json { "id": "logo-top-right", "type": "image", "asset_id": "asset-logo-1", "layout": { "x": "80%", "y": "5%", "width": "15%", "height": "10%", "fit": "contain" } } ``` ### Background Mode (`background_mode`) Similar to Videos, images support `background_mode`. If your image does not perfectly match its layout bounding box, you can control the empty space. For things like transparent `.png` logos, you should always explicitly set `"background_mode": "transparent"`, otherwise they will render with a blurred background copy of themselves. ```json { "id": "transparent-logo", "type": "image", "asset_id": "asset-logo-1", "background_mode": "transparent", "layout": { "x": "10px", "y": "10px", "width": "100px", "height": "100px", "fit": "contain" } } ``` ### Styling Images The `style` object uses snake_case keys (consistent with the rest of the manifest): - `opacity` (number, 0–1) - `border_radius` (string, e.g. `"24px"`) - `box_shadow` (string, e.g. `"0 10px 30px rgba(0,0,0,0.5)"`) ```json { "id": "styled-image", "type": "image", "asset_id": "asset-pic", "style": { "opacity": 0.8, "border_radius": "24px", "box_shadow": "0 10px 30px rgba(0,0,0,0.5)" } } ``` ### The Full Image Request Example This example places a transparent PNG logo in the top right corner of the video for the entire 10-second duration. ```bash curl -X POST https://api.reelforger.com/v1/videos/render \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "asset-bg-video", "type": "video", "url": "https://example.com/background.mp4" }, { "id": "asset-logo", "type": "image", "url": "https://example.com/logo.png" } ], "composition": { "timeline": [ { "id": "base-video", "type": "video", "asset_id": "asset-bg-video", "time": { "start_seconds": 0, "duration_seconds": 10 } }, { "id": "overlay-logo", "type": "image", "asset_id": "asset-logo", "background_mode": "transparent", "time": { "start_seconds": 0, "duration_seconds": 10 }, "layout": { "x": "80%", "y": "5%", "width": "15%", "height": "10%", "fit": "contain", "z_index": 10 } } ] } }' ``` --- ## Working with Text Text now lives in `composition.text_overlays` instead of `composition.timeline`. This keeps media timing (video/audio/image) separate from text instructions for easier no-code payload generation. ### Basic Text Overlay Object A text overlay does not require an `asset_id` (there is no external media file to download). It requires `content` and `time` (`start_seconds`, `duration_seconds`). `id` is optional. ```json { "id": "intro-text", "content": "Welcome to Reel Forge", "time": { "start_seconds": 2, "duration_seconds": 3 } } ``` ### Layout & Bounding Boxes Use `layout` to control where text renders. If omitted, text defaults to full frame (`x: "0%"`, `y: "0%"`, `width: "100%"`, `height: "100%"`). ```json { "content": "Lower Third Caption", "layout": { "x": "10%", "y": "70%", "width": "80%", "height": "20%" }, "time": { "start_seconds": 0, "duration_seconds": 4 } } ``` ### Typography and Styling (`style`) Text overlays use strict snake_case for `style` keys (consistent with image/video layer styles). Accepted keys: - `font_size` (number) - `font_family` (string) - `font_weight` (number) - `color` (string) - `text_align` (`left` | `center` | `right` | `justify`) - `letter_spacing` (number, rendered as em) - `line_height` (number) - `stroke_color` (string) - `stroke_width` (number) - `shadow_color` (string) - `shadow_blur` (number) - `shadow_offset_x` (number) - `shadow_offset_y` (number) `text_align` only controls horizontal alignment inside the overlay's bounding box. Use `layout` (or `global_layouts.text`) to control vertical placement and overlay region. ```json { "content": "LOUD AND CLEAR", "time": { "start_seconds": 1, "duration_seconds": 3 }, "style": { "font_family": "Montserrat", "font_size": 120, "font_weight": 900, "color": "#FFD700", "text_align": "center", "stroke_color": "black", "stroke_width": 4, "shadow_color": "rgba(0,0,0,0.8)", "shadow_offset_x": 0, "shadow_offset_y": 8, "shadow_blur": 16 } } ``` ### Global Text Defaults You can define shared text defaults once and override per overlay: 1. `composition.global_styles.text` then `text_overlay.style` 2. `composition.global_layouts.text` then `text_overlay.layout` Local keys always win. ```json { "composition": { "global_styles": { "text": { "color": "white", "font_family": "Inter", "font_weight": 700, "shadow_color": "rgba(0,0,0,0.6)", "shadow_offset_x": 0, "shadow_offset_y": 4, "shadow_blur": 12 } }, "global_layouts": { "text": { "x": "0%", "y": "30%", "width": "100%", "height": "20%" } }, "text_overlays": [ { "id": "text-default", "content": "Uses global defaults", "time": { "start_seconds": 0, "duration_seconds": 2 } }, { "id": "text-override", "content": "Overrides color locally", "time": { "start_seconds": 2, "duration_seconds": 2 }, "style": { "color": "#FFD700" } } ] } } ``` ### Full Text Request Example ```bash curl -X POST https://api.reelforger.com/v1/videos/render \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "asset-bg-video", "type": "video", "url": "https://example.com/background.mp4" } ], "composition": { "timeline": [ { "id": "base-video", "type": "video", "asset_id": "asset-bg-video", "time": { "start_seconds": 0, "duration_seconds": 10 } } ], "text_overlays": [ { "id": "title-text", "content": "THE TRUTH REVEALED", "time": { "start_seconds": 1, "duration_seconds": 4 }, "layout": { "x": "0%", "y": "30%", "width": "100%", "height": "20%", "z_index": 10 }, "style": { "color": "white", "font_size": 80, "font_weight": 700, "font_family": "Inter", "text_align": "center", "stroke_color": "black", "stroke_width": 4 } } ] } }' ``` --- ## Recipe 1: Simple Stitching The most common use-case for a video processing API is taking several short clips and appending them back-to-back to create a single cohesive video. In Reel Forge, you can achieve this easily without calculating timeline mathematics by using the `auto_stitch` property. ### The Goal Take three separate 5-second video clips and combine them into a single 15-second vertical video. ### The JSON Payload ```json { "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "clip-1", "type": "video", "url": "https://example.com/intro.mp4" }, { "id": "clip-2", "type": "video", "url": "https://example.com/middle.mp4" }, { "id": "clip-3", "type": "video", "url": "https://example.com/outro.mp4" } ], "composition": { "auto_stitch": true, "timeline": [ { "id": "layer-intro", "type": "video", "asset_id": "clip-1", "background_mode": "blurred" }, { "id": "layer-middle", "type": "video", "asset_id": "clip-2", "background_mode": "blurred" }, { "id": "layer-outro", "type": "video", "asset_id": "clip-3", "background_mode": "blurred" } ] } } ``` ### The cURL Request ```bash curl -X POST https://api.reelforger.com/v1/videos/render \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "clip-1", "type": "video", "url": "https://example.com/intro.mp4" }, { "id": "clip-2", "type": "video", "url": "https://example.com/middle.mp4" }, { "id": "clip-3", "type": "video", "url": "https://example.com/outro.mp4" } ], "composition": { "auto_stitch": true, "timeline": [ { "id": "layer-intro", "type": "video", "asset_id": "clip-1" }, { "id": "layer-middle", "type": "video", "asset_id": "clip-2" }, { "id": "layer-outro", "type": "video", "asset_id": "clip-3" } ] } }' ``` ### Why this works Because `auto_stitch: true` is set, the API probes the actual duration of the source clips and automatically calculates the `start_seconds` and `duration_seconds` for each layer. `clip-2` will start the exact frame `clip-1` finishes. --- ## Recipe 2: Audio & Video Mixing Adding a background track to a voiceover or a silent video is a staple of social media content. ### The Goal Take a 15-second primary video clip, and overlay a quiet, looping Lo-Fi beat underneath it. We will also add a 2-second fade-in to the music so it doesn't start abruptly. ### The JSON Payload ```json { "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "main-video", "type": "video", "url": "https://example.com/voiceover.mp4" }, { "id": "lofi-beat", "type": "audio", "url": "https://example.com/lofi-loop.mp3" } ], "composition": { "timeline": [ { "id": "layer-video", "type": "video", "asset_id": "main-video", "time": { "start_seconds": 0, "duration_seconds": 15 } }, { "id": "layer-audio", "type": "audio", "asset_id": "lofi-beat", "time": { "start_seconds": 0, "duration_seconds": 15 }, "media_settings": { "volume": 0.15, "loop": true, "fade_in_seconds": 2.0 } } ] } } ``` ### The cURL Request ```bash curl -X POST https://api.reelforger.com/v1/videos/render \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "main-video", "type": "video", "url": "https://example.com/voiceover.mp4" }, { "id": "lofi-beat", "type": "audio", "url": "https://example.com/lofi-loop.mp3" } ], "composition": { "timeline": [ { "id": "layer-video", "type": "video", "asset_id": "main-video", "time": { "start_seconds": 0, "duration_seconds": 15 } }, { "id": "layer-audio", "type": "audio", "asset_id": "lofi-beat", "time": { "start_seconds": 0, "duration_seconds": 15 }, "media_settings": { "volume": 0.15, "loop": true, "fade_in_seconds": 2.0 } } ] } }' ``` ### Why this works By separating the `time` positioning and the `media_settings`, we have granular control. The `loop: true` ensures the audio never dies if the source MP3 is shorter than 15 seconds, and `volume: 0.15` ensures it stays firmly in the background. --- ## Recipe 3: Smart Trimming If your users are selecting clips from long podcast episodes or 2-hour livestreams, you do not want to download a 4GB file just to render a 10-second TikTok. Reel Forge features an intelligent **Smart Trim** pre-processor. By using the `trim` object, our servers use HTTP Range Requests to download *only* the specific bytes required for your clip, saving massive amounts of time and ensuring renders don't time out. ### The Goal Extract a specific 10-second clip from the middle of a long video, starting exactly at the 1-minute and 30-second mark (90 seconds). ### The JSON Payload ```json { "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "long-podcast", "type": "video", "url": "https://example.com/full-2-hour-podcast.mp4" } ], "composition": { "timeline": [ { "id": "highlight-clip", "type": "video", "asset_id": "long-podcast", "trim": { "start_seconds": 90 }, "time": { "start_seconds": 0, "duration_seconds": 10 } } ] } } ``` ### The cURL Request ```bash curl -X POST https://api.reelforger.com/v1/videos/render \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "long-podcast", "type": "video", "url": "https://example.com/full-2-hour-podcast.mp4" } ], "composition": { "timeline": [ { "id": "highlight-clip", "type": "video", "asset_id": "long-podcast", "trim": { "start_seconds": 90 }, "time": { "start_seconds": 0, "duration_seconds": 10 } } ] } }' ``` ### Why this works There is a critical difference between `trim` and `time`. * `trim.start_seconds` dictates where in the **source file** to begin extracting. * `time.start_seconds` dictates where in the **final rendered video** the clip should appear. *Note: Smart Trims are capped at a maximum of 5 minutes (300 seconds) of extraction duration.* --- ## Recipe 4: The Split-Screen Split-screen layouts (like a reaction video or gameplay/facecam) are achieved by explicitly defining the `layout` bounding boxes of two video layers so that they share the screen. ### The Goal Create a vertical 1080x1920 video with one video occupying the top 50% of the screen, and a second video occupying the bottom 50%. ### The JSON Payload ```json { "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "gameplay", "type": "video", "url": "https://example.com/gameplay.mp4" }, { "id": "facecam", "type": "video", "url": "https://example.com/facecam.mp4" } ], "composition": { "timeline": [ { "id": "layer-top", "type": "video", "asset_id": "gameplay", "background_mode": "solid", "time": { "start_seconds": 0, "duration_seconds": 10 }, "layout": { "x": "0%", "y": "0%", "width": "100%", "height": "50%", "fit": "contain" } }, { "id": "layer-bottom", "type": "video", "asset_id": "facecam", "background_mode": "solid", "time": { "start_seconds": 0, "duration_seconds": 10 }, "layout": { "x": "0%", "y": "50%", "width": "100%", "height": "50%", "fit": "contain" } } ] } } ``` ### The cURL Request ```bash curl -X POST https://api.reelforger.com/v1/videos/render \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "gameplay", "type": "video", "url": "https://example.com/gameplay.mp4" }, { "id": "facecam", "type": "video", "url": "https://example.com/facecam.mp4" } ], "composition": { "timeline": [ { "id": "layer-top", "type": "video", "asset_id": "gameplay", "background_mode": "solid", "time": { "start_seconds": 0, "duration_seconds": 10 }, "layout": { "x": "0%", "y": "0%", "width": "100%", "height": "50%", "fit": "contain" } }, { "id": "layer-bottom", "type": "video", "asset_id": "facecam", "background_mode": "solid", "time": { "start_seconds": 0, "duration_seconds": 10 }, "layout": { "x": "0%", "y": "50%", "width": "100%", "height": "50%", "fit": "contain" } } ] } }' ``` ### Why this works By explicitly defining `y: "0%"` for the top and `y: "50%"` for the bottom, they stack perfectly. We use `background_mode: "solid"` and `fit: "contain"` to ensure that whatever aspect ratio the source videos are, they are padded with black bars to fit inside their respective 50% bounding boxes without bleeding into each other. --- ## Recipe 5: The "Fact Drop" The "Fact Drop" is a highly engaging social media format where a background video loops while text facts appear on screen sequentially to the beat of a music track. ### The Goal Create a 9-second video with background music. Every 3 seconds, a new fact (text overlay) appears on screen, replacing the previous one. ### The JSON Payload ```json { "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "bg-video", "type": "video", "url": "https://example.com/satisfying-loop.mp4" }, { "id": "bg-music", "type": "audio", "url": "https://example.com/upbeat-music.mp3" } ], "composition": { "global_styles": { "text": { "font_size": 80, "font_weight": 700, "text_align": "center", "stroke_color": "black", "stroke_width": 2, "shadow_color": "black", "shadow_offset_x": 2, "shadow_offset_y": 2, "shadow_blur": 8 } }, "global_layouts": { "text": { "x": "0%", "y": "8%", "width": "100%", "height": "24%" } }, "timeline": [ { "id": "layer-video", "type": "video", "asset_id": "bg-video", "time": { "start_seconds": 0, "duration_seconds": 9 } }, { "id": "layer-music", "type": "audio", "asset_id": "bg-music", "time": { "start_seconds": 0, "duration_seconds": 9 }, "media_settings": { "volume": 0.5, "loop": true } } ], "text_overlays": [ { "id": "text-1", "content": "Did you know?", "time": { "start_seconds": 0, "duration_seconds": 3 }, "style": { "color": "white" } }, { "id": "text-2", "content": "Honey never spoils.", "time": { "start_seconds": 3, "duration_seconds": 3 }, "style": { "color": "#FFD700" } }, { "id": "text-3", "content": "Even after 3,000 years!", "time": { "start_seconds": 6, "duration_seconds": 3 }, "style": { "color": "#00FF00" } } ] } } ``` ### The cURL Request ```bash curl -X POST https://api.reelforger.com/v1/videos/render \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "bg-video", "type": "video", "url": "https://example.com/satisfying-loop.mp4" }, { "id": "bg-music", "type": "audio", "url": "https://example.com/upbeat-music.mp3" } ], "composition": { "global_styles": { "text": { "font_size": 80, "font_weight": 700, "text_align": "center", "stroke_color": "black", "stroke_width": 2, "shadow_color": "black", "shadow_offset_x": 2, "shadow_offset_y": 2, "shadow_blur": 8 } }, "global_layouts": { "text": { "x": "0%", "y": "8%", "width": "100%", "height": "24%" } }, "timeline": [ { "id": "layer-video", "type": "video", "asset_id": "bg-video", "time": { "start_seconds": 0, "duration_seconds": 9 } }, { "id": "layer-music", "type": "audio", "asset_id": "bg-music", "time": { "start_seconds": 0, "duration_seconds": 9 }, "media_settings": { "volume": 0.5, "loop": true } }, ], "text_overlays": [ { "id": "text-1", "content": "Did you know?", "time": { "start_seconds": 0, "duration_seconds": 3 }, "style": { "color": "white" } }, { "id": "text-2", "content": "Honey never spoils.", "time": { "start_seconds": 3, "duration_seconds": 3 }, "style": { "color": "#FFD700" } }, { "id": "text-3", "content": "Even after 3,000 years!", "time": { "start_seconds": 6, "duration_seconds": 3 }, "style": { "color": "#00FF00" } } ] } } }' ``` > **Note:** The API expects the manifest at the top level (not nested under `"manifest"`). Include `version`, `output`, `assets`, and `composition` as direct keys. ### Why this works We define the video and audio layers to last the full `duration_seconds: 9`. Then we slice up the text overlays linearly. `text-1` ends at `3`, exactly when `text-2` begins. Text styling and placement are defined once via `global_styles.text` and `global_layouts.text`, then each overlay only overrides `color`. This keeps the recipe concise and ensures size/position stay consistent across all facts. --- ## Webhooks Reel Forge supports per-job outbound webhooks so your backend can receive render results immediately without polling. When you submit `POST /v1/videos/render`, you can include: - `webhook_url` (optional) - `webhook_headers` (optional object of string headers) - `webhook_secret` (optional per-request signing secret override) - `metadata` (optional flat key-value pairs echoed in webhook payload; max 10 keys, 500 chars each) If `webhook_url` is provided, Reel Forge dispatches a webhook event when the render reaches a terminal state. ### How to Send Webhook Config in the Render Request 1. Add `webhook_url` to your `POST /v1/videos/render` payload. 2. Optionally add `webhook_headers` for routing/auth on your side. 3. Optionally add `webhook_secret`. If you omit it, Reel Forge uses your dashboard-managed signing secret (`whsec_...`). Example: ```json { "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "clip1", "type": "video", "url": "https://example.com/clip1.mp4" } ], "composition": { "auto_stitch": true, "timeline": [ { "id": "video-1", "type": "video", "asset_id": "clip1" } ] }, "webhook_url": "https://api.example.com/reelforge/webhooks", "webhook_headers": { "X-Environment": "production" } } ``` ### Event Payload Contract Webhook payloads follow a strict event envelope: ```json { "id": "evt_12345", "type": "video.render.completed", "data": { "job_id": "a4d3a59f-9da3-4b4e-8f37-8fd6d5cbd7f1", "status": "completed", "output_url": "https://assets.reelforger.com/user_xxx/job_yyy.mp4", "error": null, "metadata": {} }, "created_at": "2026-02-28T12:00:00.000Z" } ``` ### Event Types - `video.render.completed` - `video.render.failed` ### Headers Sent by Reel Forge Every webhook request includes: - `Content-Type: application/json` - `User-Agent: ReelForge-Webhooks/1.0` - Any custom keys from your `webhook_headers` If you provide `webhook_secret`, Reel Forge also includes: - `ReelForge-Signature: ` The signature is a lowercase hex HMAC-SHA256 digest over the exact raw JSON request body. ### Signature Verification (Node.js) Use the raw body bytes from your server framework. Compute the expected signature and compare using `crypto.timingSafeEqual`. #### Verification Checklist 1. **Store your signing secret** (starts with `whsec_`) from the Dashboard Developers page. 2. **Capture the raw request body** in your webhook endpoint (do not parse JSON first). 3. Read `ReelForge-Signature` from request headers. 4. Compute `HMAC_SHA256(raw_body, webhook_secret)`. 5. Compare computed vs provided signatures using constant-time comparison. 6. If verification fails, return `401`. 7. If verification passes, parse JSON and process event. 8. Return `2xx` quickly to stop retries. ```javascript import crypto from "node:crypto"; import express from "express"; const app = express(); app.use(express.raw({ type: "application/json" })); export function verifyReelForgeSignature(rawBody, signatureHeader, webhookSecret) { if (!signatureHeader || !webhookSecret) { return false; } const expectedHex = crypto.createHmac("sha256", webhookSecret).update(rawBody).digest("hex"); const expected = Buffer.from(expectedHex, "utf8"); const provided = Buffer.from(String(signatureHeader), "utf8"); if (expected.length !== provided.length) { return false; } return crypto.timingSafeEqual(expected, provided); } app.post("/reelforge/webhooks", (req, res) => { const signatureHeader = req.headers["reelforge-signature"]; const webhookSecret = process.env.REELFORGE_WEBHOOK_SECRET; // whsec_... const isValid = verifyReelForgeSignature( req.body, // raw Buffer signatureHeader, webhookSecret ); if (!isValid) { return res.status(401).json({ success: false, error: "Invalid signature" }); } const event = JSON.parse(req.body.toString("utf8")); // Handle event types if (event.type === "video.render.completed") { // event.data.output_url is available } if (event.type === "video.render.failed") { // event.data.error contains structured failure details } return res.status(200).json({ success: true }); }); ``` #### Common Pitfalls - Do not hash the parsed JSON object; hash the raw body bytes exactly as received. - Do not trim or reformat the body before computing HMAC. - Header names are case-insensitive, but value must match exactly. - Return `2xx` within 10s, otherwise Reel Forge retries. ### Delivery Timing and Retries Webhook delivery is handled by a dedicated queue with retries: - Attempts: `5` - Backoff: exponential, base delay `5000ms` - Request timeout: `10s` - Redirects: blocked (`3xx` is treated as failure) To stop retries, your endpoint must return a `2xx` response within 10 seconds. ### Best Practices - Handle duplicate events by `event.id` (idempotent receiver). - Persist inbound events before business logic. - Return `2xx` quickly, then process asynchronously. - Keep `webhook_secret` unique per integration environment. --- ## Handling Errors When building a resilient integration with Reel Forge, it is important to understand both our synchronous HTTP errors (API rejections) and our asynchronous worker errors (Render Failures). ### Synchronous API Errors Public endpoints return JSON errors immediately using this envelope: ```json { "success": false, "error": { "message": "Human-readable message", "code": "machine_readable_code", "request_id": "uuid", "details": {} } } ``` | Status | Meaning | |---|---| | `400` | Invalid request payload (e.g., Zod schema validation failed). | | `401` | Missing or invalid API key. | | `402` | Insufficient credits. | | `404` | Job not found. | | `409` | Idempotent request exists but is still processing. | | `429` | Rate limit exceeded. | | `500` | Internal server error. | ### Rate Limits Public API key routes are rate-limited per user to protect our database infrastructure. - Window: 60 seconds - Limit: 60 requests per window --- ### Asynchronous Render Errors (Semantic Validation) Reel Forge protects the render farm by pre-flighting your requested `manifest` before attempting a heavy render. If your payload is structurally valid (returns a `202 Accepted`) but mathematically or physically impossible, the render job will fail asynchronously. When a job fails, your webhook (or `GET /v1/jobs/:jobId` polling response) will include a structured `error` object. In webhooks it appears as `data.error`; in the job status response it is the top-level `error` field when `status` is `failed`: ```json { "code": "manifest_out_of_bounds", "message": "Layer 'text-1' starts outside the computed video bounds.", "details": { "layer_id": "text-1", "start_seconds": 12, "total_duration_seconds": 10 } } ``` #### Common Worker Error Codes | Code | Explanation | How to Fix | |---|---|---| | `invalid_asset_url` | We attempted a `HEAD` or `GET` request to your asset URL, but it returned a 4xx/5xx status or a non-media Content-Type (like `text/html`). | Ensure the URL is public, does not require authentication, and points directly to the raw media file (not a webpage). | | `impossible_trim` | You requested a `trim.start_seconds` and `duration_seconds` that exceeds the actual length of the source video. | Use `ffprobe` on your end to ensure your trim math fits inside the source asset. | | `manifest_out_of_bounds` | You placed a layer (like text or an image) to start at a time that occurs *after* the final video has ended. | Ensure `layer.time.start_seconds` is strictly less than the total computed duration of your video and audio tracks. | | `asset_too_large` | The asset exceeds our maximum file size limits (or Smart Trim bytes limit). | Compress your source files or use Smart Trimming (`trim.start_seconds`) to download smaller segments. | | `duration_too_long` | The requested output duration exceeds 60 seconds, or a Smart Trim extraction exceeds 5 minutes (300 seconds). | Shorten your final video to ≤60s, or reduce Smart Trim extraction to ≤5min. | | `worker_crash` | An unexpected failure inside the Remotion engine or Chromium headless browser. | If this persists, verify your assets aren't corrupted or using unsupported, heavy codecs. | *Note: If an asynchronous error occurs during rendering, the credits reserved for that job are automatically refunded to your account.* --- ## API Reference ### `POST /v1/videos/render` Submits a render request and enqueues an asynchronous video job. #### Parameters | Name | Type | Required | Description | |---|---|---|---| | `Authorization` (header) | string | Required | `Bearer ` | | `idempotency-key` (header) | string | Optional | Request dedupe key (header takes precedence) | | `version` | string | Required | Manifest version | | `output.width` | number | Required | Output width in pixels (`> 0`) | | `output.height` | number | Required | Output height in pixels (`> 0`) | | `output.fps` | number | Required | Output FPS (`> 0`) | | `assets[]` | array | Optional | Asset list (defaults to empty array) | | `assets[].id` | string | Required (per asset) | Unique asset ID | | `assets[].type` | enum | Required (per asset) | `video \| audio \| image` | | `assets[].url` | string(url) | Required (per asset) | Public URL for media | | `composition.auto_stitch` | boolean | Optional | Defaults to `false` | | `composition.timeline[]` | array | Optional | Media layer list (video, audio, image only) | | `composition.timeline[].id` | string | Required | Unique layer ID | | `composition.timeline[].type` | enum | Required | `video \| audio \| image` | | `composition.timeline[].asset_id` | string | Required for `video`/`audio`/`image` | Must reference `assets[].id` | | `composition.timeline[].time` | object | Required for `image` (no inherent duration). Required for `video`/`audio` unless `composition.auto_stitch` is `true`; then derived from clip order. | Layer timing | | `composition.text_overlays[]` | array | Optional | Text overlay list (see [Working with Text](primitives-text.md)) | | `composition.timeline[].trim.start_seconds` | number | Optional | Trim start (`>= 0`) | | `composition.timeline[].style` | object | Optional | CSS-like style overrides | | `composition.timeline[].media_settings.*` | object | Optional | Layer media controls (volume, fades, loop, etc.) | | `webhook_url` | string(url) | Optional | Public HTTPS endpoint that accepts `POST` requests from Reel Forge webhooks | | `webhook_headers` | object | Optional | Custom headers sent with outbound webhook POST | | `webhook_secret` | string | Optional | Optional per-request signing override. If omitted, Reel Forge uses your dashboard-managed `whsec_...` signing secret | | `idempotency_key` | string | Optional | Payload-level dedupe key | | `metadata` | object | Optional | Flat key-value pairs (strings, numbers, booleans; max 10 keys, 500 chars each) echoed in webhook payload | #### Request Example (cURL) ```bash curl -X POST "https://api.reelforger.com/v1/videos/render" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{ "version": "v1", "output": { "width": 1080, "height": 1920, "fps": 30 }, "assets": [ { "id": "clip1", "type": "video", "url": "https://example.com/clip1.mp4" } ], "composition": { "auto_stitch": true, "timeline": [ { "id": "video-1", "type": "video", "asset_id": "clip1" } ] }, "webhook_url": "https://api.example.com/reelforge/webhooks", "webhook_headers": { "X-Environment": "production", "X-Integration": "render-pipeline" }, "metadata": { "make_scenario_id": "12345", "user_email": "test@test.com" } }' ``` #### Request Example (Node.js fetch with webhook config) ```js const response = await fetch("https://api.reelforger.com/v1/videos/render", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer ", }, body: JSON.stringify({ version: "v1", output: { width: 1080, height: 1920, fps: 30 }, assets: [ { id: "clip1", type: "video", url: "https://example.com/clip1.mp4" } ], composition: { auto_stitch: true, timeline: [{ id: "video-1", type: "video", asset_id: "clip1" }] }, webhook_url: "https://api.example.com/reelforge/webhooks", webhook_headers: { "X-Environment": "production" }, metadata: { make_scenario_id: "12345", user_email: "test@test.com" } // webhook_secret is optional: // if omitted, Reel Forge uses your dashboard-managed signing secret (whsec_...) }) }); const data = await response.json(); console.log(data); ``` #### Response Example (`202 Accepted`) ```json { "success": true, "job_id": "8f1fd0fe-63a5-4fef-8f2c-8f1225f6d309", "request_id": "req_abc123" } ``` #### Response Example (`200 OK`, idempotent replay) ```json { "success": true, "data": { "deduped": true, "job": { "id": "8f1fd0fe-63a5-4fef-8f2c-8f1225f6d309", "status": "processing", "output_url": null, "error_message": null, "created_at": "2026-02-26T10:00:00.000Z", "updated_at": "2026-02-26T10:00:05.000Z" } } } ``` --- ### `GET /v1/jobs/:jobId` Returns the current status for a previously submitted render job. #### Parameters | Name | Type | Required | Description | |---|---|---|---| | `Authorization` (header) | string | Required | `Bearer ` | | `jobId` (path) | string | Required | Job identifier | #### Request Example (cURL) ```bash curl -X GET "https://api.reelforger.com/v1/jobs/" \ -H "Authorization: Bearer " ``` #### Response Example (`200 OK`) ```json { "success": true, "request_id": "req_abc123", "data": { "id": "8f1fd0fe-63a5-4fef-8f2c-8f1225f6d309", "status": "completed", "output_url": "https://assets.reelforger.com/user_xxx/job_yyy.mp4", "error_message": null, "created_at": "2026-02-26T10:00:00.000Z", "updated_at": "2026-02-26T10:00:45.000Z", "total_duration_seconds": 5.6, "credit_cost": 6 } } ``` --- ## OpenAPI Schema Machine-readable JSON schema: https://api.reelforger.com/v1/openapi.json