API Changelog
The API is versioned by release date — vYYYY-MM-DD, never a counter. Every JSON object response, success and error alike, carries a top-level version field naming the version that answered it, so you can always tell which contract produced a response.
v2026-09-02
No breaking changes.
Added
GET /creditsreturns your workspace's remaining credits for the current billing period —generation_creditsandai_credits, each withavailable,limit,consumedandextra.availableandlimitarenullon an unlimited plan, so branch onnullbefore comparing numbers. The read costs no credits and is on the Read budget.
v2026-08-31
Everything below is on the Alpha design import surface (POST /designs/import/json, GET /designs/{designId}/as-import). No stable endpoint changed. As stated when that surface shipped, its contract may change without notice and is not covered by the deprecation policy — these are the changes.
Breaking Action required if you author animations
Animation keyframe values are validated. Every
attra tween animates used to be passed straight through, so a value the renderer cannot use was stored and simply never played. Each is now checked, and the units are not the same for every one:attrRange Unit opacity0…1fraction volumeEffect0…1fraction scale0…100percent, 100= as designedangleno bound signed degrees ( 1140= four clockwise turns)left,topno bound signed px typewriting0or100only— A non-number is
invalid_payload, a number outside its range isout_of_range, and atypewritingother than0/100isunknown_enum_value— all atlayers[i].animation.tweens[j].keyframes[k].attr.<name>.Check
opacityfirst. Inside a keyframe it is a0–1fraction, but a layer'sproperties.opacityis an integer0–100. Same word, two scales, so{"opacity": 100}in a keyframe is nowout_of_rangerather than "fully opaque".volumeEffectis a fraction for the same reason, andtypewritingis not a range at all — the effect types the text in whole, so50is not "half typed".A keyframe must sit inside its layer's visible window.
data.timeis absolute seconds on the timeline and must fall within the layer'sstart_at_s…end_at_s(defaulting to0andanimation.duration), otherwiseout_of_rangeat…keyframes[k].data.time. A layer's effects cannot run outside the window in which the layer exists, so such a keyframe was never reached and imported as an animation that never played. Keyframes are still not required to be ordered.GET /designs/{designId}/as-importfails on a design that breaks either rule. The export is validated against the import before it is returned, so a design holding an out-of-range keyframe value answersnot_round_trippableinstead of handing back a payload the import would reject. If you hit this on a design you did not create through the API, its stored animation is the problem — send us the design id.
Added
- A format's
background_coloris optional and defaults to white —#FFFFFFonstaticandanimated,cmyk(0,0,0,0)onprinter. Same for aprinter_multipagepage. Omitting it emits nocolor_convertedwarning. - A
buttonlayer'salignmentaccepts the single-token shorthand every other layer type already accepted: one token names one axis and the other takes its middle, socenterismiddle centerandtopistop center. Exports still return the two-token form.
v2026-08-21
No breaking changes.
Added
- Webhook deliveries are signed — every delivery carries
X-Abyssale-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 over"v1:webhook:" + t + "." + raw_body, plusX-Abyssale-Delivery-Idfor deduplication. Signing is opt-in: a workspace that has never fetched its secret keeps receiving unsigned deliveries. Additive — no payload field and no status code changed. See Signature verification. GET /signing-secretreturns the workspace's signing secret, minting it on the first call. One secret covers every webhook in the workspace. It is not the API key and cannot replace one.POST /signing-secret/rotateissues a new secret and keeps the previous one valid for 24 hours, signing each delivery with both. A second rotate inside that window answers409 previous_secret_still_activeunless you pass?force=true.POST /signing-secret/revokeends the overlap early, leaving the current secret in place.
v2026-08-20
No breaking changes.
Fixed
grouplayers carryattributes— always empty ({}on aprinter_multipagepage,[]everywhere else). Nothing on a group is customisable; the key exists so every element has the same shape.- Repeating a query parameter no longer changes the response — a duplicated
?i=advancedfell back to the plain read, and a duplicated?version=answered500. - Error messages name the offending field —
name: Missing data for required field.rather than "One or more request fields are invalid.", including errors relayed from the generation engine.errorsandidare unchanged.
Reference corrections
The API is unchanged — the reference described these wrongly, so a client validating responses against it rejected valid payloads.
code, ananimated-only read-only layer type, is documented. Skip it as you skipcontainer.- A keyframe's
attris an object ({"left": 1021, "top": 347}), not a string. mask_propertiesandfilter_propertiesvalues are objects, not scalars.
v2026-08-17
Breaking Action required
- One error envelope on every endpoint and every status —
{id, message, errors?}.errorsis now a flat array, and the code moved to top-levelid. Migration steps. - Framework failures answer real codes —
method_not_allowed,invalid_json,internal_error— instead of the placeholdererror. - Three misspelled 404 codes are corrected, each of which held a literal space:
project_not found→project_not_found, and likewise forworkspace_template_not_foundandduplication_request_not_found. - Every JSON object response carries a top-level
version. Clients that reject unknown fields must accept it. GET /designsandGET /projectshide deleted records — a design whose project was deleted is no longer listed, and onlyCREATEDprojects are returned.messageno longer begins with the HTTP status number —"404 Design … not found"is now"Design … not found". A client that slices or matches that prefix breaks silently. Branch onidinstead; see Errors.- An unknown format answers
404, not400, on synchronous generation — matching every other endpoint and the reference. - Per-workspace rate limiting, with a new
429code. Endpoints now carry a per-minute and per-hour budget by route class and answer429 request_rate_limitedwhen you exceed it. This is a differentidfromrate_limit_exceeded, which keeps its meaning — out of credits, or the global ceiling. A client that branches onrate_limit_exceededalone will not recognise a throttle. Retryrequest_rate_limited.rate_limit_exceededcovers two situations andmessagetells them apart — back off when it is the global ceiling, top up or upgrade when it is credits or plan, which no amount of retrying fixes.feature_not_in_planis never worth retrying. Every response now carriesX-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset, and a refusal addsRetry-After— pace against those rather than retrying blind. The budget is per workspace, so extra API keys do not raise it.
Added
- Design import Alpha —
POST /designs/import/jsonbuilds a design or workspace template from JSON (static,animated,printer,printer_multipage), withGET/PUT /designs/import/json/{importId}to poll and validate. The design type is checked against your plan before anything is created —animatedneeds MP4,printerandprinter_multipageneed Printable PDF — and a workspace without it gets a new429 feature_not_in_plan, on?validate_only=trueas well. GET /designs/{designId}/as-importAlpha — export any design in the exact shape the import accepts.GET /workspace-templatesandGET /workspace-template-categorieslist the workspace's shared templates and their categories.- Designs report their project —
project_id/project_nameon the listing and detail endpoints, plus aproject_idfilter.category_id/category_nameremain as deprecated aliases. GET /designs/{designId}returns the full design model — print geometry (dpi,bleed_size,safe_size), the animatedanimationblock with per-element timing and tweens, andpages[]pluselements_per_page(keyedpage_1…page_N) for multi-page print designs.?i=advancedreturns group layers, withlayer_ids, per-formatlayout, and auto-layout settings. Without the parameter the response keeps its previous shape.- Webhook payloads carry
event_type— route on it instead of inferring the event from the payload's shape.
Fixed
- A deleted design answers
404, not400, on the generation endpoints, with the sametemplate_not_foundcode a design that never existed already returned. - No error response is empty.
GET /banners/{bannerId}, the banner listing, ZIP export with unknown ids, a failed workspace-template duplication and the public renderer all returned a JSON content type with no body. POST /public/asset/importanswers400, not500, whenasset_nameis missing.- Field-level detail always uses
{path, code, message}— errors relayed from the generation engine used to arrive as{field, message}, and now carry anidrather than prose alone. - Plan and credit gates on asynchronous generation answer
429 rate_limit_exceededinstead of a400whose message merely started with "429".
Deprecated Plan a migration
remove_bgon synchronous generation — still works, but it can push the call past the endpoint's 10-second cap. Remove backgrounds asynchronously.auto_focusis unaffected.text_to_imageis gone from the synchronous schema. It was always rejected there.- The
category_idquery parameter onGET /designs— filter withproject_id. Existing requests keep working. auto_focus_properties.model: "face"— use"people"withfocus_framing: "face". It detects faces with the same model and also honoursfocus_zoomandfocus_target, which"face"ignores.focus_framingalready defaults to"face", so switching to"model": "people"is usually the whole migration."face"keeps working.
Changed
GET /fontssorts case-insensitively.
Before v2026-08-17
Releases before the versioning scheme carried no version name and no version payload field.
