Skip to content

Lilli is currently in preview for our artists and photographers.

API reference

Uploads

Artwork goes straight from your server to our storage through a signed URL, so large files never pass through the API itself.

Create an upload URL

POST
/uploads
orders:write
60/min

PUT the bytes to uploadUrl with exactly the returned contentType, then reference url as a line's imageUrl. Content-Length is baked into the signature, so the declared size is the enforced size.

Request body

FieldTypeNotes
filename
required
string

Used for the extension and for the studio's own records. It does not become the stored object name.

1-1024 characters
contentType
required
string

One of image/jpeg, image/png, image/webp, image/tiff or image/heic. Echoed back on the response, and stored as the object's own type - send the same one on the PUT.

1-255 characters
sizeBytes
required
integer

Exact byte length. Baked into the signature as Content-Length, so the declared size is the enforced size - a mismatch is rejected by storage, not by us.

max 524288000

Response

FieldTypeNotes
uploadUrl
required
string

PUT the bytes here. Short-lived - request it when you are ready to send.

contentType
required
string

Send this as the PUT's Content-Type; it becomes the stored object's type.

url
required
string

What you put on a quote line as imageUrl. On our storage host, not this API's - treat it as opaque and send it back verbatim. This is the value to keep.

key
required
string

The underlying storage key. Useful in a support conversation; you do not need it to order.

Status codes

201

A signed upload target.

400

The request was rejected. Branch on error.code.

401

Missing, malformed, revoked, expired, or belonging to a disabled account - deliberately indistinguishable.

403

The key authenticated but lacks the required scope.

429

Rate limited. Honour Retry-After.

POST https://api.lilli.com.au/v1/uploads

{
  "filename": "artwork.tif",
  "contentType": "image/tiff",
  "sizeBytes": 84213760
}

Accepted types are JPEG, PNG, WebP, TIFF and HEIC, up to 500 MB - a full-size giclée scan genuinely reaches that.

Sending the file

The signature covers the content length you declared, so the PUT has to send exactly that many bytes. If it does not, storage rejects it before we ever see it.

Content-Type is not signed, so getting it wrong will not fail the upload - it will succeed and store the file under the wrong type, which is harder to notice. Send the contentType the response gave you.

# 1. Ask for a signed target. The declared size is the ENFORCED size.
SIZE=$(wc -c < artwork.tif)
UPLOAD=$(curl -s "$LUCENT_API/uploads" \
  -H "Authorization: Bearer $LUCENT_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"filename\":\"artwork.tif\",\"contentType\":\"image/tiff\",\"sizeBytes\":$SIZE}")

# 2. PUT the bytes. Send the content type you declared - it is stored as the
#    object's own type, and is what we read the file back as.
curl -s -X PUT "$(echo "$UPLOAD" | jq -r .data.uploadUrl)" \
  -H "Content-Type: image/tiff" \
  --upload-file artwork.tif

# 3. Keep this - it is the line's imageUrl.
IMAGE_URL=$(echo "$UPLOAD" | jq -r .data.url)

Artwork ownership

A quote line may only reference artwork uploaded by the account making the request. Anything else is refused with ARTWORK_NOT_ALLOWED at pricing time - before an order exists, not after.

This holds even for a URL that is otherwise valid. Object keys are unguessable, but unguessability is not an authorisation model: a key is not a secret, and a URL that leaks into a log or a support ticket must not become a way to print someone else's work.

The practical consequence is that you cannot pass through a URL you got from anywhere other than your own POST /uploads call - including one from a previous key on a different account. Upload it again.

Products that must print something are marked requiresArtwork in the catalogue. A line for one of those with no imageUrl is refused with ARTWORK_REQUIRED rather than quietly reaching the studio as a job with nothing to print.