Skip to main content
SocialCutter

ERP and management

Normalize catalogue images in ERPNext

Read the Item doctype over the ERPNext REST API with a token, process the image with SocialCutter to ecommerce and social sizes. Python snippet.

  • ERPNext
  • Frappe
  • REST API
  • Item doctype
  • catalogue
  • ecommerce
  • Python

Why normalize the catalogue

In ERPNext the item image usually comes from suppliers or from your own photos, in uneven proportions. The same photo has to serve the item page and the social networks, and each channel asks for a different framing. SocialCutter crops centrally to the exact dimensions of each destination and returns one URL per output, so the item’s image field ends up with the normalized version.

Token authentication

ERPNext (Frappe) uses API tokens, generated per user:

  1. Open the user in ERPNext and go to Settings → API Access.
  2. Generate API Key and API Secret.
  3. Send this header on every request:
Authorization: token api_key:api_secret

The official REST API docs are at https://docs.frappe.io/framework/user/en/api/rest. Field names and upload_file behaviour change between Frappe versions (v13, v14, v15); confirm on your instance before automating in production.

Read items and their image

import requests

BASE = "https://my-erp.example.com"
HEADERS = {"Authorization": "token api_key:api_secret"}

params = {
    "fields": '["name","item_name","image"]',
    "filters": '[["image","!=",""]]',
    "limit_page_length": 50,
}
items = requests.get(f"{BASE}/api/resource/Item", headers=HEADERS, params=params, timeout=60)
items.raise_for_status()
items = items.json()["data"]

The image field stores a relative path, for example /files/product-42.jpg. Prepend the site base to download the file:

for item in items:
    if not item["image"]:
        continue
    raw = requests.get(f"{BASE}{item['image']}", headers=HEADERS, timeout=60).content

Process the image with SocialCutter

The bytes are already in memory, so the multipart endpoint is the right one. Pick destinations based on where the item is published:

DESTINATIONS = [
    {"platform": "instagram", "format": "post"},   # 1080x1080
    {"platform": "linkedin", "format": "post"},    # 1200x627
]

Each destination has fixed, known dimensions, which serve to record the result:

DestinationDimensions
instagram post1080x1080
facebook post1200x630
linkedin post1200x627
twitter post1200x675
youtube thumbnail1280x720
tiktok cover1080x1920

The File doctype: upload_file, is_private and the public folder

Uploading a file to Frappe is not just writing bytes: every upload creates a document in the File doctype. The fields that matter for this flow:

FieldTypeWhat it holds
file_urlDataThe path, for example /files/product-42.jpg
file_nameDataThe file name
is_privateCheck0 public, 1 private
attached_to_doctypeLinkThe doctype it is attached to (Item)
attached_to_nameDataThe specific document
folderLinkThe File folder it lives in
content_hashDataContent hash, used for deduplication

upload_file and its parameters

POST /api/method/upload_file accepts binary data and reads these form fields:

  • file: the binary, as multipart.
  • doctype and docname: which document it is attached to.
  • is_private: 0 or 1.
  • file_url: instead of file, to register an existing URL without uploading bytes.
  • filename, folder and docfield: optional.

The response carries message.file_url, message.file_name and message.is_private.

Public or private: where the file lands

is_private decides the folder and the URL:

is_privateFolderURLAccess
0{site}/public/files/…/files/product-42.jpgAnyone with the URL, no authentication
1{site}/private/files/…/private/files/product-42.jpgOnly the owner or whoever has read permission on the linked document

For a catalogue image that will be shown on the site or the store, the public folder is the right one: the item’s image field must point at a servable path. Keep is_private=1 for internal documents. If you flip is_private later, Frappe moves the file between folders and rewrites its file_url.

Creating the File document over REST

Like any doctype, File has its REST endpoint: POST /api/resource/File. Here the field names are the doctype’s, not the upload_file form’s: send file_url (or content with decode=1 for the binary), attached_to_doctype, attached_to_name and is_private. This is the route for registering a file that already exists at a URL without downloading and re-uploading it.

Upload the image and write back

Two steps: first upload the file as an attachment, then write its URL to the item’s image field.

# 1. Upload the processed file
upload = requests.post(
    f"{BASE}/api/method/upload_file",
    headers=HEADERS,
    files={"file": ("product-42.jpg", img_bytes, "image/jpeg")},
    data={"doctype": "Item", "docname": item["name"], "is_private": 0},
    timeout=60,
)
upload.raise_for_status()
file_url = upload.json()["message"]["file_url"]

