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:
- Open the user in ERPNext and go to Settings → API Access.
- Generate API Key and API Secret.
- 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:
| Destination | Dimensions |
|---|---|
| instagram post | 1080x1080 |
| facebook post | 1200x630 |
| linkedin post | 1200x627 |
| twitter post | 1200x675 |
| youtube thumbnail | 1280x720 |
| tiktok cover | 1080x1920 |
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:
| Field | Type | What it holds |
|---|---|---|
file_url | Data | The path, for example /files/product-42.jpg |
file_name | Data | The file name |
is_private | Check | 0 public, 1 private |
attached_to_doctype | Link | The doctype it is attached to (Item) |
attached_to_name | Data | The specific document |
folder | Link | The File folder it lives in |
content_hash | Data | Content 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.doctypeanddocname: which document it is attached to.is_private:0or1.file_url: instead offile, to register an existing URL without uploading bytes.filename,folderanddocfield: 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_private | Folder | URL | Access |
|---|---|---|---|
0 | {site}/public/files/… | /files/product-42.jpg | Anyone with the URL, no authentication |
1 | {site}/private/files/… | /private/files/product-42.jpg | Only 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
| Situation | Usual cause |
|---|---|
ERPNext 401 | Malformed or expired token; check Authorization: token api_key:api_secret |
ERPNext 403 | The token’s user lacks write permission on Item |
417 on write | The image value is not a valid file path |
Empty image | The item has no image assigned |
SocialCutter 413 | The 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
- API guide with curl: Process images with the API from the terminal
- Python guide: Automate SocialCutter with Python
- Automation: Automate image resizing with n8n
- MCP: Use SocialCutter from your LLM or editor with MCP
- API reference: https://docs.socialcutter.theboomer.dev
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.