Media
POST /v1/products/:ref/image
POST /v1/banners/:ref/image
POST /v1/logoAttach images without multipart uploads: give the API a URL to fetch
(sourceUrl) or inline base64 bytes. The server downloads/decodes,
validates the actual bytes, optimizes (WebP/PNG resize) and stores the
image — the same pipeline the app's own uploads use. Available on every
plan with an active subscription.
:ref accepts your externalId or the opaque id
(products/banners; unknown ref → 404 with PRODUCT_NOT_FOUND /
BANNER_NOT_FOUND).
Request body#
Exactly one of sourceUrl or base64 (both/neither →
400 INVALID_IMAGE_SOURCE):
| Field | Meaning |
|---|---|
sourceUrl | Public https:// URL the server fetches (see the rules below) |
base64 | Inline image bytes (plain base64 or a data: URI); the media routes accept bodies up to 21 MB, enough for the full 15 MB binary cap |
crop | Optional, "square" — server-side center-crop before processing. Products: optional. Logo: effectively required for non-square sources — the logo pipeline expects a ~1:1 input |
sourceUrl rules#
The fetch is strictly sandboxed; violations return machine codes:
https://only, default port, no credentials in the URL →400 INVALID_SOURCE_URL- The host must resolve to a public address — private/internal ranges
are rejected →
403 BLOCKED_SOURCE_URL - Redirects are re-validated per hop (max 3)
- ~10 s total fetch timeout; 15 MB hard size cap →
413 SOURCE_TOO_LARGE - Content is checked by real file signature — JPEG, PNG, WebP or HEIC
(headers/extensions are ignored; GIF not supported) →
400 INVALID_IMAGE
After acquisition, the standard per-type limits of the upload pipeline apply — the same ones the app UI enforces:
| Target | Max file size | Dimensions |
|---|---|---|
| Product image | 5 MB | — |
| Banner image | 5 MB | min 1280×720, aspect 16:9 |
| Logo | 5 MB | min 128×128, max 2048×2048, ~square (1:1) |
15 MB transport ≠ 5 MB per-type cap
The fetch/decode layer accepts up to 15 MB so agents can point at
originals, but every image type is then capped at 5 MB by the shared
upload pipeline (FILE_TOO_LARGE). Re-compress large photos before
sending — the pipeline converts to WebP anyway, so a 2–3 MB source loses
nothing.
For best performance: pre-optimize before sending
Server-side processing runs through a shared queue, so heavy originals wait longer. You'll get the fastest uploads by sending images that are already close to what the pipeline produces:
- WebP format — the server re-encodes everything to WebP anyway;
- target dimensions — products max 1024×1024, banners max 1920×1080, logo 512×512;
- well under the 5 MB cap — a 200–500 KB WebP at target size is ideal.
This is a recommendation, not a requirement — every upload is still validated and re-encoded server-side regardless (magic-byte checks and the SSRF rules above always apply).
Example — product image by URL#
curl -X POST https://api.duck-hub.com/v1/products/prod-margherita/image \
-H "Authorization: Bearer dk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "sourceUrl": "https://images.example.com/margherita.jpg", "crop": "square" }'const response = await fetch(
'https://api.duck-hub.com/v1/products/prod-margherita/image',
{
method: 'POST',
headers: {
Authorization: 'Bearer dk_live_your_api_key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
sourceUrl: 'https://images.example.com/margherita.jpg',
crop: 'square',
}),
},
)
const { mediaUrl, mediaType } = await response.json()Example — logo from local bytes (base64)#
BASE64=$(base64 -w0 logo.png)
curl -X POST https://api.duck-hub.com/v1/logo \
-H "Authorization: Bearer dk_live_your_api_key" \
-H "Content-Type: application/json" \
-d "{ \"base64\": \"$BASE64\", \"crop\": \"square\" }"import { readFile } from 'node:fs/promises'
const base64 = (await readFile('logo.png')).toString('base64')
const response = await fetch('https://api.duck-hub.com/v1/logo', {
method: 'POST',
headers: {
Authorization: 'Bearer dk_live_your_api_key',
'Content-Type': 'application/json',
},
body: JSON.stringify({ base64, crop: 'square' }),
})
const { logoUrl } = await response.json() // favicons are generated tooProduct/banner responses return { mediaUrl, mediaType }; uploading again
replaces the previous image. The logo response returns { logoUrl } and
regenerates the favicon set.
Errors#
| Code | Status | Meaning |
|---|---|---|
INVALID_IMAGE_SOURCE | 400 | Neither or both of sourceUrl/base64 |
INVALID_SOURCE_URL | 400 | Not https, bad port/credentials, unresolvable host, bad redirect, non-2xx |
BLOCKED_SOURCE_URL | 403 | Host resolves to a non-public address |
SOURCE_TOO_LARGE | 413 | Over the 15 MB fetch/decode cap |
INVALID_IMAGE | 400 | Bytes are not JPEG/PNG/WebP/HEIC |
FILE_TOO_LARGE | 400 | Image over the 5 MB per-type cap (after fetch/decode) |
IMAGE_TOO_SMALL | 400 | Below the target's minimum dimensions |
IMAGE_DIMENSIONS_TOO_LARGE | 400 | Logo over 2048×2048 px |
IMAGE_NOT_SQUARE | 400 | Logo is not ~1:1 — send crop: "square" |
IMAGE_WRONG_ASPECT_RATIO | 400 | Banner image is not 16:9 |
IMAGE_UNREADABLE / IMAGE_PROCESSING_FAILED | 400 | Dimensions unreadable / processing failed |
MEDIA_BUSY | 503 | Processing queue full during a burst — honour Retry-After (a few seconds) and resend |
PRODUCT_NOT_FOUND / BANNER_NOT_FOUND | 404 | Unknown :ref |