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:readscope to list andfulfilment:downloadto 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 returns401 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_limitedwithRetry-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=0URL-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"]
}
]
}
]
}| Field | Meaning |
|---|---|
source | Always partner: the video was bought or claimed through your integration. Videos a customer bought elsewhere on musicvideomarketplace.com are not part of this API. |
downloads | Which 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[].files | The 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). |
4Get a download link
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"
}| Field | Required | Description |
|---|---|---|
external_user_id | Yes | The customer. |
video_id | Yes | From the list response. |
kind | No (default original) | original, reel, bts or export. |
export_id | When kind is export | From exports[].export_id. |
file | No | For 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": "..." } }.
| Status | code | Cause |
|---|---|---|
| 400 | invalid_request, invalid_json | Missing or malformed fields. |
| 400 | invalid_since, invalid_type | since is not an ISO 8601 date, or type is not an event type (sections 3 and 8). |
| 401 | missing_api_key, invalid_api_key, revoked_api_key | Key missing, unknown or revoked. |
| 403 | wrong_key_type, missing_scope, partner_inactive | Not a standard key, no fulfilment scope, or your partner account is not active. |
| 403 | not_owned | The customer does not own that video or file. |
| 403 | not_available_in_test | A test key asked for an original, reel or behind-the-scenes file. Only test exports are available in test mode. |
| 404 | user_not_found | No customer with that ID has opened your embed. |
| 404 | export_not_found, file_not_available, video_unavailable | The export, file or video does not exist. |
| 429 | rate_limited | Too many requests. Retry after the Retry-After header. |
| 500 | internal_error | Retry 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.
downloadsis alwaysfalsefor 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 returns403 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
| type | When | data |
|---|---|---|
purchase.completed | A 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.ready | A file the customer exported from the editor has finished and can be downloaded. | video_id, export_id, kind (as in section 3), created_at. |
ping | Only 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
- On your dashboard, open the Credentials tab and find Webhooks.
- 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.
- Reveal your signing secret (it starts
whsec_) and store it as an environment variable on your server. - 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
idnever 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.readyEvents 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.
What's changed
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.
- GET /v1/users/{external_user_id}/videos lists the videos a customer bought or claimed through your integration, with which files can be downloaded and the exports they have made.
- POST /v1/downloads returns a one-hour link for the original video, reel, behind-the-scenes or an exported file, after checking ownership.
- Standard keys with the fulfilment:read and fulfilment:download scopes, 100 requests per minute, uniform error format.
- Exports include every deliverable the editor renders (music video, reel, lyric video, Canvas and other social formats, behind the scenes), each as its own entry; the older three-file edit records still work.
- Added 29 October 2026: webhooks (purchase.completed, export.ready) signed with a secret you can rotate, set up on the Webhooks card of your dashboard's Credentials tab, with retries and a delivery log.
- Added 29 October 2026: GET /v1/events, a feed of the same events for polling or catching up (kept for 30 days), and a since filter on the videos list.
