> ## Documentation Index
> Fetch the complete documentation index at: https://docs.filemonk.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Upload Asset

> Upload a binary file directly to Filemonk storage and turn it into an internal asset.

<Note>
  **Beta.** The direct upload flow is in active development. The request and
  response shapes shown here are stable for production use, but we may add new
  fields or capabilities (e.g. larger file ceilings, resumable uploads) without
  notice. We'll announce breaking changes in advance if any become necessary —
  [contact support](mailto:support@filemonk.io) to be notified.
</Note>

## Overview

Filemonk's direct upload API is built around a single `POST /assets` call. For
`internal` assets the response includes the presigned URL you use to upload the
file body — there is no separate "initiate" step.

The per-file ceiling is **2 GB**. Need to upload larger files via the API?
[Get in touch](mailto:support@filemonk.io).

All endpoints use the same authentication (`X-Auth-Token`) and rate-limit
bucket as the rest of the API. Plan storage and asset-count quotas are
enforced before any upload URL is issued — calls that would exceed your quota
return `422` with `code: asset_storage_limit_exceeded` or
`asset_count_limit_exceeded`.

## 1. Create the asset and get the upload URL

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST 'https://app.filemonk.io/api/v1/external/assets' \
    -H 'X-Auth-Token: fm_api_key_your_key_here' \
    -H 'Content-Type: application/json' \
    -d '{
      "asset": {
        "type": "internal",
        "filename": "brushes-pack-v2.zip",
        "byte_size": 1048576,
        "content_type": "application/zip"
      }
    }'
  ```

  ```javascript Node.js theme={null}
  import { stat } from 'node:fs/promises';

  const stats = await stat('./brushes-pack-v2.zip');

  const create = await fetch(
    'https://app.filemonk.io/api/v1/external/assets',
    {
      method: 'POST',
      headers: {
        'X-Auth-Token': 'fm_api_key_your_key_here',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        asset: {
          type: 'internal',
          filename: 'brushes-pack-v2.zip',
          byte_size: stats.size,
          content_type: 'application/zip'
        }
      })
    }
  ).then(r => r.json());

  const { asset, upload } = create;
  ```

  ```python Python theme={null}
  import os
  import requests

  byte_size = os.path.getsize('./brushes-pack-v2.zip')

  create = requests.post(
      'https://app.filemonk.io/api/v1/external/assets',
      headers={'X-Auth-Token': 'fm_api_key_your_key_here'},
      json={
          'asset': {
              'type': 'internal',
              'filename': 'brushes-pack-v2.zip',
              'byte_size': byte_size,
              'content_type': 'application/zip'
          }
      }
  ).json()

  asset = create['asset']
  upload = create['upload']
  ```
</CodeGroup>

Response:

```json theme={null}
{
  "asset": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "type": "internal",
    "filename": "brushes-pack-v2.zip",
    "content_type": "application/zip",
    "byte_size": 1048576,
    "...": "..."
  },
  "upload": {
    "method": "PUT",
    "url": "https://<bucket>.r2.cloudflarestorage.com/external_api/<shop_id>/8f3a.zip?X-Amz-Signature=...",
    "headers": {
      "Content-Type": "application/zip",
      "Content-Disposition": "inline; filename=\"brushes-pack-v2.zip\""
    },
    "expires_at": "2026-04-08T11:00:00Z"
  }
}
```

## 2. Upload the file body

`PUT` the file directly to `upload.url`. The URL is valid for one hour.

<Warning>
  You must send **all** headers returned in `upload.headers` — both
  `Content-Type` and `Content-Disposition` must be included in your PUT request.
  Any mismatch or missing header will be rejected by storage with
  `403 SignatureDoesNotMatch`.
</Warning>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PUT "$URL" \
    -H "Content-Type: application/zip" \
    -H "Content-Disposition: inline; filename=\"brushes-pack-v2.zip\"" \
    --upload-file ./brushes-pack-v2.zip
  ```

  ```javascript Node.js theme={null}
  import { readFile } from 'node:fs/promises';

  const body = await readFile('./brushes-pack-v2.zip');

  const put = await fetch(upload.url, {
    method: 'PUT',
    headers: upload.headers,
    body
  });

  if (!put.ok) {
    throw new Error(`Upload failed: ${put.status} ${await put.text()}`);
  }
  ```

  ```python Python theme={null}
  with open('./brushes-pack-v2.zip', 'rb') as f:
      put = requests.put(upload['url'], headers=upload['headers'], data=f)

  put.raise_for_status()
  ```
</CodeGroup>

That's it — the asset is ready to be linked to a Shopify product via
[`POST /digital_products`](/api-reference/digital-products/create-digital-product),
or attached directly to an order via
[`POST /orders/{id}/attach_assets`](/api-reference/orders/attach-assets).

## Putting it together: multiple files on one product

A common pattern is uploading several files for the same Shopify product
(e.g. high-res + low-res versions, or chapter-by-chapter PDFs). Create each
asset, upload its body, then attach all of them in a single
`POST /digital_products` call:

