Upload a file to the store's media library
Authorizations
A secret API key. Publishable keys cannot reach this API. A key may carry an expiry, and an expired key is refused exactly like an unknown one, with a 401 that names no reason; check the key's expires_at in the dashboard rather than inferring it from a response. When a merchant rolls a key's secret they choose a grace window of up to 3 days, and for its duration BOTH the new secret and the one it replaced authenticate, so an integration moves over on its own deploy schedule instead of at the instant the button is pressed. Move before the window closes: after it, the old secret is refused. Nothing else about this contract moves with a roll. The key keeps its id and its scopes, so the only thing an integration updates is the credential itself.
Headers
A unique key per logical write. Replaying a request with the same key returns the first response byte for byte instead of applying the write twice.
Body
MULTIPART/FORM-DATA, NOT JSON, and the only route on this API that is. Post the raw file in a part named "file"; the backend streams it into the store's object storage, records the row, and answers 201 with that row in the shape GET /api/v1/media-files/{mediaFileId} serves. THE 1 MiB ENVELOPE IS A HARD CEILING ON THIS ROUTE. Every write on this API passes through the idempotency ledger, which buffers the whole request body to hash it under a 1 MiB bound, so a larger upload is refused with 413 payload_too_large BEFORE this route runs. The storage layer's own per-object ceiling is far larger and is also enforced, so the binding limit is whichever is smaller, which today is always the envelope. This is a property of the mechanism rather than a tuning value, so size your assets for it: a photograph straight off a phone or a camera will not fit. IT SPENDS THE MERCHANT'S STORAGE ALLOWANCE, checked against the received byte count BEFORE the transfer. An upload that would take the store past its plan is 402 storage_quota_exceeded, which is not a permissions failure and never succeeds on retry: the merchant frees space or upgrades. No money moves on this call, but the metered resource it consumes is the one their plan is priced on. WHAT A RETRY DOES, and this is the route where it matters most, because nothing downstream deduplicates. Retry with the SAME Idempotency-Key and BYTE-IDENTICAL bytes: the ledger replays the stored 201 and no second object is written, and this is the retry to make. Retry with the same key after RE-ENCODING the form: most clients generate a fresh multipart boundary per request, so the body hash differs and the answer is 409 idempotency_key_reused with nothing uploaded, which means a client that wants replay must buffer the encoded form rather than rebuild it. Retry with a NEW key: a SECOND object and a SECOND library row, both counted against the plan, because every call mints a fresh object key and there is nothing for it to collide with. Retry after a 5xx: the ledger releases the key and whether the first attempt left an orphan object depends on where it failed; an object with no row is invisible to every route and is swept automatically. UPLOADING IS NOT ATTACHING. Bytes landed here are inert until something points at them, so product_id and variant_id are refused BY NAME: a product image is write_products' resource and is attached through the product routes. THE FILE IS PUBLIC, and that is not a choice this route offers. Store media is served from the cdn so galleries and the storefront can render it, and the response carries a plain non-expiring URL, so visibility is refused by name rather than accepted and ignored. REFUSED BY NAME beside those: kind, because it is derived from the content type rather than declared; content_type and filename, because both come from the file part's own headers and describe the bytes being sent instead of sitting beside them as a claim; size_bytes, because the server counts what it received and a declared figure that disagreed would be a storage number the merchant is billed on that nobody measured; object_key, because the server mints it under this store's namespace and a caller-supplied key is how one tenant would write into another's prefix; url, because it is derived after the bytes land; alt, because there is no alt-text column and text sent here would be discarded; and the provenance keys (id, store_id, created_at). A SECOND FILE PART is refused too: a form carrying "file" and "thumbnail" would otherwise upload one and drop the other under a 201 that read as though both had landed.
Required, and the part must be named exactly "file". Its own Content-Type header decides the content type, falling back to the filename extension when the client sends none, and that type is checked against a per-kind allowlist: images may be png, jpeg, webp, avif or gif, and videos mp4, webm or quicktime. Anything else is 400 unsupported_file_type, which is where an svg lands: it is absent from the allowlist deliberately, because an svg can carry script and this file would be served from the merchant's own public origin. The kind recorded on the row is derived from that type, video for video/* and image for everything else. The part must carry a filename, under 512 bytes, kept as the display name while the storage key gets a sanitised form of it; an empty part is 400 invalid_size.
Optional pixel width, and it is THE CALLER'S WORD: nothing decodes the image to check it, exactly as on the dashboard's own upload. It is accepted anyway because the read publishes width and height, so a file uploaded here that could not declare them would be permanently second-class in the library this same API lists. Omit it and the field reads null. A non-numeric value is a 400 rather than being silently dropped, and a negative one is 400 invalid_dimensions.
x >= 0Optional pixel height, on exactly the same terms as width.
x >= 0Response
Created
