Media
POST /v1/products/:ref/image
POST /v1/banners/:ref/image
POST /v1/stories/: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/stories; unknown ref → 404 with PRODUCT_NOT_FOUND /
BANNER_NOT_FOUND / STORY_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 (landscape) |
| Story photo | 5 MB | min 720×1280, aspect 9:16 (portrait) |
| 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.
Fast lane — pre-optimized media#
Server-side processing (decode → resize → WebP re-encode) runs through a shared queue, so heavy originals wait longer. If you send an image that is already what the pipeline produces, the server stores it verbatim — skipping the decode/re-encode step and its queue entirely.
A product or banner image takes the fast path automatically when all
of these hold (logo and story photos are never eligible — the logo always
re-processes to generate favicons, and stories always re-encode to the
portrait preset, so ?preoptimized=true is ignored for them):
- WebP format;
- ≤ 500 KB;
- within target dimensions — products ≤ 1024×1024, banners ≤ 1920×1080.
Nothing changes in your request — a qualifying image just uploads faster. Validation is unchanged: magic-byte checks, the size cap and the SSRF rules above always apply, and the dimensions are re-verified server-side (a small WebP can still be a decompression bomb), so verbatim storage is safe.
Claim the softer rate limit — ?preoptimized=true#
The fast path also has its own, more generous rate-limit bucket
(mediaFast: 20/min free,
100/min paid — vs the normal media bucket's
5/30). To draw from
it, add ?preoptimized=true to the product/banner image request:
POST /v1/products/:ref/image?preoptimized=true
POST /v1/banners/:ref/image?preoptimized=trueThe server verifies the bytes actually qualify for the fast path. If they
don't (not WebP, over 500 KB, or over target dimensions) the
request is rejected with 400 PREOPTIMIZED_INVALID — the softer limit
can't be spent on a heavy upload. Send such images without the flag and
they go through normal processing on the regular media bucket.
Flag optional, fast path automatic
You never need the flag to get the speed-up — a qualifying image is stored
verbatim either way. ?preoptimized=true only opts into the softer rate
bucket, and only pays off if you're sending many images and reliably keep
them within the fast-path bounds.
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 |
PREOPTIMIZED_INVALID | 400 | ?preoptimized=true but the bytes aren't a fast-path WebP (wrong format / over 500 KB / over target dimensions) — resend without the flag |
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 |
STORY_MEDIA_ASPECT_INVALID | 400 | Story photo is not portrait 9:16 |
STORY_MEDIA_TOO_SMALL | 400 | Story photo below 720×1280 — a landscape photo lands here too, because its height is under the portrait minimum |
STORY_MEDIA_TOO_LARGE | 400 | Story photo over 5 MB |
STORY_MEDIA_TYPE_UNSUPPORTED | 400 | Story photo is not JPEG/PNG/WebP/HEIC (video is never accepted) |
STORY_MEDIA_UNREADABLE | 400 | Story photo could not be decoded / processed |
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 / STORY_NOT_FOUND | 404 | Unknown :ref |