Skip to main content
POST
/
assets
Upload Asset
curl --request POST \
  --url https://api.example.com/assets
import requests

url = "https://api.example.com/assets"

response = requests.post(url)

print(response.text)
const options = {method: 'POST'};

fetch('https://api.example.com/assets', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));
<?php

$curl = curl_init();

curl_setopt_array($curl, [
CURLOPT_URL => "https://api.example.com/assets",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
]);

$response = curl_exec($curl);
$err = curl_error($curl);

curl_close($curl);

if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}
package main

import (
"fmt"
"net/http"
"io"
)

func main() {

url := "https://api.example.com/assets"

req, _ := http.NewRequest("POST", url, nil)

res, _ := http.DefaultClient.Do(req)

defer res.Body.Close()
body, _ := io.ReadAll(res.Body)

fmt.Println(string(body))

}
HttpResponse<String> response = Unirest.post("https://api.example.com/assets")
.asString();
require 'uri'
require 'net/http'

url = URI("https://api.example.com/assets")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)

response = http.request(request)
puts response.read_body
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 to be notified.

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. 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

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"
    }
  }'
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;
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']
Response:
{
  "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.
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.
curl -X PUT "$URL" \
  -H "Content-Type: application/zip" \
  -H "Content-Disposition: inline; filename=\"brushes-pack-v2.zip\"" \
  --upload-file ./brushes-pack-v2.zip
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()}`);
}
with open('./brushes-pack-v2.zip', 'rb') as f:
    put = requests.put(upload['url'], headers=upload['headers'], data=f)

put.raise_for_status()
That’s it — the asset is ready to be linked to a Shopify product via POST /digital_products, or attached directly to an order via POST /orders/{id}/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:
Node.js
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

Beta. The order asset and notification endpoints are in active development. See Upload File to Order for the full guide.
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:
// 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'
  }
});
# 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'
# 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()
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.
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.