# Photos by link

Where links are taken, what is checked, all-or-nothing calls, limits and safe retries.

Most catalogues already live somewhere with photo URLs: a supplier feed, an
old shop, a spreadsheet. Instead of downloading and re-uploading every file,
send the links — Shopdesk downloads them, checks them and stores its own copy.
This guide covers where links are taken, the limits, and how to retry safely.

## Where a link is taken

| Call                                                         | Field                                                              | Scope            |
| ------------------------------------------------------------ | ------------------------------------------------------------------ | ---------------- |
| `POST /products`                                             | `images[].source_url` (up to 10)                                   | `products:write` |
| `PATCH /products/{id}`                                       | `images[].source_url` (up to 10 new; the list is the full gallery) | `products:write` |
| `POST /products/{id}/images`                                 | `images[].source_url` in a JSON body (up to 10)                    | `products:write` |
| `POST` / `PATCH /categories`                                 | `image_url`, `banner_url`                                          | `catalog:write`  |
| `POST` / `PATCH /brands`                                     | `logo_url`, `banner_url`                                           | `catalog:write`  |
| `POST /attributes`, `POST` / `PATCH /attributes/{id}/values` | `swatch_image_url`                                                 | `catalog:write`  |

If you have the bytes instead of a link, three routes also take the file
itself as a `multipart/form-data` part:

- `POST /products/{id}/images`: up to 10 `image` parts, with each photo's
  options in an `images` JSON text field;
- `POST` / `PATCH /categories`: `image` and `banner` parts;
- `POST` / `PATCH /brands`: `logo` and `banner` parts.

Product create and update, and attribute swatches, take links only — upload
product photos as files through `POST /products/{id}/images`. See the
[reference](https://www.shopdeskbd.com/developers/reference.md) for both encodings of each route.

## What happens to a link

1. **The link is checked before any connection:** `https` only, no user name
   or password in it, a public host name. It is resolved once, and every
   address it resolves to must be public — never a private, loopback or
   link-local address, whether IPv4 or IPv6.
2. **It is downloaded** from the address that was checked, following at most
   3 redirects, each checked the same way. Each link gets 10 seconds, and all
   the links of one call get 30 seconds together. Up to 4 download at once.
3. **The bytes are checked:** PNG, JPEG, WebP or GIF, read from the bytes —
   the file name and the server's `Content-Type` are ignored, and SVG is never
   accepted. At most 10 MB, 8000 pixels on the longest side and 40 megapixels
   in all (16 for a GIF), and the whole image must decode.
4. **A copy is stored,** re-encoded as WebP with metadata removed. Your link is
   not kept anywhere; responses carry the stored URL.

## All or nothing

A call either stores every photo it was given or none of them.

- **One link fails, the call fails.** The answer is `422 VALIDATION_ERROR`
  with `fields` naming the link — `images[3].source_url`, `logo_url` — and a
  message saying why (not found, not an image, too large, not a public
  host, too slow). Nothing is written: no product, no variant, no photo.
- **Checked before downloaded.** Everything that can be checked without
  downloading is checked first, so a mistake there costs no download: the
  shape of `images[]` (including at most one `is_thumbnail`), option names, a
  full gallery, the 30-photo cap, the plan's product limit, the brand and
  categories, variant SKUs, and a category's parent or a slug.
- **Nothing is left behind.** If the save fails after photos were stored, the
  stored copies are deleted again.

The call answers once every photo is stored, with the photos' final `id`s —
that is what lets a variant point at a photo in the same call
(`image_index`).

## Retrying

Send an `Idempotency-Key` with every call that carries links. On a JSON body
the key's fingerprint covers the links, so:

- **A retry with the same key and body** answers with the stored response —
  **nothing is downloaded again**.
- **The same key with different links** is `422 IDEMPOTENCY_CONFLICT`: a key
  names one request.
- **A call that failed** releases its key, so after fixing the link you can
  retry under the same key.

On a multipart upload the key covers the route only, not the files — reuse a
key only to retry the same upload. See [Idempotency](https://www.shopdeskbd.com/developers/idempotency.md).

## Limits

| Limit                                  | Value                                                             |
| -------------------------------------- | ----------------------------------------------------------------- |
| New photos per call                    | 10 (send more with further calls)                                 |
| Photos per product                     | 30                                                                |
| Size per image                         | 10 MB, 8000 px on the longest side, 40 megapixels (16 for a GIF)  |
| Bytes per call                         | 50 MB downloaded, or 50 MB of uploaded files                      |
| Time                                   | 10 s per link, 30 s per call                                      |
| Writes                                 | each image counts as one write against the per-minute write limit |
| Calls handling images at once, per key | 2, downloading or uploading (a third is `429 RATE_LIMITED`)       |
| Links per company per UTC day          | 2,000 (then `429 RATE_LIMITED`)                                   |

A call that would pass a limit is refused before anything is downloaded, and
every `429` here carries `Retry-After`. Ten 9 MB photos pass the per-image
limit but not the per-call one: send them over two calls. The daily budget
counts links only; uploaded files count against the others. See
[Rate limits](https://www.shopdeskbd.com/developers/rate-limits.md#image-downloads).

## Adding photos to a product later

```bash
curl -sS https://api.shopdeskbd.com/ext/v1/products/PRODUCT_ID/images \
  -H "Authorization: Bearer $SHOPDESK_API_KEY" \
  -H "Idempotency-Key: 6c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f" \
  -H "Content-Type: application/json" \
  -d '{
    "images": [
      { "source_url": "https://cdn.example.com/tee/side.jpg", "alt_text": "Side view" },
      { "source_url": "https://cdn.example.com/tee/model.jpg", "position": 0 }
    ]
  }'
```

The batch is one change to the gallery and answers `{ "data": [...] }` — the
new photos in the order you sent them. `position` places a photo (applied in
list order); without it a photo is appended.

## From the store MCP server

An AI assistant connected to the
[store MCP server](https://www.shopdeskbd.com/developers/ai.md#the-store-mcp-server) sends links through
four tools. Each calls the same service as its REST twin, so everything on
this page applies unchanged — the checks, the all-or-nothing rule and the
limits, which a key shares between REST and MCP.

| Tool                           | Takes links in                   | Scope                             |
| ------------------------------ | -------------------------------- | --------------------------------- |
| `create_product`               | `images[].source_url` (up to 10) | `products:write`                  |
| `add_product_images_from_urls` | `images[].source_url` (up to 10) | `products:write`                  |
| `create_category`              | `image_url`, `banner_url`        | `catalog:write` + `products:read` |
| `create_brand`                 | `logo_url`, `banner_url`         | `catalog:write`                   |

These four declare `openWorldHint: true`, because they reach outside the
store to download; a client that confirms such tools will ask before running
them. Swatch images by link, a full gallery restated on update, and
multipart uploads stay on REST.

- **A failed link** comes back as a tool result with `isError: true`, and its
  text is the REST error object: `VALIDATION_ERROR`, with `fields` naming the
  link (`images[1].source_url`, `logo_url`). Nothing was written.
- **Retrying:** pass an `idempotency_key` — the same value on every retry of
  the same call. A retry with the same key and arguments answers with the
  first result and downloads nothing; the same key with different links is
  `IDEMPOTENCY_CONFLICT`; a call that failed releases its key.
- **Metering:** each link counts as one write, as on REST, plus the image
  limits above (two image calls at once per key, 2,000 links per company per
  UTC day).