# 2. Write the URL to the image field
requests.put(
    f"{BASE}/api/resource/Item/{item['name']}",
    headers=HEADERS,
    json={"image": file_url},
    timeout=60,
).raise_for_status()

The SocialCutter response carries image_id and an outputs array with the URL, platform and format of each output. Download the one you want as the item image.

ERPNext does not store image dimensions on the item by default: they are fixed per destination. If you need to keep them, use a custom field or the description field.

Full Python snippet

import json

import requests

BASE = "https://my-erp.example.com"
ERP_HEADERS = {"Authorization": "token api_key:api_secret"}

SC_URL = "https://api.socialcutter.theboomer.dev"
SC_KEY = "sc_your_key"

DESTINATIONS = [
    {"platform": "instagram", "format": "post"},
    {"platform": "linkedin", "format": "post"},
]

# 1. Read items with an image
params = {
    "fields": '["name","item_name","image"]',
    "filters": '[["image","!=",""]]',
    "limit_page_length": 50,
}
items = requests.get(f"{BASE}/api/resource/Item", headers=ERP_HEADERS, params=params, timeout=60)
items.raise_for_status()

for item in items.json()["data"]:
    raw = requests.get(f"{BASE}{item['image']}", headers=ERP_HEADERS, timeout=60).content

    # 2. Process with SocialCutter
    sc = requests.post(
        f"{SC_URL}/api/v1/images/process/upload",
        headers={"X-API-Key": SC_KEY},
        files={"file": (f"{item['name']}.jpg", raw, "image/jpeg")},
        data={"destinations": json.dumps(DESTINATIONS)},
        timeout=60,
    )
    sc.raise_for_status()
    result = sc.json()

    for output in result["outputs"]:
        print(item["name"], output.get("platform"), output.get("format"), output.get("url"))

    # 1:1 output as the main item image
    square = next(o for o in result["outputs"] if o["platform"] == "instagram")
    img = requests.get(square["url"], timeout=60)
    img.raise_for_status()

    # 3. Upload the file and write the URL
    upload = requests.post(
        f"{BASE}/api/method/upload_file",
        headers=ERP_HEADERS,
        files={"file": (f"{item['name']}-sq.jpg", img.content, "image/jpeg")},
        data={"doctype": "Item", "docname": item["name"], "is_private": 0},
        timeout=60,
    )
    upload.raise_for_status()
    file_url = upload.json()["message"]["file_url"]

    requests.put(
        f"{BASE}/api/resource/Item/{item['name']}",
        headers=ERP_HEADERS,
        json={"image": file_url},
        timeout=60,
    ).raise_for_status()
    print("Updated", item["name"], item["item_name"])

Typical errors

SituationUsual cause
ERPNext 401Malformed or expired token; check Authorization: token api_key:api_secret
ERPNext 403The token’s user lacks write permission on Item
417 on writeThe image value is not a valid file path
Empty imageThe item has no image assigned
SocialCutter 413The original image is over 5 MB

If upload_file returns a different structure, print the full response with print(upload.json()): the exact key name changes between Frappe versions.

Cost

  • 1 use per destination (platform and format) per request.
  • Repeated destinations in the same request are not charged twice.
  • Failed processing is refunded.

Processing 100 items for two destinations is 200 uses. For a large catalogue, use POST /api/v1/images/batch or spread the work into batches.

Next steps

Frequently asked questions

How do I authenticate against ERPNext?

With an API token. Generate a key and a secret (api_key and api_secret) on the user and send them in the Authorization: token api_key:api_secret header.

Which field holds the item image?

The Item doctype has an image field that stores the file path, for example /files/my-photo.jpg. Prepend the site base to download it.

How do I upload the processed image?

With POST /api/method/upload_file as multipart, passing the file and optionally doctype=Item and docname. The response carries message.file_url, which you write to the item's image field.

What exactly does is_private do?

It decides which folder the file ends up in. is_private=0 leaves it in the public folder with a /files/… URL, readable by anyone who has the URL. is_private=1 leaves it in the private folder with a /private/files/… URL, readable only by the owner or by whoever has permission on the linked document.

Are dimensions stored automatically?

No. ERPNext does not store image dimensions on the item by default. Dimensions are fixed per destination; to keep them, use a custom field or the item's description field.

How much does processing one item cost?

1 use per destination. Processing one item for Instagram post and LinkedIn post is 2 uses.