Build your own front end on the Music Video Marketplace catalogue. Phase 1 exposes read-only catalogue data and short-lived fully-proxied preview streams for all four media surfaces: full music videos, vertical reels, behind-the-scenes clips, and silent hover previews. Raw upstream URLs are never returned — every byte is streamed through our edge. Partners must embed stream_url in a plain HTML5 <video> element only (see Player implementation). Commerce + entitlements + downloads arrive in Phase 2, webhooks in Phase 3.
Embedding our Workspace inside your own product instead? See the Partner Embed (SSO) contract.
/v1/media/stream/:token no longer 302-redirects. It streams MP4 bytes directly with HTTP Range support. Point a plain <video> at it — never follow the URL by hand.full, reel, bts, and hover previews) stream through the same proxy. Using the Vimeo Player SDK, @vimeo/player, an iframe, hls.js, Video.js, JW Player, or any host-specific player is forbidden and will break the first time we move an asset.preview.available_kinds — only mint kinds listed there. Anything else returns 404 source_not_available.<video> issues many Range requests).X-Stream-Quota-Remaining.https://awfrtgkoramnvpsvrjjv.supabase.co/functions/v1/partner-api
All authenticated endpoints require an API key:
mvm_test_… — sandboxmvm_live_… — productionAuthorization: Bearer mvm_test_xxxxxxxxxxxxxxxxxxxxxxxx
Shown once when issued — we only store a hash. Ask us to revoke + re-issue if leaked.
Default: 100 requests / minute / key. Daily mint quota: 5,000 / key.
X-RateLimit-Limit: 100 X-RateLimit-Remaining: 87 X-Stream-Quota-Remaining: 4982
Over-limit responses return 429 with Retry-After.
Every playable surface maps to one of these. Pass kind in POST /v1/media/preview-url; you get back a stream_url you embed in a plain HTML5 <video>.
kind | Purpose | Typical use | Aspect | Recommended <video> attrs |
|---|---|---|---|---|
full | Watermarked preview of the full music video | Watch / detail page | 16:9 | controls playsinline preload="metadata" |
reel | Short vertical clip | Social embeds, story-style cards | 9:16 | autoplay muted loop playsinline |
bts | Behind-the-scenes footage | Bonus content tab | 16:9 | controls playsinline preload="metadata" |
full (hover) | Same as full, used as a silent loop on hover | Grid card hover state | 16:9 | autoplay muted loop playsinline |
Hover is a client-side recipe on top of kind: "full", not a separate API surface. Use the same mint; change attributes; tear down on mouse-leave to save bandwidth and quota.
List active videos. Cursor pagination + basic filters.
curl "https://awfrtgkoramnvpsvrjjv.supabase.co/functions/v1/partner-api/v1/videos?limit=20&genre=hip-hop" \ -H "Authorization: Bearer $MVM_API_KEY"
limit (max 100), cursor, genre, mood, q, min_duration, max_duration.
curl "https://awfrtgkoramnvpsvrjjv.supabase.co/functions/v1/partner-api/v1/videos/<uuid>" \ -H "Authorization: Bearer $MVM_API_KEY"
Response includes preview.available_kinds — render only buttons for kinds listed there.
Curated bundles of videos.
Taxonomy for filtering.
Mint a short-lived proxied stream_url. Tokens expire in 15 minutes; re-mint per playback.
curl -X POST "https://awfrtgkoramnvpsvrjjv.supabase.co/functions/v1/partner-api/v1/media/preview-url" \
-H "Authorization: Bearer $MVM_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "video_id": "<uuid>", "kind": "full" }'kind: full | reel | bts (legacy variant also accepted). Response: { data: { stream_url, expires_in: 900 } }.
Opaque proxied MP4. Supports Range (returns 206 Partial Content) and HEAD for metadata preflight. Carries X-Stream-Proxy: mvm/1. Your <video> calls this — you do not.
# verify range support curl -I -H "Range: bytes=0-1023" "<stream_url>" # → HTTP/2 206 # → content-type: video/mp4 # → content-range: bytes 0-1023/...
stream_url is an opaque MP4 byte stream served from our edge. Treat it like any other CDN MP4 — embed in plain HTML5 <video>. Nothing else is supported.
<video> tagRange requests (for seeking)playsInline on iOScrossorigin="anonymous" only if you need <canvas> frames@vimeo/player / player.vimeo.com iframesstream_url across users, sessions, sitemaps, or CDNs<video src="<stream_url with kind:'full'>" controls playsinline preload="metadata" style="width: 100%; aspect-ratio: 16 / 9; background: #000;" ></video>
<video src="<stream_url with kind:'reel'>" autoplay muted loop playsinline preload="metadata" style="height: 100%; aspect-ratio: 9 / 16; background: #000;" ></video>
<video src="<stream_url with kind:'bts'>" controls playsinline preload="metadata" style="width: 100%; aspect-ratio: 16 / 9; background: #000;" ></video>
<div class="card" data-video-id="<uuid>" style="position:relative;aspect-ratio:16/9">
<img class="poster" src="<thumbnails.poster>" alt=""
style="width:100%;height:100%;object-fit:cover" />
<video class="hover-video" muted loop playsinline preload="none"
style="position:absolute;inset:0;width:100%;height:100%;
object-fit:cover;opacity:0;transition:opacity .2s"></video>
</div>
<script>
const DEBOUNCE_MS = 150;
document.querySelectorAll('.card').forEach((card) => {
const video = card.querySelector('.hover-video');
const id = card.dataset.videoId;
let timer = null;
card.addEventListener('mouseenter', () => {
timer = setTimeout(async () => {
const r = await fetch('https://awfrtgkoramnvpsvrjjv.supabase.co/functions/v1/partner-api/v1/media/preview-url', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + window.MVM_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({ video_id: id, kind: 'full' }),
});
const { data } = await r.json();
video.src = data.stream_url;
video.style.opacity = '1';
video.play().catch(() => {});
}, DEBOUNCE_MS);
});
card.addEventListener('mouseleave', () => {
clearTimeout(timer);
video.pause();
video.removeAttribute('src'); // stop bandwidth immediately
video.load();
video.style.opacity = '0';
});
});
</script>The 150 ms debounce avoids burning your daily mint quota when a user sweeps the cursor across a grid. Always remove src + call load() on leave — pausing alone keeps the connection open.
import { useEffect, useState } from "react";
async function mintPreview(
videoId: string,
kind: "full" | "reel" | "bts" = "full",
) {
const res = await fetch("https://awfrtgkoramnvpsvrjjv.supabase.co/functions/v1/partner-api/v1/media/preview-url", {
method: "POST",
headers: {
Authorization: `Bearer ${import.meta.env.VITE_MVM_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ video_id: videoId, kind }),
});
if (!res.ok) throw new Error(`mint failed: ${res.status}`);
const { data } = await res.json();
return data.stream_url as string; // valid 15 min
}
export function VideoPreview({
videoId,
kind = "full",
}: { videoId: string; kind?: "full" | "reel" | "bts" }) {
const [src, setSrc] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
mintPreview(videoId, kind).then((url) => {
if (!cancelled) setSrc(url);
});
return () => { cancelled = true; };
}, [videoId, kind]);
if (!src) return <div>Loading…</div>;
const vertical = kind === "reel";
return (
<video
src={src}
controls={kind !== "reel"}
autoPlay={kind === "reel"}
muted={kind === "reel"}
loop={kind === "reel"}
playsInline
preload="metadata"
style={{
width: vertical ? "auto" : "100%",
height: vertical ? "100%" : "auto",
aspectRatio: vertical ? "9 / 16" : "16 / 9",
background: "#000",
}}
/>
);
}import { useRef } from "react";
export function HoverPreviewCard({
videoId,
poster,
}: { videoId: string; poster: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
const timerRef = useRef<number | null>(null);
const onEnter = () => {
timerRef.current = window.setTimeout(async () => {
const url = await mintPreview(videoId, "full");
const v = videoRef.current;
if (!v) return;
v.src = url;
v.style.opacity = "1";
v.play().catch(() => {});
}, 150); // debounce
};
const onLeave = () => {
if (timerRef.current) clearTimeout(timerRef.current);
const v = videoRef.current;
if (!v) return;
v.pause();
v.removeAttribute("src");
v.load();
v.style.opacity = "0";
};
return (
<div
onMouseEnter={onEnter}
onMouseLeave={onLeave}
style={{ position: "relative", aspectRatio: "16 / 9" }}
>
<img
src={poster}
alt=""
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
<video
ref={videoRef}
muted
loop
playsInline
preload="none"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
opacity: 0,
transition: "opacity .2s",
}}
/>
</div>
);
}stream_url is valid for 15 minutes and bound to the partner key + request origin. Mint a fresh URL every time a user starts a new playback session. Don't store it in your database; don't put it in a sitemap; don't share it across users.
All errors share the same shape:
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded"
}
}| HTTP | error.code | Meaning | Recommended action |
|---|---|---|---|
| 401 | missing_api_key · invalid_api_key · revoked_api_key | Bearer header missing, malformed, or revoked. | Stop retrying. Re-issue key. |
| 401 | invalid_token | Stream token expired or tampered. | Re-mint via /v1/media/preview-url. |
| 403 | origin_not_allowed | Request origin not on your allow-list. | Contact us to add the domain. |
| 404 | source_not_available | No source provisioned for that kind. | Hide that kind; check preview.available_kinds. |
| 429 | rate_limited · stream_quota_exceeded | Per-minute or daily mint quota hit. | Honour Retry-After; debounce hover previews. |
| 502 | upstream_unavailable | Source provisioned but upstream temporarily unreachable. | Retry once with backoff; surface a fallback poster. |
Machine-readable spec: https://awfrtgkoramnvpsvrjjv.supabase.co/functions/v1/partner-api/v1/openapi.json
Import the OpenAPI URL into Postman or Insomnia for a ready-to-use collection.