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.
Subscribe to this page: RSS feed.
The format follows Keep a Changelog, with one deliberate addition: a Breaking section, ahead of the standard types. Keep a Changelog files breaking changes under Changed or Removed; an API's are the first thing a reader needs to find, so they get their own heading and a severity badge. Everything else uses the standard types — Added, Changed, Deprecated, Removed, Fixed, Security.
Unreleased
Nothing yet. Changes land here as they are merged, and move into a dated section when that version ships.
v2026-09-25
One colour grammar for generation and for the design import: a colour gets the same verdict from every generation endpoint as from POST /designs/import/json, so a design the import creates can always be generated. The grammar is on Colors & Gradients.
Breaking Action required if you send out-of-range colours
Generation refuses a colour it used to draw wrong, or fail on, with
400 invalid_payload. The error message names the field and the accepted forms. Refused now:Value Before A CMYK component or alpha above 100—cmyk(150,0,0,0)200, wrong colourThe same inside a gradient stop 500A gradient stop offset above 100%500A gradient stop opacity above 1—2,1.5200, drawn as1A 4-digit hex gradient stop — #F00F200A stop's transparency is its opacity, so a stop color is
#RRGGBB,#RGBorcmyk(C,M,Y,K)(the 8-digit form was already refused). Send1where you sent an opacity above1.
Changed
- Generation accepts the spellings the grammar allows everywhere else: a
#RGBgradient stop, andcmyk( 0,0,0,100 )with spaces inside the parentheses. - The design import accepts every spelling generation accepts Alpha:
cmyk(...)with a%after the components or leading zeros (cmyk(0%,10%,20%,30%),cmyk(007,0,0,0)), a 5-componentcmyk(...)(read ascmyka(...)), and a#RGBgradient stop. They are stored in the canonical spelling —cmyk(0,10,20,30),#FF0000. - The published patterns in the OpenAPI spec describe what you may send, those spellings included. They used to describe the stored form only, so a client validating against the spec refused colours the API accepts.
Fixed
- A gradient written
LINEAR-GRADIENT(...)is drawn. Generation accepted the upper-case form, then failed to draw it. - The design import refuses a gradient stop offset above
100%Alpha. It was accepted, and every generation of the saved design then failed with a500. - The design import refuses a gradient that mixes hex and
cmyk(...)stopsAlpha, as generation always did.
v2026-09-24
No contract-breaking changes. Two fixes change rendered output: button shadow_* now shadows the box, and a diagonal gradient on a non-square element now renders at the angle the editor shows.
Added
A
buttonelement takes an icon at generation time.icon_urlis a public HTTP(s) URL of the image to place beside the label,icon_colorrecolours it. Either can be sent on its own, and a button whose design already carries an icon keeps it when neither is.The icon's geometry belongs to the design, not the request — where it sits, how big it is and how far it stands from the label are set in the editor, the same way a text layer's font family is.
icon_coloronly bites on an SVG: recolouring rewrites the paint inside the file, and a raster has none to rewrite.The design import understands a button icon.
icon_srctakes the three formssrcalready accepts — a filename you upload, anhttps://URL, or a data URI — besideicon_color,icon_size,icon_gapandicon_alignment.icon_sizeis a percentage of the label's font size, not a length (10–200, default100), so the icon keeps its proportion when the label auto-fits.icon_gapis a length.A
button's icon can be sent as base64:icon_encoded. The alternative toicon_urlwhen you hold the bytes rather than a URL — the twin ofimage_encoded, taking a bare base64 body or adata:image/<type>;base64,data URI, with the same file types and size cap.icon_urlwins if both are sent, and the value is never stored on the generation request. It already reached the renderer undeclared, so a data URI failed the generation with a bare500; a value that is not an image is now refused withinvalid_payload.A
buttontakes a label shadow —text_shadow_color,text_shadow_blur,text_shadow_offset_xandtext_shadow_offset_yshadow the label, next to theshadow_*family that shadows the box. A button is the one layer with two shadows, and both can be set at once.A linear gradient may carry 2 to 8 colour stops. The grammar
linear-gradient(<x>% <y>% <x1>% <y1>%, …)now takes up to eight<offset>% <color> <alpha>stops instead of exactly two, everywhere a gradient is accepted — at generation time and on the design import alike.Each stop keeps the offset it was authored at, so a ramp written at
0/20/75/100renders at those positions rather than being re-spaced evenly. Two stops stay valid and unchanged: this widens the grammar, it does not move it. A ninth stop is rejected withinvalid_payload. On a printer design the extra stops follow the same rules as the first two —cmyk(C,M,Y,K)or#RRGGBB, and every one of them opaque.A printer design accepts a linear gradient on a background. Every
linear-gradient(...)used to be refused on aprinter/printer_multipagedesign, because the print pipeline collapsed it to its first colour and would have shipped a flat fill instead of the ramp you authored. The whole stack now carries a real gradient — every stop converted, an axial shading in the PDF — so it is accepted in the one place print can draw one:background_colorof ashapeorbuttonlayer.Stops may be written
cmyk(C,M,Y,K)or#RRGGBB; a hex stop is converted per stop with acolor_convertedwarning. Duplicating a static design to print follows the same rule: a gradient on a shape or button background is kept, each stop converted; one on the format background or a text colour is flattened to its first stop.Every stop must be opaque. A PDF shading carries no alpha channel, so a stop with an alpha below
1is refused rather than flattened onto white — that would be a colour you never asked for. A gradient anywhere else on print is still refused: a textcolor, astar_color, a border, a page or format background.GET /designs/{designId}/as-importreturns the icon, so a design built in the editor with a button icon round-trips unchanged.
Changed
A missing preview on a design import is reported per format, with the reason.
preview_generation_failedused to be one entry atformats— "1 of 4 preview(s) failed to render" — naming neither the format nor the cause. There is now one entry per format without a preview, atformats[i](pages[i]on a multipage document), with a newreasonfield:render_failed(themessagequotes why when it is about the design, so the same payload will fail the same way) orpreview_not_stored(the format rendered but its preview could not be stored — this case used to go unreported, leaving aDONEimport whosepreview_urlanswered an error).The design import checks its
https://sources in parallel, within 10 seconds. Each distinctsrcURL used to be checked one after another, so a payload with about ten slow hosts could run past the 29-second gateway limit: the call answered500 Endpoint request timed out, yet the import could still be created. A source that has not answered within 10 seconds of the start of the check is now reported asunreachable_src.A
buttonlayer'sradiusmay be per corner —{ tl, tr, br, bl }as well as a single number, exactly asshape,imageandlogoalready allowed. A button whose corners differ is now exported that way too: the export previously emitted only the top-left corner, so re-importing a design with three square corners rounded all four. A uniform radius is still emitted as a single number, so payloads that never used per-corner radii are unchanged.A gradient stop is stricter on the design import. A stop's alpha must lie between
0and1, and acmyk(C,M,Y,K)stop is written without spaces — the one form every renderer reads. Either used to be accepted and slip past the print opacity check; both now answerinvalid_payload. A gradient withcmyk(...)stops on a static or animated format background is refused withunsupported_for_type, as it already was on a layer.
Fixed
The five auto-focus settings can be sent at the top level, beside
auto_focus, as well as insideauto_focus_properties—auto_focus_model,focus_objects,focus_framing,focus_zoomandfocus_target. They were already accepted there but documented nowhere, andauto_focus_modelwas silently ignored when sent that way. It is now honoured, withauto_focus_properties.modelstill winning when both are sent.A
button'sstroke_width,text_alignandtext_transformare validated. All three are documented button properties, but a value outside the documented set was forwarded to the renderer rather than refused —text_align: "middle"was accepted and silently did nothing.A
ratinglayer'sstar_colorandbackground_colorno longer accept a gradient. The published pattern said they did, and the design import took one — but a rating is drawn as filled polygons and no renderer can shade it, so the generation failed with a bare500and the preview never arrived. Both fields now take#RRGGBB/#RRGGBBAAorcmyk(...)/cmyka(...), and a gradient is refused at import withinvalid_payload. Generation-time overrides already refused one. A design carrying such a gradient cannot render today, so no working payload loses anything.shadow_*on abuttonnow shadows the button, not its label. A shadow sent for the button was painted behind the text — a second, offset copy of the label — and the box got none. If you relied on that, sendtext_shadow_*instead.A gradient where print cannot draw one is refused at generation. On a
printer/printer_multipagedesign, a gradient on the format background (root), a text or button-labelcolor, or a text background was accepted and then failed the generation with a bare500. It is now400 invalid_payloadat submit, naming the element. A shape or buttonbackground_colorstill takes one.A diagonal gradient on a non-square element rendered at the wrong angle. A gradient's coordinates are fractions of the element's box, as the editor draws them, but the image renderer read them as pixels — which only agrees on a square. On a 400×120 button a corner-to-corner diagonal came out 17° off. Buttons, text fills and backgrounds now render as the editor shows them, so an existing design with such a gradient renders slightly differently; horizontal and vertical gradients are unchanged.
An HTML5 export dropped most of a gradient on the format background. It kept only the first two stops, spaced them evenly and discarded their alpha. Every stop now keeps its offset and alpha. The direction is still snapped to horizontal, vertical or a 45° diagonal.
v2026-09-21
No breaking changes.
Added
expandon the image element extends an image beyond its original borders using AI, filling the empty spacefitting_type: "fill"leaves around an off-center image instead of leaving it blank. Models:flux-2-pro-outpaint(default),image-outpaint,bria-expand. Ignored whenfitting_typeiscover, since that mode already fills the whole bounding box. Asynchronous generation only — rejected with400 invalid_payloadon synchronous generation, same astext_to_image.
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.
Changed
GET /fontssorts case-insensitively.
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.
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".
Before v2026-08-17
Releases before the versioning scheme carried no version name and no version payload field.
