# 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.
- URL: https://socialcutter.theboomer.dev/en/guides/erpnext/
- Idioma: en
- Familia: erp
- Actualizado: 2026-09-24
- Palabras clave: 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

```python
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:

```python
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:

```python
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.
- `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_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.

```python
# 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

```python
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](/en/guides/curl/)
- Python guide: [Automate SocialCutter with Python](/en/guides/python/)
- Automation: [Automate image resizing with n8n](/en/guides/n8n/)
- MCP: [Use SocialCutter from your LLM or editor with MCP](/en/guides/mcp/)
- API reference: https://docs.socialcutter.theboomer.dev