Media

http
POST /v1/products/:ref/image
POST /v1/banners/:ref/image
POST /v1/stories/:ref/image
POST /v1/logo

Attach 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):

FieldMeaning
sourceUrlPublic https:// URL the server fetches (see the rules below)
base64Inline 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
cropOptional, "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:

TargetMax file sizeDimensions
Product image5 MB
Banner image5 MBmin 1280×720, aspect 16:9 (landscape)
Story photo5 MBmin 720×1280, aspect 9:16 (portrait)
Logo5 MBmin 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:

http
POST /v1/products/:ref/image?preoptimized=true
POST /v1/banners/:ref/image?preoptimized=true

The 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#

bash
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" }'

Example — logo from local bytes (base64)#

bash
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\" }"

Product/banner responses return { mediaUrl, mediaType }; uploading again replaces the previous image. The logo response returns { logoUrl } and regenerates the favicon set.

Errors#

CodeStatusMeaning
INVALID_IMAGE_SOURCE400Neither or both of sourceUrl/base64
INVALID_SOURCE_URL400Not https, bad port/credentials, unresolvable host, bad redirect, non-2xx
BLOCKED_SOURCE_URL403Host resolves to a non-public address
SOURCE_TOO_LARGE413Over the 15 MB fetch/decode cap
INVALID_IMAGE400Bytes are not JPEG/PNG/WebP/HEIC
PREOPTIMIZED_INVALID400?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_LARGE400Image over the 5 MB per-type cap (after fetch/decode)
IMAGE_TOO_SMALL400Below the target's minimum dimensions
IMAGE_DIMENSIONS_TOO_LARGE400Logo over 2048×2048 px
IMAGE_NOT_SQUARE400Logo is not ~1:1 — send crop: "square"
IMAGE_WRONG_ASPECT_RATIO400Banner image is not 16:9
STORY_MEDIA_ASPECT_INVALID400Story photo is not portrait 9:16
STORY_MEDIA_TOO_SMALL400Story photo below 720×1280 — a landscape photo lands here too, because its height is under the portrait minimum
STORY_MEDIA_TOO_LARGE400Story photo over 5 MB
STORY_MEDIA_TYPE_UNSUPPORTED400Story photo is not JPEG/PNG/WebP/HEIC (video is never accepted)
STORY_MEDIA_UNREADABLE400Story photo could not be decoded / processed
IMAGE_UNREADABLE / IMAGE_PROCESSING_FAILED400Dimensions unreadable / processing failed
MEDIA_BUSY503Processing queue full during a burst — honour Retry-After (a few seconds) and resend
PRODUCT_NOT_FOUND / BANNER_NOT_FOUND / STORY_NOT_FOUND404Unknown :ref