Upload Native Docs
Developer documentation

Build on Upload Native

A lightweight storefront SDK and a set of custom DOM events let you hook into every upload. Drive uploads from your own UI, mirror progress, and react to files as they are added. No build step required.

Overview

Upload Native ships as a Shopify theme app block (product page and cart) and a checkout UI extension. With the app embed enabled, the block exposes a global window.UploadNative SDK and emits namespaced upload-native:* events on document. Everything is framework-agnostic, so you can call it from a theme <script>, a section, or your own bundle.

  • SDK. Upload files, validate, and persist the reference from custom UI.
  • Events. upload-native:* CustomEvents fire through the lifecycle (select, upload, success/error, complete).
  • Cart attributes. Uploads persist as opaque references (upload_reference plus _ids) that survive to the order.

Installation

Enable the app embed under Theme settings › App embeds › Upload Native. That loads upload-sdk.js and defines window.UploadNative on every storefront page. Bytes upload directly to your Shopify Files through the app proxy. They never pass through our servers, and no customer personal data is stored.

Example

First, get a File from a file input (or a drag-and-drop event):

// Get a File from an <input type="file"> (or a drop event's dataTransfer.files).
const input = document.querySelector<HTMLInputElement>('#proof');

input?.addEventListener('change', () => {
  const file: File | undefined = input.files?.[0];
  if (file) void handleUpload(file);
});

Then upload it and attach the reference so checkout and the order can read it:

import type { UploadRef } from './types';

// Upload the bytes straight to Shopify Files, then attach the reference to the cart.
async function handleUpload(file: File): Promise<void> {
  const cart: { token: string } = await fetch('/cart.js').then((r) => r.json());

  const ref: UploadRef = await UploadNative.upload(file, {
    base: '/apps/upload-app',          // the app proxy (HMAC-verified by the server)
    allowed: ['application/pdf', 'image/*'],
    reference: cart.token,             // opaque order reference, no PII
    onProgress: (loaded: number, total: number): void => {
      setProgress(Math.round((loaded / total) * 100));
    },
  });

  await UploadNative.attach(ref, { target: 'cart_attribute' });
}

attach(refs, options) takes the upload() result(s). target is 'cart_attribute' (default) or 'line_item_property' (pass form, a node inside the form[action*="/cart/add"]). Without the helper, write the cart attribute yourself:

// Without the attach() helper: write the cart attribute yourself.
await fetch('/cart/update.js', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    attributes: {
      upload_reference: ref.fileUrl,
      upload_reference_ids: ref.fileId,
    },
  }),
});

Prefer a per-product line-item property over a cart attribute when each item carries its own file and you want it on the order’s line item. The SDK writes a visible properties[Uploaded file] (the CDN URL) plus a hidden properties[_upload_reference_ids] (the file GID; the leading underscore hides it from the customer at checkout, not from the merchant). Shopify shows the URL as plain selectable text in the admin order page, so your theme must wrap it in an <a href> to make it clickable on the storefront cart:

// Attach the uploaded file to a cart LINE-ITEM property (per product), so it
// shows on the order's line item. Shopify does NOT auto-hotlink the URL — your
// theme renders the link (see the cart-template Liquid below).
//
// Option A — let the SDK write it onto the add-to-cart form:
const form = document.querySelector('form[action*="/cart/add"]');
const ref = await UploadNative.upload(file, { base: '/apps/upload-app' });
await UploadNative.attach(ref, { target: 'line_item_property', form });
// → properties[Uploaded file] = <file URL>             (shown on the order)
//   properties[_upload_reference_ids] = <file GID>     (hidden: leading "_")

// Hide the file line from the cart & checkout (still kept on the order):
await UploadNative.attach(ref, { target: 'line_item_property', form, hidden: true });
// → properties[_Uploaded file] = <file URL>            (leading "_" hides it from shoppers)

// Option B — write the hidden input yourself before the native add-to-cart submit.
// (The URL alone is enough to hotlink it; the SDK additionally writes the hidden
//  properties[_upload_reference_ids] GID companion, which this variant omits.)
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'properties[Uploaded file]';
input.value = ref.fileUrl;       // the Shopify Files CDN URL
form.appendChild(input);
{%- comment -%} In your theme's cart template / cart line items {%- endcomment -%}
{% for property in item.properties %}
  {%- if property.last == blank -%}{%- continue -%}{%- endif -%}
  {{ property.first }}:
  {% if property.last contains '/cdn.shopify.com/' or property.last contains '/uploads/' %}
    <a href="{{ property.last }}" target="_blank" rel="noopener">{{ property.last | split: '/' | last }}</a>
  {% else %}
    {{ property.last }}
  {% endif %}
{% endfor %}
Checkout’s required-upload enforcement reads the cart attribute, so use target: 'cart_attribute' when an upload must block checkout.

window.UploadNative

The global SDK, available once the app embed (or a block) mounts.

