Automation and APIs for video creators
Headless video tools: connect clipping to your site or CMS
A headless video tool does the processing and hands you files and data. It has no opinion about where they end up. Your site, CMS or app decides how clips are stored, reviewed, published and embedded. This guide covers the plumbing between the two.
If you arrived from an old link about Angular and headless Drupal, welcome. The decoupled architecture that community explored years ago is exactly how modern video workflows are wired, so there is a section on that below.
What "headless" means for video
In CMS terms, headless means the content repository exposes data over an API and does not render the pages itself. For video tooling the idea is the same, applied to processing:
- The processor (your own ffmpeg workers, or a hosted clipping tool) produces files: clips, posters, caption files and a manifest describing them.
- Storage holds the bytes, usually an object store fronted by a CDN.
- The CMS holds the metadata: title, transcript excerpt, tags, status, which page or post uses which clip.
- The front end, coupled or decoupled, reads the CMS and points a player at the CDN URL.
Keeping those four roles separate is the whole trick. When they blur, for example when the CMS stores gigabytes of video in its own database or local file system, backups balloon and every deployment becomes risky.
Storage first
Put video files in object storage, not on the web server. A few rules that save pain later:
- Use immutable keys that include a job or content ID, for example
clips/2026/10/ep-412/003-vertical.mp4. Never overwrite a published file; write a new key. - Serve through a CDN that supports HTTP Range requests, so players can seek without downloading the whole file.
- Encode MP4 files with the index at the front (
-movflags +faststart) so playback starts before the download finishes. See video format and codec for short clips. - Generate a poster image per clip at render time. It is one extra ffmpeg call and saves a layout shift on every page.
- Keep private drafts behind signed URLs and only make the final key public on publish.
# poster frame one second in, next to the clip
ffmpeg -ss 1 -i 003-vertical.mp4 -frames:v 1 -q:v 3 003-vertical.jpg
Drupal: Media entities over JSON:API
Drupal's core Media module models each video as a media entity with a source field. On a standard install it provides a local video type and a remote video type that uses oEmbed for YouTube or Vimeo URLs. Field machine names vary by site, so check yours under Structure, Media types before writing code. The examples below assume a video bundle with a file field called field_media_video_file.
Core's JSON:API module supports file uploads, documented in the JSON:API file uploads guide. Write operations must be allowed in the JSON:API settings, and the file field must accept the mp4 extension. The upload sends raw bytes, not multipart form data:
curl -s -X POST -b cookie.txt \
-H "Accept: application/vnd.api+json" \
-H "Content-Type: application/octet-stream" \
-H "X-CSRF-Token: $TOKEN" \
-H 'Content-Disposition: file; filename="003-vertical.mp4"' \
--data-binary "@003-vertical.mp4" \
https://cms.example.com/jsonapi/media/video/field_media_video_file
The response contains the new file entity's UUID. Then create the media entity that references it:
POST /jsonapi/media/video HTTP/1.1
Host: cms.example.com
Content-Type: application/vnd.api+json
Accept: application/vnd.api+json
X-CSRF-Token: {token}
{
"data": {
"type": "media--video",
"attributes": { "name": "Episode 412, clip 3: the deploy that broke Friday" },
"relationships": {
"field_media_video_file": {
"data": { "type": "file--file", "id": "{file-uuid-from-upload}" }
}
}
}
}
For very large sources, uploading through PHP is the wrong path. Upload the file to object storage directly and have Drupal reference it through a remote stream wrapper or a remote video media type instead.
WordPress: the media endpoint
WordPress exposes media at POST /wp/v2/media (see the media endpoint reference). For a script, authenticate with an Application Password, which WordPress has shipped since version 5.6 according to the REST API authentication docs. Like Drupal, the upload body is the raw file:
curl -s -X POST --user "bot:abcd efgh ijkl mnop qrst uvwx" \
-H "Content-Type: video/mp4" \
-H 'Content-Disposition: attachment; filename="003-vertical.mp4"' \
--data-binary "@003-vertical.mp4" \
https://blog.example.com/wp-json/wp/v2/media
The JSON response includes the attachment ID and source_url. Store the ID in your manifest, then attach it to a draft post in a second call. Upload size is limited by your PHP and web server configuration, which is another reason to keep long sources out of WordPress and only push finished clips.
Webhooks: let the processor call you
Polling a processing job every few seconds works, but webhooks are cleaner. When a job finishes, the processor sends an HTTP POST to your endpoint with the manifest. A typical payload:
{
"event": "clips.ready",
"event_id": "evt_7f3c2a",
"job_id": "ep-412",
"clips": [
{
"index": 3,
"start": 1290.55,
"end": 1331.10,
"url": "https://cdn.example.com/clips/2026/10/ep-412/003-vertical.mp4",
"poster": "https://cdn.example.com/clips/2026/10/ep-412/003-vertical.jpg",
"aspect": "9:16"
}
]
}
Your receiver should verify, deduplicate and respond fast. Here is a minimal handler pattern in Python using only the standard library for the signature check:
import hashlib
import hmac
import json
import os
SECRET = os.environ["WEBHOOK_SECRET"].encode()
def verify(secret: bytes, body: bytes, signature_header: str) -> bool:
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)
def handle(body: bytes, headers: dict, seen_ids: set, queue) -> int:
if not verify(SECRET, body, headers.get("X-Signature", "")):
return 401
event = json.loads(body)
if event["event_id"] in seen_ids:
return 200 # duplicate delivery, already handled
seen_ids.add(event["event_id"])
queue.put(event) # do the slow CMS work in a background worker
return 202
- Compute the HMAC over the raw request body, before any JSON parsing.
- Compare with a constant time function such as
hmac.compare_digest. - Store event IDs and ignore duplicates; senders retry.
- Return quickly and move the CMS writes to a queue.
- Log the payload so you can replay it when your CMS was down.
If you build your own pipeline, as described in how an automated clip pipeline works, the notify stage is where this webhook is sent from.
Decoupled front ends
A decoupled front end, whether Angular, React, Vue or a static site generator, reads clips from the CMS API and renders its own player. With Drupal JSON:API, fetching the latest clips with their files in one request looks like this:
GET /jsonapi/media/video?include=field_media_video_file&sort=-created&page[limit]=12 HTTP/1.1
Host: cms.example.com
Accept: application/vnd.api+json
The included array carries the file entities, including their URIs. Map them to CDN URLs in one place in your front end, not in every component, so a storage move is a one line change.
Lessons from the early headless Drupal days still apply:
- Cache API responses at the edge. Clip lists change rarely compared to how often they are read.
- Do not expose write credentials to the browser. Uploads go through your backend or a pipeline worker.
- Prerender pages that list clips so search engines and link previews see real content and a poster image.
Embeds that behave on mobile
For self-hosted clips, the plain HTML video element is usually enough:
<video controls playsinline preload="metadata"
width="1080" height="1920"
poster="https://cdn.example.com/clips/2026/10/ep-412/003-vertical.jpg">
<source src="https://cdn.example.com/clips/2026/10/ep-412/003-vertical.mp4" type="video/mp4">
<track kind="captions" src="/captions/ep-412-003.vtt" srclang="en" label="English">
</video>
playsinline keeps iOS from forcing full screen, preload="metadata" avoids downloading every clip on a list page, and explicit width and height let the browser reserve a 9:16 box. Add a WebVTT captions track when captions are not burned in. For long videos with many viewers, adaptive streaming (HLS or DASH) is worth the extra packaging step; for short clips a single well encoded MP4 is often fine.
If the clip already lives on YouTube, use Drupal's remote video media type or WordPress's built in embeds and let the platform serve it.
Where the clips come from
Everything above works the same whether you render clips with your own workers or download them from a hosted tool. If you do not want to maintain the clipping stage, you can upload a long video to ViceForge, export the finished clips with captions in the aspect ratio you need, and push them into your CMS with the scripts above. In that setup the hand off from the clipping tool is a manual step, while the CMS side stays fully scriptable.
Next, publish those clips automatically: automating Shorts publishing with APIs and webhooks.