POST/v1/catalog-images
Upload a product's photo
Stores a photo for a product, as Square's CreateCatalogImage does: a multipart/form-data
body naming the product in product_id and carrying the picture in image_file. It writes no
product: the answer's url goes in the product's image_url on its next update, from the
version it was read at. Every upload is an image of its own, so one uploaded and abandoned
never replaces the photo the tills are showing.
A JPEG, PNG, WebP or GIF of at most 5 MB, judged by its bytes rather than by the type the
client names; anything else is a 422 on /image_file. Only somebody who may change the
catalog may upload, and only for a product of their own business.
Parameters
-
Idempotency-Keyheader · stringRequired
A client-generated key making this request safe to retry.
Required on every mutation that creates a resource or moves money. A till on bad wifi cannot tell a lost response from a lost request; without a key its only options are to risk a duplicate sale or to drop a real one. With a key it retries freely: the first request to arrive is processed, and every replay returns that same stored response.
Rules:
- Mint the key once, when the operation is first queued, and reuse it for every retry. A fresh key per attempt defeats the mechanism entirely.
- Keys are scoped per merchant and per operation, and are retained for 30 days — long enough that a till switched off mid-sync can retry with the same key however long it stayed on the shelf.
- Replaying a key with a different request body is a client bug and is rejected
with
422, rather than silently returning the first result — quietly serving the old response would hide a real inconsistency in the caller.
A UUIDv7 or ULID is a good choice.
Request body multipart/form-data · CatalogImageUpload
image_filestring · binaryRequired
The picture, at most 5 MB.
product_idstring · uuidRequired
The product the photo is for.
Responses
-
201
The photo is stored, at
url.CatalogImage · application/json
idstring · uuidRequiredRead only
urlstring · uriRequiredRead only
Where the photo is served, for a product's
image_url.
-
400
The request could not be parsed. Malformed JSON, a bad query parameter, or a header that failed to decode, among them an
API-Versionlater than the server speaks or not a date (api-version-unknown, with the server's own inapi_version).Distinct from
422: a400never reached validation, soinvalid_paramsis absent. Retrying without changing the request will fail again, except that a request refused for itsAPI-Versiongoes once the server is updated. -
401
No credentials, or credentials that could not be verified — missing bearer token, malformed JWT, expired token, or a signature that failed against the issuer's JWKS.
Refresh the token and retry once. Repeated
401after a refresh means the device needs to re-enrol. -
403
Authenticated, but not permitted. The token is valid and its merchant was resolved, but the caller's role does not allow this operation, or the target belongs to a different merchant.
Cross-tenant access is reported as
403on a resource the caller may not see, and as404where even confirming existence would leak information. Retrying will not help. -
404
No such resource, or none visible to this caller.
Also returned instead of
403where acknowledging existence would itself leak information — so a404does not prove the id is unused globally, only that it is not yours. -
410
The request names an
API-Versionthis server no longer serves (docs/decisions/0011). Nothing in it was read.api_versionnames the version the server speaks: the client is behind, and sends the request again once it is updated. -
415
The request
Content-Typeis not supported. This API acceptsapplication/jsonon all mutations. -
422
The request parsed but failed validation.
invalid_paramslists every failing field with a JSON Pointer, not just the first, so a client can mark up a whole form in one pass rather than making the user fix errors one round trip at a time.Also returned when an
Idempotency-Keyis replayed with a different body, and when device-computed order totals disagree with the server's recomputation. -
429
Rate limit exceeded. Honour
Retry-Afterrather than applying your own backoff.A fleet of tills coming back online together after an outage is the realistic cause, which is exactly when uncoordinated client-side retry turns a brief outage into a sustained one. The limit is per process, and per site for what records trade there — a request spends its site's budget, and a till's own smaller one inside it — and per
(client, merchant)for everything else; noRateLimitfield is sent, so this response and itsRetry-Afterare the whole of the contract. -
500
Something failed on our side.
trace_ididentifies the request in our tracing — quote it in a support ticket.The body never carries internal detail: no SQL, no stack traces, no upstream error strings. Safe to retry with the same
Idempotency-Key.