Music Video Marketplace · v1 · 2026-10-14

Partner fulfilment API

Deliver your customers' videos and exports from your own site: list what a customer owns, mint short-lived download links from your server with a standard API key, and get told the moment a purchase completes or an export is ready with signed webhooks or a polling feed.

What changed in v1

First published version: server-to-server endpoints to list a customer's videos and exports and to get one-hour download links, authenticated with a standard API key.

Partner fulfilment API · v1

Deliver your customers' videos from your own site

Your customers use the embed, but you may want them to find and download their videos from your own account pages. This server-to-server API lets your backend list what a customer owns and exported, and fetch a short-lived download link for any file. Your customers never see our site, and your API key never reaches a browser.

Base URL: https://musicvideomarketplace.com/api/public/partner/v1.

1Authentication

Create a standard API key in your partner dashboard (Credentials → API keys). Send it as a bearer token from your server:

 
Authorization: Bearer mvm_live_xxxxxxxxxxxxxxxxxxxxxxxx
  • Keys need the fulfilment:read scope to list and fulfilment:download to get links. Keys created from 14 October 2026 have both. Older standard keys do not: create a new one.
  • Catalog keys are rejected with 403 wrong_key_type. A revoked key returns 401 revoked_api_key.
  • You can only reach customers linked to your own partner account. Your partner is taken from the key, never from the request.
  • Keep the key on your server. If it leaks, revoke it in the dashboard and create another.
  • Rate limit: 100 requests per minute per key. Over it returns 429 rate_limited with Retry-After.

2Who the customer is

Customers are identified by the same partner_external_user_id you send in the signed embed payload. A customer exists here from the moment they first open your embed. Before that, calls return 404 user_not_found.

3List a customer's videos and exports

 
GET https://musicvideomarketplace.com/api/public/partner/v1/users/{external_user_id}/videos?limit=50&offset=0

URL-encode the user ID. limit is 1 to 100 (default 50); results are newest first. NewAdd since (an ISO 8601 date or date-time, for example since=2026-10-29T09:30:00Z) to get only the videos this customer acquired, or exported something from, since then. total counts only those. An invalid since returns 400 invalid_since.

 
{
  "external_user_id": "your-user-123",
  "total": 2,
  "limit": 50,
  "offset": 0,
  "videos": [
    {
      "video_id": "0b6f2c1e-6c1a-4a53-9a4f-3f0f0f0f0f0f",
      "title": "Sunset Vibes",
      "artist": "Example Artist",
      "thumbnail_url": "https://…",
      "duration_seconds": 240,
      "acquired_at": "2026-10-02T14:21:07Z",
      "source": "partner",
      "downloads": { "original": true, "reel": true, "bts": false },
      "exports": [
        {
          "export_id": "6d3a5b1c-2b7e-4c0a-8f55-9a1d3c7e2b10",
          "name": "My edit",
          "created_at": "2026-10-03T09:12:44Z",
          "files": ["main"]
        },
        {
          "export_id": "b21e7f40-93cd-4f53-8c1a-0e6a5d2b7c11",
          "name": "My edit",
          "created_at": "2026-10-03T09:12:51Z",
          "files": ["social_reel"]
        }
      ]
    }
  ]
}
FieldMeaning
sourceAlways partner: the video was bought or claimed through your integration. Videos a customer bought elsewhere on musicvideomarketplace.com are not part of this API.
downloadsWhich original files this customer can download: the music video, the vertical reel, and behind-the-scenes. false means not owned or no file exists.
exports[]Everything this customer has exported from the editor for that video, newest first, including every deliverable. Each rendered file is its own entry with its own export_id. Only finished files are listed.
exports[].filesThe kind of file: main (music video), reel, lyric, social_canvas (Spotify Canvas and other loop formats), social_reel (vertical reel and clips), social, final, bts (behind the scenes).
 
POST https://musicvideomarketplace.com/api/public/partner/v1/downloads
Content-Type: application/json

