Developers
Publish to MultiVendor
Let your AI assistant, design tool, plugin or script put what people create on sale — e-books, PDFs, music, video, art packs, templates, code and games — straight into their store on any MultiVendor marketplace.
- ✓ The journey: connect a store → upload → create a draft → review & approve → publish → get the link → buyers pay → secure delivery.
- ✓ Sellers are paid directly into their own Stripe account. MultiVendor takes no commission — and neither does your app, unless you agree it with the seller.
- ✓ New connections create drafts. Publishing needs the seller's explicit permission and still follows each marketplace's approval rules.
Base URL https://multivendor.com/api/v1 · OpenAPI 3.1 reference · Register an app
Quick start
- The seller opens Seller centre → Connected apps → Create an API key and copies it (it starts with
mv_sk_). Building for many sellers? Use OAuth instead. - Find the store and what it allows:
GET /me, thenGET /stores/{store_id}/capabilities. - Upload the file:
POST /stores/{store_id}/uploads→ PUT the bytes to the returned URL →POST /uploads/{upload_id}/complete→ poll untilstatus: "ready". - Create a draft:
POST /stores/{store_id}/productswith the upload id. POST /products/{id}/publish(or/submitto let the seller review it) and showlinks.product_url.
curl https://multivendor.com/api/v1/me -H "Authorization: Bearer $MULTIVENDOR_API_KEY"Authentication
Send a token on every request: Authorization: Bearer <token>. A token belongs to exactly one store; every request is checked against it on the server, and other stores simply don't exist for it.
- API keys (
mv_sk_…) — made by the seller for their own scripts and private integrations. They don't expire; the seller can remove them any time. - OAuth access tokens (
mv_at_…) — for apps used by many sellers. Valid 30 days; renew with the refresh token.
Tokens never give access to payments, payouts, Stripe accounts or buyers' payment details. Treat them like passwords: keep them on your server, never in a browser or app bundle.
OAuth for apps
Register your app at multivendor.com/app/developers to get a client_id and client_secret. Then use the authorization-code flow with PKCE:
- Send the seller to their marketplace's consent screen (each marketplace has its own address; for MultiVendor Market it is
https://multivendor.com/market):https://{marketplace}/seller/oauth/authorize ?response_type=code &client_id=mvapp_... &redirect_uri=https://yourapp.com/oauth/callback &scope=stores:read products:read products:write assets:write &state=RANDOM_STATE &code_challenge=BASE64URL(SHA256(code_verifier)) &code_challenge_method=S256 - The seller signs in, sees your app name and the permissions, and clicks Allow. We redirect to your
redirect_uriwith?code=…&state=…(orerror=access_denied). Checkstate. - Exchange the code within 10 minutes (it works once):
You getcurl -X POST https://multivendor.com/api/v1/oauth/token \ -d grant_type=authorization_code -d client_id=mvapp_... -d client_secret=mv_cs_... \ -d code=CODE -d redirect_uri=https://yourapp.com/oauth/callback -d code_verifier=VERIFIERaccess_token,refresh_token,expires_inand the grantedscope. - Renew:
grant_type=refresh_tokenwith your client credentials — you get a new pair and the old one stops working. End access yourself withPOST /oauth/revoke.
Redirect addresses must be registered exactly, and use https (http only for localhost). Sellers see and remove connected apps in Seller centre → Connected apps; you'll receive connection.revoked.
Permissions (scopes)
stores:read— store name, limits, readiness; manage your webhooksproducts:read— read listings and their statusproducts:write— create and edit drafts; ask the seller to reviewassets:write— upload filesproducts:publish— publish, unpublish and edit live listings (still subject to marketplace approval)
Ask for the least you need. Without products:publish, use POST /products/{id}/submit: the seller gets an email with a secure review link and publishes it themselves.
Uploading files
Files go directly into the store's private storage with signed URLs — nothing large passes through our API servers.
POST /stores/{store_id}/uploads
{ "file_name": "guide.pdf", "size_bytes": 2483021, "kind": "main" } // kind: main | preview
→ single: { "upload_id": "upl_…", "method": "single", "upload_url": "https://…", "headers": { "Content-Type": "application/octet-stream" } }
→ multipart: { "upload_id": "upl_…", "method": "multipart", "part_size_bytes": 67108864, "parts": [{ "part_number": 1, "upload_url": "…" }, …] }- Single (up to 100 MB):
PUTthe whole file toupload_urlwithContent-Type: application/octet-stream. - Multipart (bigger files): PUT each 64 MB slice to its URL, keep each response's
ETag, then send{ "parts": [{ "part_number": 1, "etag": "…" }] }to complete. - Then
POST /uploads/{upload_id}/completeand pollGET /uploads/{upload_id}(or listen forasset.ready) untilready—file.idis then usable. Unfinished uploads are removed after 24 hours. - Every file is checked before it can be sold: real file type, size, preview limits, ZIP safety (no unsafe paths, links or decompression bombs), a virus check and a SHA-256 checksum. Rejected files return
status: "rejected"with a readableerror. - Accepted: PDF, EPUB, MP3/WAV/M4A/OGG/FLAC, MP4/MOV/WebM, JPEG/PNG/WebP and ZIP (templates, code, plugins, games — ZIPs with programs are stored, never run, and shown to buyers as software). Loose executables are refused.
- Limits (see
capabilities.uploadsfor the store's live values): files up to 2 GB by default; previews up to 50 MB and 30 seconds of video; storage per store depends on its plan.
Listings & publishing
POST /stores/{store_id}/products
{
"title": "The Sourdough Guide",
"description": "A beginner's guide to baking sourdough at home…",
"price": 19,
"digital": { "format": "pdf", "file_id": "upl_…", "license": "Personal use" },
"seo": { "title": "The Sourdough Guide — PDF", "description": "…" }
}digital.format: ebook, pdf, audio, video, images, software or other.file_idtakes a ready upload id or file id; or useexternal_urlto deliver from the seller's own https link. A cover is drawn automatically unlessauto_cover: false.- Listings always start as drafts.
POST /products/{id}/validatelists anyblockersin plain language. POST /publishreturnsstatus: "published"withlinks.product_url— orpending_approvalwhen the marketplace approves listings first; you'll getproduct.publishedwhen it goes live.- Status values:
draft,pending_approval,published,hidden/rejected(set by the marketplace). Approval is tied to arevision: changing title, description, price, file or images after approval needs approval again. - Checkout:
checkout.availableis true only when buyers can actually pay (the seller has connected Stripe and the listing is live). Never show a “buy” link before that;checkout.reasonsays what's missing. - Every create, edit, approval and publish is recorded in the listing's history, including which app did it.
Webhooks
POST /webhooks
{ "url": "https://yourapp.com/multivendor/events", "events": ["asset.ready", "product.published", "order.paid"] }
→ { "id": "whk_…", "secret": "whsec_…" } // the secret is shown onceEvents: asset.ready, asset.rejected, product.approval_required, product.published, product.publication_failed, product.unpublished, order.paid, order.refunded, connection.revoked. Each is sent as JSON with a unique id (also in MultiVendor-Event-Id) — use it to ignore duplicates. Reply 2xx within 10 seconds; failures are retried with backoff for about a day. Addresses must be public https.
Verify every event: MultiVendor-Signature: t=<unix>,v1=<hex> is an HMAC-SHA256 of "<t>.<raw body>" with your secret.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret, toleranceSec = 300) {
const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false; // too old
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}Limits & errors
Errors always look like { "error": { "code", "message", "details"?, "request_id" } }; every response has an X-Request-Id header — include it when you contact us. Up to 120 requests a minute per token. Lists are paginated with ?limit= (max 100) and ?starting_after=<last id>.
Retries: send an Idempotency-Key header on POSTs. The first response is stored for 24 hours and replayed for the same key (with Idempotent-Replayed: true); the same key with a different body returns 409 idempotency_conflict.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_request / invalid_json | The body is missing or a field is wrong — see details[]. |
| 401 | unauthorized / invalid_token / token_expired | No key, a revoked key, or an expired access token (refresh it). |
| 403 | insufficient_scope | The key lacks a permission — details.required names it. |
| 403 | api_not_in_plan / api_not_in_store_plan | The marketplace or store plan doesn't include the API. |
| 404 | store_not_found / product_not_found / file_not_found | Doesn't exist in this key's store (other stores are invisible). |
| 409 | upload_not_ready / idempotency_conflict | Wait for the upload to be ready, or use a new Idempotency-Key. |
| 422 | unprocessable / not_ready | Business rule — e.g. listing blockers in details.blockers. |
| 429 | rate_limited | More than 120 requests a minute for this key. Back off and retry. |
| 500 | server_error | Our problem. Retry with the same Idempotency-Key. |
Testing safely
- Use a test store: open a free store on MultiVendor Market (or any marketplace) and create an API key there.
- While MultiVendor runs in Stripe test mode, buyers can pay with the card
4242 4242 4242 4242, any future date and any CVC — no real money moves. The buyer gets a receipt with a private download link. - Keep published test listings clearly named, and unpublish them when you're done (
POST /products/{id}/unpublish).
Full example
Upload a PDF → create a $19 draft → get it approved → publish → print the link. Node 18+, no dependencies:
MULTIVENDOR_API_KEY=mv_sk_... node publish-pdf.mjs ./my-guide.pdf "My Guide" 19#!/usr/bin/env node
// Publish to MultiVendor — complete example (Node 18+, no dependencies).
//
// Upload a PDF → create a $19 draft → get it approved → publish → print the link.
//
// Usage:
// export MULTIVENDOR_API_KEY=mv_sk_... # Seller centre → Connected apps → Create an API key
// node examples/publish-pdf.mjs ./my-guide.pdf "My Guide" 19
//
// With "products:publish" the script publishes directly (or sends the listing for
// the marketplace's approval). Without it, it asks the seller to review and
// publish, and prints the review link.
import { readFile, stat } from "node:fs/promises";
import { basename } from "node:path";
const API = (process.env.MULTIVENDOR_API_URL ?? "https://multivendor.com/api/v1").replace(/\/$/, "");
const KEY = process.env.MULTIVENDOR_API_KEY;
const [file, title = "My guide", price = "19"] = process.argv.slice(2);
if (!KEY || !file) {
console.error('Usage: MULTIVENDOR_API_KEY=mv_sk_... node examples/publish-pdf.mjs ./file.pdf "Title" 19');
process.exit(1);
}
async function api(method, path, body, extraHeaders = {}) {
const res = await fetch(`${API}${path}`, { method, headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...extraHeaders }, body: body ? JSON.stringify(body) : undefined });
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${method} ${path} → ${res.status} ${json.error?.code}: ${json.error?.message} (request ${json.error?.request_id})`);
return json;
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const run = `${Date.now()}`; // one id per run, so retries never duplicate
// 1. Which store is this key for, and what can it do?
const me = await api("GET", "/me");
const caps = await api("GET", `/stores/${me.store_id}/capabilities`);
console.log(`Store: ${caps.store.name} on ${caps.store.marketplace.name}`);
if (!caps.uploads.supported) throw new Error("This store can't take uploads through the API.");
// 2. Upload the file straight to private storage.
const size = (await stat(file)).size;
const up = await api("POST", `/stores/${me.store_id}/uploads`, { file_name: basename(file), size_bytes: size, kind: "main" }, { "Idempotency-Key": `upload-${run}` });
const bytes = await readFile(file);
let parts = [];
if (up.method === "single") {
const r = await fetch(up.upload_url, { method: "PUT", headers: up.headers, body: bytes });
if (!r.ok) throw new Error(`Upload failed: ${r.status}`);
} else {
for (const p of up.parts) {
const chunk = bytes.subarray((p.part_number - 1) * up.part_size_bytes, p.part_number * up.part_size_bytes);
const r = await fetch(p.upload_url, { method: "PUT", headers: up.headers, body: chunk });
if (!r.ok) throw new Error(`Part ${p.part_number} failed: ${r.status}`);
parts.push({ part_number: p.part_number, etag: r.headers.get("etag") });
}
}
await api("POST", `/uploads/${up.upload_id}/complete`, { parts }, { "Idempotency-Key": `complete-${run}` });
// 3. Wait for the safety checks.
let upload;
for (let i = 0; i < 120; i++) {
upload = await api("GET", `/uploads/${up.upload_id}`);
if (upload.status === "ready" || upload.status === "rejected") break;
await sleep(2000);
}
if (upload.status !== "ready") throw new Error(`File not accepted: ${upload.error ?? upload.status}`);
console.log(`Uploaded and checked: ${upload.file.name} (${upload.file.size_bytes} bytes)`);
// 4. Create the draft listing.
const product = await api("POST", `/stores/${me.store_id}/products`, {
title,
description: `${title} — a downloadable PDF, delivered instantly after purchase.`,
price: Number(price),
digital: { format: "pdf", file_id: up.upload_id },
}, { "Idempotency-Key": `product-${run}` });
console.log(`Draft created: ${product.id} at ${product.currency} ${product.price}`);
// 5. Check it's ready, then publish (or ask the seller to).
const check = await api("POST", `/products/${product.id}/validate`);
if (!check.ready) throw new Error(`Not ready: ${check.blockers.join(" ")}`);
if (me.scopes.includes("products:publish")) {
const p = await api("POST", `/products/${product.id}/publish`, null, { "Idempotency-Key": `publish-${run}` });
if (p.status === "published") console.log(`Published! ${p.links.product_url}`);
else console.log(`Sent for the marketplace's approval. The seller is emailed when it's live. Status: ${p.status}`);
if (!p.checkout.available) console.log(`Note: buyers can't pay yet — ${p.checkout.reason}`);
} else {
const p = await api("POST", `/products/${product.id}/submit`, null, { "Idempotency-Key": `submit-${run}` });
console.log(`Asked the seller to review and publish it: ${p.links.review_url}`);
}
Questions or a partnership idea? Contact us.