Skip to content

Publishing downloadable files

Some plugins make files that a merchant wants to hand out: a product feed, a price list, a catalogue export their trade customers import into their own shop. Published files give each one a home in the store's Files and a link that never changes, however often your plugin replaces the file.

The merchant puts that link on their storefront once. Your plugin refreshes the file on whatever schedule it likes, and everyone who follows the link gets the current version.

What you get

  • The file lives in the merchant's Files, in a folder called Plugin exports, and counts towards their storage allowance like any other file.
  • It is served from Orbit's CDN as a download (Content-Disposition: attachment), never as a web page.
  • Its URL stays the same for the life of the file. Replacing it keeps the URL; the CDN copy is cleared, so the new version is live within five minutes. If the link leaks, rotate it.
  • Each file belongs to your plugin. Other plugins, and store API keys, cannot see or change it.

Who can download it

A published file is public to anyone who has its URL. That is what lets a merchant hand the link to a customer, a marketplace or a feed reader with no login. Every response says so with "visibility": "public-url".

The URL is not guessable: after the store, your plugin and the slug it holds a random 128-bit token, stores/{storeId}/public/plugin-files/{pluginId}/{slug}/{token}/{filename}. Nobody finds a file they were not given the link to. But anyone who is given the link, or finds it in a page, an email or a log, can download the file, with nothing to sign in to.

So do not publish private data here: no cost prices, no margins, no customer lists, nothing personal. A published file is meant for what the merchant would show anyone who asked.

Scopes

ScopeLets your plugin
published-file:listList the files it has published to the store
published-file:readRead one of them
published-file:createPublish a file, replace it, and move it to a new link
published-file:deleteRemove a file it published

These are plugin scopes. A store API key cannot hold them, because a key has no plugin to publish as.

Publishing in three calls

A file is identified by a slug you choose, such as shopify or price-list. Publishing to a slug that already has a file replaces it.

  1. Start the upload. Tell Orbit the filename, type and exact size.

    HTTP
    POST /v1/published-files/shopify/uploads
    Content-Type: application/json
    
    { "filename": "shopify-products.csv", "contentType": "text/csv", "size": 9175111 }

    The answer holds a presigned upload URL, the headers you must send with it, and an upload id:

    JSON
    {
      "uploadId": "6f1c2a9e-1111-4222-8333-444455556666",
      "uploadUrl": "https://...",
      "requiredHeaders": { "content-type": "text/csv" },
      "expiresAt": "2026-10-19T03:15:00.000Z"
    }
  2. Upload the bytes. PUT exactly size bytes to uploadUrl with requiredHeaders. The size is part of the signature: a different length is refused by storage.

  3. Complete. Orbit checks the upload and publishes it at the file's fixed URL.

    HTTP
    POST /v1/published-files/shopify/uploads/6f1c2a9e-1111-4222-8333-444455556666/complete
    JSON
    {
      "slug": "shopify",
      "filename": "shopify-products.csv",
      "contentType": "text/csv",
      "size": 9175111,
      "url": "https://cdn.myorbitcommerce.net/stores/.../plugin-files/your-plugin/shopify/3f9c0a7e5b1d4c2e8a6f0b9d7c5e3a1f/shopify-products.csv",
      "visibility": "public-url",
      "createdAt": "2026-10-18T03:04:00.000Z",
      "updatedAt": "2026-10-19T03:04:00.000Z"
    }

In Node, streaming the file from disk:

TypeScript
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { Readable } from 'node:stream'

async function publish(token: string, slug: string, path: string, filename: string) {
  const api = 'https://api.myorbitcommerce.net'
  const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
  const { size } = await stat(path)

  const start = await fetch(`${api}/v1/published-files/${slug}/uploads`, {
    method: 'POST',
    headers: auth,
    body: JSON.stringify({ filename, contentType: 'text/csv', size })
  }).then((r) => r.json())
  const { uploadId, uploadUrl, requiredHeaders } = start.data ?? start

  const put = await fetch(uploadUrl, {
    method: 'PUT',
    headers: { ...requiredHeaders, 'content-length': String(size) },
    body: Readable.toWeb(createReadStream(path)) as ReadableStream,
    duplex: 'half'
  } as RequestInit)
  if (!put.ok) throw new Error(`Upload refused: ${put.status}`)

  const done = await fetch(`${api}/v1/published-files/${slug}/uploads/${uploadId}/complete`, {
    method: 'POST',
    headers: auth
  }).then((r) => r.json())
  return (done.data ?? done).url as string
}

Retrying the PUT on a storage 5xx is safe. Do not retry complete blindly: if the first call succeeded and only its answer was lost, a second one finds no upload. Read the file with GET /v1/published-files/{slug} instead.

The rules

  • Slug: lowercase letters, numbers and dashes, up to 63 characters, starting with a letter or number.
  • Types: CSV (text/csv, a .csv filename, UTF-8 text) or ZIP (application/zip, a .zip filename). Orbit checks the first bytes match.
  • Size: up to 250 MB per file.
  • Filename: letters, numbers, spaces, dots, dashes and underscores, up to 120 characters. It is fixed when the file is first published, because it is part of the URL. To rename, delete the file and publish it again.
  • Uploads expire 15 minutes after you start them.
  • Caching: a replaced file can take up to five minutes to reach everyone.

Errors

StatusWhen
400The slug, filename, type or size is not valid, or the upload was empty
401No valid token, or the token lacks the scope (Missing required scopes: ...)
403The token is not a plugin token (a store API key, for example)
404No file has that slug for your plugin, or no upload has that id
409The file is published under another filename, or another publish of it finished first
413Over 250 MB, or over the store's storage allowance
415The uploaded bytes are not the declared type

Listing and removing

GET /v1/published-files lists your plugin's files for the store, as { items, meta }, with page and limit (up to 100).

DELETE /v1/published-files/{slug} removes a file. Its link stops working, and stays dead: publishing the slug again gives the file a new link.

A merchant can also delete one of your files in their Files. The next time your plugin publishes that slug, it comes back at the same link.

When your plugin is uninstalled, Orbit removes all of its files from the store.

If a link gets out where it should not, move the file to a new one:

HTTP
POST /v1/published-files/shopify/rotate-url

It needs published-file:create. The file gets a new token, so a new URL, and the answer is the file with that URL. The old URL stops working at once: the old copy is removed and cleared from the CDN. The file itself does not change, and later publishes keep the new URL. Give the merchant the new link, since anything that pointed at the old one stops getting the file.