Lumra Media Player SDK
Drop-in video player with HLS streaming, paywalls, chapters, analytics, colour grading, and VAST ads. Works in any HTML page or React app. The core player is free.
Quick Start — CDN
freeOne script tag. Works in any HTML page — PHP, Django, WordPress, plain HTML. No npm, no build step. The core player is completely free with no license required.
<div id="player" style="aspect-ratio:16/9"></div>
<script src="https://cdn.jsdelivr.net/npm/@lumra/embed@0.1.17/dist/lumra-player.global.js"></script>
<script>
Lumra.createPlayer(document.getElementById('player'), {
src: 'https://your-cdn.com/video.m3u8', // HLS or MP4
poster: 'https://your-cdn.com/thumb.jpg',
mediaInfo: {
title: 'My Video',
creator: { name: 'Studio Name' },
},
})
</script>
HLS (.m3u8) and MP4 both work. Adaptive bitrate is automatic via hls.js. Safari uses native HLS. Add paywalls, chapters, and analytics for free — no API key required.
React / Next.js
freeInstall the React package for full TypeScript types and hook-based access.
import { LumraPlayer } from '@lumra/react'
export function VideoPage() {
return (
<LumraPlayer
src="https://your-cdn.com/stream.m3u8"
poster="https://your-cdn.com/thumb.jpg"
style={{ width: '100%', aspectRatio: '16/9' }}
/>
)
}
Next.js (App Router)
Lumra requires a browser environment. Use dynamic to prevent SSR errors:
import dynamic from 'next/dynamic'
const LumraPlayer = dynamic(
() => import('@lumra/react').then(m => m.LumraPlayer),
{ ssr: false }
)
Paywall & Stripe
The player renders the lock overlay. You control access on your server — the player calls onUnlock and you redirect to Stripe.
Flow
GET /api/access?resourceId=video-123 — returns {"{ allowed: true/false }"}paywall: {"{ locked: !allowed }"} — overlay appears with your pricing optionsonUnlock(optionId) fires — POST to your backend, get back {"{ url }"}, redirect to Stripeplayer.update({"{ paywall: { locked: false } }"})const { allowed } = await fetch('/api/access?resourceId=video-123').then(r => r.json())
const player = Lumra.createPlayer(document.getElementById('player'), {
src: 'https://your-cdn.com/premium.m3u8',
paywall: {
locked: !allowed,
title: 'Unlock this video',
subtitle: 'One-time purchase',
options: [
{ id: 'buy', label: 'Buy', badge: '$9.99', description: 'Own forever' },
{ id: 'rent', label: 'Rent', badge: '$2.99', description: '48-hour access' },
],
onUnlock: async (optionId) => {
const { url } = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resourceId: 'video-123', optionId }),
}).then(r => r.json())
window.location.href = url
},
},
})
The ?payment=success param is for your UI to re-check. Never grant access based on URL params alone — they can be forged. The Stripe webhook signature is cryptographically verified.
Payment Modules & Stripe Connect
The paywall's onUnlock(optionId) callback is your integration point — return a checkout URL from any payment provider and the player redirects. The player never touches payment logic directly.
Reusable payment module pattern
Wrap your checkout logic in a factory function so it's portable across videos:
// paymentModule.js — drop in any project
function createStripePaywall(resourceId, options) {
return {
locked: true,
title: 'Unlock this video',
subtitle: 'One-time purchase',
options,
onUnlock: async (optionId) => {
const { url } = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resourceId, optionId }),
}).then(r => r.json())
window.location.href = url
},
}
}
// Usage — one line per video
const player = Lumra.createPlayer(el, {
src: 'https://your-cdn.com/film.m3u8',
paywall: createStripePaywall('film-001', [
{ id: 'buy', label: 'Buy', badge: '$12.99', description: 'Own forever' },
{ id: 'rent', label: 'Rent', badge: '$3.99', description: '48-hour access' },
]),
})
Stripe Standard Checkout — server route
Your /api/checkout creates a Stripe session and returns the URL. The Stripe secret key never leaves your server.
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
// Price catalogue — map optionId → Stripe Price ID
const PRICES = {
buy: { priceId: 'price_xxx', mode: 'payment' },
rent: { priceId: 'price_yyy', mode: 'payment' },
}
app.post('/api/checkout', async (req, res) => {
const { resourceId, optionId } = req.body
const { priceId, mode } = PRICES[optionId]
const session = await stripe.checkout.sessions.create({
mode,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${BASE_URL}/watch/${resourceId}?payment=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/watch/${resourceId}`,
metadata: { resourceId, optionId },
})
res.json({ url: session.url })
})
Listen for checkout.session.completed on your /api/webhook endpoint. The ?payment=success URL is only for UI feedback — never grant access based on URL params, they can be forged. Verify Stripe's webhook signature with stripe.webhooks.constructEvent().
Stripe Connect — pay creators directly
Use Connect when you're building a platform where creators receive revenue. Your platform takes an application fee; Stripe routes the rest to the creator's connected account.
app.post('/api/checkout', async (req, res) => {
const { resourceId, optionId } = req.body
// Look up the video and its creator's connected Stripe account
const video = await db.videos.findById(resourceId)
const creatorConnectId = video.creatorStripeAccountId // e.g. 'acct_xxxx'
const session = await stripe.checkout.sessions.create(
{
mode: 'payment',
line_items: [{ price: video.stripePriceId, quantity: 1 }],
payment_intent_data: {
application_fee_amount: Math.round(video.priceInCents * 0.10), // 10% platform fee
transfer_data: { destination: creatorConnectId },
},
success_url: `${BASE_URL}/watch/${resourceId}?payment=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/watch/${resourceId}`,
metadata: { resourceId, creatorId: video.creatorId },
},
{ stripeAccount: creatorConnectId } // charge on behalf of creator
)
res.json({ url: session.url })
})
Creators complete Stripe's onboarding via stripe.accountLinks.create() with type: 'account_onboarding'. Store the returned account.id in your database. See Stripe Connect onboarding docs.
Unlock the player after payment returns
On the success page, re-check access in your DB (after the webhook has run) then call player.update():
// On page load — check URL for returning payment
const params = new URLSearchParams(location.search)
if (params.get('payment') === 'success') {
// Poll until the webhook has granted access (usually < 2 s)
let attempts = 0
const poll = async () => {
const { allowed } = await fetch(`/api/access?resourceId=${VIDEO_ID}`).then(r => r.json())
if (allowed) {
player.update({ paywall: { locked: false } })
} else if (++attempts < 6) {
setTimeout(poll, 1000)
}
}
poll()
}
Other payment providers
The onUnlock callback works with any provider that returns a redirect URL — the pattern is always the same: POST to your server, get back {"{ url }"}, redirect.
// Lemon Squeezy
onUnlock: async (optionId) => {
const { checkoutUrl } = await fetch('/api/checkout/lemonsqueezy', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variantId: VARIANT_IDS[optionId] }),
}).then(r => r.json())
window.location.href = checkoutUrl
}
// PayPal Orders v2
onUnlock: async (optionId) => {
const { approveUrl } = await fetch('/api/checkout/paypal', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: SKU_MAP[optionId] }),
}).then(r => r.json())
window.location.href = approveUrl // redirects to PayPal approval page
}
// Custom / crypto / any provider
onUnlock: async (optionId) => {
const { url } = await fetch('/api/checkout', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resourceId: VIDEO_ID, optionId }),
}).then(r => r.json())
window.location.href = url // your server decides where to redirect
}
Chapters
freeAdd named markers to the seek bar. Users click to jump — or drive navigation from your own UI with player.seek().
Lumra.createPlayer(el, {
src: 'https://your-cdn.com/video.m3u8',
chapters: [
{ at: 0, title: 'Introduction' },
{ at: 60, title: 'Getting Started' },
{ at: 240, title: 'Advanced Topics' },
{ at: 480, title: 'Wrap Up' },
],
})
Seek bar markers, click-to-jump, and player.seek(seconds) all work with zero license key. Only the onChapterChange callback requires the premium plugin — see below.
Analytics
freeFire milestone beacons at 25%, 50%, 75%, and 100% watched. No license required.
Lumra.createPlayer(el, {
src: 'https://your-cdn.com/video.m3u8',
analytics: {
resourceId: 'video-123',
endpoint: '/api/analytics', // receives POST with { resourceId, milestone, currentTime, duration }
},
})
LUT Colour Grading
Premium · $49Real-time WebGL colour grading via standard .cube LUT files. Switch grades and adjust intensity without reloading. Zero performance impact.
LUMRA-LUT-XXXXXXXX-XXXXXXXX
Dev key for localhost: LUMRA-LUT-DEMO0001-DEMO0001 — only activates on localhost. A "License required" banner appears on any public domain. Purchase a production key →
Proxy mode (recommended)
Keep your key in an environment variable. The browser never sees it.
Lumra.createPlayer(el, {
src: 'https://your-cdn.com/video.m3u8',
lut: {
// ✅ No licenseKey in browser — your proxy adds it server-side
verifyEndpoint: '/api/lumra-verify', // see Hiding Your Key below
url: '/luts/cinematic.cube',
intensity: 0.85,
},
})
// Hot-swap LUTs at runtime — no reload
await player.setLut('/luts/vivid.cube')
player.setIntensity(0.5)
Direct mode (localhost / dev only)
Lumra.createPlayer(el, {
src: 'https://your-cdn.com/video.m3u8',
lut: {
licenseKey: 'LUMRA-LUT-XXXXXXXX-XXXXXXXX', // your key
verifyEndpoint: 'https://lumra.reelfoundry.au/api/verify',
url: '/luts/cinematic.cube',
intensity: 0.85,
},
})
VAST Ads
Premium · $79Pre-roll, mid-roll, and post-roll video ads via VAST 2.0 / 3.0. Impression and tracking beacons fire automatically.
LUMRA-ADS-XXXXXXXX-XXXXXXXX
Dev key for localhost: LUMRA-ADS-DEMO0001-DEMO0001. Purchase a production key →
Proxy mode (recommended)
Lumra.createPlayer(el, {
src: 'https://your-cdn.com/video.m3u8',
ads: {
// ✅ No licenseKey in browser — proxy adds LUMRA_ADS_KEY server-side
verifyEndpoint: '/api/lumra-verify',
preRoll: {
vast: 'https://your-ad-server.com/vast.xml',
skipAfter: 5,
},
onAdStart: () => console.log('Ad started'),
onAdEnd: () => console.log('Ad ended'),
onAdSkip: () => console.log('Ad skipped'),
},
})
Direct mode
Lumra.createPlayer(el, {
src: 'https://your-cdn.com/video.m3u8',
ads: {
licenseKey: 'LUMRA-ADS-XXXXXXXX-XXXXXXXX', // your key
verifyEndpoint: 'https://lumra.reelfoundry.au/api/verify',
preRoll: {
vast: 'https://your-ad-server.com/vast.xml',
skipAfter: 5,
},
},
})
Engagement Heatmap
Premium · $39YouTube-style replay heatmap on the seek bar. Your backend serves view-count data per time segment; the plugin normalises and overlays it.
LUMRA-HEAT-XXXXXXXX-XXXXXXXX
Dev key for localhost: LUMRA-HEAT-DEMO0001-DEMO0001. Purchase a production key →
Lumra.createPlayer(el, {
src: 'https://your-cdn.com/video.m3u8',
heatmap: {
// ✅ No licenseKey in browser — proxy adds LUMRA_HEAT_KEY server-side
verifyEndpoint: '/api/lumra-verify',
// GET endpoint returning { data: [120, 450, 890, ...] }
data: 'https://yourapp.com/api/heatmap/video-id',
onData: (normalised) => console.log(normalised), // 0–1 per segment
},
})
Each number in the data array is a view count for one time segment (one value per 5 seconds is a good granularity). The plugin divides by the maximum to normalise to 0–1 and overlays a gradient on the seek bar.
Chapters — onChapterChange callback
Premium · $29The free chapters prop gives you seek bar markers and click-to-jump — no plugin or license needed. The premium chaptersPlugin adds one thing: a JavaScript callback that fires in your code every time the viewer crosses a chapter boundary during playback.
LUMRA-CHAP-XXXXXXXX-XXXXXXXX
Dev key for localhost: LUMRA-CHAP-DEMO0001-DEMO0001. Purchase a production key →
import { chaptersPlugin } from '@lumra/plugins'
player.use(chaptersPlugin({
// ✅ No licenseKey in browser — proxy adds LUMRA_CHAP_KEY server-side
verifyEndpoint: '/api/lumra-verify',
chapters: [
{ at: 0, title: 'Introduction' },
{ at: 60, title: 'Getting Started' },
{ at: 240, title: 'Advanced Topics' },
],
onChapterChange: (chapter, index) => {
// Fires each time the viewer crosses a chapter boundary
if (chapter) console.log('Now in:', chapter.title, '(index', index + ')')
},
}))
Hiding Your License Key
In plain HTML / CDN pages your licenseKey is visible in the page source. Use a server-side proxy to keep it out of the browser entirely.
Keys are domain-locked, which limits abuse — but the best practice is never exposing a secret on the client. The proxy pattern below keeps your key in an environment variable on your server.
How it works
{"{ product, origin }"} to /api/lumra-verify — no key in browser codeproduct from env, forwards to LumraPer-plugin key types & environment variables
| Plugin | Key format | Recommended env var |
|---|---|---|
lut | LUMRA-LUT-XXXXXXXX-XXXXXXXX | LUMRA_LUT_KEY |
ads | LUMRA-ADS-XXXXXXXX-XXXXXXXX | LUMRA_ADS_KEY |
heatmap | LUMRA-HEAT-XXXXXXXX-XXXXXXXX | LUMRA_HEAT_KEY |
chapters | LUMRA-CHAP-XXXXXXXX-XXXXXXXX | LUMRA_CHAP_KEY |
Frontend — no keys in browser
<script>
Lumra.createPlayer('#player', {
lut: {
// ✅ No licenseKey here — proxy adds the right key server-side
verifyEndpoint: '/api/lumra-verify',
url: '/luts/cinematic.cube',
intensity: 0.85,
},
ads: {
verifyEndpoint: '/api/lumra-verify', // same endpoint — product field routes the key
preRoll: { vast: 'https://your-vast-url', skipAfter: 5 },
},
})
</script>
Node.js / Express proxy
A single endpoint handles all plugins — the product field in the request body tells you which key to use.
// Map product names to environment variables
const KEY_MAP = {
lut: process.env.LUMRA_LUT_KEY,
ads: process.env.LUMRA_ADS_KEY,
heatmap: process.env.LUMRA_HEAT_KEY,
chapters: process.env.LUMRA_CHAP_KEY,
}
app.post('/api/lumra-verify', async (req, res) => {
const key = KEY_MAP[req.body?.product]
if (!key) return res.status(400).json({ error: 'Unknown product' })
const resp = await fetch('https://lumra.reelfoundry.au/api/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...req.body, key }),
})
res.json(await resp.json())
})
PHP proxy
<?php // /api/lumra-verify.php
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$keyMap = [
'lut' => getenv('LUMRA_LUT_KEY'),
'ads' => getenv('LUMRA_ADS_KEY'),
'heatmap' => getenv('LUMRA_HEAT_KEY'),
'chapters' => getenv('LUMRA_CHAP_KEY'),
];
$product = $body['product'] ?? '';
if (!isset($keyMap[$product])) { http_response_code(400); echo '{"error":"Unknown product"}'; exit; }
$body['key'] = $keyMap[$product];
$ch = curl_init('https://lumra.reelfoundry.au/api/verify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
]);
header('Content-Type: application/json');
echo curl_exec($ch);
Next.js API Route
// app/api/lumra-verify/route.ts
const KEY_MAP: Record<string, string | undefined> = {
lut: process.env.LUMRA_LUT_KEY,
ads: process.env.LUMRA_ADS_KEY,
heatmap: process.env.LUMRA_HEAT_KEY,
chapters: process.env.LUMRA_CHAP_KEY,
}
export async function POST(req: Request) {
const body = await req.json()
const key = KEY_MAP[body?.product]
if (!key) return Response.json({ error: 'Unknown product' }, { status: 400 })
const resp = await fetch('https://lumra.reelfoundry.au/api/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, key }),
})
return Response.json(await resp.json())
}
Add your keys to .env.local (or your server environment) — never commit this file:
LUMRA_LUT_KEY=LUMRA-LUT-XXXXXXXX-XXXXXXXX
LUMRA_ADS_KEY=LUMRA-ADS-XXXXXXXX-XXXXXXXX
LUMRA_HEAT_KEY=LUMRA-HEAT-XXXXXXXX-XXXXXXXX
LUMRA_CHAP_KEY=LUMRA-CHAP-XXXXXXXX-XXXXXXXX
License Keys
Each premium plugin requires its own key. Keys are purchased once and never expire.
| Plugin | Price | Key format | Demo key (localhost only) |
|---|---|---|---|
lut — LUT Colour Grading | $49 one-time | LUMRA-LUT-XXXXXXXX-XXXXXXXX | LUMRA-LUT-DEMO0001-DEMO0001 |
ads — VAST Ads | $79 one-time | LUMRA-ADS-XXXXXXXX-XXXXXXXX | LUMRA-ADS-DEMO0001-DEMO0001 |
heatmap — Engagement Heatmap | $39 one-time | LUMRA-HEAT-XXXXXXXX-XXXXXXXX | LUMRA-HEAT-DEMO0001-DEMO0001 |
chapters — Chapter Callbacks | $29 one-time | LUMRA-CHAP-XXXXXXXX-XXXXXXXX | LUMRA-CHAP-DEMO0001-DEMO0001 |
Demo keys only activate on localhost / 127.0.0.1. On any public domain they show a "License required" banner. Purchase a production key →
Use the server-side proxy pattern — your key stays in an environment variable on your server. Keys are also domain-locked at checkout for an extra layer of protection.
Production keys are optionally locked to the domain you specify at checkout. To add or change a domain after purchase, email licenses@reelfoundry.au.