Getting Started
v0.1.17 Live Demo → Purchase →

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.

Free core player HLS + Adaptive Paywall Chapters Analytics LUT Colour Grading VAST Ads Heatmap CDN / React / npm

Quick Start — CDN

free

One 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.

html
<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>
That's it — no license key needed

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

free

Install the React package for full TypeScript types and hook-based access.

$ npm install @lumra/react @lumra/core
tsx
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:

tsx
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

Page load
Check GET /api/access?resourceId=video-123 — returns {"{ allowed: true/false }"}
Player
Pass paywall: {"{ locked: !allowed }"} — overlay appears with your pricing options
Click
onUnlock(optionId) fires — POST to your backend, get back {"{ url }"}, redirect to Stripe
Stripe
User pays. Stripe fires a webhook — you grant access in your DB there
Return
Re-check access, call player.update({"{ paywall: { locked: false } }"})
javascript — CDN
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
    },
  },
})
Grant access inside the webhook — not the success 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:

javascript
// 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.

javascript — Node.js / Express
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 })
})
Grant access in the webhook — not the success 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.

javascript — Node.js / Express (Connect)
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 })
})
Connect account setup

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():

javascript
// 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.

javascript — examples
// 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

free

Add named markers to the seek bar. Users click to jump — or drive navigation from your own UI with player.seek().

javascript
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' },
  ],
})
Chapters are free — no license needed

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

free

Fire milestone beacons at 25%, 50%, 75%, and 100% watched. No license required.

javascript — CDN
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 · $49

Real-time WebGL colour grading via standard .cube LUT files. Switch grades and adjust intensity without reloading. Zero performance impact.

Key format: 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.

javascript — CDN (proxy mode)
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)

javascript — CDN
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 · $79

Pre-roll, mid-roll, and post-roll video ads via VAST 2.0 / 3.0. Impression and tracking beacons fire automatically.

Key format: LUMRA-ADS-XXXXXXXX-XXXXXXXX

Dev key for localhost: LUMRA-ADS-DEMO0001-DEMO0001. Purchase a production key →

Proxy mode (recommended)

javascript — CDN (proxy mode)
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

javascript — CDN
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 · $39

YouTube-style replay heatmap on the seek bar. Your backend serves view-count data per time segment; the plugin normalises and overlays it.

Key format: LUMRA-HEAT-XXXXXXXX-XXXXXXXX

Dev key for localhost: LUMRA-HEAT-DEMO0001-DEMO0001. Purchase a production key →

javascript — CDN (proxy mode)
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 · $29

The 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.

Key format: LUMRA-CHAP-XXXXXXXX-XXXXXXXX

Dev key for localhost: LUMRA-CHAP-DEMO0001-DEMO0001. Purchase a production key →

typescript — npm (proxy mode)
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.

Don't put production keys in browser code

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

Browser
Sends {"{ product, origin }"} to /api/lumra-verifyno key in browser code
Your server
Looks up the right key for product from env, forwards to Lumra
Lumra
Validates key + domain, returns a signed JWT
Browser
Receives JWT, caches in sessionStorage — plugin unlocks

Per-plugin key types & environment variables

PluginKey formatRecommended env var
lutLUMRA-LUT-XXXXXXXX-XXXXXXXXLUMRA_LUT_KEY
adsLUMRA-ADS-XXXXXXXX-XXXXXXXXLUMRA_ADS_KEY
heatmapLUMRA-HEAT-XXXXXXXX-XXXXXXXXLUMRA_HEAT_KEY
chaptersLUMRA-CHAP-XXXXXXXX-XXXXXXXXLUMRA_CHAP_KEY

Frontend — no keys in browser

html — CDN
<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.

javascript — Express
// 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
<?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

typescript — Next.js App Router
// 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:

.env
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.

PluginPriceKey formatDemo key (localhost only)
lut — LUT Colour Grading$49 one-timeLUMRA-LUT-XXXXXXXX-XXXXXXXXLUMRA-LUT-DEMO0001-DEMO0001
ads — VAST Ads$79 one-timeLUMRA-ADS-XXXXXXXX-XXXXXXXXLUMRA-ADS-DEMO0001-DEMO0001
heatmap — Engagement Heatmap$39 one-timeLUMRA-HEAT-XXXXXXXX-XXXXXXXXLUMRA-HEAT-DEMO0001-DEMO0001
chapters — Chapter Callbacks$29 one-timeLUMRA-CHAP-XXXXXXXX-XXXXXXXXLUMRA-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 →

Never put production keys in browser code

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.

Domain locking

Production keys are optionally locked to the domain you specify at checkout. To add or change a domain after purchase, email licenses@reelfoundry.au.