{
  "external_user_id": "your-user-123",
  "video_id": "0b6f2c1e-6c1a-4a53-9a4f-3f0f0f0f0f0f",
  "kind": "original"
}
FieldRequiredDescription
external_user_idYesThe customer.
video_idYesFrom the list response.
kindNo (default original)original, reel, bts or export.
export_idWhen kind is exportFrom exports[].export_id.
fileNoFor exports. Normally leave it out, because the export_id already names one file. If you send it, it must match the kind listed for that export, otherwise you get 404 file_not_available.
 
{
  "download_url": "https://…",
  "filename": "Sunset_Vibes.mp4",
  "expires_in": 3600,
  "kind": "original"
}
  • The link is valid for one hour and downloads the file with the right file name. Do not store links: ask for a new one each time a customer clicks Download, or redirect them to the link straight away.
  • Ownership is checked on every request. Revoked licences stop working immediately.
  • Reel files need a licence that covers the reel (partner purchases do) or the reel add-on. Behind-the-scenes needs that add-on. An export must belong to the same customer and video.
  • An export that is still being made returns 409 export_not_ready: try again shortly. Only finished files appear in the list.
  • Each link request is counted as a download in your usage statistics.

5Example (Node)

 
const BASE = 'https://musicvideomarketplace.com/api/public/partner/v1';
const headers = { Authorization: `Bearer ${process.env.MVM_API_KEY}`, 'Content-Type': 'application/json' };

// Your "My videos" page
async function myVideos(userId) {
  const res = await fetch(`${BASE}/users/${encodeURIComponent(userId)}/videos`, { headers });
  if (res.status === 404) return [];            // the customer has not used the embed yet
  if (!res.ok) throw new Error((await res.json()).error.message);
  return (await res.json()).videos;
}

// Your "Download" button handler: send the customer straight to a fresh link
async function downloadUrl(userId, videoId) {
  const res = await fetch(`${BASE}/downloads`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ external_user_id: userId, video_id: videoId, kind: 'original' }),
  });
  if (!res.ok) throw new Error((await res.json()).error.message);
  return (await res.json()).download_url;
}

6Errors

Errors use one shape: { "error": { "code": "...", "message": "..." } }.

StatuscodeCause
400invalid_request, invalid_jsonMissing or malformed fields.
400invalid_since, invalid_typesince is not an ISO 8601 date, or type is not an event type (sections 3 and 8).
401missing_api_key, invalid_api_key, revoked_api_keyKey missing, unknown or revoked.
403wrong_key_type, missing_scope, partner_inactiveNot a standard key, no fulfilment scope, or your partner account is not active.
403not_ownedThe customer does not own that video or file.
403not_available_in_testA test key asked for an original, reel or behind-the-scenes file. Only test exports are available in test mode.
404user_not_foundNo customer with that ID has opened your embed.
404export_not_found, file_not_available, video_unavailableThe export, file or video does not exist.
429rate_limitedToo many requests. Retry after the Retry-After header.
500internal_errorRetry shortly.

7Test mode

An API key labelled test works against your test customers only: the customers who signed in to the embed with your test client ID (see the partner embed guide, section 14). Pass the same partner_external_user_id you signed with. Responses include "test": true.

  • The list shows the videos the test customer “bought” with the test card. downloads is always false for original, reel and behind-the-scenes: full-quality files are never available in test mode.
  • Test exports (watermarked, 540p) can be listed and downloaded with kind: "export". Asking for an original returns 403 not_available_in_test.
  • A live key never sees test customers, and a test key never sees live customers.

8Webhooks and the events feed New

You do not have to keep asking whether something has happened. We can post a signed message to your server the moment it does, and the same events are always available as a feed you can read on your own schedule.

Events

typeWhendata
purchase.completedA customer now has a video through your integration (bought on your checkout, bought in the embed, or claimed), or has bought an add-on.video_id, order_ref, source (partner_pays, mvm, claim or addon), acquired_at, and addon for an add-on.
export.readyA file the customer exported from the editor has finished and can be downloaded.video_id, export_id, kind (as in section 3), created_at.
pingOnly when you press Send test event on your dashboard.message.