```javascript Node.js theme={null}
import { readFile, stat } from 'node:fs/promises';

const API_KEY = 'fm_api_key_your_key_here';
const BASE = 'https://app.filemonk.io/api/v1/external';

async function uploadFile(path, filename, contentType) {
  const { size } = await stat(path);

  const { asset, upload } = await fetch(`${BASE}/assets`, {
    method: 'POST',
    headers: {
      'X-Auth-Token': API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      asset: { type: 'internal', filename, byte_size: size, content_type: contentType }
    })
  }).then(r => r.json());

  const body = await readFile(path);
  const put = await fetch(upload.url, {
    method: 'PUT',
    headers: upload.headers,
    body
  });
  if (!put.ok) {
    // Free quota on failure so we don't leave an orphan.
    await fetch(`${BASE}/assets/${asset.id}`, {
      method: 'DELETE',
      headers: { 'X-Auth-Token': API_KEY }
    });
    throw new Error(`Upload failed for ${filename}: ${put.status}`);
  }

  return asset.id;
}

const files = [
  ['./brushes-hi-res.zip', 'brushes-hi-res.zip', 'application/zip'],
  ['./brushes-low-res.zip', 'brushes-low-res.zip', 'application/zip'],
  ['./README.pdf', 'README.pdf', 'application/pdf']
];

const assetIds = [];
for (const [path, name, ct] of files) {
  assetIds.push(await uploadFile(path, name, ct));
}

await fetch(`${BASE}/digital_products`, {
  method: 'POST',
  headers: {
    'X-Auth-Token': API_KEY,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    digital_product: {
      shopify_product_id: 8847261098265,
      asset_ids: assetIds,
      custom_message: 'Thanks for your purchase!'
    }
  })
});
```

To add files to a product that already exists, fetch the current `asset_ids`
from `GET /digital_products/{id}`, union them with the new ones, and `PATCH`
with the full list — the update is a replace, not an append.

## Delivering a file to a specific order

<Note>
  **Beta.** The order asset and notification endpoints are in active development.
  See [Upload File to Order](/api-reference/orders/upload-to-order) for the full
  guide.
</Note>

For custom or made-to-order products, you often need to upload a file and
deliver it to a single order rather than linking it to a Shopify product.
After uploading, attach the asset to an order and trigger the customer
notification email:

<CodeGroup>
  ```javascript Node.js theme={null}
  // Steps 1 & 2 from above — create the asset and PUT the file body.
  // Then attach to an order and notify the customer:

  const orderId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

  // Attach asset to the order
  await fetch(`${BASE}/orders/${orderId}/attach_assets`, {
    method: 'POST',
    headers: {
      'X-Auth-Token': API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ asset_ids: [asset.id] })
  });

  // Send the download email to the customer
  await fetch(`${BASE}/orders/${orderId}/notify`, {
    method: 'POST',
    headers: {
      'X-Auth-Token': API_KEY,
      'Content-Type': 'application/json'
    }
  });
  ```

  ```bash cURL theme={null}
  # Attach the uploaded asset to an order
  curl -X POST "https://app.filemonk.io/api/v1/external/orders/$ORDER_ID/attach_assets" \
    -H 'X-Auth-Token: fm_api_key_your_key_here' \
    -H 'Content-Type: application/json' \
    -d "{\"asset_ids\": [\"$ASSET_ID\"]}"

  # Trigger the customer notification email
  curl -X POST "https://app.filemonk.io/api/v1/external/orders/$ORDER_ID/notify" \
    -H 'X-Auth-Token: fm_api_key_your_key_here' \
    -H 'Content-Type: application/json'
  ```

  ```python Python theme={null}
  # Attach the uploaded asset to an order
  requests.post(
      f'{BASE}/orders/{order_id}/attach_assets',
      headers={'X-Auth-Token': API_KEY},
      json={'asset_ids': [asset_id]}
  ).raise_for_status()

  # Trigger the customer notification email
  requests.post(
      f'{BASE}/orders/{order_id}/notify',
      headers={'X-Auth-Token': API_KEY}
  ).raise_for_status()
  ```
</CodeGroup>

The customer will receive a download email containing the newly attached file.
You can also use an `external` asset (pass `external_url` instead of uploading)
— the same attach + notify flow works for any asset type.

## Cleaning up abandoned uploads

If you create an asset but never `PUT` the file body (or the `PUT` fails and
you don't retry), the asset row still counts against your plan's storage and
asset-count quotas. **Call `DELETE /assets/{id}` to free quota immediately** —
it removes the asset row, the underlying blob, and the R2 object in a single
call.

```bash theme={null}
curl -X DELETE 'https://app.filemonk.io/api/v1/external/assets/<asset_id>' \
  -H 'X-Auth-Token: fm_api_key_your_key_here'
```

## Notes

* Plan quota is checked at create time using the declared `byte_size`.
* Uploads must finish within one hour of the URL being issued.
* The `key` of the underlying R2 object is namespaced to your shop
  (`external_api/<shop_id>/...`) — you do not need to manage keys yourself.
* `watermark_asset` and `autogenerate_licence_key` work the same way for
  uploaded assets as for `external` / `licence` types — pass them on the
  create call if needed.
