---
title: Upload a product image
description: "Multipart upload of a single image to the product. PNG/JPG/WebP, ≤5 MB, max 20 images. No Idempotency-Key (multipart can't be hashed safely)."
canonical: "https://www.tracepass.eu/docs/upload-product-image"
locale: en
source: "https://www.tracepass.eu/docs/upload-product-image"
---

# Upload a product image

> Multipart upload of a single image to the product. PNG/JPG/WebP, ≤5 MB, max 20 images. No Idempotency-Key (multipart can't be hashed safely).

```http
POST /api/v1/products/{id}/images
```

Upload a single image file (multipart/form-data, field name `file`) and append it to the product's `imageUrls` array. Use this when you don't have a CDN URL ready — the image lands in our R2 bucket and the public URL is returned in the response.

PNG / JPG / WebP only, max 5 MB per file, max 20 images per product. **No Idempotency-Key support** — multipart bodies aren't safely hashable. Client should check existence + skip if retrying. The matching `DELETE /api/v1/products/{id}/images/{index}` removes a single image by zero-based index.

## Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Authorization` | header | string | yes | `Bearer <token>` — either a `tp_` API key (Developer → API Keys; simplest, for server-to-server) or an OAuth 2.0 access token (Developer → OAuth Apps; for user-authorized apps, scoped + revocable). The Authentication page has the full OAuth flow and scope list. |
| `Content-Type` | header | string | yes | `multipart/form-data` with the boundary parameter set by your HTTP client. |
| `id` | path | ObjectId | yes | Product ID. |
| `file` | body | binary (PNG / JPG / WebP, ≤ 5 MB) | yes | Image bytes posted as the `file` form field. |

## Examples

```bash
curl -sS -X POST \
  https://app.tracepass.eu/api/v1/products/6650a1b2c3d4e5f6a7b8c9d0/images \
  -H "Authorization: Bearer tp_REDACTED_xxxxxxxxxxxx" \
  -F "file=@./battery-hero.jpg"
```

```typescript
import { readFile } from "node:fs/promises";

const file = await readFile("./battery-hero.jpg");
const form = new FormData();
form.set("file", new Blob([file], { type: "image/jpeg" }), "battery-hero.jpg");

const res = await fetch(
  `https://app.tracepass.eu/api/v1/products/${id}/images`,
  {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.TRACEPASS_API_KEY}` },
    body: form,
  },
);
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
const { imageUrl, imageUrls } = await res.json();
```

```python
import os, requests

with open("battery-hero.jpg", "rb") as fh:
    res = requests.post(
        f"https://app.tracepass.eu/api/v1/products/{product_id}/images",
        headers={"Authorization": f"Bearer {os.environ['TRACEPASS_API_KEY']}"},
        files={"file": ("battery-hero.jpg", fh, "image/jpeg")},
    )
res.raise_for_status()
data = res.json()
print(data["imageUrl"])
```

## Responses

### 201 — Uploaded

```json
{
  "imageUrl": "https://r2.tracepass.eu/<companyId>/products/<id>/images/<imageId>.jpg",
  "imageUrls": [
    "https://existing-image-1.jpg",
    "https://r2.tracepass.eu/<companyId>/products/<id>/images/<imageId>.jpg"
  ]
}
```

### 400 — No file

```json
{ "error": "No file provided. Send as multipart/form-data with field name 'file'." }
```

### 413 — Too large

```json
{ "error": "Image must be under 5MB" }
```

### 415 — Unsupported

```json
{ "error": "Unsupported MIME type — accepts image/png, image/jpeg, image/webp" }
```

### 422 — Cap reached

```json
{ "error": "Product already has 20 images (max). Delete one first." }
```

## Related

- [Update a product (replace imageUrls)](https://www.tracepass.eu/docs/update-product.md)
- [Create a product](https://www.tracepass.eu/docs/create-product.md)