Every event has the same envelope. Use external_user_id to find the customer on your side, then call section 3 or 4 for the files.

 
{
  "id": "5f0c2c52-3d0b-4d3e-9d0c-6a6a2f9d1b11",
  "type": "export.ready",
  "created_at": "2026-10-29T10:42:11.482Z",
  "test": false,
  "external_user_id": "your-user-123",
  "data": {
    "video_id": "0b6f2c1e-6c1a-4a53-9a4f-3f0f0f0f0f0f",
    "export_id": "6d3a5b1c-2b7e-4c0a-8f55-9a1d3c7e2b10",
    "kind": "main",
    "created_at": "2026-10-29T10:41:58Z"
  }
}

Set it up

  1. On your dashboard, open the Credentials tab and find Webhooks.
  2. Choose Live or Test, paste the public https address on your server, and save. Test events go to your test address, live events to your live one.
  3. Reveal your signing secret (it starts whsec_) and store it as an environment variable on your server.
  4. Press Send test event. It goes through exactly the code real events use, and the answer shows in Recent deliveries.

The address must be https on the standard port, on the public internet. Localhost, internal names and private network addresses are refused. We do not follow redirects.

Check the signature

Every request carries X-MVM-Signature: t=<unix seconds>,v1=<hex> and X-MVM-Event-Id. The signature is an HMAC-SHA256 of "<t>.<raw request body>" using your signing secret. Compute it over the raw body (before any JSON parsing), compare in constant time, and reject anything where t is more than 5 minutes old so a captured request cannot be replayed.

import crypto from 'node:crypto';
import express from 'express';

const app = express();

// Keep the raw body: the signature is over exactly what we sent.
app.post('/webhooks/mvm', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-MVM-Signature') || '';
  const t = /t=(\d+)/.exec(header)?.[1];
  const v1 = /v1=([0-9a-f]+)/.exec(header)?.[1];
  if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);

  const expected = crypto.createHmac('sha256', process.env.MVM_WEBHOOK_SECRET)
    .update(`${t}.${req.body.toString('utf8')}`).digest('hex');
  const ok = expected.length === v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(req.body.toString('utf8'));
  // event.id is the same on every retry: skip ids you have already handled.
  handleEvent(event); // keep this quick, or queue it
  res.sendStatus(200);
});

Retries and repeats

  • Answer with any 2xx within 10 seconds to say you have it. Anything else, a timeout, or a redirect counts as a failure.
  • We try again after 5 minutes, then 30 minutes, then 3 hours. After the third failure we stop for that event. Your dashboard shows each delivery, what your server answered, and since when it has been failing.
  • An event can arrive more than once, and events can arrive out of order. The id never changes between retries: keep the ids you have handled and ignore repeats.
  • Do the real work after you have answered (queue it). A slow handler looks like a failure.

Or read the feed instead

If you would rather poll, or you need to catch up after downtime, the same events are available for 30 days:

 
GET https://musicvideomarketplace.com/api/public/partner/v1/events?since=2026-10-29T09:30:00Z&limit=100&type=export.ready

Events come oldest first, for your key's environment only (a test key sees test events). limit is 1 to 500 (default 100); typeis optional. Save next_since and send it as since next time to carry on exactly where you stopped. With no new events next_since is the since you sent. Dedupe by id, as with webhooks.

 
{
  "test": false,
  "events": [ { "id": "…", "type": "purchase.completed", "created_at": "2026-10-29T10:02:41.120Z", "test": false, "external_user_id": "your-user-123", "data": { "video_id": "…", "order_ref": "ord_8841", "source": "partner_pays", "acquired_at": "2026-10-29T10:02:41Z" } } ],
  "next_since": "2026-10-29T10:02:41.120Z"
}

The feed uses the same standard key and fulfilment:read scope as section 3.

9Good practice

  • Check that the person asking is signed in to your site and is that customer, before you call us. We trust your server's word on who the customer is.
  • Never put the API key in a browser, app or repository. Use an environment variable.
  • Cache the list for a short time if you like, but never cache download links.
  • Watch your error rate and per-key calls on the Usage tab of your dashboard.

Questions: info@musicvideomarketplace.com. Support is staffed Monday–Friday, 9–5 UK time.