Developer documentation
Brand-Pipe API
One HTTPS call turns a raw clip into a platform-ready video: fitted to 9:16, 1:1 or 16:9, with your logo, loudness normalised to −14 LUFS, optionally with burned-in captions. This page is the complete reference.
https://api.brand-pipe.com1. Get a key
Check out the free tier (25 credits, no card). After checkout you accept the terms and your key is shown once. Keys start with bp_. Store it in a secret manager or your automation tool's credential store.
2. Process a clip
curl -X POST https://api.brand-pipe.com/v1/process \
-H "X-API-Key: $BRAND_PIPE_KEY" \
-H "Content-Type: application/json" \
-d '{
"video_url": "https://example.com/raw.mp4",
"logo_url": "https://example.com/logo.png",
"format": "vertical"
}'3. Download the result
{
"success": true,
"data": {
"job_id": "6bbee170c2a54f0b9c1e…",
"result_url": "https://…/6bbee170….mp4?X-Amz-Signature=…",
"duration_s": 10.0,
"had_audio": true,
"format": "vertical",
"video_reencoded": true,
"source_color_warning": null,
"audio_normalized": true,
"audio_skip_reason": null
},
"error": null
}result_url is a signed link, valid for one hour. The file itself is deleted after 24 hours, so download it or hand it straight to your upload step.
Authentication
Send your key in the X-API-Key header on every request.
X-API-Key: bp_your_keyDo not use Authorization: Bearer. The hosting platform in front of the API reads Bearer tokens as its own identity tokens and rejects the request with 401 before Brand-Pipe sees it.
- Missing or invalid key →
401. - Key has no credits left →
402. Buy a top-up with the same email; the credits land on the key you already have. - Rate limit: 300 requests per minute per key, status polls included →
429. Back off and retry. - Lost key: keys are not stored in plaintext. Email support@brand-pipe.com and you get a new key with your remaining credits.
Response envelope
Every response, including errors and unexpected failures, is JSON with the same three fields. Branch on success, not only on the HTTP status.
| Field | Type | Meaning |
|---|---|---|
success | boolean | true when the request did what it was asked to. |
data | object | null | The payload. On some errors it carries extra context, such as credits_refunded or a status_url. |
error | string | null | A human-readable message that is safe to show or log. null on success. |
{ "success": false, "data": null, "error": "No credits left on this API key." }Process a video (synchronous)
/v1/processanswers when the video is ready · up to 60 s outputDownloads the source, renders it and answers with the result in the same request, typically within seconds. Use it for short clips. For anything longer, or for clients with short timeouts, use POST /v1/jobs.
Headers
| Header | Description |
|---|---|
X-API-Keyrequired | Your API key. |
Content-Typerequired | application/json |
Idempotency-Key | Optional, 1–255 printable characters. Makes retries safe. See Idempotency. |
Request body
| Field | Type | Default | Description |
|---|---|---|---|
video_urlrequired | URL | — | Public http(s) URL of the source video. Anything ffmpeg can read: MP4, MOV, WebM, MKV with H.264, HEVC, VP9, AV1 and more. Max 200 MB. |
format | string | vertical | vertical 1080×1920 (Shorts, TikTok, Reels) · square 1080×1080 · landscape 1920×1080. The source is fitted inside the frame without cropping. |
background | string | black | What fills the frame where the source does not reach: black bars or a blurred, enlarged copy of the video. |
logo_url | URL | null | PNG with transparency. Scaled to 15 % of the output width and placed 20 px from the bottom-right corner. |
audio_normalize | boolean | true | Normalise loudness to −14 LUFS integrated, −1 dBTP true peak. Near-silent sources (below −50 LUFS) are left as they are instead of being amplified into hiss. |
start_s | number ≥ 0 | 0 | Where the output window starts in the source, in seconds. Snaps to the nearest keyframe, so the cut is accurate to a second or two. |
duration_s | number > 0 | rest of source | Length of the output window. The duration limit applies to this window, not to the whole source, so you can cut a clip out of a long recording. |
subtitles | string | null | SRT or WebVTT text, UTF-8, max 512 KB. Burned into the picture. +1 credit. See Captions. |
subtitles_url | URL | null | Same, fetched from a public URL. Send either subtitles or subtitles_url, not both. |
All URLs must point to public hosts. Private, loopback, link-local and cloud metadata addresses are rejected with 400, including when a redirect leads there.
Response data
| Field | Type | Description |
|---|---|---|
job_id | string | Identifier of this render. With an Idempotency-Key it can also be read on GET /v1/jobs/{job_id}. |
result_url | string | Signed download URL for the MP4 (H.264, AAC, fast start). Valid for 1 hour; the file is kept for 24 hours. |
duration_s | number | Duration of the output in seconds. |
had_audio | boolean | Whether the source had an audio stream. |
format | string | The preset that was rendered. |
video_reencoded | boolean | false when the source already matched the preset (same resolution, H.264, 8-bit 4:2:0, no logo, no captions). The picture is then copied losslessly and only the audio is processed. |
source_color_warning | string | null | Set when the source's colour tags contradict themselves, for example an SD colour matrix on HD material. Reported only, never corrected. |
audio_normalized | boolean | Whether loudness normalisation ran. |
audio_skip_reason | string | null | Why it did not: not requested, source has no audio stream, or a note that the source measured as silent. |
Too big for the synchronous endpoint? Before any work starts, the API estimates the encode time. A source that would not finish in time (roughly: more than ~58 s of 1080p30, ~28 s of 1080p60, ~22 s of 4K60) is refused with 413 and a message that names POST /v1/jobs. The credit is returned right away (credits_refunded: true).
Submit an async job
/v1/jobsanswers 202 immediately · up to 10 min outputTakes the same body as /v1/process, plus an optional callback_url. It answers in well under a second and renders in the background. The credit is charged when the job is accepted and refunded if it fails.
| Extra field | Type | Description |
|---|---|---|
callback_url | URL | Optional. When the job finishes, its status is POSTed here. See Callbacks. |
curl -X POST https://api.brand-pipe.com/v1/jobs \
-H "X-API-Key: $BRAND_PIPE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: upload-2026-09-16-001" \
-d '{
"video_url": "https://example.com/long-recording.mp4",
"start_s": 30,
"duration_s": 240,
"background": "blur",
"callback_url": "https://hooks.example.com/brand-pipe"
}'{
"success": true,
"data": {
"job_id": "3f9c1a…",
"status": "queued",
"status_url": "https://api.brand-pipe.com/v1/jobs/3f9c1a…"
},
"error": null
}Jobs are never dropped under load. They wait in a queue and start as capacity frees up.
Get job status
/v1/jobs/{job_id}free · does not use creditsPolling costs nothing, but it counts toward the rate limit. An interval of 5–10 seconds is plenty. Only the key that submitted a job can read it; any other key gets 404. Each poll signs a fresh result_url, so a workflow that comes back hours later still gets a working link, as long as that is within 24 hours.
{
"success": true,
"data": {
"job_id": "3f9c1a…",
"status": "done",
"result_url": "https://…signed…",
"duration_s": 240.0,
"had_audio": true,
"format": "vertical",
"video_reencoded": true,
"source_color_warning": null,
"audio_normalized": true,
"audio_skip_reason": null,
"error": null,
"credits_refunded": false,
"callback_delivered": true
},
"error": null
}A failed job has result_url: null, the same public message in data.error that the synchronous endpoint would have returned, and credits_refunded: true. The request itself still answers 200 with success: true, because the status was read successfully. Check data.status.
Callbacks
Pass callback_url to POST /v1/jobs and you do not have to poll. When the job reaches done or failed, Brand-Pipe POSTs the job status to that URL: the same object as data in GET /v1/jobs/{id}, without the envelope.
Content-Type: application/json,User-Agent: BrandPipe/….- Any 2xx counts as delivered. A network error or 5xx is retried once after 2 seconds; a 4xx is final. Redirects are not followed.
- The URL must be public, and it is checked again at delivery time.
- The outcome is recorded as
callback_deliveredon the job, so polling remains a fallback if your endpoint was down.
Callbacks are notifications, not authenticated messages. Anyone who knows your callback URL could post to it. If the result matters, read status_url with your own key before acting on it.
Idempotency & retries
Networks drop responses, and automation tools retry. Send an Idempotency-Key header on either POST endpoint and a repeat of the same request returns the first result instead of rendering and charging again. Keys belong to your API key and are remembered for 24 hours.
| If the first request with this key… | the repeat gets |
|---|---|
| finished | The same result (sync: 200 with the stored data and a freshly signed URL; async: 202 with the job's current status), plus the header Idempotent-Replayed: true. No credit. |
| is still running (sync) | 409 with data.status_url. Poll it or retry later. No second render is started. |
| is queued or running (async) | 202 with the same job_id and its current status. |
| failed | A fresh attempt. The failed one was refunded, so the net cost is one charge. |
| was rejected before charging (401, 402, 422…) | Nothing was stored; the repeat is a normal new request. |
| had a different body | 409: a key names one request, not a whole workflow. |
Use a value that is unique per item. In n8n that is {{ $execution.id }}-{{ $itemIndex }}. The execution id alone gives every item of a multi-item run the same key, and only the first item would be rendered.
The guarantee covers retries, which always arrive after the first attempt has ended. Two identical requests sent at the same moment can both run.
Burned-in captions
Send an SRT or WebVTT file you already have, such as the output of your transcription step, and it is rendered into the picture. Brand-Pipe does not transcribe.
{
"video_url": "https://example.com/raw.mp4",
"subtitles": "1\n00:00:00,500 --> 00:00:02,800\nThree tips for better Shorts\n"
}| Formats | SRT and WebVTT, inline (subtitles) or by URL (subtitles_url). UTF-8 only. |
| Style | One style: white, bold, heavy black outline, centred above the area where TikTok and Shorts draw their own buttons. Scales with the output, so every format looks the same. Not configurable yet. |
| Markup | <i>, <b>, <v Speaker> and inline timestamps are removed; HTML entities are resolved. Captions cannot restyle or reposition themselves. |
| Timing | Cue times refer to the source. With start_s/duration_s they are shifted onto the output window, and cues outside it are dropped. |
| Limits | 512 KB, 2,000 cues, 500 characters per cue, at most 8 cues on screen at once. A broken cue is skipped; a file with no usable cue is a 422. |
| Cost | +1 credit, so a captioned video costs 2. Captions always re-encode the picture. |
n8n
Three ready-made workflows are available on the website: n8n-brand-pipe.json (process and download), n8n-brand-pipe-youtube.json (schedule, generate, process, upload to YouTube) and n8n-brand-pipe-jobs.json (async job for long clips, the callback resumes a Wait node).
- Create a Header Auth credential named Brand-Pipe API Key with header name
X-API-Keyand your key as the value. - In the HTTP Request node, add the header
Idempotency-Key={{ $execution.id }}-{{ $itemIndex }}and enable Retry On Fail. - For long clips, use
POST /v1/jobsand follow it with a Wait node set to Resume: On Webhook Call, HTTP method POST. Send{{ $execution.resumeUrl }}ascallback_url, and set Limit Wait Time so the execution cannot hang forever. n8n-brand-pipe-jobs.json is exactly that, with a timeout branch that reads the status URL. The resume URL has to be reachable from the internet (n8n Cloud is; a local instance needs a publicWEBHOOK_URL), otherwise the API rejects it at submission.
Code examples
Submit an async job and poll until it is finished.
import os, time, httpx
API = "https://api.brand-pipe.com"
HEADERS = {"X-API-Key": os.environ["BRAND_PIPE_KEY"]}
def render(video_url: str, idempotency_key: str) -> str:
r = httpx.post(f"{API}/v1/jobs", headers={**HEADERS, "Idempotency-Key": idempotency_key},
json={"video_url": video_url, "format": "vertical"}, timeout=30)
body = r.json()
if not body["success"]:
raise RuntimeError(f"{r.status_code}: {body['error']}")
status_url = body["data"]["status_url"]
while True:
job = httpx.get(status_url, headers=HEADERS, timeout=30).json()["data"]
if job["status"] == "done":
return job["result_url"]
if job["status"] == "failed":
raise RuntimeError(job["error"])
time.sleep(5)const API = "https://api.brand-pipe.com";
const headers = { "X-API-Key": process.env.BRAND_PIPE_KEY, "Content-Type": "application/json" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function render(videoUrl, idempotencyKey) {
const res = await fetch(`${API}/v1/jobs`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": idempotencyKey },
body: JSON.stringify({ video_url: videoUrl, format: "vertical" }),
});
const body = await res.json();
if (!body.success) throw new Error(`${res.status}: ${body.error}`);
for (;;) {
const { data: job } = await (await fetch(body.data.status_url, { headers })).json();
if (job.status === "done") return job.result_url;
if (job.status === "failed") throw new Error(job.error);
await sleep(5000);
}
}API=https://api.brand-pipe.com
STATUS_URL=$(curl -s -X POST $API/v1/jobs \
-H "X-API-Key: $BRAND_PIPE_KEY" -H 'Content-Type: application/json' \
-d '{"video_url":"https://example.com/raw.mp4","format":"vertical"}' | jq -r .data.status_url)
while :; do
JOB=$(curl -s "$STATUS_URL" -H "X-API-Key: $BRAND_PIPE_KEY")
STATUS=$(echo "$JOB" | jq -r .data.status)
[ "$STATUS" = done ] && { echo "$JOB" | jq -r .data.result_url; break; }
[ "$STATUS" = failed ] && { echo "$JOB" | jq -r .data.error >&2; exit 1; }
sleep 5
doneLimits
POST /v1/process | POST /v1/jobs | |
|---|---|---|
| Output length | 60 s, and the encode-time estimate (see above) | 600 s (10 min) |
| Source file | 200 MB, public http(s) URL | same |
| Response | when the video is ready | 202 immediately |
| Rate limit | 300 requests/min per key | same, status polls included |
| Download link | 1 hour | 1 hour per signing, re-signed on every poll |
| Retention | result 24 h | result, job status and idempotency keys 24 h |
Processing runs in Frankfurt (Google Cloud); results are stored on Cloudflare R2 in the EU jurisdiction.
Credits & refunds
- 1 credit per video, whatever its length. 2 credits with captions.
- Credits are charged when a request is accepted, and refunded automatically if the download, encode or upload fails afterwards. The error response then carries
data.credits_refunded: true; failed jobs show the same field. - Requests rejected up front (invalid body, blocked URL, bad key) are never charged. A source too large for the endpoint is charged and refunded at once.
- One exception: an encode that runs into the processing time limit keeps its credit (
credits_refunded: false). The estimate refuses almost every such source before work starts; what remains is a source that is far denser than it declares. - Status polls and idempotent replays are free.
- Credits never expire. Top-ups bought with the same email land on your existing key.
Errors
The error field carries the message shown here. Stack traces and internal details are never returned.
| Status | Message / cause | What to do |
|---|---|---|
400 | URL is not allowed (private host or non-http scheme) · malformed Idempotency-Key | Use a public URL; key of 1–255 printable characters. |
401 | Invalid or missing API key. | Check the X-API-Key header. |
402 | No credits left on this API key. | Top up with the same email. |
404 | Job not found. Also returned for jobs that belong to another key or have expired (kept at least 24 hours). | Check the job id and key. |
409 | Idempotency key reused with a different body · first request with this key still in progress (data.status_url) | Use a new key per request; or poll the status URL. |
413 | Source file larger than 200 MB · source needs more processing time than the endpoint allows | Use POST /v1/jobs, shorten with start_s/duration_s, or send a lower frame rate. |
422 | Invalid request: <fields> · not a readable video or too long · subtitles could not be read | Fix the named fields; check the file and duration. |
424 | Could not download the source file. | Make sure the URL is reachable without login and returns the file. |
429 | Rate limit exceeded for this API key. | Back off and retry, with an Idempotency-Key. |
500 | Video processing failed · processing exceeded the time limit | Retry once; contact support if it persists. |
503 | Authentication, result storage or the job queue is temporarily unavailable. | Retry after a short wait. Safe with an Idempotency-Key. |
A good default retry policy: retry 429, 500 and 503 with exponential backoff and an Idempotency-Key. Do not retry other 4xx errors unchanged.
Questions or something not covered here? support@brand-pipe.com