Upload Assets
Alpha
The contract may change without notice, and imports created with it are not guaranteed to remain compatible. Not covered by the deprecation policy.
The second phase of a design import. For every entry returned in the uploads[] array of the Create Import response, you POST the asset binary directly to S3.
You can skip this phase
Skip this phase entirely when uploads[] is empty — that happens when every source is a public https:// URL or a data URI. In that case the import is already QUEUED; skip straight to polling Import Status.
from Create responseEach entry carries a presigned url and a fields map — the exact form parts S3 expects.
POST → presigned urlReplay every fields entry as a form field, then append the binary as the file part last.
204 No ContentAn empty 204 means it's uploaded. Repeat for every entry in uploads[].
What uploads[] actually is
You choose, per asset, how Abyssale gets the bytes — and that choice is made by what you put in src, nothing else:
src you send | What happens |
|---|---|
"https://example.com/hero.jpg" | Abyssale fetches it server-side. No upload. |
"data:image/jpeg;base64,/9j/4AAQ…" | Decoded and stored at POST time. No upload. |
"hero.jpg" — a bare filename | Abyssale has no bytes. It hands you a one-time upload slot for that layer, and waits. |
Every bare filename produces one entry in uploads[]: a pre-authorised S3 form with a url to POST to and a fields map that carries the signature. You are posting straight to S3, not to Abyssale — which is why upload errors come back as XML.
To skip this phase entirely, never send a bare filename. Public URLs and data URIs bypass phases 2 and 3 completely.
A complete worked example
One image layer, one file to upload, start to finish.
1. POST the payload, using a filename as src
curl -X POST https://api.abyssale.com/designs/import/json \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Upload demo",
"type": "static",
"target": { "kind": "design", "project_uuid": "'"$PROJECT_UUID"'" },
"formats": [
{ "name": "square", "width": 1080, "height": 1080, "background_color": "#FFFFFF" }
],
"layers": [
{
"name": "hero",
"type": "image",
"layout": { "x": 0, "y": 0, "width": 1080, "height": 1080 },
"properties": { "src": "hero.jpg" }
}
]
}'"src": "hero.jpg" is the whole trigger. The filename is a label, not a path — nothing is read from your disk, and it never has to match the file you eventually upload.
2. Read the response
{
"id": "9c1f0f4e-6a2b-4f0b-9a3d-7c2e4b1f8a10",
"status": "WAITING_FOR_VALIDATION",
"links": {
"status": "https://api.abyssale.com/designs/import/json/9c1f0f4e-…",
"validate": "https://api.abyssale.com/designs/import/json/9c1f0f4e-…"
},
"uploads": [
{
"target": "layer",
"name": "hero",
"url": "https://<presigned-upload-host>",
"fields": {
"key": "<presigned-object-key>",
"Content-Type": "image/jpeg",
"policy": "eyJleHBpcmF0aW9uIjoi…",
"x-amz-algorithm": "AWS4-HMAC-SHA256",
"x-amz-credential": "AKIA…/20260804/eu-west-1/s3/aws4_request",
"x-amz-date": "20260804T101500Z",
"x-amz-signature": "9f86d081884c7d65…"
},
"max_bytes": 20000000,
"expires_at_ts": 1785938100
}
]
}status is WAITING_FOR_VALIDATION: the import exists but is parked until the bytes arrive. uploads[0].name is "hero" — the layer name from your payload, which is how you know which of your files this slot wants.
3. POST the file to S3
Replay every key in fields as a form field, then add the binary as a part named file — last, because S3 ignores anything after it. <presigned-upload-host> and <presigned-object-key> stand in for the url and fields.key the response gave you — always read them from the response, never hard-code them.
curl -X POST "https://<presigned-upload-host>" \
-F "key=<presigned-object-key>" \
-F "Content-Type=image/jpeg" \
-F "policy=eyJleHBpcmF0aW9uIjoi…" \
-F "x-amz-algorithm=AWS4-HMAC-SHA256" \
-F "x-amz-credential=AKIA…/20260804/eu-west-1/s3/aws4_request" \
-F "x-amz-date=20260804T101500Z" \
-F "x-amz-signature=9f86d081884c7d65…" \
-F "file=@./my-local-picture.jpg"204 No Content and an empty body means it worked. Note the local file is my-local-picture.jpg while src said hero.jpg — they are unrelated, as above.
Do not hand-copy the fields in real code. Whatever the response contains, replay it:
# Upload every entry in uploads[], replaying fields generically.
# $RESPONSE is the POST body from step 1; ./assets/<layer-name>.jpg are your files.
echo "$RESPONSE" | jq -c '.uploads[]' | while read -r upload; do
name=$(jq -r '.name' <<< "$upload")
url=$(jq -r '.url' <<< "$upload")
args=$(jq -r '.fields | to_entries[] | "-F\n\(.key)=\(.value)"' <<< "$upload")
mapfile -t form <<< "$args"
curl -sS -X POST "$url" "${form[@]}" -F "file=@./assets/${name}.jpg" \
&& echo "uploaded ${name}"
done4. Validate
Only once every entry is uploaded:
curl -X PUT "https://api.abyssale.com/designs/import/json/9c1f0f4e-…" \
-H "x-api-key: $API_KEY"This is the step that tells Abyssale to start building. It checks each expected asset is really in place — miss one and you get 422 with missing_assets naming the layer. On success the import flips to QUEUED; poll Import Status until DONE.
The order is not optional
POST → upload every file → PUT. Validating early fails with missing_assets; uploading after validating is too late, the build has already started.
Upload descriptor shape
Each entry in uploads[] looks like this:
{
"target": "layer",
"name": "hero",
"url": "https://<presigned-upload-host>",
"fields": {
"key": "<presigned-object-key>",
"Content-Type": "image/jpeg",
"policy": "...",
"x-amz-signature": "..."
},
"max_bytes": 20000000,
"expires_at_ts": 1749831334
}| Field | Meaning |
|---|---|
target | Always "layer" — every upload is an image/logo layer asset. Preview thumbnails are generated automatically by the backend and are never uploaded. |
name | Name of the layer the asset belongs to. |
url | Presigned S3 endpoint. POST multipart/form-data to this URL. |
fields | Form parts that must be replayed verbatim and in order, before a final file part containing the binary. Normally includes Content-Type, derived from your src filename's extension (hero.jpg → image/jpeg, scan.tif → image/tiff). |
max_bytes | Hard size limit enforced by S3. |
expires_at_ts | Unix timestamp after which the URL no longer accepts uploads. |
Limits
| Design type | max_bytes | Content-Type |
|---|---|---|
static | 20 MB (20000000) | Must start with image/. |
printer | 500 MB (500000000) | Must start with image/. TIFF is accepted — see Print Imports. |
printer_multipage | 500 MB (500000000) | Same as printer, per page. |
animated | Per layer type: 100 MB video, 25 MB audio, 20 MB image/logo | Prefix-enforced per layer type: image/ on image/logo, video/ on video, audio/ on audio. Above the cap, host the file and use a public https:// URL src instead. |
A qrcode layer's icon_src is the one exception to the table: it caps at 1 MB (1000000) on every design type, not at the design type's cap. The centre icon is a small overlay, and the same 1 MB ceiling applies whether you send it as a filename to upload or inline as a data URI.
S3 enforces both, and rejects violations itself:
| Violation | S3 response |
|---|---|
Body larger than max_bytes | 400 with EntityTooLarge |
Content-Type outside the layer type's allowed prefix | 403 with AccessDenied |
Upload failures are raw S3 XML
These are S3's own responses, not Abyssale's. They come back as raw XML (<Error><Code>EntityTooLarge</Code>…), not the structured {"errors": […]} envelope the API uses everywhere else — the upload POST never touches Abyssale. Parse accordingly.
When fields has no Content-Type
For a filename whose extension cannot be mapped to a media type, Content-Type is omitted from fields and the policy falls back to a starts-with prefix condition. In that case you must add your own Content-Type form part matching the layer's prefix (image/ on image/logo, video/ on video, audio/ on audio), before the file part. Whenever Content-Type is present in fields — the normal case — simply replay it like every other field.
Multi-page uploads carry a page
On printer_multipage, each upload descriptor also carries a page field (page_1 … page_N). Layer names may repeat across pages, so match on page and name to POST the right binary to the right descriptor.
Expiry & retries
If you don't validate the import before expires_at_ts, the presigned URL stops accepting uploads (presigned URLs are valid for 1 hour from creation). Once that window closes the import is terminal — it can never be validated:
GETreportsstatus: "FAILED"withnext_check_after_ms: nulland amissing_assetserror, andlinks.validateis gone.PUTreturns422with codemissing_assets— the same status and code as the never-uploaded case, so no extra client branch is needed.
To recover, re-POST the original payload — you'll get a fresh import row with a new set of presigned URLs. POST is not idempotent, so the previous row is simply abandoned.
Next step
Once every asset has been uploaded, validate the import — see Validate Import. The validate call also checks that every expected asset is in place; missing uploads are reported as missing_assets.
