@xformmedia/sdk
v0.14.0
Published
Official SDK for the xform.media service
Downloads
80
Readme
@xformmedia/sdk
Official SDK for the xform.media service.
Installation
npm install @xformmedia/sdkOverview
The SDK ships two clients:
| Client | Key type | Use for |
|---|---|---|
| XformClient | Source key | Video ingest, upload, and playback operations on a single source |
| XformAdminClient | Organization key | Programmatic source management + all video operations (enterprise / server-to-server) |
API keys are created in the xform admin:
- Source keys — Source detail page → API Keys tab
- Organization keys — Org settings → API Keys
XformClient — Source-scoped client
import { XformClient } from '@xformmedia/sdk'
const client = new XformClient({
sourceId: 'your-source-id',
organizationId: 'your-org-id',
apiKey: 'xfm_your_source_key',
baseUrl: 'https://admin.xform.media',
})client.ingest(options)
Trigger transcoding for a video already in your connected S3/R2 bucket. Call this after your own upload completes.
const result = await client.ingest({
key: 'recordings/standup.mp4',
callbackUrl: 'https://your-app.com/webhooks/xform', // optional
})
console.log(result.videoKey) // "recordings-standup"
console.log(result.transcodeStatus) // "pending"videoKey — the output identifier
Every ingested video is stored under a stable videoKey that becomes:
- its URL slug —
/_v/{videoKey}.m3u8,/_d/{videoKey},/_embed/{videoKey} - its R2 output path prefix
- its per-source lookup key (used by
videos.get(), analytics, re-ingest, etc.)
It is how the video is identified, independent of the source files it was assembled from.
| Ingest type | videoKey behavior |
|---|---|
| Single file (key) | Optional. Auto-derived from key if omitted (e.g. recordings/standup.mp4 → recordings-standup). Pass explicitly if you want a stable id independent of the bucket path. |
| Multi-segment (segments) | Required. Auto-derivation would use segments[0].key, which collides whenever videos share a common first segment (brand intro, lead-in clip, etc.) and would silently overwrite the wrong video on re-ingest. You must name the output explicitly. |
Format: 1-200 chars, lowercase alphanumeric plus - and _, may not start or end with -. The server rejects invalid values with 400.
// Single-file ingest with explicit videoKey (recommended)
await client.ingest({
videoKey: 'alice-standup-2026-03',
key: 'recordings/alice/standup.mp4',
})
// Multi-segment ingest — videoKey is required
await client.ingest({
videoKey: 'alice-standup-branded',
segments: [
{ key: 'brand/intro.mp4' }, // shared across all videos
{ key: 'recordings/alice/standup.mp4' },
{ key: 'brand/outro.mp4' }, // shared across all videos
],
})Without the explicit videoKey in the second example, every video using brand/intro.mp4 as its first segment would try to reuse the same derived key and collide.
Trimming source segments
Each entry in segments may carry optional startSec / endSec to trim its source before concatenation. Times are in seconds, relative to the source file.
// Trim the first 2s and last 3s of a 60s recording
await client.ingest({
videoKey: 'alice-standup-trimmed',
segments: [{ key: 'recordings/standup.mp4', startSec: 2, endSec: 57 }],
})
// Cut out seconds 10-15 from a 30s recording
await client.ingest({
videoKey: 'alice-standup-cut',
segments: [
{ key: 'recordings/standup.mp4', startSec: 0, endSec: 10 },
{ key: 'recordings/standup.mp4', startSec: 15, endSec: 30 },
],
})The same source file can appear in multiple segments — the worker downloads each unique key once and reuses the buffer.
Picking a thumbnail frame
By default the thumbnail is the frame at t=0. Pass thumbnailTimestampSec to extract any other frame from the transcoded MP4 via ffmpeg. The chosen timestamp is persisted on the video and automatically re-applied on subsequent re-ingests.
// Use the frame at 12.5s as the thumbnail
await client.ingest({
videoKey: 'alice-lesson-1',
key: 'recordings/alice/lesson-1.mp4',
thumbnailTimestampSec: 12.5,
})To change the thumbnail later without re-transcoding, see videos.regenerateThumbnail().
Auto-generating captions
xform can generate WebVTT captions for you via AWS Transcribe. Two trigger modes:
| Ingest shape | Behavior |
|---|---|
| Multi-segment (segments with length > 1) | Captions are generated automatically — browser-side speech recognition doesn't survive server-side concatenation, so if you didn't supply captionsVTT, the server will fill them in. |
| Single-segment (key or single-entry segments) | Opt in by passing generateCaptions: true. Useful as a backup when browser-side capture fails or isn't available. |
// Multi-segment — captions auto-generated, no flag needed
await client.ingest({
videoKey: 'session-abc',
segments: [
{ key: 'recordings/part-1.webm' },
{ key: 'recordings/part-2.webm' },
],
})
// Single-segment — opt in explicitly
await client.ingest({
videoKey: 'alice-lesson-1',
key: 'recordings/alice/lesson-1.mp4',
generateCaptions: true,
})How it works:
- Caption generation runs asynchronously after the transcode completes. The video flips to
readyimmediately (audio and video are serviceable), and captions arrive 1-2× real-time later. - When captions land,
captionsPathis populated on the video doc and avideo.captions_readyevent fires on both the SSE stream and your source webhook. generateCaptions: trueacts as an override — it regenerates captions even if the video already has some. Useful for "redo captions" flows.captionsVTTalways wins: if you supply a VTT at ingest time,generateCaptionsis ignored and your captions are stored as-is.- Auto-generation on multi-segment ingests is skipped when captions already exist on the video (e.g., the user hand-edited them and is re-ingesting with new segments). Pass
generateCaptions: trueto force a refresh.
// Listen for captions arriving after ingest
const stream = client.events()
stream.on('video.captions_ready', (video) => {
console.log(`Captions ready: https://${subdomain}.xform.media/_v/${video.videoKey}/captions.vtt`)
})To edit captions later without regenerating, see videos.uploadCaptions().
Re-ingesting an existing video (append / replace)
Pass overwrite: true together with the same videoKey as the original ingest to replace a ready video in place without changing its public URLs, analytics, or database id. Use this for append-to-recording flows where you want viewers to keep hitting the same URL after new content is added.
// Original ingest
await client.ingest({
videoKey: 'session-abc',
segments: [{ key: 'recordings/session-abc/part-1.webm' }],
})
// Later: append a second segment, keeping the same videoKey.
await client.ingest({
videoKey: 'session-abc',
segments: [
{ key: 'recordings/session-abc/part-1.webm' }, // original
{ key: 'recordings/session-abc/part-2.webm' }, // new append
],
overwrite: true,
})Rules:
overwrite: trueis required to replace areadyvideo — without it, a second ingest against the samevideoKeyreturns409.- Failed videos can always be re-ingested without the flag (same as a retry).
- Re-ingest is rejected with
409if the existing video isprocessingorpending— wait for it to finish, even withoverwrite: true. Concurrent transcodes against the samevideoKeyare not supported. captionsVTTcannot be combined withoverwrite: true(returns400). Useclient.videos.uploadCaptions()to update captions on an existing video.- The database
id,videoKey, stream/download URLs, embed URL, and analytics history all stay intact — only the transcoded output and stored segments list are replaced.
client.createUploadUrl(options)
Get a presigned URL to upload a file directly to xform's storage, bypassing your own servers.
const { uploadUrl, key } = await client.createUploadUrl({
filename: 'recording.mp4',
contentType: 'video/mp4',
})
// Upload directly from browser or server
await fetch(uploadUrl, {
method: 'PUT',
body: fileBlob,
headers: { 'Content-Type': 'video/mp4' },
})
// Then trigger transcoding
const result = await client.ingest({ key })client.events()
Open an SSE connection to receive real-time video status updates. Events are emitted as videos transition through: pending → processing → ready / failed.
const stream = client.events()
stream.on('video.ready', (video) => {
console.log(`${video.videoKey} is ready (${video.duration}s)`)
console.log(`Stream: https://${subdomain}.xform.media${video.streamUrl}`)
console.log(`Download: https://${subdomain}.xform.media${video.downloadUrl}`)
console.log(`Thumbnail: https://${subdomain}.xform.media${video.thumbnailUrl}`)
})
stream.on('video.failed', (video) => {
console.error(`${video.videoKey} failed: ${video.errorMessage}`)
})
// Listen to all events
stream.on('*', (video) => {
console.log(video.videoKey, video.transcodeStatus)
})
// Close when done
stream.close()This is an alternative to polling client.videos.get() or providing a callbackUrl. The stream uses Server-Sent Events over a persistent HTTP connection — no webhook endpoint needed.
Ingest + wait for ready:
const stream = client.events()
stream.on('video.ready', (video) => {
console.log(`Stream: https://${subdomain}.xform.media${video.streamUrl}`)
console.log(`Download: https://${subdomain}.xform.media${video.downloadUrl}`)
stream.close()
})
stream.on('video.failed', (video) => {
console.error(video.errorMessage)
stream.close()
})
await client.ingest({ key: 'recordings/standup.mp4' })client.videos.get(videoKey)
Get the current status and metadata of a video.
const video = await client.videos.get('recordings-standup')
if (video.transcodeStatus === 'ready') {
const subdomain = 'acme' // your source subdomain
console.log(`Stream: https://${subdomain}.xform.media/_v/${video.videoKey}.m3u8`)
console.log(`Download: https://${subdomain}.xform.media/_d/${video.videoKey}`)
}client.videos.delete(videoId)
Delete a video and all its renditions by database ID. This action is irreversible.
const result = await client.ingest({ key: 'recordings/standup.mp4' })
// Later...
await client.videos.delete(result.id)client.videos.list(options?)
List all videos for this source, newest first.
const { data } = await client.videos.list({ limit: 20, skip: 0 })
for (const video of data) {
console.log(video.videoKey, video.transcodeStatus, video.duration)
}client.videos.rename(videoId, displayName)
Set the human-readable display name for a video. The videoKey (URL slug) is unchanged — only the label shown in the dashboard and displayName field are updated. Fires a video.renamed webhook if configured.
const result = await client.ingest({ key: 'recordings/standup.mp4' })
await client.videos.rename(result.id, 'Daily Standup — March 6')client.videos.regenerateThumbnail(videoKey, timestampSec)
Pick a new thumbnail frame for an already-transcoded video. Runs asynchronously: ffmpeg extracts the frame from the transcoded MP4, overwrites thumbnail.jpg in R2, and purges the CDN cache so viewers see the new frame on their next request. The chosen timestamp is persisted, so subsequent re-ingests automatically re-apply it.
// Use the frame at 12.5s as the new thumbnail
await client.videos.regenerateThumbnail('alice-lesson-1', 12.5)Requires transcodeStatus === 'ready' and timestampSec < duration. Returns 409 if the video is still pending or processing, 400 if the timestamp is past the video's duration.
To set the thumbnail at ingest time instead, pass thumbnailTimestampSec to ingest().
client.videos.uploadCaptions(videoKey, vtt)
Upload or replace the WebVTT captions track for a video. Captions are served from /_v/{videoKey}/captions.vtt and surfaced in the embed player when present.
const vtt = `WEBVTT
00:00:00.000 --> 00:00:04.000
Welcome to today's standup.
00:00:04.000 --> 00:00:08.000
Let's start with updates from the platform team.
`
await client.videos.uploadCaptions('alice-standup', vtt)Captions can also be supplied at first ingest via the captionsVTT option. They cannot be combined with overwrite: true — use this method to update captions on an existing video.
client.videos.deleteCaptions(videoKey)
Remove the captions track for a video.
await client.videos.deleteCaptions('alice-standup')client.videos.analytics(options?)
Fetch playback analytics for this source. Returns a summary by default, or a detailed report (with breakdowns by day, referrer, quality, engagement heatmap, search terms, UTM campaigns, and recent sessions) when detailed: true.
// Source-wide summary
const summary = await client.videos.analytics()
console.log(summary.totalViews, summary.uniqueSessions, summary.avgCompletionRate)
// Per-video, last 30 days
const stats = await client.videos.analytics({
videoKey: 'alice-standup',
since: '2026-03-15T00:00:00Z',
})
// Detailed report (includes heatmap, referrers, UTM, etc.)
const detailed = await client.videos.analytics({ detailed: true })
console.log(detailed.viewsByDay)
console.log(detailed.engagementHeatmap) // 20 buckets across video duration
console.log(detailed.utmCampaigns)The return type is conditional on detailed — TypeScript narrows to VideoAnalyticsDetailed when detailed: true, otherwise VideoAnalyticsSummary.
client.embed(videoKey, options?)
Build the iframe HTML for embedding a video in a third-party page. Requires streamBaseUrl to be set on the client.
const client = new XformClient({
sourceId,
organizationId,
apiKey,
baseUrl: 'https://admin.xform.media',
streamBaseUrl: 'https://acme.xform.media',
})
const html = client.embed('alice-standup', {
autoplay: true,
muted: true,
logo: 'https://example.com/logo.svg',
color: '#7c3aed',
captions: true,
utmTracking: true,
})
// Returns: <iframe src="https://acme.xform.media/_embed/alice-standup?autoplay=1&..." ...></iframe>Options:
| Option | Description |
|---|---|
| autoplay | Start playback automatically (browsers require muted: true to honor this) |
| muted | Start muted |
| startAt | Seek to this timestamp (seconds) on load |
| loop | Restart from the beginning when playback ends |
| controls | Show player controls (default true; pass false for chromeless embeds) |
| quality | Pin to a rendition: '1080p' \| '720p' \| '480p' \| 'auto' (default) |
| logo | Absolute URL of a logo image to overlay on the player |
| color | Accent color for the player (any CSS color) |
| poster | Show the thumbnail before playback (default true) |
| title | Display a title overlay |
| captions | Show the captions toggle (default off, even if captions are uploaded) |
| search | Show the in-player search box (default true) |
| utmTracking | Inject a script that forwards UTM params from the parent page URL into the embed |
client.usage()
Get storage and bandwidth usage for this source.
const usage = await client.usage()
console.log(usage.totalVideos)
console.log(usage.totalDurationSeconds)
console.log(usage.totalFileSizeBytes)client.updatePlan(input)
Change the source's plan, billing cycle, or add-ons. All fields are optional — omit any to leave it unchanged.
// Upgrade to Pro (prorated immediately)
await client.updatePlan({ name: 'Pro', billingCycle: 'monthly' })
// Switch to annual billing
await client.updatePlan({ billingCycle: 'annual' })
// Enable Video add-on on Starter plan
await client.updatePlan({ addOns: { video: true } })
// Downgrade to Starter with Smart Crop (no credit issued, takes effect next cycle)
const billing = await client.updatePlan({
name: 'Starter',
billingCycle: 'monthly',
addOns: { smartCrop: true },
})
console.log(billing.status) // "active"
console.log(billing.plan.name) // "Starter"
console.log(billing.plan.billingCycle) // "monthly"Behaviour:
- Upgrades are prorated and charged immediately
- Downgrades take effect at the next billing cycle — no credit is issued
- Downgrading Pro → Starter automatically removes any attached custom domain
- Upgrading Starter → Pro automatically removes the Smart Crop add-on (Pro includes it)
XformAdminClient — Organization-scoped client
For enterprise integrations that need to create and manage sources programmatically. Requires an organization-level API key.
import { createXformAdminClient } from '@xformmedia/sdk'
const admin = createXformAdminClient({
organizationId: 'your-org-id',
apiKey: 'xfm_your_org_key',
baseUrl: 'https://admin.xform.media',
})admin.sources.checkSubdomain(subdomain)
Check if a subdomain is available before creating a source.
const { available } = await admin.sources.checkSubdomain('my-app')admin.sources.create(input)
Create a new source. This provisions a subdomain, Cloudflare DNS record, and Stripe subscription item.
const source = await admin.sources.create({
name: 'My App Media',
subdomain: 'my-app',
provider: 'cloudflare-r2',
credentials: {
bucket: 'my-bucket',
endpoint: 'https://accountid.r2.cloudflarestorage.com',
accessKeyId: 'your-r2-key-id',
secretAccessKey: 'your-r2-secret',
},
plan: {
name: 'Starter',
billingCycle: 'monthly',
},
})
console.log(source.id) // "64a1b2c3..."
console.log(source.subdomain) // "my-app"
// Media now served at: https://my-app.xform.media/Supported providers: 'aws', 'cloudflare-r2', 'public-web'
admin.sources.list()
const sources = await admin.sources.list()admin.sources.get(sourceId)
const source = await admin.sources.get('source-id')
console.log(source.subdomain, source.status)admin.sources.update(sourceId, input)
Update a source's name or credentials.
await admin.sources.update('source-id', {
name: 'New Name',
credentials: {
accessKeyId: 'rotated-key-id',
secretAccessKey: 'rotated-secret',
},
})admin.source(sourceId) — per-source operations
All video operations, event streaming, and plan management are available via admin.source(id):
const src = admin.source(sourceId)
// Real-time events
const stream = src.events()
stream.on('video.ready', (video) => console.log(video.videoKey, 'ready'))
// Plan management
await src.updatePlan({ name: 'Pro' })
await src.updatePlan({ addOns: { video: true } })
// Video ingest
const { uploadUrl, key } = await src.createUploadUrl({ filename: 'clip.mp4', contentType: 'video/mp4' })
await fetch(uploadUrl, { method: 'PUT', body: blob, headers: { 'Content-Type': 'video/mp4' } })
const job = await src.ingest({ key })
// Video status
const video = await src.videos.get(job.videoKey)
const { data } = await src.videos.list({ limit: 50 })
// Delete a video by ID
await src.videos.delete(job.id)
// Usage
const usage = await src.usage()admin.source(sourceId).webhook — Webhook configuration
Configure a webhook to receive HTTP POST notifications when video events occur. All payloads are signed with HMAC-SHA256 for verification.
const src = admin.source(sourceId)
// Set a webhook (subscribes to all events by default)
await src.webhook.set({
url: 'https://your-app.com/webhooks/xform',
secret: 'whsec_your_signing_secret',
})
// Or subscribe to specific events only
await src.webhook.set({
url: 'https://your-app.com/webhooks/xform',
secret: 'whsec_your_signing_secret',
events: ['video.ready', 'video.failed'],
})
// Get current config (secret is masked)
const config = await src.webhook.get()
// { url: "https://...", secret: "whse••••••••", events: [...] }
// Remove webhook
await src.webhook.delete()Webhook events:
| Event | Trigger | Payload data fields |
|---|---|---|
| video.pending | Ingest accepted, job queued | (empty) |
| video.processing | Worker picked up the job and started transcoding | attempt, maxAttempts |
| video.ready | Transcode completed | streamUrl, downloadUrl, duration, thumbnailUrl, width, height |
| video.failed | Transcode failed (after final retry) | errorMessage |
| video.renamed | Video display name changed | displayName |
| video.captions_ready | Auto-generated captions finished uploading (fires after video.ready for multi-segment or generateCaptions: true ingests) | captionsUrl |
Payload format:
{
"event": "video.ready",
"sourceId": "64a1b2c3...",
"videoKey": "recordings-standup",
"timestamp": "2026-03-06T12:00:00.000Z",
"data": {
"streamUrl": "/_v/recordings-standup.m3u8",
"downloadUrl": "/_d/recordings-standup",
"duration": 42.5,
"thumbnailUrl": "/_v/recordings-standup/thumbnail.jpg",
"width": 1920,
"height": 1080
}
}Verifying signatures:
import { createHmac } from 'crypto'
function verifyWebhook(body: string, signature: string, secret: string): boolean {
const expected = createHmac('sha256', secret).update(body).digest('hex')
return signature === expected
}
// In your webhook handler:
app.post('/webhooks/xform', (req, res) => {
const signature = req.headers['x-webhook-signature']
const event = req.headers['x-webhook-event']
if (!verifyWebhook(JSON.stringify(req.body), signature, 'whsec_your_secret')) {
return res.status(401).send('Invalid signature')
}
// Process event...
res.sendStatus(200)
})Webhooks are retried up to 3 times with exponential backoff if your endpoint returns a non-2xx status.
Full end-to-end example
// 1. Create source
const { available } = await admin.sources.checkSubdomain('pixelfilm-user-123')
if (!available) throw new Error('Subdomain taken')
const source = await admin.sources.create({
name: 'User 123',
subdomain: 'pixelfilm-user-123',
provider: 'cloudflare-r2',
credentials: { bucket, endpoint, accessKeyId, secretAccessKey },
plan: { name: 'Starter', billingCycle: 'monthly' },
})
// 2. Upload and ingest with real-time status
const src = admin.source(source.id)
const stream = src.events()
stream.on('video.ready', (video) => {
console.log(`https://${source.subdomain}.xform.media/_v/${video.videoKey}.m3u8`)
stream.close()
})
stream.on('video.failed', (video) => {
console.error(video.errorMessage)
stream.close()
})
const { uploadUrl, key } = await src.createUploadUrl({
filename: 'recording.mp4',
contentType: 'video/mp4',
})
await fetch(uploadUrl, { method: 'PUT', body: blob, headers: { 'Content-Type': 'video/mp4' } })
await src.ingest({ key })Error Handling
All methods throw XformError on API failures.
import { XformError } from '@xformmedia/sdk'
try {
await admin.sources.create({ ... })
} catch (err) {
if (err instanceof XformError) {
console.error(err.statusCode, err.message)
console.error(err.body) // full response body
}
}| Status | Meaning |
|---|---|
| 400 | Invalid input — missing fields, bad subdomain format, etc. |
| 401 | Invalid or expired API key |
| 403 | Key scope mismatch — wrong source, or source key used on org endpoint |
| 404 | Source or video not found |
| 409 | Conflict — subdomain already taken, or video already exists and overwrite: true was not set, or video is currently processing |
| 500 | Server error — retry with backoff |
TypeScript
All types are exported:
import type {
// Clients
XformClientOptions,
XformAdminClientOptions,
// Sources
Source,
SourceBilling,
SourcePlan,
SourceCredentials,
SourceProvider,
SourceStatus,
CreateSourceInput,
UpdateSourceInput,
UpdatePlanInput,
BillingCycle,
BillingStatus,
PlanName,
CheckSubdomainResult,
// Webhooks
WebhookEventType,
WebhookConfig,
SetWebhookInput,
// Videos
Video,
VideoEvent,
VideoEventType,
VideoEventStream,
VideoRendition,
VideoTranscodeStatus,
VideoRenditionQuality,
IngestSegment,
IngestResult,
UploadUrlResult,
ListVideosResult,
VideoUsageResult,
// Analytics
VideoAnalyticsOptions,
VideoAnalyticsSummary,
VideoAnalyticsDetailed,
// Embed
EmbedOptions,
} from '@xformmedia/sdk'