v1.2 · Proxied media

    Partner API

    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.

    What changed in v1.2 (breaking)

    • /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.
    • All four media kinds (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.
    • Catalogue responses now include preview.available_kinds — only mint kinds listed there. Anything else returns 404 source_not_available.
    • Stream tokens expire in 15 minutes, partner + origin scoped, and are not single-use (a single <video> issues many Range requests).
    • Daily stream-mint quota per key (default 5,000). See X-Stream-Quota-Remaining.

    Base URL

    https://awfrtgkoramnvpsvrjjv.supabase.co/functions/v1/partner-api

    Authentication

    All authenticated endpoints require an API key:

    • mvm_test_… — sandbox
    • mvm_live_… — production
    Authorization: Bearer mvm_test_xxxxxxxxxxxxxxxxxxxxxxxx

    Shown once when issued — we only store a hash. Ask us to revoke + re-issue if leaked.

    Rate limits

    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.

    Media kinds

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

    kindPurposeTypical useAspectRecommended <video> attrs
    fullWatermarked preview of the full music videoWatch / detail page16:9controls playsinline preload="metadata"
    reelShort vertical clipSocial embeds, story-style cards9:16autoplay muted loop playsinline
    btsBehind-the-scenes footageBonus content tab16:9controls playsinline preload="metadata"
    full (hover)Same as full, used as a silent loop on hoverGrid card hover state16:9autoplay 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.

    Endpoints

    GET /v1/videos

    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.

    GET /v1/videos/:id

    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.

    GET /v1/collections · /v1/collections/:id

    Curated bundles of videos.

    GET /v1/categories · /v1/tags

    Taxonomy for filtering.

    POST /v1/media/preview-url

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

    GET | HEAD /v1/media/stream/:token

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

    Player implementation (required reading)

    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.

    Required player capabilities

    • Native HTML5 <video> tag
    • MP4 container, H.264 video, AAC audio
    • HTTP Range requests (for seeking)
    • playsInline on iOS
    • crossorigin="anonymous" only if you need <canvas> frames

    Forbidden

    • Vimeo Player SDK / @vimeo/player / player.vimeo.com iframes
    • YouTube IFrame API or any YouTube embed
    • hls.js, dash.js, Shaka, Video.js, JW Player, Plyr, Bitmovin
    • Caching, sharing, or persisting stream_url across users, sessions, sitemaps, or CDNs
    • Sniffing the response to discover the upstream CDN and hitting it directly

    Vanilla HTML — full video (with controls)

    <video
      src="<stream_url with kind:'full'>"
      controls
      playsinline
      preload="metadata"
      style="width: 100%; aspect-ratio: 16 / 9; background: #000;"
    ></video>

    Vanilla HTML — reel (vertical, autoplay loop)

    <video
      src="<stream_url with kind:'reel'>"
      autoplay
      muted
      loop
      playsinline
      preload="metadata"
      style="height: 100%; aspect-ratio: 9 / 16; background: #000;"
    ></video>

    Vanilla HTML — behind-the-scenes

    <video
      src="<stream_url with kind:'bts'>"
      controls
      playsinline
      preload="metadata"
      style="width: 100%; aspect-ratio: 16 / 9; background: #000;"
    ></video>

    Vanilla HTML — hover preview on a grid card

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

    React — universal player hook

    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",
          }}
        />
      );
    }

    React — hover preview card

    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>
      );
    }

    Re-minting & caching

    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.

    Errors

    All errors share the same shape:

    {
      "error": {
        "code": "rate_limited",
        "message": "Rate limit exceeded"
      }
    }
    HTTPerror.codeMeaningRecommended action
    401missing_api_key · invalid_api_key · revoked_api_keyBearer header missing, malformed, or revoked.Stop retrying. Re-issue key.
    401invalid_tokenStream token expired or tampered.Re-mint via /v1/media/preview-url.
    403origin_not_allowedRequest origin not on your allow-list.Contact us to add the domain.
    404source_not_availableNo source provisioned for that kind.Hide that kind; check preview.available_kinds.
    429rate_limited · stream_quota_exceededPer-minute or daily mint quota hit.Honour Retry-After; debounce hover previews.
    502upstream_unavailableSource provisioned but upstream temporarily unreachable.Retry once with backoff; surface a fallback poster.

    OpenAPI & Postman

    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.

    Roadmap

    • Phase 2 — orders, exclusive quotes, memberships, claims, signed download URLs.
    • Phase 3 — webhooks (HMAC-signed), idempotency, partner usage dashboard.