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
| Scope | Lets your plugin |
|---|---|
published-file:list | List the files it has published to the store |
published-file:read | Read one of them |
published-file:create | Publish a file, replace it, and move it to a new link |
published-file:delete | Remove 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.
-
Start the upload. Tell Orbit the filename, type and exact size.
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:
{ "uploadId": "6f1c2a9e-1111-4222-8333-444455556666", "uploadUrl": "https://...", "requiredHeaders": { "content-type": "text/csv" }, "expiresAt": "2026-10-19T03:15:00.000Z" } -
Upload the bytes.
PUTexactlysizebytes touploadUrlwithrequiredHeaders. The size is part of the signature: a different length is refused by storage. -
Complete. Orbit checks the upload and publishes it at the file's fixed URL.
POST /v1/published-files/shopify/uploads/6f1c2a9e-1111-4222-8333-444455556666/complete{ "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:
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.csvfilename, UTF-8 text) or ZIP (application/zip, a.zipfilename). 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
| Status | When |
|---|---|
| 400 | The slug, filename, type or size is not valid, or the upload was empty |
| 401 | No valid token, or the token lacks the scope (Missing required scopes: ...) |
| 403 | The token is not a plugin token (a store API key, for example) |
| 404 | No file has that slug for your plugin, or no upload has that id |
| 409 | The file is published under another filename, or another publish of it finished first |
| 413 | Over 250 MB, or over the store's storage allowance |
| 415 | The 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.
Rotating a link
If a link gets out where it should not, move the file to a new one:
POST /v1/published-files/shopify/rotate-urlIt 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.