MethodReturnsDescription
upload(file, options)Promise<UploadRef>Upload one file end to end.
uploadMany(files, options)Promise<SettledResult[]>Upload many with controlled concurrency (default 3).
validate(file, options)string | nullUX-only pre-check. null means the file is acceptable.
attach(refs, options)Promise<void>Persist the upload result(s) to the cart attribute or line-item properties.
init(root?)voidNo-op kept for API compatibility. Blocks self-render; no manual wiring needed.
versionstringBuild marker.

attach also exposes the lower-level writers writeCartAttribute and writeLineItemProperties for full control.

Upload options

The second argument to upload() and uploadMany():

OptionTypeDefaultDescription
base string '/apps/upload-app' The app proxy path (HMAC-verified by the server).
allowed string[] (none) Accepted MIME types or extensions, e.g. ['application/pdf','image/*'].
reference string (none) Cart token. An opaque, no-PII order reference.
onProgress function (none) (loaded, total) byte progress for your bar.
signal AbortSignal (none) Cancel an in-flight upload.

Types

TypeScript shapes for the SDK surface (illustrative; the SDK ships as plain JS on the storefront):

// window.UploadNative: available once the app embed (or a block) mounts.
interface UploadNative {
  upload(file: File, options: UploadOptions): Promise<UploadRef>;
  uploadMany(files: File[], options: UploadManyOptions): Promise<SettledResult<UploadRef>[]>;
  validate(file: File, options: ValidateOptions): string | null; // null = ok
  attach(refs: UploadRef | UploadRef[], options?: AttachOptions): Promise<void>;
  init(root?: ParentNode): void;        // no-op; blocks self-render, kept for compatibility
  readonly version: string;
}

interface UploadRef {
  fileId: string;   // Shopify File GID, e.g. "gid://shopify/GenericFile/123"
  fileUrl: string;  // CDN URL of the stored file
}

interface UploadOptions {
  base: string;                                   // app proxy path, "/apps/upload-app"
  allowed?: string[];                             // MIME types / extensions
  reference?: string;                             // cart token (opaque, no PII)
  onProgress?: (loaded: number, total: number) => void;
  signal?: AbortSignal;                           // cancel an in-flight upload
}

interface UploadManyOptions extends Omit<UploadOptions, 'onProgress'> {
  concurrency?: number;                           // default 3
  onProgress?: (loaded: number, total: number) => void; // aggregate across files
  onFileDone?: (index: number, result: SettledResult<UploadRef>) => void;
}

interface AttachOptions {
  target?: 'cart_attribute' | 'line_item_property'; // default "cart_attribute"
  name?: string;                                  // attribute/property key, default "upload_reference"
  multiple?: boolean;                             // default refs.length > 1
  form?: Element;                                 // required for line_item_property
  hidden?: boolean;                               // line_item_property only: _-prefix the property
                                                  //   so it stays on the order but is hidden from
                                                  //   the cart & checkout. default false
}

interface ValidateOptions {
  accept: string;     // e.g. ".pdf,image/*"
  maxBytes: number;
  maxFiles: number;
}

type SettledResult<T> =
  | { status: 'fulfilled'; value: T }
  | { status: 'rejected'; reason: unknown };

Custom events

The built-in app block emits bubbling CustomEvents through the upload lifecycle, so you can wire analytics or custom behavior without replacing the UI. They fire on the <upload-native-block> element and bubble to document. Every detail includes the blockId.

EventWhendetail
upload-native:selectfiles chosen and passed client validation{ files: [{ name, size, type }] }
upload-native:uploadper file, when its upload begins{ name, size, type }
upload-native:successper file, on success{ fileId, fileUrl, name, size }
upload-native:errorper file, on failure{ message, name }
upload-native:completeafter the batch is persisted{ urls, gids, attribute, target }
// Events bubble from <upload-native-block> to document; detail carries blockId.
document.addEventListener('upload-native:success', (e: Event) => {
  const { fileId, fileUrl, name } = (e as CustomEvent).detail;
  console.log('uploaded', name, fileUrl, fileId);
});

document.addEventListener('upload-native:error', (e: Event) => {
  const { name, message } = (e as CustomEvent).detail;
  console.warn('failed', name, message);
});

Cart attributes

attach writes the file reference where checkout and the Shopify order read it. A single file stores a scalar value; multiple files store a JSON array. A companion upload_reference_ids holds the Shopify File GID(s). For per-product capture, the same values are written as line-item properties on the product form.

Errors

upload() rejects on 402 (store upload limit), 413 (file too large), and 415 (type not allowed). Map these to buyer-friendly copy. The server is always the source of truth; validate() is a UX convenience.

const err: string | null = UploadNative.validate(file, {
  accept: '.pdf,image/*',
  maxBytes: 20 * 1024 * 1024,    // client cap; Shopify Files caps generic files at 20 MB
  maxFiles: 1,
});
if (err) showError(err); // null means the file is acceptable

Prefer the no-code option? Add the “Upload Native” block from the theme editor. See Support.