# Normalize catalogue images in Odoo
> Read products from Odoo over XML-RPC, process their image with SocialCutter to ecommerce and social dimensions, and write it back. Python snippet.
- URL: https://socialcutter.theboomer.dev/en/guides/odoo/
- Idioma: en
- Familia: erp
- Actualizado: 2026-09-24
- Palabras clave: Odoo, XML-RPC, product.template, catalogue, ecommerce, Python, images
## Why normalize the catalogue

In a catalogue, photos arrive in different proportions: some square, some portrait, some landscape. Publishing the same product on the shop page, Instagram and LinkedIn needs three different framings. Doing it product by product does not scale. SocialCutter crops centrally to the exact dimensions of each destination and returns one URL per output; Odoo stores the resulting image on the product itself so the catalogue stays consistent.

## How Odoo's external API works

Odoo exposes an external XML-RPC API on two endpoints:

- `/xmlrpc/2/common`: only `authenticate`, returns the `uid`.
- `/xmlrpc/2/object`: `execute_kw`, runs any model method.

```python
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common")
uid = common.authenticate(DB, USER, API_KEY, {})
models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object")
```

Products live in `product.template` (the template) and `product.product` (the variant). For catalogue normalization, work on `product.template`.

### Version note

This flow assumes **Odoo 14 or later**. The large image field is `image_1920`; on older versions the field is called `image`. Model and method names are stable, but confirm the available fields on your instance with `fields_get` before writing to production:

```python
fields = models.execute_kw(DB, uid, API_KEY, "product.template", "fields_get",
                           [], {"attributes": ["string", "type"]})
print([k for k in fields if k.startswith("image")])
```

### The product image fields

`product.template` inherits `image.mixin`, so it does not expose a single image field but a family of them:

| Field | Max | How it is filled |
|---|---|---|
| `image_1920` | 1920x1920 | The editable field: you write the base64 here |
| `image_1024` | 1024x1024 | Stored related field, derived from `image_1920` |
| `image_512` | 512x512 | Stored related field, derived from `image_1920` |
| `image_256` | 256x256 | Stored related field, derived from `image_1920` |
| `image_128` | 128x128 | Stored related field, derived from `image_1920` |

The detail that matters when automating: **writing the large base64 into `image_1920` is what triggers the smaller sizes to be generated**. `image_1024`…`image_128` are `related` fields with `store=True` and they are read-only: you do not write them by hand, Odoo recomputes them from `image_1920`. Writing a small value straight into `image_128` does not build the chain.

Those sizes are scaled keeping the aspect ratio, without cropping: an oversized image is shrunk to the limit without distortion. That is the opposite of a fixed framing, so the crop is best resolved before writing. SocialCutter crops centrally to the destination dimensions, and that result is what gets stored in `image_1920`.

### XML-RPC and JSON-RPC

All of this works the same over both protocols. They are the same calls to the `object` service, with the same `execute_kw` method and the same arguments `[db, uid, password, model, method, args, kwargs]`; only the endpoint and the packaging change:

- XML-RPC: `POST /xmlrpc/2/object`
- JSON-RPC: `POST /jsonrpc`, with `{"service": "object", "method": "execute_kw", "args": [...]}`

```python
import requests

payload = {
    "jsonrpc": "2.0",
    "method": "call",
    "params": {
        "service": "object",
        "method": "execute_kw",
        "args": [ODOO_DB, uid, ODOO_KEY, "product.template", "write",
                 [[product_id], {"image_1920": b64_result}]],
    },
    "id": 1,
}
requests.post(f"{ODOO_URL}/jsonrpc", json=payload, timeout=60).raise_for_status()
```

Odoo documents `/xmlrpc`, `/xmlrpc/2` and `/jsonrpc` as deprecated, with removal planned in Odoo 22, and points to the new JSON-2 API with API-key authentication. Keep that in mind if you start a new integration.

## Read products and their image

```python
products = models.execute_kw(
    DB, uid, API_KEY, "product.template", "search_read",
    [[("image_1920", "!=", False)]],
    {"fields": ["name", "image_1920"], "limit": 50},
)
```

The `image_1920` field arrives as a base64 string. Decode it to bytes before uploading.

## Process the image with SocialCutter

Odoo already holds the bytes, so the multipart endpoint is the right one. Pick destinations based on where the product will be 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 |
| instagram story | 1080x1920 |
| facebook post | 1200x630 |
| linkedin post | 1200x627 |
| twitter post | 1200x675 |
| youtube thumbnail | 1280x720 |
| tiktok cover | 1080x1920 |

## Write back to the product

The SocialCutter response carries `image_id` and an `outputs` array with the URL, platform and format of each output. Download the output you want as the catalogue image and re-encode it to base64 for Odoo's binary field:

```python
models.execute_kw(DB, uid, API_KEY, "product.template", "write",
                  [[product_id], {"image_1920": b64_result}])
```

Odoo expects base64 in binary fields. Odoo does not store image dimensions as a standard product field: if you need to keep them, write them to a custom field (`x_image_width`, `x_image_height`) or the description.

## Full Python snippet

```python
import base64
import json
import xmlrpc.client

import requests

ODOO_URL = "https://my-odoo.example.com"
ODOO_DB = "my_db"
ODOO_USER = "user@example.com"
ODOO_KEY = "odoo_api_key"           # use an API key, not the real password

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

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

common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/common")
uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_KEY, {})
if not uid:
    raise SystemExit("Odoo authentication failed")

models = xmlrpc.client.ServerProxy(f"{ODOO_URL}/xmlrpc/2/object")


def sc_process(image_bytes, filename):
    resp = requests.post(
        f"{SC_URL}/api/v1/images/process/upload",
        headers={"X-API-Key": SC_KEY},
        files={"file": (filename, image_bytes, "image/jpeg")},
        data={"destinations": json.dumps(DESTINATIONS)},
        timeout=60,
    )
    resp.raise_for_status()
    return resp.json()


products = models.execute_kw(
    ODOO_DB, uid, ODOO_KEY, "product.template", "search_read",
    [[("image_1920", "!=", False)]],
    {"fields": ["name", "image_1920"], "limit": 50},
)

for product in products:
    raw = base64.b64decode(product["image_1920"])
    result = sc_process(raw, f"product-{product['id']}.jpg")

    # Inspect the real shape of each output
    for output in result["outputs"]:
        print(product["id"], output.get("platform"), output.get("format"), output.get("url"))

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

    models.execute_kw(
        ODOO_DB, uid, ODOO_KEY, "product.template", "write",
        [[product["id"]], {"image_1920": base64.b64encode(img.content).decode("ascii")}],
    )
    print("Updated", product["id"], product["name"])
```

## Typical errors

| Situation | Usual cause |
|---|---|
| `xmlrpc.client.Fault` on authenticate | Wrong database, user or API key |
| Empty `image_1920` | The product has no image: the smaller sizes derive from `image_1920`, so they are empty too |
| SocialCutter `401` | The `X-API-Key` header is missing or the key is revoked |
| SocialCutter `413` | The original image is over 5 MB |
| `write` has no effect | The `uid` lacks write permission on the model |

## 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 products for two destinations is 200 uses. For a large catalogue, use the `POST /api/v1/images/batch` endpoint 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/)
- ERPNext guide: [Normalize catalogue images in ERPNext](/en/guides/erpnext/)
- API reference: https://docs.socialcutter.theboomer.dev