Embed the MVM Workspace in your own product with signed single sign-on: HMAC signing, payload contract, embed URL, branding and message contracts.
Behavioural and contract changes to the embed since launch: embed modes, rolling sessions, self-serve branding in the signed payload, stricter identity handling, and entitlement-gated media resolution.
Drop the Music Video Marketplace Workspace into your own product as an iframe, with your users already signed in. Your server signs a small JSON payload describing the logged-in user with your client_secret (HMAC-SHA256); we verify it, link or provision the matching MVM account, and boot the embed under your branding. No user-facing login step, no password sharing.
client_secret.Sign server-side only. Your client_secret must never reach a browser.
| Credential | Description | Where it is used |
|---|---|---|
client_id | Your unique partner identifier (32-char hex). Not secret. | Embed URL path + request bodies |
client_secret | HMAC signing key. | Server-side signing only — never expose to browsers |
Both values are issued by Music Video Marketplace during onboarding — there is no public signup and no self-serve credential rotation. Email info@musicvideomarketplace.com to request credentials, to have a secret rotated immediately if it leaks, or to change your embed_mode or stored branding. Your partner record also carries your account status (only active partners can open sessions), partner_type, a monthly per-user download limit, and any partner-wide auto-discount.
client_secret.Worked example (Node)
const crypto = require('crypto');
const CLIENT_ID = 'pk_partner_demo_0000000000000000';
const CLIENT_SECRET = process.env.MVM_CLIENT_SECRET; // server-side only
// 1. one canonical JSON string — sign and send exactly this
const payloadString = JSON.stringify({
partner_external_user_id: 'your-user-123',
email: 'jane@example.com',
display_name: 'Jane Doe',
timestamp: new Date().toISOString(),
});
// 2. lowercase hex HMAC of the RAW string
const sig = crypto.createHmac('sha256', CLIENT_SECRET)
.update(payloadString)
.digest('hex');
// 3. base64 of the SAME raw string, URL-encoded
const b64 = Buffer.from(payloadString, 'utf8').toString('base64');
const embedUrl =
`https://musicvideomarketplace.com/partner/embed/${CLIENT_ID}` +
`?payload=${encodeURIComponent(b64)}&sig=${sig}`;Same thing in Python
import base64, hmac, hashlib, json, urllib.parse
from datetime import datetime, timezone
payload_string = json.dumps({
"partner_external_user_id": "your-user-123",
"email": "jane@example.com",
"display_name": "Jane Doe",
"timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
}, separators=(",", ":"))
sig = hmac.new(CLIENT_SECRET.encode(), payload_string.encode(), hashlib.sha256).hexdigest()
b64 = base64.b64encode(payload_string.encode()).decode()
embed_url = f"https://musicvideomarketplace.com/partner/embed/{CLIENT_ID}?payload={urllib.parse.quote(b64)}&sig={sig}"Byte-for-byte matters. Re-serialising the object on the way out (different key order, different spacing, a pretty-printer, a trailing newline) changes the bytes and the signature no longer matches. Compare case-insensitively on our side is handled — we lowercase your signature before comparing — but everything else must match exactly.
{
"partner_external_user_id": "your-user-123",
"email": "user@example.com",
"display_name": "Jane Doe",
"timestamp": "2026-09-02T13:45:00.000Z",
"logo_url": "https://cdn.yourbrand.example/logo.svg",
"accent_color": "#1D4ED8"
}| Field | Required | Description |
|---|---|---|
partner_external_user_id | Required | Your platform's stable, unique ID for the currently signed-in user. Missing → 400. This is the identity key for the whole session: it is upserted against (partner_id, partner_external_user_id), so re-using one value across users merges them into a single MVM account and they will see each other's purchases and projects. |
email | Strongly recommended | Used to link an existing MVM account (matched case-insensitively) or auto-provision a new passwordless, email-confirmed one. Without it we cannot resolve an internal user, internal_user_id comes back null, and ownership / purchase history cannot be attributed. |
display_name | Optional | Display name stored on the link record and shown in the embed. |
timestamp | Optional, strongly recommended | ISO 8601 (e.g. 2026-09-02T13:45:00.000Z). Must be within ±5 minutes of our server clock, otherwise 403 Payload expired. If the field is omitted entirely the payload never expires, which makes a leaked URL replayable indefinitely — always send it. |
logo_url | Optional | Absolute http(s) logo URL. Overrides your stored logo for this session. |
accent_color | Optional | 6-digit hex, leading # (e.g. #1D4ED8). Overrides your stored accent for this session. |
Unknown extra keys are ignored (they are still covered by the signature). Entitlement cannot be asserted in the payload — see “Entitlement model” below.
https://musicvideomarketplace.com/partner/embed/{CLIENT_ID}?payload={BASE64_JSON}&sig={HEX_HMAC}payload is base64 of the signed JSON string, URL-encoded. sig is the lowercase hex HMAC of the raw JSON string. Both are required; without them the embed renders a “missing session” state and no session is created. If payload is not valid base64 we fall back to treating it as the raw JSON string, so sending raw JSON also works — base64 is preferred because it survives URL encoding cleanly.
Recommended host page pattern
<iframe
id="mvm-embed"
title="Music Video Marketplace Workspace"
style="width:100%;height:100%;min-height:720px;border:0;border-radius:12px;background:#0F0F10"
allow="autoplay; fullscreen; clipboard-write"
></iframe>
<script>
// your endpoint, your auth — it mints a fresh signed URL per load
fetch('/api/mvm-embed-url')
.then((r) => r.json())
.then(({ embedUrl }) => { document.getElementById('mvm-embed').src = embedUrl; });
</script>Sizing & responsiveness
100dvh of the iframe viewport and scrolls internally. Give the iframe a real height — a flex/grid cell that fills your page, or an explicit height. Do not rely on content-based auto-height; the iframe will collapse.allow="autoplay; fullscreen; clipboard-write" is required for video playback, full-screen preview and copy-to-clipboard actions to work.allow-scripts allow-same-origin allow-popups allow-forms; the Workspace needs script execution, its own origin storage, and popups for checkout.embed_mode — catalog vs workspaceYour partner record carries an embed_mode column with two values. It is returned as embed_mode in the verification response and decides what the embed renders after sign-in.
| Mode | What the user gets | Choose it when |
|---|---|---|
workspace default for new partners | Lands straight in the full-height Workspace editor with no video pre-selected (a “sourceless” boot). Catalogue search, filtering, song-matching, clip drag-in, saved projects, export history and checkout all happen inside the editor. Host chrome is deliberately absent so the editor keeps the full iframe height. | You want one surface that does everything, and your users are creating edits — this is the mode we develop against and the one to pick unless you have a reason not to. |
catalog | The legacy flow: a browse/recommendations landing page, a video detail view, a checkout view, a purchases view, and the editor opened per video from there. View state persists in the iframe's sessionStorage per user identity. | You mainly want catalogue discovery and purchase inside your product, with editing as a secondary step. |
Configuration is admin-side, not self-serve. There is no partner UI and no payload field for it — partners.embed_mode is set by Music Video Marketplace on request, and it cannot be overridden per session or per user from your side. Ask us to switch it; the change takes effect on the next session.
logo_url and accent_color are part of the signed payload, so they are covered by the same HMAC signature as the identity fields and cannot be injected from a browser without your client_secret. There is no separate branding signature and no separate branding endpoint.
Resolution order, evaluated per field, per session:
logo_url / accent_color, when present and valid.partners.branding_config.logoUrl / .primary, set by your partner manager.null).Validation
logo_url must parse as a URL with an http: or https: protocol. Use https.accent_color must match /^#[0-9a-fA-F]{6}$/. Shorthand (#abc), named colours and rgb() are rejected. Accepted values are normalised to uppercase.The verification response echoes what was actually used in branding_source — the fastest way to confirm your fields were accepted:
"branding_logo_url": "https://cdn.yourbrand.example/logo.svg",
"branding_accent_color": "#1D4ED8",
"branding_source": { "logo": "payload", "accent": "branding_config" }| Value | Meaning |
|---|---|
payload | Taken from your signed payload (present and valid). |
branding_config | Payload omitted it or it failed validation — your stored config was used. |
none | Neither available — no partner branding applied. |
Separately, branding_config.mode (returned as branding_mode) selects the theme treatment: branded (MVM look), clean (neutral, no MVM gradient) or whitelabel (theme derived from your accent). Like embed_mode, this is admin-configured.
The embed calls this for you on load. Call it directly only to test a signature from your own backend, or to pre-flight credentials in CI.
POST https://awfrtgkoramnvpsvrjjv.supabase.co/functions/v1/partner-verify-session
Content-Type: application/json
{
"client_id": "pk_partner_demo_0000000000000000",
"payload": "{\"partner_external_user_id\":\"user-123\",\"email\":\"user@example.com\",\"timestamp\":\"2026-09-02T13:45:00.000Z\"}",
"signature": "<lowercase hex hmac of that exact payload string>"
}Here payload is the raw JSON string you signed (a JSON object is also accepted and re-serialised, which will usually break the signature — send the string). Base64 is only used in the iframe URL parameter.
200 OK
{
"session_token": "6d638412...cef6", // opaque, send in request bodies
"session_expires_at": "2026-09-03T01:47:56.107Z",// 12h from creation, extended on activity
"linked_user_id": "5f521ef7-...", // partner_linked_users.id
"internal_user_id": "4a96ecc1-...", // resolved MVM auth user, or null
"partner_id": "acec8b73-...",
"partner_name": "Rights Hub",
"embed_mode": "workspace", // "workspace" | "catalog"
"branding_logo_url": null,
"branding_accent_color": null,
"branding_source": { "logo": "none", "accent": "none" },
"partner_type": "purchase", // "purchase" | "platform"
"monthly_download_limit": 1,
"branding_mode": "whitelabel", // "branded" | "clean" | "whitelabel"
"discount_percentage": 0 // display only; price is recomputed server-side
}Sessions last 12 hours and are extended on activity. Every subsequent partner API call passes session_token in the JSON body (not as a header), and every call that grants value re-validates the token against the database, so a revoked or expired session stops working immediately even mid-edit.
Every failure is JSON { "error": "<message>" } with a meaningful status code. The strings below are exact and stable — branch on status first, message second. All of these were verified against the live endpoint.
| Status | error | Cause / fix |
|---|---|---|
400 | Missing client_id, payload, or signature | One of the three body fields is absent or empty. |
400 | Missing partner_external_user_id in payload | Signature was valid but the payload has no user id. Always send a per-user id. |
403 | Invalid client_id | No partner matches that client_id. Check the URL path segment. |
403 | Partner is not active | Your partner record is suspended or not yet activated — contact us. |
403 | Invalid signature | HMAC mismatch: wrong secret, signed the base64 instead of the raw JSON, base64 instead of hex digest, or the JSON string was re-serialised after signing. |
403 | Payload expired | timestamp is more than 5 minutes from our server clock, in either direction. |
500 | Failed to create user link / Failed to create session | Server-side persistence failure. Retry once; if it repeats, contact us. |
500 | Internal server error | Unhandled failure (including malformed JSON in the request body). |
Note that an invalid signature returns 403, not 401, and identity resolution failures are non-fatal: if we cannot resolve or create an MVM auth user from the email, the session is still returned with internal_user_id: null rather than an error.
Entitlement is resolved host-side and is authoritative. A partner cannot grant it. There is no payload field, no header and no session flag that makes a video owned. Every resolution re-derives ownership from our own records — completed orders, membership claims, per-video add-on grants, admin overrides — keyed to the MVM user your payload resolved to. The editor inside the iframe treats what the host sends as final and re-asks at export time; it never decides ownership itself.
What a non-entitled user actually sees today
quality: "preview", the same preview the public site plays). No master and no signed download URL is minted — s3Url is null. Bucket/key identifiers are still passed for scene-cut lookup only, so a preview clip can still be split on real cut points.workspace mode the purchase runs as a modal checkout over the embed, one video at a time in a queue; each success unlocks that source immediately without reloading the editor. A user with no owned sources sees export disabled with a purchase CTA rather than an error.{ status: "unavailable", reason } and cannot be added — the reason string is shown to the user.Practical consequence for you: never build UI that promises a download based on your own records. Ask, then render what we return.
postMessage contractRead this first: the message contract below is between our embed page (the host, running inside your iframe) and the Workspace editor iframe it mounts. Your host page implements none of it. We do not post messages to your page and we do not listen for messages from it — your only integration surface is the signed embed URL. It is documented here because partners embedding the Workspace ask what the surface does, and because it defines exactly which actions are available in each mode.
Editor → host
| Type | When it fires | Payload (abridged) | Host behaviour |
|---|---|---|---|
EDITOR_READY | Editor finished booting. | — | Host replies with VIDEO_DATA. |
EDITOR_VIDEO_DATA_ACK | Editor accepted a VIDEO_DATA. | { … } diagnostic | Logged; handshake considered complete. |
EDIT_COMPLETE | An edit render finished in-editor. | edit result | Forwarded to the host callback. |
EXPORT_COMPLETE | An export finished. | { exportId, … } | Host persists it and replies EXPORT_COMPLETE_ACK with ok. |
STATUS_UPDATE | Progress/status text changes. | { status } | Surface progress. |
PROJECT_SAVED | Project saved. | { projectId, projectName } | Refresh the project list. |
ADD_TO_CART | User adds an edit to cart. | { videoId, projectId?, projectName? } | Consumer site only. |
REQUEST_PURCHASE | Export blocked by unpaid sources. | { videoId, videoIds?, item?, price?, reason } | Opens checkout; in partner mode a modal queue over the embed. |
REQUEST_VIDEO_PURCHASE | Purchase of a specific video requested. | { videoId } | Opens checkout for that video. |
NAVIGATE_TO_PURCHASE | Editor wants the purchase page. | { videoId } | Host navigates / opens checkout. |
NAVIGATE_TO_UPGRADE | Upgrade CTA pressed. | — | Consumer site only. |
NAVIGATE_TO_ACCOUNT | Account CTA pressed. | — | Suppressed for partners (accountNavigation: false). |
REQUEST_EXIT_EDITOR | User picks “exit editor”. | { videoId?, projectId?, hasUnsavedChanges?, reason? } | Suppressed for partners (canExit: false) — the embed is the page. |
REQUEST_OPEN_PROJECT | User picks a saved project / related video. | { videoId?, projectId?, hasUnsavedChanges? } | Host re-boots the editor on that project. |
REQUEST_CATALOG_SEARCH | Catalogue panel search / song match. | { requestId, mode: "text"|"similar"|"song", query, videoId, limit, filters, sort, cursor, audioProfile? } | Answers CATALOG_SEARCH_RESULTS or CATALOG_SEARCH_ERROR. Rate limited ~10 searches / 10s. |
REQUEST_CATALOG_FACETS | Filter UI opens. | { requestId } | Answers CATALOG_FACETS (categories, energy, atmosphere, orientation). |
REQUEST_CATALOG_COLLECTIONS | Collections panel opens. | { requestId } | Answers CATALOG_COLLECTIONS. |
REQUEST_ADD_VIDEO | Clip added / dropped / previewed / project reopened. | { requestId, videoId, intent: "add"|"preview"|"drop"|"open" } | Answers VIDEO_SOURCE — master only when entitled, else preview grade. |
REQUEST_CATALOG_STATUS | Editor re-checks availability/ownership of timeline sources. | { requestId, videoIds[] } | Answers CATALOG_STATUS. |
REQUEST_PURCHASE_QUOTE | Export gate needs prices. | { requestId, videoIds[] } | Answers PURCHASE_QUOTE (per-item purchaseRequired). |
REQUEST_COLLECTION_QUOTE | Collection bundle pricing. | { requestId, videoIds[], projectId? } | Answers COLLECTION_QUOTE or COLLECTION_QUOTE_ERROR. |
REQUEST_TOKEN_REFRESH | Editor JWT near expiry / rejected. | — | Host mints a fresh token from the partner session token and replies TOKEN_REFRESHED. |
OPEN_PARTNER_EXPORTS | “Your exports” pressed in the editor top bar (partner sessions). | — | Host opens export history as a modal inside the iframe, so the editor keeps full height. |
REPORT_VIDEO_ISSUE | Editor cannot use a source (e.g. no usable master). | { videoId, title?, artist?, reason, hadVimeoUrl, hadS3, reportedAt? } | Host logs it server-side and re-verifies the source — no user action needed. |
Host → editor
| Type | When | Payload |
|---|---|---|
VIDEO_DATA | On EDITOR_READY, and again whenever boot data changes (token arrives, source presigned, ownership changes). | See shape below. |
TOKEN_REFRESHED | Answer to REQUEST_TOKEN_REFRESH, or when a token first lands after handshake. | { authToken, accessToken } |
PURCHASE_COMPLETE | Checkout resolved (success / failed / cancelled). | { videoId, status, canExport, ownsSocialClips, ownedVideoIds[], error? } |
EXPORT_COMPLETE_ACK | After the host persists an EXPORT_COMPLETE. | { exportId, ok } |
RESET_PROJECT | Host wants a blank project for the current video. | { videoId } |
Bridge answers: CATALOG_SEARCH_RESULTS, CATALOG_SEARCH_ERROR ({ requestId, code, message }), CATALOG_FACETS, CATALOG_COLLECTIONS, VIDEO_SOURCE, CATALOG_STATUS, PURCHASE_QUOTE, COLLECTION_QUOTE, COLLECTION_QUOTE_ERROR — each echoes the requestId it answers. | ||
VIDEO_DATA shape (partner session)
{
"type": "VIDEO_DATA",
"payload": {
"videoId": "", // empty in a sourceless workspace boot
"vimeoUrl": "", "s3Url": "", // keys always present; empty until a source is picked
"s3Bucket": "…", "s3Key": "…", // lookup identifiers only (scene cuts)
"sessionMode": "catalog", // "video" | "catalog" (catalog = no source yet)
"hasSource": false,
"userId": "<mvm auth user id>",
"metadata": { "title": "", "artist": "", "duration": 0, "width": 1920, "height": 1080 },
"currentUser": { "id": "…", "email": "…" },
"permissions": {
"canExport": false,
"entitlementSource": "none", // membership | purchase | addon | admin | none
"hidePurchaseCta": false,
"exportCtaLabel": "Purchase to export"
},
"ownedVideoIds": [], // authoritative per-source purchase entitlement
"ownership": "preview", // "owned" | "preview" for the selected video
"deliverables": { /* per-deliverable entitlement + price */ },
"authToken": "<short-lived HS256 JWT>",
"accessToken": "<same>",
"projects": [], "relatedVideos": [],
"capabilities": {
"canSwitchProject": true, "canSwitchVideo": true,
"canExit": false, // partner embeds are the whole page
"catalogSearch": true, "catalogPreviewSources": true, "catalogSongMatch": true,
"exportPurchaseGate": true,
"addonDeliverables": false, // partner checkout carries no add-ons yet
"accountNavigation": false
},
"partner": {
"partnerId": "…", "name": "Rights Hub",
"logoUrl": null, "accentColor": null,
"embedMode": "workspace"
},
"hostMode": "partner" // "consumer" on the main site
}
}The partner object is presentational and informational only — every server call re-derives partner identity from the opaque session token, never from this payload. Likewise ownedVideoIds / permissions are produced by the host from our records; the editor consumes them, it does not assert them.
Origin validation
event.origin is not the editor origin, and logs the rejection. Messages from any other frame are dropped before the type is inspected.targetOrigin (the editor origin captured at handshake) — never "*".message listener on the page hosting our iframe, check event.origin === "musicvideomarketplace.com" before trusting anything. We currently send you nothing, so a message claiming to come from us is a red flag.Identity mismatch between your payload and your own token issuer
This is the single most common cause of “it works but everything is empty”. The signed payload's partner_external_user_id (and email) must be the real signed-in end user, and it must be the same subject your own token-issuing / API-calling code uses. If your embed endpoint signs the real user while a service account, QA fixture, or hardcoded “integration user” is used elsewhere in your stack, the session resolves to a different MVM account than the one your other calls act on. Nothing errors: the embed boots, branding is right, the catalogue loads — but purchases, projects and export history belong to the other identity, so the user sees an empty library or an unexpected “not licensed” state on something they just bought.
Self-diagnosis: read linked_user_id and internal_user_id from the verification response and log them next to your own user id. If two different end users produce the same internal_user_id, or one user produces different ones across loads, your payload identity is not stable. Fix it at the source — do not compensate in the UI.
Clock skew rejecting valid payloads
timestamp is compared with an absolute ±5 minute window, so a host clock that is ahead fails just as hard as one behind, and 403 Payload expired looks identical to an expired link. Run NTP on the signing host, generate the timestamp at signing time (not at request time from a cached value), and always emit UTC ISO 8601 with an explicit offset — a local-time string with no zone is interpreted as UTC and silently shifts by your offset.
Caching or reusing a signed URL
Each signed URL is good for at most 5 minutes and creates a new session row when used. Never cache the embed URL in your CDN, HTML, or app state — mint it per iframe load from your own endpoint. Reloading a page with a stale URL is the second most common source of 403 Payload expired.
Signing the wrong bytes
403 Invalid signature almost always means one of: you signed the base64 instead of the raw JSON; you sent a base64 digest instead of lowercase hex; your HTTP client re-serialised the JSON object (key order/spacing changed) after signing; or the payload was mutated after signing (adding timestamp last is a classic). Build the string once, sign the string, send the string.
Sharing one identity across users
Because linkage is keyed on (partner_id, partner_external_user_id), a constant value (your admin id, "user", a tenant id) merges all your users into one MVM account with shared purchases and projects. Similarly, two different external ids with the same email resolve to the same MVM auth user, because email is the linkage key for ownership.
Iframe with no height, or an over-restrictive sandbox
A collapsed or 400px-tall iframe makes the Workspace look broken rather than erroring. Give it real height (see §4). A sandbox attribute missing allow-same-origin breaks session storage; missing allow-popups breaks checkout.
Expecting entitlement to follow your own records
Sending a “premium” flag, a plan name, or an owned-video list in the payload does nothing — those keys are ignored. Entitlement comes only from MVM orders, memberships and grants (see §9).
client_secret lives only in server-side config, never in client JS or a public repo.timestamp included, generated at signing time, host clock NTP-synced.branding_source in the verification response.allow="autoplay; fullscreen; clipboard-write".error string (§8), with a retry path for 500.embed_mode confirmed with us (workspace unless you need the legacy catalog flow).Questions this page does not answer: info@musicvideomarketplace.com. Support is staffed Monday–Friday, 9–5 UK time.