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

  1. 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.
  2. Find the store and what it allows: GET /me, then GET /stores/{store_id}/capabilities.
  3. Upload the file: POST /stores/{store_id}/uploads → PUT the bytes to the returned URL → POST /uploads/{upload_id}/complete → poll until status: "ready".
  4. Create a draft: POST /stores/{store_id}/products with the upload id.
  5. POST /products/{id}/publish (or /submit to let the seller review it) and show links.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:

  1. 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
  2. The seller signs in, sees your app name and the permissions, and clicks Allow. We redirect to your redirect_uri with ?code=…&state=… (or error=access_denied). Check state.
  3. Exchange the code within 10 minutes (it works once):
    curl -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=VERIFIER
    You get access_token, refresh_token, expires_in and the granted scope.
  4. Renew: grant_type=refresh_token with your client credentials — you get a new pair and the old one stops working. End access yourself with POST /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 webhooks
  • products:read — read listings and their status
  • products:write — create and edit drafts; ask the seller to review
  • assets:write — upload files
  • products: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): PUT the whole file to upload_url with Content-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}/complete and poll GET /uploads/{upload_id} (or listen for asset.ready) until ready — file.id is 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 readable error.
  • 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.uploads for 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_id takes a ready upload id or file id; or use external_url to deliver from the seller's own https link. A cover is drawn automatically unless auto_cover: false.
  • Listings always start as drafts. POST /products/{id}/validate lists any blockers in plain language.
  • POST /publish returns status: "published" with links.product_url — or pending_approval when the marketplace approves listings first; you'll get product.published when it goes live.
  • Status values: draft, pending_approval, published, hidden / rejected (set by the marketplace). Approval is tied to a revision: changing title, description, price, file or images after approval needs approval again.
  • Checkout: checkout.available is 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.reason says 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 once

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

HTTPCodeMeaning
400invalid_request / invalid_jsonThe body is missing or a field is wrong — see details[].
401unauthorized / invalid_token / token_expiredNo key, a revoked key, or an expired access token (refresh it).
403insufficient_scopeThe key lacks a permission — details.required names it.
403api_not_in_plan / api_not_in_store_planThe marketplace or store plan doesn't include the API.
404store_not_found / product_not_found / file_not_foundDoesn't exist in this key's store (other stores are invisible).
409upload_not_ready / idempotency_conflictWait for the upload to be ready, or use a new Idempotency-Key.
422unprocessable / not_readyBusiness rule — e.g. listing blockers in details.blockers.
429rate_limitedMore than 120 requests a minute for this key. Back off and retry.
500server_errorOur 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.