openapi: 3.0.3
info:
  version: "v2026-08-21"
  # Also the <title> of api-reference.abyssale.com and the page's h1 — brand first, then what the
  # API does, inside the ~60 characters a search result shows.
  title: Abyssale API Reference — Image, Video & PDF Generation API
  license:
    name: Proprietary
    url: https://www.abyssale.com/terms-of-service
  x-logo:
    url: 'https://cdn.prod.website-files.com/6214efb2d4b5d94158f2ff03/674734715bd8e93b78fd7042_abyssale_v3_black.svg'
    altText: 'Abyssale logo'
    href: 'https://www.abyssale.com'
    backgroundColor: '#FAFAFA'
  description: |
    ### Abyssale API Reference

    Abyssale is a **programmatic creative asset generation API** for creating marketing visuals,
    ad creatives, videos, and print-ready PDFs at scale. Design once in Abyssale, then generate
    thousands of personalized variations — across every format, channel, and language —
    via a single REST API call.

    This documentation is about the Abyssale `Rest API`. If you would like to discover other Abyssale features or to have a comprehensive understanding on how everything works, go to the [Abyssale developers hub](https://developers.abyssale.com).

    Base URL: `https://api.abyssale.com`

    #### API versioning

    The API is versioned by **release date**: `vYYYY-MM-DD` (e.g. `v2026-08-21`), never
    a counter. Every JSON object payload the API returns — success and error alike — carries a
    top-level **`version`** field naming the version that answered. The changelog
    (`CHANGELOG.md`) lists every version and what changed in it, breaking changes first.

    #### General Rest API information:
    - Data exchange format: **JSON**. All API calls must contain the `Content-Type: application/json` header.
    - Rate limits: **10 requests/second**
    - Usage counts **successful visual creation only** — a call that generates nothing (a read, a
      failed generation, a dry-run validation) does not consume it. Visuals created from the API
      console on a design page count too.
    - Trial plan: **30 generation credits** and **100 AI credits** included — credits, not calls: one request over six formats spends six.
    - Available Integrations: [Zapier](https://zapier.com/apps/abyssale/integrations), [Make (ex Integromat)](https://www.make.com/en/integrations/abyssale), [Airtable](https://airtable.com/marketplace/blkB14c3EShbFLlh4/abyssale) & [n8n](https://n8n.io/integrations/abyssale/) _(if you prefer using nocode)_

    #### Which approach should I use?

    | Use case | Recommended approach |
    |----------|----------------------|
    | **Personalized images in emails** (rendered on open) | Static design → [Dynamic image URL](/api-reference/#dynamic-images) |
    | **Embed a live, customizable image on a website** | Static design → [Dynamic image URL](/api-reference/#dynamic-images) |
    | **Single image on demand** (synchronous response) | [Synchronous generation](/api-reference/generateImage) |
    | **Bulk campaign creatives** across multiple formats | [Async generation](/api-reference/generateMultiFormatMedia) |
    | **Animated GIF or video** for social/ads | Animated design → [Async generation](/api-reference/generateMultiFormatMedia) with `gif` or `video` options |
    | **HTML5 banner ads** for ad networks | Animated design → [Async generation](/api-reference/generateMultiFormatMedia) with `html5` options |
    | **Print-ready PDF** (single page) | Printer design → [Async generation](/api-reference/generateMultiFormatMedia) with `print` options |
    | **Multi-page print document** (brochure, catalog) | Printer multipage design → [Multi-page PDF](/api-reference/generateMultiPagePdf) |

    # Authentication

    Abyssale uses **API Key Authentication**.
    Your API key is passed along with every API call, allowing Abyssale to identify your company account and access your data.
    
    [How to get your API Key?](https://developers.abyssale.com/rest-api/authentication#how-to-get-an-api-key) 

    <SecurityDefinitions />

    #### Common error responses (applicable to all endpoints):
    - `401 Unauthorized` — missing or invalid API key
    - `429 Too Many Requests` — a rate limit was exceeded, or credits are insufficient
    - `500 Internal Server Error` — unexpected server-side failure

    # Rate limits

    Two limits apply to every request, one after the other. Both answer `429`, and the `id` in the
    body tells you which one you hit.

    | # | Limit | Applies to | Budget | `id` |
    |---|---|---|---|---|
    | 1 | Global ceiling | Every endpoint | **10 requests/second** | `rate_limit_exceeded` |
    | 2 | Endpoint budget | Endpoints in a limited tier | see below | `request_rate_limited` |

    **1. The global ceiling** is 10 requests per second across every endpoint, checked first. It
    is a burst guard: sustain that rate and you will exhaust most endpoint budgets within the
    minute anyway, so in practice it is the endpoint budget you meet first.

    **2. Endpoint budgets.** Every endpoint belongs to ONE tier, and the tier sets the budget.
    The table below covers every endpoint in this reference; a handful of internal and deprecated
    routes are limited too, but they are not part of the published contract.
    Each tier is counted **separately**, so writes never eat into your read budget, and each
    budget is enforced over **two windows at once** — per minute and per hour — both of which
    must pass. Generation endpoints are in no tier: nothing below bounds them per minute or per
    hour, only the global ceiling and your plan's credits.

    | Endpoint | Tier | Per minute | Per hour |
    |---|---|---|---|
    | `GET /designs` | Read | 120 | 3 000 |
    | `GET /designs/{designId}` | Read | 120 | 3 000 |
    | `GET /designs/{designId}/formats/{formatSpecifier}` | Read | 120 | 3 000 |
    | `POST /designs/{designId}/dynamic-image-url` | Write | 60 | 1 200 |
    | `GET /designs/{designId}/as-import` | Write | 60 | 1 200 |
    | `GET /designs/import/json` | Read | 120 | 3 000 |
    | `POST /designs/import/json` | Write | 60 | 1 200 |
    | `POST /designs/import/json?validate_only=true` | Validation | 120 | 2 400 |
    | `GET /designs/import/json/{importId}` | Polling | 600 | 18 000 |
    | `PUT /designs/import/json/{importId}` | Write | 60 | 1 200 |
    | `GET /design-duplication-requests/{duplicateRequestId}` | Polling | 600 | 18 000 |
    | `GET /banners/{bannerId}` | Read | 120 | 3 000 |
    | `POST /async/banners/export` | Write | 60 | 1 200 |
    | `GET /fonts` | Read | 120 | 3 000 |
    | `GET /projects` | Read | 120 | 3 000 |
    | `POST /projects` | Write | 60 | 1 200 |
    | `GET /workspace-templates` | Read | 120 | 3 000 |
    | `GET /workspace-template-categories` | Read | 120 | 3 000 |
    | `POST /workspace-templates/{companyTemplateId}/use` | Write | 60 | 1 200 |
    | `GET /generation-request/{generationRequestId}` | Polling | 600 | 18 000 |
    | `POST /auth` | Read | 120 | 3 000 |
    | `GET /signing-secret` | Read | 120 | 3 000 |
    | `POST /signing-secret/rotate` | Write | 60 | 1 200 |
    | `POST /signing-secret/revoke` | Write | 60 | 1 200 |
    | `POST /banner-builder/{designId}/generate` | none | — | — |
    | `POST /async/banner-builder/{designId}/generate` | none | — | — |
    | `POST /async/banner-builder/{designId}/generate-multipage-pdf` | none | — | — |

    **Write is the tightest** because one call is several database writes, two or more storage
    uploads, a queue publish and one outbound fetch per distinct layer `src`. Count round trips,
    not calls: exporting a design and importing it back spends two write calls, so 1 200/hour is
    600 export–edit–import cycles.

    **Validation is looser than write** so that iterating on a payload until it validates never
    costs you an import. It is not free — it still decodes every data URI you send.

    **Polling is the loosest** because those endpoints are meant to be called in a loop. An
    import's status response tells you when to come back (`next_check_after_ms`, 2 seconds while
    processing), which is 30 requests a minute for one job. The budget sits far above any sane
    cadence — honour `next_check_after_ms` and you will not meet it.

    **Generation has no endpoint budget** — which is not the same as unlimited. The global 10
    requests/second ceiling still applies, and your plan's credits gate it on top
    (`id: rate_limit_exceeded` for both; on a credit refusal retrying never helps, the plan or the
    balance has to change). Polling the resulting job IS budgeted, in the polling tier.

    Every response from a limited route carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
    `X-RateLimit-Reset` for whichever window you are closest to exhausting, and a `429` adds
    `Retry-After`. Two exceptions, both because the limiter never ran: a `401` (authentication
    fails first, so there is no workspace to charge) and the rare case where the limiter's backing
    store is unreachable and it lets the request through. **Read those headers and pace your calls** — this is a limit you can see
    coming, which matters most for automated clients and AI agents retrying a rejected payload.
    One thing worth knowing before you build a retry loop: a refused request **still counts**, so
    retrying into a `429` spends your hourly budget.

    # Deprecation

    Some fields in this reference are marked **deprecated**. That mark is a commitment and a
    warning, in that order:

    - **It keeps working.** A deprecated field is still populated and still behaves as
      documented. Nothing is removed from under a running integration.
    - **It will be removed eventually.** There is no date, and we do not commit to one. "Not yet"
      is the only guarantee, so treat a deprecation as work to schedule rather than work to
      ignore.
    - **Each one names its replacement.** Read the field's description: it says what to use
      instead, and whether the two are equal today (`template_id` duplicates `id`;
      `category_id` / `category_name` duplicate the project fields on a design).

    Deprecation is announced in **three** places, and only these count: the `deprecated` flag in
    this reference, the field's own description, and a dated entry in the
    [changelog](https://developers.abyssale.com/rest-api/changelog).

    **A route that disappears from this reference is deprecated too.** Omission is how a route is
    retired here: it keeps answering for existing callers, but it is no longer part of the
    published contract and should not be adopted. If you are calling something you cannot find
    documented, treat that as a signal, not as an oversight.

    Two things this does **not** cover. Anything marked **Alpha** — today, the whole Design
    Import section — may change or vanish without notice and without a changelog entry; that is
    what the label means. And a **new** field appearing in a response is not a breaking change,
    so parse permissively and ignore what you do not recognise.

    # Webhooks

    Asynchronous work — batch generation, exports — finishes on our side, not in your request.
    You learn about it in one of two ways: **poll** the job's status endpoint, or receive a
    **webhook**. The payloads are documented at the bottom of this reference; what follows is the
    delivery contract they all share.

    | Property | Detail |
    |---|---|
    | Method | `POST` over HTTPS |
    | Content type | `application/json` |
    | Acknowledgement | Return **`200`** or **`201`** within **15 seconds** |
    | Retries | The first attempt, then up to **5 retries** — about **3 hours** in total |
    | Backoff | 15 s, 3 min, 10 min, 30 min, 2 h |
    | Identifying header | `X-Referer: api.abyssale.com` |
    | Signature | `X-Abyssale-Signature` — HMAC-SHA256, see below |
    | Delivery id | `X-Abyssale-Delivery-Id` — 64 lowercase hex characters, stable across retries |
    | Static IPs | Available for allow-listing — contact support |

    Anything other than `200`/`201` is treated as a failure and retried; 15 seconds is a hard
    timeout, so acknowledge first and do your work afterwards. **Answer `410 Gone` to stop the
    retries permanently** — that is how a receiver says the endpoint is retired. Four responses
    are terminal: `410`, and a `400` whose body is exactly `Queue is full.`,
    `There is no scenario listening for this webhook.` or `Organization is in read-only mode.`
    Every other failure keeps its place in the backoff schedule.

    One delivery differs: **`NEW_EXPORT` sent to an export's own `callback_url`** is retried the same
    6 times over about 3 hours, but its first gap is **60 seconds** rather than 15 — the ladder is
    60 s, 3 min, 10 min, 30 min, 2 h. It has no subscription behind it, so nothing can be
    deactivated; a delivery that exhausts its retries is simply dropped. Dashboard subscriptions and
    a `callback_url` on a *generation* request both use the 15 s ladder above.

    Three failures also retire the subscription once the retries run out: a `404`, a **timeout**, or
    a **connection error** still failing on the sixth and final attempt deactivates it just as a
    `410` would, so a receiver unreachable for the full ~3 hours is retired silently. A persistent
    `5xx` behaves differently — the delivery is dropped but the subscription survives, so failing
    with a `503` during maintenance is safer than letting connections time out.

    **Every payload carries a top-level `event_type`** (`NEW_BANNER`, `NEW_BANNER_BATCH`,
    `NEW_EXPORT`, …). Dispatch on that field rather than inferring the event from the payload's
    shape, so a new event type never lands in the wrong branch of your handler.

    **Two ways to subscribe, and they are not interchangeable.** A `callback_url` in a request
    body is per-job: it fires once, for that job only. A webhook configured in the Abyssale
    dashboard is per-workspace: it fires for every matching event, including work started outside
    the API. `NEW_BANNER` has no `callback_url` of its own — it comes from a dashboard webhook.

    **Design your receiver to be idempotent and to tolerate re-delivery.** A retry sends the same
    payload again, and a webhook is not ordered with respect to your own polling: you may see the
    job finish through the status endpoint first. Treat the payload as a notification that
    something is ready, keyed on its id, not as a one-shot event you must not miss.

    **`X-Abyssale-Delivery-Id` is the key to deduplicate on.** Every delivery carries it — signed
    or not, since it is independent of signing — as **64 lowercase hex characters**. It is derived
    from the event and its destination, not from the attempt, so all six attempts of the same
    delivery carry the same value while the signature's `t` changes on each one. Record it and
    ignore a value you have already processed.

    The id identifies a *delivery*, not an event: one event fanned out to several subscribed URLs
    produces a **different** id per subscription. It is therefore unique within one receiver, which
    is all deduplication needs, but two of your endpoints receiving the same event cannot correlate
    on it — use the payload's own ids (`generation_request_id`, `export_id`, …) for that.

    # Verifying a webhook

    Signing is **opt-in, and the secret coming into existence is what turns it on**: a workspace that
    has never called a `/signing-secret` endpoint keeps receiving unsigned deliveries. `GET` is the
    normal way to create it, but `rotate` and `revoke` mint one too if none exists, so either of
    those also switches signing on. Once the secret exists, every delivery is signed, so you can
    prove it came from us and that nobody altered it in transit. Do
    not treat `X-Referer` as authentication — it is an unverifiable header anyone can set. The
    signature is the real thing.

    ## Get your signing secret

    `GET /signing-secret` returns the workspace's secret. Fetch it once, store it like any other
    credential, and reuse it: **one secret covers every webhook in the workspace**, whether the
    event was subscribed in the dashboard or requested with a `callback_url`. There is nothing to
    choose per endpoint.

    ```json
    {
      "secret": "whsec_…",
      "created_at_ts": 1755561234,
      "rotated_at_ts": null,
      "previous_secret_expires_at_ts": null
    }
    ```

    **This is not your API key, and the two are not interchangeable.** Your API key authorises
    calls *to* us and can spend credits; the signing secret only proves a delivery came *from* us.
    Never use one in place of the other.

    ## The header

    ```
    X-Abyssale-Signature: t=1755561234,v1=5f2c…
    ```

    `t` is the Unix second we signed at. Each `v1` is
    `HMAC-SHA256(secret, "v1:webhook:" + t + "." + raw_body)`, hex-encoded. To verify: rebuild that
    string, hash it with your secret, and compare against any `v1` present.

    ```python
    import hmac, hashlib, time

    def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
        parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
        t = next((v for k, v in parts if k == "t"), None)
        if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
            return False
        signed = f"v1:webhook:{t}.".encode() + raw_body
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        # `isascii()` first: compare_digest raises on a non-ASCII str, and the header is untrusted.
        return any(v.isascii() and hmac.compare_digest(expected, v) for k, v in parts if k == "v1")
    ```

    ```javascript
    const crypto = require("crypto");

    function verify(rawBody, header, secret, tolerance = 300) {
      const parts = header.split(",").map((p) => p.split("="));
      const t = (parts.find(([k]) => k === "t") || [])[1];
      if (!t || !/^\d+$/.test(t)) return false;
      if (Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
      const expected = crypto.createHmac("sha256", secret)
        .update(`v1:webhook:${t}.`).update(rawBody).digest("hex");
      return parts.some(([k, v]) =>
        k === "v1" && v.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(v), Buffer.from(expected)));
    }
    ```

    ## Five things that will silently break your verification

    1. **Hash the raw body, never re-serialized JSON.** Parsing and re-encoding reorders keys and
       changes spacing, and the signature stops matching. Use `request.get_data()` in Flask,
       `express.raw({ type: "application/json" })` in Express, and keep the bytes as received.
    2. **There can be more than one `v1`.** During a rotation we sign with both the new and the old
       secret and send both hashes. Parsing the header into a dictionary keeps only one of them, so
       your handler works for months and then fails the first time you rotate. Check every `v1`.
    3. **`t` changes on every retry; `X-Abyssale-Delivery-Id` does not.** Deduplicate on the
       delivery id. Use `t` only to reject stale deliveries, with a tolerance of a few minutes.
    4. **Compare in constant time** (`hmac.compare_digest`, `crypto.timingSafeEqual`) rather than
       with `==`.
    5. **Reject a malformed header, never raise on one.** Anyone who can reach your webhook URL can
       send whatever they like in `X-Abyssale-Signature`, so every path through your verifier has to
       return false rather than throw — a forged header that produces a 500 is an availability
       problem, and we will then retry it. The constant-time helpers are themselves a
       trap here: `hmac.compare_digest` raises on a string containing a non-ASCII character, and
       `crypto.timingSafeEqual` throws when the two buffers differ in length. Guard both before
       comparing, as the snippets above do.

    ## Rotating

    `POST /signing-secret/rotate` issues a new secret and keeps the previous one valid for **24
    hours**. During that window every delivery carries two `v1` hashes, one per secret, so you can
    deploy the new value whenever you like without dropping a delivery. Keep accepting the old
    secret for a few minutes after you switch — retries already in flight were signed earlier.

    Rotating **twice** inside that window is refused with `409 previous_secret_still_active`,
    because the second rotate would drop the secret your receiver is still using. Wait for the
    window, `revoke` first, or pass `?force=true` to override.

    ## If your secret leaks

    Rotate, deploy the new secret, and **stop accepting the old one**. That takes effect the moment
    you deploy: you are the verifier, so nothing needs to happen on our side for a forged request to
    start failing. `POST /signing-secret/revoke` additionally drops the previous secret on our side,
    ending the overlap early — signing picks that up within **60 seconds**, so do not wait on it.

    ## What a valid signature does and does not tell you

    It proves the delivery came from us and was not modified. It does **not** make the payload
    authoritative about the current state of anything — a retry can arrive hours after the event,
    with the body rebuilt at send time. Keep treating a webhook as "something is ready, go look",
    and read the API when the details matter. Keep the secret server-side; a signature you compute
    in a browser is a secret you have published.

externalDocs:
  description: Abyssale developer hub — guides, tutorials and webhook reference
  url: https://developers.abyssale.com/

servers:
  - url: https://api.abyssale.com
    description: Main Production Server
tags:
  - name: Designs
    externalDocs:
      description: Working with designs — listing, details and formats
      url: https://developers.abyssale.com/rest-api/designs/
    description: |
      A design is a layout including at least:
      - one format (dimension, i.e. Facebook feed 1200x628 or IAB 300x250)
      - one element (text, button, image, shape...)

      All elements have their own properties that can be customized (color, text payload, image...).

      Once designed, all properties of the design will act as a reference for subsequent image generation. Obviously, those properties can be overridden by sending new parameters in the generation API call.

      Abyssale provides a list of free template designs in the Template Library page. Feel free to use one to start designing your design instead of doing it from scratch.

      **All of your designs are listed in the different project pages, only those are accessible via API.**

  - name: Asset Generation
    externalDocs:
      description: Generation guides — synchronous, asynchronous and every output type
      url: https://developers.abyssale.com/rest-api/generation/
    description: |
      Generate images, animated videos, GIFs, HTML5 banners, and print-ready PDFs
      programmatically from your Abyssale designs.

      The generation workflow is simple:
      1. Build your design in Abyssale (or pick one from the Template Library)
      2. Send your dynamic data — text, images, colors — via the API
      3. Receive the generated asset URL in the response

      Use the **synchronous** endpoint for single-image generation with an immediate response.
      Use the **asynchronous** endpoint for bulk banner creation across multiple formats at once —
      ideal for marketing automation, personalized ad campaigns, and batch asset production at scale.

  - name: Authentication
    externalDocs:
      description: Getting an API key and authenticating requests
      url: https://developers.abyssale.com/rest-api/authentication
    description: |
      Every request is authenticated with an `x-api-key` header carrying a key generated from
      your Workspace settings.

      Use `POST /auth` to confirm a key works and to find out which workspace it belongs to.
      Any problem with the key answers **`401`** — unknown, revoked, or on a plan that does not
      include API access. The `id` in the body distinguishes them: `api_access_denied` is the
      plan case, `unauthorized` covers the rest.

      **Use a workspace key, not a user key.** A user-scoped key exists to confine a credential
      to the design plugins: it reaches only `POST /auth`, `GET /fonts` and the plugin endpoints,
      and **every other operation in this reference answers `401`**, whatever the plan. It is not
      plan-checked either — so it answers `200` on `/auth` even where a workspace key on the same
      plan would answer `401 api_access_denied`. If a key passes `/auth` but 401s everywhere
      else, that is why: it is a user key, and it was never meant to drive the API.

  - name: Webhooks
    description: |
      Manage the secret used to verify that a webhook delivery came from us.

      One secret per workspace, covering **every** webhook: both those configured in the dashboard
      and those requested per job with a `callback_url`. Fetch it once and store it alongside your
      other credentials — there is nothing to configure per endpoint.

      It is **not** your API key and cannot be substituted for one. The API key authorises calls to
      us and can spend credits; this secret only proves a delivery came from us, which is why it is
      safe to hold in a receiver that has no other access to your account.

      The header format, the verification snippets and the rotation rules are documented in the
      **Webhooks** section of this reference's introduction.

  - name: Fonts
    externalDocs:
      description: Using workspace and Google fonts in a generation
      url: https://developers.abyssale.com/rest-api/fonts
    description: |
      Retrieve the fonts available in your Abyssale workspace. Fonts can be applied
      to text and button elements during image generation. Two types are supported:
      Google Fonts (free, always available) and custom fonts uploaded to your workspace.

      Reference a font by its ID when overriding the `font` property in a generation request.

  - name: Exports
    externalDocs:
      description: Packaging generated assets into an archive
      url: https://developers.abyssale.com/rest-api/asset-export
    description: |
      Export a collection of previously generated banners as a single ZIP archive.
      Useful for downloading bulk assets for offline use, sharing with creative teams,
      or archiving a campaign's outputs. The export is processed asynchronously —
      provide a callback URL to receive a webhook notification when the archive is ready.

  - name: Files
    externalDocs:
      description: Retrieving a generated file
      url: https://developers.abyssale.com/rest-api/generation/
    description: |
      Retrieve metadata and download URLs for generated files (images, videos, GIFs,
      HTML5 banners, and PDFs). Listen to webhook events to be notified in real time
      when a file or batch generation completes.

  - name: Projects
    externalDocs:
      description: Organising designs into projects
      url: https://developers.abyssale.com/rest-api/projects
    description: |
      Projects let you organize your designs into logical groups — for example by
      campaign, client, or content type. All designs must belong to a project to
      be accessible via the API. Use projects to scope design retrieval and keep
      large design libraries manageable.

  - name: Workspace Templates
    externalDocs:
      description: Workspace templates and how they differ from designs
      url: https://developers.abyssale.com/rest-api/workspace-templates
    description: |
      Workspace templates are master designs shared across your organization. Duplicate
      them into any project to create editable copies — useful for spinning up campaign
      variants, onboarding new clients, or maintaining brand-consistent starting points.
      The duplication is processed asynchronously; poll the status endpoint to track progress.

  - name: Design Import
    externalDocs:
      description: Design import guides — payload reference, per-type rules and worked examples
      url: https://developers.abyssale.com/rest-api/designs/import/
    description: |
      > **Alpha.** This API is in Alpha. The contract may change without notice and imports created
      > with it are not guaranteed to remain compatible. It is not covered by the deprecation
      > policy. This applies to every operation in this section.

      Create a design from a JSON structure — `static`, `animated`, `printer` or
      `printer_multipage`. The import produces a **design** in a project by default; pass
      `target.kind: "workspace_template"` for a reusable workspace blueprint instead.

      1. **POST** `/designs/import/json` — submit the structure. Returns an import id and a flat
         `uploads[]` array of presigned S3 POSTs, one per image/logo layer whose `src` is a
         filename.
      2. **Upload assets** — POST each file to its presigned URL with the returned `fields`.
      3. **PUT** `/designs/import/json/{importId}` — validate and queue the import.

      **No upload needed** when every `src` is a public URL or data URI: the POST returns the
      import already `QUEUED` with an empty `uploads[]`, and there is no step 2 or 3.

      **Dry run:** `POST …?validate_only=true` runs schema and payload cross-field validation
      without creating anything — `204` on success, the error contract on failure. Three checks
      need I/O and are skipped, so a `204` does not guarantee the real POST succeeds:
      `unknown_font`, `project_not_found` and `unreachable_src`.

      Poll **GET** `/designs/import/json/{importId}` for status. It carries `next_check_after_ms`
      (stop polling when it is `null`), structured `warnings[]`, and a structured `error` when
      `status` is `FAILED`. A failed import is still HTTP `200` — branch on `status`, not on the
      status code. Thumbnails are rendered for you; the payload has no preview image field
      (`preview_image` is `unknown_field`).

      **Errors** use the same envelope as the rest of the API — a top-level `id` and `message`,
      plus an `errors` array locating each problem. A rejected payload usually fails in several
      places at once, which is what `errors` is for; `id` is the shared `code` when every entry
      agrees and `invalid_payload` when they differ:
      ```json
      {
        "id": "out_of_range",
        "message": "font_size must be between 2 and 1000",
        "errors": [
          { "path": "layers[2].properties.font_size", "code": "out_of_range",
            "message": "font_size must be between 2 and 1000",
            "expected": { "min": 2, "max": 1000 }, "received": 1500 }
        ]
      }
      ```

      **Round trip:** `GET /designs/{designId}/as-import` returns any existing design — every
      type, multipage included — in the exact shape this endpoint accepts.

      **Per design type** — full rules in the guides linked above:

      | Type | Authored in | Colors | Key limits |
      |---|---|---|---|
      | `static` | px, integer | hex | format 1–5000 px |
      | `animated` | px, integer | hex | required `animation.duration` 1–60 s; format within a 1920×1200 box in either orientation |
      | `printer` | one top-level `unit` (`mm`\|`in`), float geometry, `font_size` in pt 0.48–240 | `cmyk()`/`cmyka()`; a solid hex is converted with a `color_converted` warning; gradients are rejected | layer assets 500 MB presigned |
      | `printer_multipage` | as `printer`, print setup declared once at the top level | as `printer` | `pages` 1–30, each 1–80 layers |

      Data URIs are capped at 2 MB decoded on every type. Presigned layer assets: 20 MB static,
      100 MB video / 25 MB audio / 20 MB image on animated, 500 MB on print.

  - name: Dynamic Images
    externalDocs:
      description: Dynamic image URLs — customise an image from its query string
      url: https://developers.abyssale.com/dynamic-images/overview-dynamic-images
    description: |
      Dynamic images are live image URLs that render on-the-fly based on query parameters.
      Once created for a design, the URL can be embedded anywhere — emails, websites,
      social posts — and customized with different text, colors, or images per use case,
      without additional API calls. Ideal for personalization at scale.

      The one endpoint below mints the URL. **Everything after that is the URL itself**, which is
      served from `img.abyssale.com` and is not part of this API — so its grammar is documented
      here rather than under an operation.

      ```
      https://img.abyssale.com/{dynamicImageId}[/{formatNameOrUid}]?{overrides}
      ```

      `{dynamicImageId}` is the `id` from the creation response — **not** the design id. The
      `dynamic_image_url` that response returns already includes the format segment, so you can
      append a query string to it directly. Omit the format segment and the design's first format
      is rendered; give a format **name** or its **`uid`** (prefer `uid` — it survives a rename).
      An unknown format answers `404`.

      **The URL is public.** No API key, no signature: anyone holding it can render variants.
      That is the point — it goes in an email — but it means the design, not the URL, is the
      trust boundary. `enable_rate_limit` on the creating call caps each visitor at 5
      GENERATIONS per 24 h — the visitor is hashed from IP + `User-Agent`, and only requests that
      actually render count, so anything answered from cache is served without touching the
      budget.

      **Overrides.** Each query parameter names a layer of the design:

      | Form | Syntax | Example |
      |---|---|---|
      | Long | `layer_name.property_name=value` | `title.color=%23FF0000` |
      | Short | `layer_name=value` — the property is inferred from the layer's type | `title=Welcome` |

      The short form sets `payload` on `text` / `button` / `qrcode` layers, `image_url` on
      `image` / `logo`, and `rating_score` on `rating`. Every property listed under
      [Element Properties](https://developers.abyssale.com/rest-api/generation/element-properties)
      is addressable in the long form.

      Five rules worth knowing before you generate URLs programmatically:

      - **URL-encode every value.** `#` becomes `%23`; spaces and nested URLs must be escaped. An
        empty value is a hard `400`.
      - A long-form key must contain **exactly one dot**. More dots and the parameter is silently
        ignored.
      - A short-form key naming a layer that is not on the rendered format is **silently
        dropped**; a long-form key naming an unknown *property* answers `400`.
      - A repeated key keeps the **first** value.
      - A literal `\n` in a text value becomes a real line break.

      Note the asymmetry: unknown layers fail silently, unknown properties fail loudly. Render
      the URL once and look at it before wiring it into a campaign.

paths:
  /designs:
    get:
      operationId: listDesigns
      x-codeSamples:
        - lang: cURL
          source: |
            curl -H "x-api-key: $ABYSSALE_API_KEY" \
              "https://api.abyssale.com/designs?type=static"
        - lang: Python
          source: |
            import os, requests

            r = requests.get(
                "https://api.abyssale.com/designs",
                headers={"x-api-key": os.environ["ABYSSALE_API_KEY"]},
                params={"type": "static"},
                timeout=30,
            )
            for design in r.json():
                print(design["id"], design["name"])
        - lang: Node.js
          source: |
            import abyssale from '@abyssale/sdk';

            const { data } = await abyssale.listDesigns({ type: 'static' });
            console.log(data.map((d) => `${d.id}  ${d.name}`).join('\n'));
      summary: List all designs
      description: |
        Retrieve all designs available in your Abyssale workspace. Each design defines
        the visual layout, available formats (dimensions), and configurable elements
        (text, images, shapes, etc.) that can be overridden during image generation.
        Filter by design type or project using query parameters.

        A design lives in a **project**. Organisation-level master designs are *workspace
        templates*, live in a **category**, and are listed by `GET /workspace-templates`
        instead — they never appear here.
      tags:
      - Designs
      parameters:
        - in: query
          name: project_id
          schema:
            type: string
            format: uuid
          required: false
          description: Unique identifier (UUID) of a project. Filter designs by project.
        - in: query
          name: type
          schema:
            type: string
            enum: [static, animated, printer, printer_multipage]
          required: false
          description: >-
            Filter designs by one of these types: static, animated, printer,
            printer_multipage. An unknown value is **ignored** and the full unfiltered list is returned — this never answers 400 (long-standing behavior existing integrations rely on).
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: The workspace's designs.
          content:
            application/json:
              schema:
                type: array
                items:
                  allOf:
                  - $ref: "#/components/schemas/Design"
                  - type: object
                    properties:
                      preview_url:
                        type: string
                        format: uri
                        example: https://production-banners.s3-eu-west-1.amazonaws.com/templates/e0d292f2-ec21-11e9-a539-3c408bf94155/a9b3c668-7b84-4924-adf0-815dae727d32.png
                        description: Preview Image URL of the first design format
        '400':
          description: |
            `project_id` is not a UUID. An unknown `type` is ignored rather than rejected, so it
            never answers `400`.

            The reported `path` is always `category_id`, whichever of the two spellings you
            sent — `category_id` is the deprecated alias of `project_id` and the two share one
            validator, which kept the older wording so the released contract did not change.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                id: invalid_payload
                message: "malformed id category_id"
                errors:
                  - path: "category_id"
                    code: invalid_payload
                    message: "malformed"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []
  /designs/{designId}:
    get:
      operationId: getDesign
      summary: Get design details
      description: |
        Retrieve the full specification of a design: its formats (dimensions and preview URLs),
        all configurable elements with their properties, and any template variables defined
        in text layers. Use this endpoint to discover what data to send in a generation request.

        For printer designs, each format additionally carries its print settings read-only:
        `dpi` (integer render DPI computed at import time, capped at 300), and `bleed_size` /
        `safe_size` (floats in the design's unit). **All three are always present on a printer
        format** — a design with no stored print settings, or with a zone disabled, reports the
        values the renderer itself uses: `dpi: 300` and `bleed_size` / `safe_size` of `0`, where
        0 means the zone is off. Read them unconditionally; there is no need to test for the keys.

        For animated designs, the response additionally carries the animation model: a
        top-level `animation` object (`duration` — timeline length in seconds; `screenshot_at_s`
        — the HTML5 backup-screenshot moment, null on designs created before it was recorded), a per-element
        `animation` object (`start_at_s`, `end_at_s`, `tweens` `[{id, type, keyframes}]` with
        raw keyframe objects) on elements that carry timing or tweens, and video/audio media
        attributes (`video_url`/`audio_url`, `video_duration`, `video_muted`/`audio_muted`,
        `max_volume`) in the standard attribute shape.

        **Multipage print designs (`printer_multipage`) return a different shape.** A multipage
        design has no formats — each page is one format row — so the response carries `pages` and
        `elements_per_page` **instead of** `formats`, `elements` and `variables`. A client coded
        against `formats[]` will not work on a multipage design, and because a dynamic image is
        minted per format, a multipage design has no `dynamic_image_url` anywhere in the response
        (it is a `static`-only feature in any case).

        `pages[]` items are `DesignPage` objects, not formats: `id` (`page_1 … page_N`), `width`,
        `height`, `unit` and `preview_url` — no format `uid`. `elements_per_page` is an **object
        keyed by page id** (not an array), each value being that page's element list.

        **The print settings are returned once, at the root** — `dpi`, `bleed_size` and
        `safe_size` describe the document, not a page, because a multipage design has a single
        print setup. That is the same reason the import declares them at the top level. A page
        does not repeat them.

        **Group layers require `?i=advanced`.** By default this endpoint returns its original
        released shape, in which no group layer appears at all — its children are listed
        individually and the group itself is invisible. Pass `i=advanced` to have them injected
        (into `elements`, or into each page's list in `elements_per_page` on a multipage design).
        Each one carries `layer_ids` (one list for the whole design — a group holds the same
        children in every format), a per-format `layout`, its computed `hidden` / `locked`, and,
        except on animated designs, a per-format `group` block of auto-layout settings
        (`auto_layout` / `direction` / `placement` / `gap`).

        A **masked** group — one whose members are clipped to a shape — is still `type: "group"`
        and additionally carries `mask`, keyed by format. The geometry can differ per format, but
        a group is masked in all of its formats or in none, so the presence of `mask` is what
        distinguishes a masked group from a plain one.

        The per-format read `GET /designs/{designId}/formats/{formatSpecifier}` is always the
        advanced view and needs no parameter; there `layout`, `mask` and `group` are flattened to
        the single format.
      tags:
      - Designs
      parameters:
        - $ref: '#/components/parameters/DesignId'
        - in: query
          name: i
          required: false
          schema:
            type: string
            enum: [advanced]
          description: |
            Pass `advanced` to receive the design's **full** layer set and property set —
            notably `group` layers, which are omitted from the default response. Any other
            value (or none) returns the default shape.
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: The design's formats, elements and variables.
          content:
            application/json:
              schema:
                allOf:
                - $ref: "#/components/schemas/Design"
                - type: object
                  properties:
                    formats:
                      type: array
                      description: |
                        The list of formats available in the design.

                        The id property can be used to specify the format you would like to generate in the generation API call.
                      items:
                        $ref: "#/components/schemas/DesignFormat"
                    elements:
                      type: array
                      description: |
                        The list of all elements (& customizable properties) contained in the design.

                        `name` is the layer name (root is a special element that allows to customize the image background color.)

                        `attributes` contains the list of all customizable properties:
                        - `id`: The attribute ID
                        - `values`: the related value for this attribute in each format (if defined)
                      items:
                        $ref: "#/components/schemas/DesignElement"
                    variables:
                      $ref: "#/components/schemas/DesignVariables"
                    animation:
                      $ref: "#/components/schemas/DesignAnimation"
                    dpi:
                      type: integer
                      example: 300
                      description: |
                        **`printer_multipage` designs only** — the document's render DPI,
                        computed at import time and capped at 300. Read-only.
                    bleed_size:
                      type: number
                      example: 3
                      description: |
                        **`printer_multipage` designs only** — the document's bleed, a float in
                        the design's unit; `0` means the zone is off.

                        Declared once, here, because a multipage document has a single print
                        setup — the same reason the import declares `unit` / `width` / `height` /
                        `bleed_size` / `safe_size` / `dpi` at the top level and leaves a page
                        carrying only `background_color` and `layers`. **A page does not repeat
                        it.**
                    safe_size:
                      type: number
                      example: 5
                      description: |
                        **`printer_multipage` designs only** — the document's safe zone, a float
                        in the design's unit; `0` means the zone is off.
                    pages:
                      type: array
                      description: |
                        **`printer_multipage` designs only** — returned *instead of* `formats`.
                        The document's ordered pages. Every page shares the document's dimensions
                        and print settings, so a page carries neither: read those from the root.
                      items:
                        $ref: "#/components/schemas/DesignPage"
                    elements_per_page:
                      type: object
                      description: |
                        **`printer_multipage` designs only** — returned *instead of* `elements`.
                        An object keyed by page id (`page_1 … page_N`), each value being that
                        page's element list (group layers included only when `i=advanced` is
                        passed). Defaults to `{}`.

                        Items are `DesignPageElement`, **not** `DesignElement`: a page is a single
                        format, so values are flat rather than keyed by format name. Note also
                        that its `attributes` is an object, where every other read returns an
                        array — see the schema.
                      additionalProperties:
                        type: array
                        x-additionalPropertiesName: page-id
                        items:
                          $ref: "#/components/schemas/DesignPageElement"

        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Design not found (`template_not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /designs/{designId}/formats/{formatSpecifier}:
    get:
      tags:
      - Designs
      operationId: getDesignFormat
      summary: Get format details
      description: |
        Retrieve detailed information for a specific format within a design, including
        dimensions, unit, preview URL, dynamic image URL, element layout, and variables.
        Useful for inspecting a single format before generating or building dynamic image URLs.

        For printer designs the response additionally carries the format's print settings
        read-only: `dpi` (integer render DPI computed at import time, capped at 300), and
        `bleed_size` / `safe_size` (floats in the design's unit, `0` when the zone is off). All
        three are always present on a printer format; a design with no stored print settings
        reports the renderer's own defaults (`dpi: 300`, zones `0`).

        For animated designs the response additionally carries the design's `animation`
        object (`duration`, `screenshot_at_s` — seconds), per-element `animation`
        timing/tween data, and video/audio media attributes — the same read surface as
        `GET /designs/{designId}`.

        **This endpoint is always the advanced view.** Unlike `GET /designs/{designId}`, it
        needs no `i=advanced` parameter: it always returns the full property set and always
        injects the format's `group` layers, flattened to this single format (`layout`, the
        `group` block, and the computed `hidden` / `locked` are plain values, not keyed by
        format name). A group that does not exist in the requested format is not emitted.

        **Multipage print designs (`printer_multipage`):** a multipage design is one document —
        it has **no formats**, and its pages are not addressable through this endpoint. Any
        `formatSpecifier` yields `404` with `id: format_not_found`. Read the whole document with
        `GET /designs/{designId}` or export it with `GET /designs/{designId}/as-import`.
      parameters:
      - $ref: '#/components/parameters/DesignId'
      - name: formatSpecifier
        in: path
        required: true
        schema:
          type: string
        description: |
          Format name or UID. Prefer the UID — it survives a rename.

          Not applicable to `printer_multipage` designs: they have no formats, so every value
          here answers `404` with `id: format_not_found`. See the endpoint description.
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: Format details retrieved successfully
          content:
            application/json:
              schema:
                allOf:
                - $ref: "#/components/schemas/DesignFormat"
                - type: object
                  properties:
                    animation:
                      $ref: '#/components/schemas/DesignAnimation'
                    design:
                      $ref: '#/components/schemas/DesignFormatSummary'
                    elements:
                      type: array
                      description: |
                        The design's elements as they stand **in this format**: attribute values
                        and `layout` are the requested format's, not maps keyed by format name
                        (see `DesignFormatElement`). Group layers are included, flattened to this
                        format.
                      items:
                        $ref: "#/components/schemas/DesignFormatElement"
                    variables:
                      $ref: "#/components/schemas/DesignVariables"
                    version:
                      $ref: '#/components/schemas/ApiVersion'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Format not found (`format_not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /designs/{designId}/dynamic-image-url:
    post:
      tags:
      - Dynamic Images
      operationId: createDynamicImageUrl
      summary: Create a dynamic image URL
      description: >-
        Creates a dynamic image URL for a given design (`static` designs only). Only one
        dynamic image is allowed per design — subsequent calls return the existing dynamic
        image (200 instead of 201). `enable_rate_limit` limits each visitor to 5 generations per
        24 h, counted from a hash of IP + `User-Agent`; requests answered from cache do not count
        against it. `enable_production_mode` switches from test mode (variants saved to the
        workspace, 10 req/s per image) to production mode (built for scale: no per-image throttle,
        variants not saved). Production mode is not unlimited — the global 10 requests/second
        ceiling, your workspace credits and your bandwidth all still apply, and bandwidth is
        measured on every delivery including cache hits. A non-`static` design answers
        `400 template_not_static`, and a design that is not in the `CREATED` status answers
        `400 template_not_active`.
      parameters:
      - $ref: '#/components/parameters/DesignId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                enable_rate_limit:
                  type: boolean
                  default: false
                  description: Enable API rate limiting for this dynamic image
                enable_production_mode:
                  type: boolean
                  default: false
                  description: Enable Production mode for this dynamic image
            example:
              enable_rate_limit: true
              enable_production_mode: true
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: Existing dynamic image retrieved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DynamicImageResponse'
        '201':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: Dynamic image successfully created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DynamicImageResponse'
        '400':
          description: >-
            The design is not `static` (`template_not_static`) or not active
            (`template_not_active`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Design not found (`template_not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /banners/{bannerId}:
    get:
      operationId: getFile
      summary: Get generated file
      description: |
        Retrieve the metadata and download URLs for a previously generated file
        (image, video, GIF, HTML5 banner, or PDF). Returns both the S3 storage URL
        and a CDN-hosted URL suitable for embedding directly in websites or emails.
      tags:
      - Files
      parameters:
        - in: path
          name: bannerId
          schema:
            type: string
            format: uuid
          required: true
          description: Unique identifier (UUID) of the file
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: The file's metadata and download URLs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Banner"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Banner not found (`visual_not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /banner-builder/{designId}/generate:
    post:
      tags:
      - Asset Generation
      operationId: generateImage
      x-codeSamples:
        - lang: cURL
          source: |
            # Every field is optional — an empty body renders the design's saved content.
            curl -X POST \
              -H "x-api-key: $ABYSSALE_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                    "template_format_name": "facebook-post",
                    "elements": {
                      "title": { "payload": "Summer sale — 40% off" },
                      "cta": { "payload": "Shop now" }
                    }
                  }' \
              "https://api.abyssale.com/banner-builder/$DESIGN_ID/generate"
        - lang: Python
          source: |
            import os, requests

            r = requests.post(
                f"https://api.abyssale.com/banner-builder/{os.environ['DESIGN_ID']}/generate",
                headers={
                    "x-api-key": os.environ["ABYSSALE_API_KEY"],
                    "Content-Type": "application/json",
                },
                json={
                    "template_format_name": "facebook-post",
                    "elements": {"title": {"payload": "Summer sale — 40% off"}},
                },
                timeout=30,
            )
            r.raise_for_status()
            print(r.json()["file"]["cdn_url"])
      summary: Generate an image (synchronous)
      description: |
        Synchronously generate a single image from a design. Send your dynamic data
        (text, images, colors) as element overrides in the request body and receive
        the generated file URL immediately in the response.

        **`static` designs only.** An `animated`, `printer` or `printer_multipage` design answers
        `400` with `id: template_not_static` — use
        [asynchronous generation](/api-reference/generateMultiFormatMedia)
        instead. This is a property of the endpoint, not of your plan: a video or a print PDF
        cannot be produced inside a synchronous request, so there is no combination of parameters
        that makes this work.

        Best for: real-time image generation, single-asset workflows, or when you need
        the result inline without polling. The rendering budget is hard-capped at **10
        seconds**: the call returns the finished file within that window or fails with
        `500 internal_server_error` (typical renders take tens of milliseconds).

        For bulk generation across multiple formats, use the asynchronous endpoint instead.

        **AI features and the 10-second cap.** `text_to_image` is **not accepted here** — an AI
        generation takes far longer than the budget, so the field exists only on the asynchronous
        endpoint and sending it answers `400 invalid_payload`. Background removal (`remove_bg`)
        *is* accepted but is **deprecated on this endpoint**: it adds an AI round-trip before the
        render, so a large or slow source image can push the call past the cap and fail with
        `500 internal_server_error` rather than returning your asset. Existing integrations keep
        working; new ones should remove backgrounds asynchronously. `auto_focus` is unaffected
        and fully supported here — it runs on Abyssale's own detection model and is fast enough
        that it does not put the budget at risk.

        **All body fields are optional** — omit `elements` to render the design's saved
        default content. A `template_format_name` that does not exist on the design answers
        `404` with `id: format_not_found` (the format is addressed as a resource, so this is
        a 404 by contract, not a 400).
      parameters:
        - $ref: '#/components/parameters/DesignId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                elements:
                  allOf:
                    - $ref: "#/components/schemas/Elements"
                  description: >-
                    Element overrides keyed by layer name. On this synchronous endpoint every
                    value MUST be an object — a bare string value answers `400 invalid_payload`
                    (the asynchronous endpoint is lenient).
                template_format_name:
                  type: string
                  example: "facebook-post"
                  description: Format ID to generate. Optional — when omitted, the design's first format is generated.
                image_file_type:
                  type: string
                  enum: [png, jpeg, webp, avif, pdf, auto]
                  description: >-
                    Output file type (`auto` ≡ omitted). When omitted: `jpeg`, or `png` when the
                    format's background color is transparent.
                original_visual_id:
                  type: string
                  format: uuid
                  description: >-
                    Regenerate an existing visual in place, keeping its share URL (visual
                    versioning). Unknown or unrelated visuals answer 404 (`visual_not_found`,
                    `not_related_to_same_template`, `not_related_to_same_format`).
                file_compression_level:
                  type: integer
                  example: 80
                  description: Output quality, 1-100 — 100 is the best quality (least compression), 1 the smallest file. Despite the name, higher = better.
                  minimum: 1
                  maximum: 100
            example:
              template_format_name: facebook-post
              image_file_type: png
              elements:
                root:
                  background_color: "#F3F3F3"
                title:
                  payload: "Summer sale — 40% off"
                  color: "#111111"
                product-image:
                  image_url: https://example.com/product.jpg
                cta:
                  payload: "Shop now"
                  background_color: "#5B4CF5"
      responses:
        '200':
          description: The generated banner, including its file URLs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Banner"
              example:
                id: 64238d01-d402-474b-8c2d-fbc957e9d290
                version: 1
                sharing_id: 5fcec999-2bfb-4dd7-ba38-2d9e16c49149
                file:
                  type: png
                  url: https://production-banners.s3.eu-west-1.amazonaws.com/demo/996739f4-b563-428a-a6e8-ec3cb8bd03d4.png
                  cdn_url: https://cdn.abyssale.com/demo/996739f4-b563-428a-a6e8-ec3cb8bd03d4.png
                  filename: 996739f4-b563-428a-a6e8-ec3cb8bd03d4.png
                format:
                  id: facebook-post
                  unit: px
                  width: 1200
                  height: 1200
        '400':
          description: |
            The request body or parameters are invalid, **or** the design is not `static`
            (`template_not_static` — this endpoint renders single images only).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                template_not_static:
                  summary: The design is animated, printer or printer_multipage
                  value:
                    id: template_not_static
                    message: "The current template is of type 'animated', expected 'static'. Use the /async endpoint instead."
                validation_error:
                  summary: Field-level validation failure
                  value:
                    id: invalid_payload
                    message: "elements.root.background_color: Must be a 6 or 8 hexadecimal color (given: #zzz)"
                    errors:
                      - path: "elements.root.background_color"
                        code: invalid_payload
                        message: "Must be a 6 or 8 hexadecimal color (given: #zzz)"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/GenerationRateLimited'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /async/banner-builder/{designId}/generate:
    post:
      tags:
      - Asset Generation
      summary: Generate multiple formats (asynchronous)
      operationId: generateMultiFormatMedia
      x-codeSamples:
        - lang: cURL
          source: |
            # Omit template_format_names to generate every format of the design.
            curl -X POST \
              -H "x-api-key: $ABYSSALE_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                    "template_format_names": ["facebook-feed", "instagram-post"],
                    "callback_url": "https://example.com/webhooks/abyssale",
                    "elements": {
                      "title": { "payload": "Summer sale — 40% off" }
                    }
                  }' \
              "https://api.abyssale.com/async/banner-builder/$DESIGN_ID/generate"
            # {"generation_request_id":"3f2a9c18-…"} — then poll it, or wait for the webhook.
        - lang: Python
          source: |
            import os, time, requests

            headers = {"x-api-key": os.environ["ABYSSALE_API_KEY"]}
            design_id = os.environ["DESIGN_ID"]

            r = requests.post(
                f"https://api.abyssale.com/async/banner-builder/{design_id}/generate",
                headers=headers,
                json={"template_format_names": ["facebook-feed", "instagram-post"]},
                timeout=30,
            )
            r.raise_for_status()
            request_id = r.json()["generation_request_id"]

            # Polling is in the generous polling tier; a webhook avoids the loop entirely.
            while True:
                poll = requests.get(
                    f"https://api.abyssale.com/generation-request/{request_id}",
                    headers=headers,
                    timeout=30,
                )
                body = poll.json()
                if body["is_finalized"]:
                    break
                time.sleep(2)

            for banner in body["banners"]:
                print(banner["file"]["cdn_url"])
            for failure in body.get("errors", []):
                print("failed:", failure["template_format_name"], failure["reason"])
      description: |
        Asynchronously generate multiple formats of an image, video, GIF, HTML5 banner,
        or PDF in a single API call. Ideal for bulk banner production, multi-channel
        marketing campaigns, and programmatic ad creative generation at scale.

        Specify one or more format IDs to generate, or omit the list to generate all
        formats defined in the design. Batches are queued and worker-driven, with no hard
        completion bound — most complete within a couple of minutes; rely on the webhook or
        polling, never a fixed wait. Provide a `callback_url` to receive a webhook
        notification when generation is complete, or poll
        `GET /generation-request/{generationRequestId}` for status.

        You can find available formats and elements by calling `GET /designs/{designId}`.

        **This is where AI image work belongs.** Having no completion bound, this endpoint is the
        only one that accepts `text_to_image` (AI generation and inpainting), and it is the
        recommended home for `remove_bg` too — both add an AI round-trip that the synchronous
        endpoint's 10-second cap cannot absorb.

        **All body fields are optional** — omit `elements` to render the design's saved
        default content, and omit `template_format_names` to generate every format. A
        `template_format_names` entry that does not exist on the design answers `404` with
        `id: format_not_found` (the format is addressed as a resource, so this is a 404 by
        contract, not a 400).
      parameters:
        - $ref: '#/components/parameters/DesignId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                elements:
                  $ref: "#/components/schemas/AsyncElements"
                template_format_names:
                  type: array
                  items:
                    type: string
                  example: ["facebook-feed", "instagram-post", "iab-medium"]
                  description: |
                    **Format IDs you would like to generate**.

                    _The array can be empty or not provided if you want to generate all formats of this design._
                callback_url:
                  type: string
                  format: uri
                  description:  |
                    **The url that will be called once generation of your images is done.**

                    We will do a `POST` request of the [NewBannerBatch](#operation/newBannerBatch) event on this URL with a JSON payload.
                image_file_type:
                  type: string
                  enum: [png, jpeg, webp, avif, gif, pdf, html5, mp4, auto]
                  description: >-
                    Output file type (`auto` ≡ omitted); must match the design type, else
                    `400 invalid_filetype` — static → jpeg/png/webp/avif/pdf, animated →
                    mp4/gif/html5, print → pdf. When omitted: static → jpeg (png when the
                    format background is transparent); animated → mp4; print → pdf.
                file_compression_level:
                  type: integer
                  minimum: 1
                  maximum: 100
                  description: Output quality, 1-100 — 100 is the best quality (least compression), 1 the smallest file. Despite the name, higher = better.
                html5:
                  type: object
                  description: HTML5 output options (animated designs only)
                  properties:
                    page_title:
                      type: string
                      maxLength: 255
                      default: "Abyssale page title example"
                    click_tag:
                      type: string
                      maxLength: 255
                      default: "https://www.abyssale.com"
                    ad_network:
                      type: string
                      maxLength: 255
                      default: "default"
                      description: >-
                        Ad network to optimize the HTML5 output for (e.g. `google-ads`,
                        `google-marketing`, `adform`, `amazon-ads`, `adroll`). Free string —
                        unknown values are validated downstream.
                    include_backup_image:
                      type: boolean
                      nullable: true
                    repeat:
                      type: integer
                      minimum: -1
                      maximum: 100
                      nullable: true
                      description: Number of loop repetitions. -1 = infinite.
                gif:
                  type: object
                  description: GIF output options (animated designs only)
                  properties:
                    max_fps:
                      type: integer
                      minimum: 2
                      maximum: 9
                    repeat:
                      type: integer
                      minimum: -1
                      maximum: 100
                      description: Number of loop repetitions. -1 = infinite.
                video:
                  type: object
                  description: Video output options (animated designs only)
                  properties:
                    fps:
                      type: integer
                      minimum: 2
                      maximum: 30
                print:
                  type: object
                  description: Print output options (printer/printer_multipage designs only)
                  properties:
                    color_profile:
                      type: string
                      format: uuid
                      description: |
                        UUID of the color profile to apply. The built-ins, which are the same for
                        every workspace:

                        | UUID | Profile |
                        |---|---|
                        | `be2ab219-8fe4-4d85-91ba-65bdc9ddaf01` | CMYK — ISO Coated v2 (ECI) |
                        | `03738f37-5b6f-4be9-9100-706b1711f9dd` | CMYK — ISO Coated v2 (ECI) 300% |
                        | `e0c86a8a-050c-41f1-885a-0f74b9baac50` | CMYK — ISO Uncoated |
                        | `0e0355e6-2931-4c83-92f2-64db9f5ddffc` | CMYK — US Web Coated (SWOP 2006 5v2) |
                        | `fac91df8-1155-11ef-b77e-f93002785645` | CMYK — ISO Newspaper26 v4 |
                        | `db3c123e-1127-11ef-b77e-f93002785645` | RGB — No color profile |

                        A workspace may also hold custom profiles, whose UUIDs are not listed
                        here. Omitted, the design's own profile is used. Not an enum: the list can
                        grow, and a generated client should not reject a UUID it has not seen.
                    display_crop_marks:
                      type: boolean
                pages:
                  allOf:
                    - $ref: "#/components/schemas/Pages"
                  description: >-
                    **`printer_multipage` designs only**, where it replaces `elements`: a
                    multipage design is one document with no formats, so content is addressed per
                    page. Ignored on every other design type. The dedicated
                    [multipage PDF operation](/api-reference/generateMultiPagePdf)
                    takes the same field and is the clearer choice for print output; this one
                    exists so a `printer_multipage` design can also be driven through the generic
                    async endpoint.
                original_visual_id:
                  type: string
                  format: uuid
                  description: >-
                    Regenerate an existing visual in place, keeping its share URL (visual
                    versioning). The request must target exactly ONE format — combining this
                    with more than one `template_format_names` entry answers
                    `400 more_than_one_format`. Unknown or unrelated visuals answer 404
                    (`visual_not_found`, `not_related_to_same_template`,
                    `not_related_to_same_format`).
            example:
              template_format_names: ["facebook-feed", "instagram-post"]
              image_file_type: png
              callback_url: https://example.com/webhooks/abyssale
              elements:
                root:
                  background_color: "#F3F3F3"
                title:
                  payload: "Summer sale — 40% off"
                  color: "#111111"
                product-image:
                  image_url: https://example.com/product.jpg
      responses:
        '200':
          description: Generation queued; poll `generation_request_id` or wait for the webhook.
          content:
            application/json:
              schema:
                type: object
                properties:
                  generation_request_id:
                    type: string
                    format: uuid
                    description: Unique identifier (UUID) of the batch generation
                  version:
                    $ref: '#/components/schemas/ApiVersion'
              example:
                generation_request_id: 3f2a9c18-6b1e-4f4a-9a75-2b6d0e1c8f77
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/GenerationRateLimited'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /auth:
    post:
      tags:
        - Authentication
      operationId: verifyApiKey
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST -H "x-api-key: $ABYSSALE_API_KEY" \
              https://api.abyssale.com/auth
            # {"company":"Acme Inc.","version":"v2026-08-21"}
        - lang: Python
          source: |
            import os, requests

            r = requests.post(
                "https://api.abyssale.com/auth",
                headers={"x-api-key": os.environ["ABYSSALE_API_KEY"]},
                timeout=30,
            )
            print(r.status_code, r.json())
        - lang: Node.js
          source: |
            import abyssale from '@abyssale/sdk';

            const { data, error } = await abyssale.verifyApiKey();
            if (error) console.error(error);
            else console.log(data.company);
      summary: Verify an API key
      description: |
        Check that an API key is usable, and find out which workspace it belongs to.

        This is the endpoint to call when you want to confirm a key works — it runs the whole
        authentication path and answers `200` only if **all** of the following hold:

        - the key exists,
        - it is still active (a revoked key answers `401`),
        - and, **for a workspace key**, the plan includes API access (`401 api_access_denied`
          otherwise).

        The plan check is skipped for a **user-scoped** key, which therefore answers `200` here
        whatever the plan. That is not a loophole: a user key is confined to the plugin surface,
        so a `200` from this endpoint means "this key is live", not "this key can generate". If
        you need to know whether a key can drive the API, call a real endpoint with it.

        Every failure is a **`401`**, whether the key is unknown, revoked, or on a plan without
        API access. This endpoint does not answer `403`.

        The response carries the workspace name, so a caller holding several keys can tell which
        one it has. Integration platforms use it to label a connection.

        > **Do not use `GET /ready` to test a key.** It is a service health check and is exempt
        > from authentication, so it answers `200` regardless of the key you send — including one
        > that has been revoked.

        Takes no request body. It is a `POST` for historical reasons; live integrations depend on
        the current shape, so it will not be changed.
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: The key is valid and the plan includes API access.
          content:
            application/json:
              schema:
                type: object
                properties:
                  company:
                    type: string
                    description: Name of the workspace the key belongs to.
                    example: Acme Inc.
                  version:
                    $ref: '#/components/schemas/ApiVersion'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /signing-secret:
    get:
      tags:
        - Webhooks
      operationId: getSigningSecret
      x-codeSamples:
        - lang: cURL
          source: |
            curl -H "x-api-key: $ABYSSALE_API_KEY" \
              https://api.abyssale.com/signing-secret
            # {"secret":"whsec_…","created_at_ts":1755561234,"rotated_at_ts":null}
      summary: Get the webhook signing secret
      description: |
        Retrieve the workspace's webhook signing secret, creating it on the first call and returning
        the same value on every call after that.

        One secret covers every webhook in the workspace — dashboard subscriptions and per-job
        `callback_url` deliveries alike. Fetch it once at setup and store it with your other
        credentials.

        Use it to verify the `X-Abyssale-Signature` header on incoming deliveries; the header format
        and ready-made verification snippets are in the **Webhooks** section of the introduction.
        Keep it server-side: a signature computed in a browser is a secret you have published.

        > A workspace with no secret receives **unsigned** deliveries; the secret coming into
        > existence is what turns signing on. This endpoint is the normal way to create it, but
        > `rotate` and `revoke` mint one too if none exists.
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: The workspace's signing secret.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SigningSecret'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /signing-secret/rotate:
    post:
      tags:
        - Webhooks
      operationId: rotateSigningSecret
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST -H "x-api-key: $ABYSSALE_API_KEY" \
              https://api.abyssale.com/signing-secret/rotate
      summary: Rotate the webhook signing secret
      description: |
        Issue a new signing secret and keep the previous one valid for **24 hours**.

        During that window every delivery carries **two** `v1` hashes — one per secret — so a
        receiver holding either value still verifies. That is what lets you deploy the new secret on
        your own schedule instead of coordinating a cutover.

        Keep accepting the old secret for a few minutes after you switch: retries already in flight
        were signed before you deployed.

        Takes no request body. If the workspace has no secret yet, one is created and immediately
        rotated, so the response always carries a usable value.

        **Calling this twice in a row is refused.** A second rotate while the previous secret is
        still inside its 24-hour window would demote the secret the first rotate minted and drop the
        one your receiver is still verifying with — the outage the window exists to prevent. You get
        `409 previous_secret_still_active`, and nothing changes. Either wait for the window to close
        (`previous_secret_expires_at_ts` from the first response tells you when), call
        `POST /signing-secret/revoke` to end the overlap now and rotate immediately after, or repeat
        the call with `?force=true` if you really do mean to rotate twice and accept that anything
        signed with the oldest secret stops verifying.
      parameters:
        - name: force
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: |
            Rotate even though the previous secret is still valid, revoking it. Only needed to
            override the `409` above; a first rotate never needs it.
      responses:
        '200':
          description: The new secret. The previous one stays valid for 24 hours.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SigningSecret'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: |
            Conflict — the previous secret is still within its grace window, so rotating would
            revoke it. Nothing changed. Retry with `?force=true` to rotate anyway.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                previous_secret_still_active:
                  summary: A second rotate inside the 24-hour window
                  value:
                    id: previous_secret_still_active
                    message: "A previous signing secret is still valid until 1787319076 and rotating now would revoke it. Wait for it to expire, or repeat this call with `force=true` to rotate anyway."
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /signing-secret/revoke:
    post:
      tags:
        - Webhooks
      operationId: revokeSigningSecret
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST -H "x-api-key: $ABYSSALE_API_KEY" \
              https://api.abyssale.com/signing-secret/revoke
      summary: End the rotation overlap early
      description: |
        Invalidate the **previous** secret, ending the 24-hour overlap early. The current secret is
        untouched. Signing applies the change within **60 seconds** — deliveries already in flight
        may still carry a signature from the revoked secret for up to a minute.

        Use it after a rotation prompted by a leak rather than by routine hygiene. Note what it does
        and does not achieve for webhooks: **you** are the verifier, so a forged request stops being
        accepted the moment you deploy a receiver that no longer trusts the old secret — that does
        not wait on us. This endpoint closes the window on our side, which matters because we keep
        signing with the old secret until it lapses.

        Deliberately abrupt: within a minute, anything still signed with the old secret stops
        verifying, so a receiver you have not yet redeployed will start rejecting deliveries.

        Takes no request body, and is safe to call when there is no overlap to end. Like `rotate`, it
        **mints a secret if the workspace has none** — so calling it on a workspace that never
        enabled signing turns signing on.
      responses:
        '200':
          description: The current secret. Any previous secret is no longer valid.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SigningSecret'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /fonts:
    get:
      tags:
        - Fonts
      operationId: listFonts
      summary: List available fonts
      description: |
        Retrieve all fonts available in your Abyssale workspace, including Google Fonts
        and any custom fonts you have uploaded. Use a font's `id` to override the font
        on text or button elements in a generation request.
      parameters:
        - in: query
          name: type
          required: false
          schema:
            type: string
            enum: [google, custom]
          description: >-
            Filter fonts by type. Omit to return both Google and custom fonts. An unknown value is **ignored** and the full unfiltered list is returned — this never answers 400 (long-standing behavior existing integrations rely on).
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: The fonts available to this workspace.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Font"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /async/banners/export:
    post:
      tags:
      - Exports
      operationId: exportBanners
      summary: Export generated assets as a ZIP archive (asynchronous)
      description: |
        Asynchronously package a set of previously generated assets — any output type
        (JPEG, PNG, WEBP, AVIF, PDF, MP4, GIF, HTML5 bundle) — into a single ZIP
        archive for download. Provide a `callback_url` to receive a webhook notification
        when the export is ready, containing the archive URL.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  minItems: 1
                  description: An array of string that corresponds to the banners IDs you would like to export.
                  items:
                    type: string
                    format: uuid
                    example: "64238d01-d402-474b-8c2d-fbc957e9d290"
                callback_url:
                  type: string
                  format: uri
                  description: The url that will be called once the generation of your export is done.
                  example: "https://your-webhook.com/export-abyssale"
              required:
                - ids
      responses:
        '200':
          description: Export queued; keep `export_id` to match the webhook.
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          content:
            application/json:
              schema:
                type: object
                properties:
                  export_id:
                    type: string
                    format: uuid
                    example: "64238d01-d402-474b-8c2d-fbc957e9d290"
                    description: Unique identifier (UUID) of the asynchronous export. Keep it to be able to identify the asynchronous response.
                  version:
                    $ref: '#/components/schemas/ApiVersion'
                required:
                  - export_id
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: >-
            None of the ids in `ids` exist in this workspace (`visual_not_found`). A request
            mixing known and unknown ids SUCCEEDS, and the archive contains only the ones that
            were found. An id that is not a valid UUID answers `endpoint_not_found` instead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /async/banner-builder/{designId}/generate-multipage-pdf:
    post:
      tags:
      - Asset Generation
      operationId: generateMultiPagePdf
      summary: Generate a multi-page PDF (asynchronous)
      description: |
        Asynchronously generate a multi-page print-ready PDF from a printer_multipage design.
        Each page is defined by a `pages` dictionary entry, allowing different element
        overrides per page — ideal for brochures, catalogs, and variable data printing.

        Provide a `callback_url` to receive a webhook when the PDF is ready,
        or poll `GET /generation-request/{generationRequestId}` for status.

        You can find available formats and elements by calling `GET /designs/{designId}`.
      parameters:
        - $ref: '#/components/parameters/DesignId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                pages:
                  $ref: "#/components/schemas/Pages"
                callback_url:
                  type: string
                  format: uri
                  description: |
                    **The url that will be called once generation of your PDF is done.**

                    We will do a `POST` request of the [NewBannerBatch](#operation/newBannerBatch) event on this URL with a JSON payload.
                print:
                  type: object
                  description: Print output options
                  properties:
                    color_profile:
                      type: string
                      format: uuid
                      description: |
                        UUID of the color profile to apply. The built-ins, which are the same for
                        every workspace:

                        | UUID | Profile |
                        |---|---|
                        | `be2ab219-8fe4-4d85-91ba-65bdc9ddaf01` | CMYK — ISO Coated v2 (ECI) |
                        | `03738f37-5b6f-4be9-9100-706b1711f9dd` | CMYK — ISO Coated v2 (ECI) 300% |
                        | `e0c86a8a-050c-41f1-885a-0f74b9baac50` | CMYK — ISO Uncoated |
                        | `0e0355e6-2931-4c83-92f2-64db9f5ddffc` | CMYK — US Web Coated (SWOP 2006 5v2) |
                        | `fac91df8-1155-11ef-b77e-f93002785645` | CMYK — ISO Newspaper26 v4 |
                        | `db3c123e-1127-11ef-b77e-f93002785645` | RGB — No color profile |

                        A workspace may also hold custom profiles, whose UUIDs are not listed
                        here. Omitted, the design's own profile is used. Not an enum: the list can
                        grow, and a generated client should not reject a UUID it has not seen.
                    display_crop_marks:
                      type: boolean
                original_visual_id:
                  type: string
                  format: uuid
                  description: |
                    **Do not use — visual versioning is not wired on this endpoint yet.** Every
                    other generation path resolves this UUID to an internal visual id and checks
                    it belongs to the same design and format; this one does not, so the raw UUID
                    is forwarded unresolved, no `visual_not_found` /
                    `not_related_to_same_template` / `not_related_to_same_format` check runs, and
                    the outcome is undefined. Use
                    `POST /async/banner-builder/{designId}/generate` for versioned regeneration.
              required:
                - pages
      responses:
        '200':
          description: Generation queued; poll `generation_request_id` or wait for the webhook.
          content:
            application/json:
              schema:
                type: object
                properties:
                  generation_request_id:
                    type: string
                    format: uuid
                    description: Unique identifier (UUID) of the batch generation
                  version:
                    $ref: '#/components/schemas/ApiVersion'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/GenerationRateLimited'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /generation-request/{generationRequestId}:
    get:
      operationId: getGenerationRequest
      summary: Poll async generation request status
      description: |
        Poll the status of an asynchronous generation request. Use this endpoint to track
        the progress of a batch generation operation started with the async generate endpoint.
        The result is available for 7 days after generation.
      tags:
        - Asset Generation
      parameters:
        - in: path
          name: generationRequestId
          required: true
          schema:
            type: string
            format: uuid
          description: Unique identifier (UUID) of the generation request
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: Generation finished — `is_finalized` is `true` and `banners` is populated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationRequestStatus"
        '202':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: Generation still in progress
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenerationRequestStatus"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Generation request not found (`generation_request_not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '410':
          description: >-
            Gone — the generation request expired (`generation_request_gone`; requests are
            kept 7 days).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /projects:
    get:
      operationId: listProjects
      summary: List projects
      description: |
        Retrieve all projects in your workspace. Projects group designs into logical
        collections. Only designs that belong to a project are accessible via the API.
      tags:
        - Projects
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: The workspace's projects.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/ProjectSummary"
              examples:
                projects_list:
                  summary: List of projects
                  value:
                    - id: "9d00e9fe-9bd5-4471-acad-baa702a867e9"
                      name: "Animation Tests"
                      created_at_ts: 1654763057
                    - id: "bdf9ab44-f5fd-47ad-881d-a45906901233"
                      name: "HTML5 Tests"
                      created_at_ts: 1663767647
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

    post:
      operationId: createProject
      summary: Create a project
      description: |
        Create a new project to organize your designs. Assign designs to this project
        in the Abyssale editor to make them accessible via the API.
      tags:
        - Projects
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  minLength: 2
                  maxLength: 100
                  description: Name of the project
                  example: "Summer Campaign 2024"
              required:
                - name
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: Project created successfully
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ProjectSummary"
                  - type: object
                    properties:
                      version:
                        $ref: '#/components/schemas/ApiVersion'
              examples:
                created_project:
                  summary: Successfully created project
                  value:
                    id: "cb2c4add-4867-11f0-96f2-0a00d9eb8f78"
                    name: "Summer Campaign 2024"
                    created_at_ts: 1749827107
        '400':
          description: |
            Bad Request — `name` is missing, outside 2–100 characters, or already taken
            (`project_already_exists`).

            Project names are unique within a workspace, and the comparison is made on the
            **trimmed** name: `"  Summer  "` collides with an existing `"Summer"`. Note the
            length check runs before trimming, so `"  a  "` passes validation and is stored as
            the single character `a`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                already_exists:
                  summary: A project of that name exists in this workspace
                  value:
                    id: project_already_exists
                    message: "Project already exists with the same name"
                name_out_of_range:
                  summary: name outside 2–100 characters
                  value:
                    id: out_of_range
                    message: "name: Length must be between 2 and 100."
                    errors:
                      - path: "name"
                        code: out_of_range
                        message: "Length must be between 2 and 100."
                name_missing:
                  summary: name absent
                  value:
                    id: missing_required
                    message: "name: Missing data for required field."
                    errors:
                      - path: "name"
                        code: missing_required
                        message: "Missing data for required field."
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []
  /workspace-templates:
    get:
      operationId: listWorkspaceTemplates
      summary: List all workspace templates
      description: |
        Retrieve the organisation-level master designs shared across your workspace.

        A workspace template lives in a **category** (`category_id`), not a project — it
        belongs to the organisation until you duplicate it into a project with
        `POST /workspace-templates/{companyTemplateId}/use`. Designs, which do live in a
        project, are listed by `GET /designs`; the two listings never overlap.

        Items have the same shape as a design, except the grouping fields are named
        `category_id` / `category_name`.
      tags:
        - Workspace Templates
      parameters:
        - in: query
          name: category_id
          schema:
            type: string
            format: uuid
          required: false
          description: |
            Unique identifier (UUID) of a workspace category. List the available ids with
            `GET /workspace-template-categories`. Categories are optional, so this filter only
            ever returns categorised templates — omit it to include those at the workspace root.
        - in: query
          name: type
          schema:
            type: string
            enum: [static, printer, animated, printer_multipage]
          required: false
          description: >-
            Filter workspace templates by one of these types: static, animated,
            printer, printer_multipage. An unknown value is **ignored** and the full unfiltered list is returned — this never answers 400 (long-standing behavior existing integrations rely on).
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: The workspace's shared templates.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/WorkspaceTemplate"
        '400':
          description: |
            `category_id` is not a UUID. The only way this endpoint answers `400`: an unknown
            `type` is ignored rather than rejected, so it never does.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                id: invalid_payload
                message: "malformed id category_id"
                errors:
                  - path: "category_id"
                    code: invalid_payload
                    message: "malformed"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []
  /workspace-template-categories:
    get:
      operationId: listWorkspaceTemplateCategories
      summary: List workspace template categories
      description: |
        The categories that group your workspace templates. Use an `id` from here as the
        `category_id` filter on `GET /workspace-templates`.

        This is the workspace-template counterpart of `GET /projects`, which groups designs.
      tags:
        - Workspace Templates
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: The categories grouping workspace templates.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/WorkspaceTemplateCategory"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []
  /workspace-templates/{companyTemplateId}/use:
    post:
      operationId: duplicateWorkspaceTemplate
      summary: Duplicate a workspace template into a project
      description: |
        Copy a shared workspace template into one of your projects, creating an editable
        design instance. The duplication is asynchronous — use the returned
        `duplication_request_id` to poll for completion via
        `GET /design-duplication-requests/{duplicateRequestId}`.
      tags:
        - Workspace Templates
      parameters:
        - in: path
          name: companyTemplateId
          schema:
            type: string
            format: uuid
          required: true
          description: Unique identifier (UUID) of the workspace template to duplicate
          example: "0c967bd0-4137-4690-ad70-249aa021c68b"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                project_id:
                  type: string
                  format: uuid
                  description: Target project ID where the template will be duplicated
                  example: "d59adee9-4867-11f0-96f2-0a00d9eb8f78"
                name:
                  type: string
                  description: Optional custom name for the duplicated template
                  example: "My Custom Template"
                  maxLength: 100
                  minLength: 2
              required:
                - project_id
            examples:
              basic_duplication:
                summary: Basic template duplication
                value:
                  project_id: "d59adee9-4867-11f0-96f2-0a00d9eb8f78"
              custom_name_duplication:
                summary: Duplication with custom name
                value:
                  project_id: "d59adee9-4867-11f0-96f2-0a00d9eb8f78"
                  name: "Holiday Campaign Template"
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: Duplication request accepted and processing
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DuplicationRequest"
              examples:
                duplication_started:
                  summary: Duplication request created
                  value:
                    duplication_request_id: "40c32a4e-4869-11f0-96f2-0a00d9eb8f78"
        '400':
          description: >-
            `project_id` is absent or not a UUID (`missing_required` / `wrong_type`), or `name`
            is outside 2-100 characters (`out_of_range`). Field detail is in `errors[]`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: >-
            Workspace template not found (`workspace_template_not_found`), or the `project_id`
            does not exist in this workspace (`project_not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /design-duplication-requests/{duplicateRequestId}:
    get:
      operationId: getDuplicationRequest
      summary: Poll template duplication request status
      description: |
        Poll the status of a template duplication request. Use this endpoint to track
        the progress of an asynchronous duplication operation.
      tags:
        - Workspace Templates
      parameters:
        - in: path
          name: duplicateRequestId
          schema:
            type: string
            format: uuid
          required: true
          description: Unique identifier (UUID) of the duplication request
          example: "40c32a4e-4869-11f0-96f2-0a00d9eb8f78"
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: Duplication request status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DuplicationRequestStatus"
              examples:
                completed_duplication:
                  summary: Successfully completed duplication
                  value:
                    request_id: "40c32a4e-4869-11f0-96f2-0a00d9eb8f78"
                    status: "COMPLETED"
                    created_at_ts: 1749827734
                    completed_at_ts: 1749827736
                    errored_at_ts: null
                    target_project:
                      id: "d59adee9-4867-11f0-96f2-0a00d9eb8f78"
                      name: "HTML5 Tests"
                      created_at_ts: 1749827125
                    designs:
                      - original_design_id: "0c967bd0-4137-4690-ad70-249aa021c68b"
                        target_design_id: "afb1a61a-6c50-4bc3-a49b-3381822d4e81"
                        target_design_name: "New Design name"
                pending_duplication:
                  summary: Duplication in progress
                  value:
                    request_id: "40c32a4e-4869-11f0-96f2-0a00d9eb8f78"
                    status: "IN_PROGRESS"
                    created_at_ts: 1749827734
                    completed_at_ts: null
                    errored_at_ts: null
                    target_project:
                      id: "d59adee9-4867-11f0-96f2-0a00d9eb8f78"
                      name: "HTML5 Tests"
                      created_at_ts: 1749827125
                    designs: []
                failed_duplication:
                  summary: Failed duplication
                  value:
                    request_id: "40c32a4e-4869-11f0-96f2-0a00d9eb8f78"
                    status: "ERROR"
                    created_at_ts: 1749827734
                    completed_at_ts: null
                    errored_at_ts: 1749827750
                    target_project:
                      id: "d59adee9-4867-11f0-96f2-0a00d9eb8f78"
                      name: "HTML5 Tests"
                      created_at_ts: 1749827125
                    designs: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Duplication request not found (`duplication_request_not_found`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '410':
          description: >-
            The duplication request has expired (`duplication_request_gone`). Statuses are kept
            for 7 days.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /designs/import/json:
    get:
      operationId: listDesignImports
      summary: List imports
      description: |
        **Alpha.** This API is in Alpha. The contract may change without notice and imports
        created with it are not guaranteed to remain compatible. It is not covered by
        deprecation policy.

        List this company's JSON template imports, newest first.

        Use this to recover an import whose id you no longer have. Because the flow is
        `POST` → upload → `PUT`, a client that loses the id between those steps leaves behind an
        import it cannot otherwise resume or inspect; listing is how you find it again.

        Rows are summaries. The unbounded `warnings[]` array and the `error` object are served by
        `GET /designs/import/json/{importId}` — a row carries `warning_count` instead, and a
        `links.status` pointing at that endpoint.

        `status` filters on the **effective** status, matching what the item endpoint reports: an
        import whose presigned upload window has closed counts as `FAILED`, not
        `WAITING_FOR_VALIDATION`.

        Lists JSON imports exclusively — imports created through other channels are not returned.

        **No cursor.** Results are capped by `limit`; narrow with `created_after` /
        `created_before` to reach imports beyond the cap.
      tags:
        - Design Import
      parameters:
        - in: query
          name: status
          schema:
            type: string
            enum: [WAITING_FOR_VALIDATION, QUEUED, PROCESSING, DONE, FAILED]
          required: false
          description: Return only imports whose effective status matches.
        - in: query
          name: created_after
          schema:
            type: integer
          required: false
          description: Unix timestamp (seconds). Return imports created at or after this moment.
        - in: query
          name: created_before
          schema:
            type: integer
          required: false
          description: Unix timestamp (seconds). Return imports created at or before this moment.
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
          required: false
          description: Maximum number of imports to return.
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: The matching imports, newest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/DesignImportSummary"
        '400':
          description: |
            A query parameter is invalid — `invalid_query_param` for `limit` / `created_after` /
            `created_before`, `unknown_enum_value` for an unrecognised `status`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []
    post:
      operationId: createDesignImport
      x-codeSamples:
        - lang: cURL
          source: |
            # 1. Validate first — free of the write budget, creates nothing.
            curl -X POST \
              -H "x-api-key: $ABYSSALE_API_KEY" \
              -H "Content-Type: application/json" \
              -d @design.json \
              "https://api.abyssale.com/designs/import/json?validate_only=true"
            # 204 = the payload is good. Then submit it for real:
            curl -X POST \
              -H "x-api-key: $ABYSSALE_API_KEY" \
              -H "Content-Type: application/json" \
              -d @design.json \
              "https://api.abyssale.com/designs/import/json"
        - lang: Python
          source: |
            import json, os, time, requests

            headers = {"x-api-key": os.environ["ABYSSALE_API_KEY"]}
            payload = json.load(open("design.json"))
            base = "https://api.abyssale.com/designs/import/json"

            # An upload entry identifies its asset by LAYER NAME, not by filename — it carries no
            # path — so keep your own map from layer name to the file on disk. Build it while you
            # build the payload; you cannot recover it from the response.
            files_by_layer = {"hero": "/tmp/hero.jpg", "brand": "/tmp/logo.png"}

            created = requests.post(base, headers=headers, json=payload, timeout=60)
            created.raise_for_status()
            body = created.json()

            # One presigned POST per asset given as a bare filename. When every src is a URL or
            # a data URI this list is empty and the import is already QUEUED — no PUT needed.
            for upload in body["uploads"]:
                # On printer_multipage, layer names are unique per page rather than per design,
                # so key on (page, name) — the entry carries `page` there.
                key = upload["name"]
                with open(files_by_layer[key], "rb") as fh:
                    requests.post(upload["url"], data=upload["fields"], files={"file": fh}, timeout=300)

            if body["uploads"]:
                requests.put(f"{base}/{body['id']}", headers=headers, timeout=30).raise_for_status()

            # A failed import is still HTTP 200 — branch on status, not on the status code.
            while True:
                status = requests.get(f"{base}/{body['id']}", headers=headers, timeout=30).json()
                if status["next_check_after_ms"] is None:
                    break
                time.sleep(status["next_check_after_ms"] / 1000)

            print(status["status"], status.get("result"), status["warnings"])
      summary: Create a design from JSON
      description: |
        **Alpha.** This API is in Alpha. The contract may change without notice and imports
        created with it are not guaranteed to remain compatible. It is not covered by
        deprecation policy.

        Submit a template structure as JSON. Returns an import ID, a `links` block, and a flat
        `uploads[]` array of presigned S3 POSTs — one for every asset given as a bare filename:
        `image`/`logo` `src`, `video`/`audio` `src` on animated designs, and a `qrcode` layer's
        `icon_src`. When every asset is a public URL or a data URI, the import is returned already
        in `QUEUED` with an empty `uploads[]` — no PUT call is needed.

        The payload carries no preview image: design thumbnails are generated by the backend
        after import (every format is rendered and the first visual becomes the thumbnail; the
        import reports `PROCESSING` while previews render).

        **Dry run:** pass `?validate_only=true` to validate the payload without creating an
        import. Runs the full schema and all payload-only structural validators (conflicting
        filename sources, data-URI validity and decoded-size caps, TIFF type constraints,
        video/audio extensions, animated keyframes, qrcode icons). Excluded: `unknown_font`,
        `project_not_found` and `unreachable_src`.
        Returns `204 No Content` on success.

        **Plan entitlement.** A design type your plan does not include is refused here rather
        than at generation time, so a workspace never ends up owning a design it cannot render:

        | `type` | Requires | Available from |
        |---|---|---|
        | `static` | — | any plan |
        | `animated` | MP4 | **Pro** |
        | `printer`, `printer_multipage` | Printable PDF | **Suite** |

        These are the same entitlements that gate *generating* the corresponding assets, so a
        plan that can render an animated design can also import one. See
        [abyssale.com/pricing](https://www.abyssale.com/pricing).

        A workspace without the feature gets `429 feature_not_in_plan`. The check also applies to
        `?validate_only=true`, so a dry run never returns `204` for a design the real call refuses.

      tags:
        - Design Import
      parameters:
        - in: query
          name: validate_only
          required: false
          schema:
            type: boolean
            default: false
          description: When `true`, runs schema and cross-field validation and returns `204 No Content` without creating an import row or generating presigned URLs.
        - in: query
          name: strict
          required: false
          schema:
            type: boolean
            default: false
          description: |
            When `true`, a text layer that cannot fit its box even at `min_font_size` fails the
            import instead of importing with a `text_fit_failed` warning.

            **It never changes this response.** Text fitting is measured while the design is
            built, long after this call has answered, so `strict` cannot produce a `400`. The
            import is accepted exactly as it would be otherwise, and the difference shows up when
            you poll: `status: "FAILED"` with `error.code: "text_fit_failed"` — delivered as
            HTTP `200`, like every other terminal import state. Without `strict` the same
            condition appears as a `warnings[]` entry and the import succeeds.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DesignImportRequest'
            example:
              name: 'Summer sale'
              type: static
              target:
                kind: design
                project_uuid: '9d1f2b7c-5a44-4c3e-9f21-0b8e6d4a1c73'
              formats:
                - name: facebook-post
                  width: 1200
                  height: 1200
                  background_color: '#F3F3F3'
              layers:
                - name: hero
                  type: image
                  layout:
                    x: 0
                    y: 0
                    width: 1200
                    height: 700
                  properties:
                    src: hero.jpg
                    fitting_type: cover
                - name: title
                  type: text
                  layout:
                    x: 80
                    y: 760
                    width: 1040
                    height: 220
                  properties:
                    payload: 'Summer sale — 40% off'
                    color: '#111111'
                    font_size: 72
                    text_align: center
                    vertical_align: middle
      responses:
        '200':
          description: Import created. Upload each entry of `uploads[]`, then call PUT.
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DesignImportCreateResponse'
        '204':
          description: |
            Dry-run validation succeeded (`validate_only=true`). Nothing was created.

            A dry run is counted against its own, more generous rate-limit budget — see the
            `X-RateLimit-*` headers. Converging on a valid payload by looping here is supported.
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
        '400':
          description: |
            Structured validation error (schema, cross-field, pre-flight `unknown_font` /
            `unreachable_src`, `project_not_found`, or `unsupported_for_type` — a field or value
            only valid on the other design type, e.g. TIFF sources on `static`, or gradient
            colors on `printer`. A hex color on `printer`, or a cmyk
            token on `static`/`animated`, is **not** an error: it is converted with a
            `color_converted` warning — and `shadow` / `text_shadow` / `pattern_name` /
            `pattern_color` / `overlay` on `printer` are accepted and
            dropped with a `property_dropped_for_type` warning). Every payload
            problem on this endpoint is a 400: `project_not_found` returns 400, not 404. A
            malformed or non-object request body is reported as `invalid_payload` / `wrong_type`
            at path `body`, and an unparseable query-parameter value as `invalid_query_param`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          description: |
            Three different limits answer here, and the `id` tells them apart:

            - `request_rate_limited` — the per-workspace request rate limit for this route
              (60/minute and 1 200/hour as a write, 120/minute and 2 400/hour with
              `?validate_only=true`). Wait `Retry-After` seconds and retry.
            - `feature_not_in_plan` — your plan does not include the design type you are
              importing: MP4 for `animated`, printable PDF for `printer` and
              `printer_multipage`. Retrying never helps; the plan has to change. Carries no
              `errors[]` array — the payload is fine, so there is no field to point at.
            - `rate_limit_exceeded` — the gateway's global ceiling, or a credit restriction.
          headers:
            Retry-After:
              $ref: '#/components/headers/RetryAfter'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                id: feature_not_in_plan
                message: "Animated designs are not included in your plan, please update your plan."
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /designs/import/json/{importId}:
    get:
      operationId: getDesignImport
      summary: Get import status
      description: |
        **Alpha.** This API is in Alpha. The contract may change without notice and imports
        created with it are not guaranteed to remain compatible. It is not covered by
        deprecation policy.

        Poll the status of a JSON template import. Scoped to the caller's company.

        Returns `next_check_after_ms` (suggested delay before the next poll, `null` on terminal states),
        a stable append-only `warnings[]` array, and a structured `error` object when `status` is `FAILED`.

        **Expired upload windows.** An import that reaches `WAITING_FOR_VALIDATION` and is never
        validated before its presigned upload window closes is reported as terminal:
        `status: "FAILED"`, `next_check_after_ms: null`, no `validate` link, and
        `error: {path: "uploads", code: "missing_assets", ...}`. This is derived at read time from
        the import's creation timestamp — the GET performs no write, so the import keeps its
        original state. A poller should therefore always drive off terminal states rather than
        waiting indefinitely.
      tags:
        - Design Import
      parameters:
        - in: path
          name: importId
          required: true
          schema:
            type: string
            format: uuid
          description: Import UUID returned by the POST endpoint.
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: Import found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DesignImportStatusResponse'
              examples:
                done:
                  summary: Finished — the design exists
                  value:
                    id: c6cddf2b-d4b2-4eae-be8d-7eacd86ace01
                    name: Spring campaign
                    status: DONE
                    created_at_ts: 1786455103
                    validated_at_ts: 1786455103
                    next_check_after_ms: null
                    links:
                      status: https://api.abyssale.com/designs/import/json/c6cddf2b-d4b2-4eae-be8d-7eacd86ace01
                      dashboard: https://app.abyssale.com/designs/10feccaa-f7d3-491c-af21-de442a844b17
                    warnings: []
                    error: null
                    result:
                      kind: design
                      uuid: 10feccaa-f7d3-491c-af21-de442a844b17
                      project_uuid: 1d1bc138-9554-11f1-be33-f1074f796ebe
                    version: v2026-08-21
                processing:
                  summary: Still working — poll again after next_check_after_ms
                  value:
                    id: c6cddf2b-d4b2-4eae-be8d-7eacd86ace01
                    name: Spring campaign
                    status: PROCESSING
                    created_at_ts: 1786455103
                    validated_at_ts: 1786455103
                    next_check_after_ms: 2000
                    links:
                      status: https://api.abyssale.com/designs/import/json/c6cddf2b-d4b2-4eae-be8d-7eacd86ace01
                    warnings: []
                    error: null
                    result: null
                    version: v2026-08-21
                failed:
                  summary: Terminal failure — the HTTP status is still 200, branch on `status`
                  value:
                    id: c6cddf2b-d4b2-4eae-be8d-7eacd86ace01
                    name: Spring campaign
                    status: FAILED
                    created_at_ts: 1786455103
                    validated_at_ts: 1786455103
                    next_check_after_ms: null
                    links:
                      status: https://api.abyssale.com/designs/import/json/c6cddf2b-d4b2-4eae-be8d-7eacd86ace01
                    warnings: []
                    error:
                      path: layers[3].properties.font_size
                      code: text_fit_failed
                      message: The text does not fit the layer at the requested size.
                    result: null
                    version: v2026-08-21
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: |
            Import not found or does not belong to the caller's company. `id: not_found`, with
            one `errors` entry at path `id`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

    put:
      operationId: validateDesignImport
      summary: Validate an import and queue it
      description: |
        **Alpha.** This API is in Alpha. The contract may change without notice and imports
        created with it are not guaranteed to remain compatible. It is not covered by
        deprecation policy.

        Call this after every entry of `uploads[]` has been POSTed to S3. The edge API performs a
        pre-flight `HEAD` on every expected key. If any are missing, it returns
        `422 missing_assets` with the list of unfulfilled targets.

        On success, the import status transitions to `QUEUED` and backend processing picks it up.
        PUT is idempotent on `QUEUED` rows. PUT on a `PROCESSING`, `DONE`, or `FAILED` row returns
        `409 template_import_already_processed`.
      tags:
        - Design Import
      parameters:
        - in: path
          name: importId
          required: true
          schema:
            type: string
            format: uuid
          description: Import UUID returned by the POST endpoint.
      responses:
        '200':
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          description: Import queued for processing.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DesignImportValidateResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: |
            Import not found or does not belong to the caller's company. `id: not_found`, with
            one `errors` entry at path `id`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: |
            Import is past the `QUEUED` state and cannot be re-validated.
            `id: template_import_already_processed`, with one `errors` entry at path `status`; the
            reported status is always one of the five **public** values
            (`WAITING_FOR_VALIDATION`, `QUEUED`, `PROCESSING`, `DONE`, `FAILED`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '422':
          description: |
            One or more presigned uploads were never completed (`missing_assets`). Also returned
            when the presigned upload window has expired before validation, in which case the
            import is terminal.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

  /designs/{designId}/as-import:
    get:
      operationId: getDesignAsImport
      summary: Export a design as an import payload
      description: |
        **Alpha.** The contract may change without notice and imports created with it are not
        guaranteed to remain compatible. Not covered by the deprecation policy.

        Return an existing design — any type, `printer_multipage` included — serialized into the
        exact shape `POST /designs/import/json` accepts. Useful as a worked example for an agent,
        or as a round-trip regression test (`as-import` → `POST` → `as-import` should diff empty).

        `src` values are Abyssale-hosted `https://` URLs, so no asset needs re-uploading. The
        response is validated against the import schema before it is returned: a design holding
        anything the schema cannot express answers `400 not_round_trippable` with structured
        details.

        **Stored state the contract has no field for is dropped and named in `warnings[]`**,
        never folded into a field that means something else — a re-import reproduces the design
        minus what was reported, never different artwork.

        Values outside the import bounds are clipped and warned about rather than failing the
        export. What cannot degrade fails instead: a gradient on a print design, a
        `printer_multipage` whose pages disagree on their `unit` (each page's geometry is
        expressed in its own unit, so one ruler cannot describe them), an animated format outside
        the 1920×1200 box, and an export left with no layers.

        **Rate limit:** shares the write budget with `POST /designs/import/json` — 60
        requests/minute and 1 200/hour across both, so a round trip costs two calls and 1 200/hour
        is 600 export–edit–import cycles. See [Rate limits](/rest-api/rate-limits).
      externalDocs:
        description: Export a design — full warning list and per-type behaviour
        url: https://developers.abyssale.com/rest-api/designs/as-import
      tags:
        - Design Import
      parameters:
        - $ref: '#/components/parameters/DesignId'
      responses:
        '200':
          description: |
            Envelope containing the import payload. `payload` is ready to be POSTed to
            `/designs/import/json` (for `target.kind=design` re-imports, add a `target` object —
            the payload never contains one). `warnings[]` describes values that were clipped,
            converted or dropped to stay within the export contract.
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DesignAsImportResponse'
        '400':
          description: |
            Design contains features outside the import schema (`not_round_trippable`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: |
            Design not found or does not belong to the caller's company. `id: template_not_found` —
            the same code every other design lookup returns.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                id: template_not_found
                message: "Design 9c2f31a0-8810-4c4b-bbf5-44a02e1f1d2f not found"
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
      security:
        - api_key: []

components:
  headers:
    # Present on every response from a rate-limited route, not only on a 429. Read them and
    # pace yourself rather than discovering the limit by hitting it.
    XRateLimitLimit:
      description: |
        Requests allowed in the current window for this class of route. When several windows
        apply (a burst window and a sustained one), this describes the one closest to being
        exhausted — the one you will actually hit.
      schema:
        type: integer
      # The Write tier's per-minute budget — the smallest limit any tier carries. The old `10`
      # matched no tier at all; it was the gateway's per-second ceiling, which these headers do
      # not describe.
      example: 60
    XRateLimitRemaining:
      description: Requests still allowed in the window described by `X-RateLimit-Limit`.
      schema:
        type: integer
        minimum: 0
      example: 4
    XRateLimitReset:
      description: Unix timestamp (seconds) at which that window rolls over and the budget resets.
      schema:
        type: integer
      example: 1786000020
    RetryAfter:
      description: |
        Seconds to wait before retrying. Sent only on a `429 request_rate_limited`, and never
        `0` — retrying sooner will be refused again.
      schema:
        type: integer
        minimum: 1
      example: 27
  parameters:
    DesignId:
      name: designId
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: |
        Unique identifier (UUID) of the design. A value that is not a UUID does not match the
        route and answers `404` with `id: endpoint_not_found`.
  responses:
    BadRequest:
      description: Bad Request — the request body or parameters are invalid.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            invalid_json:
              summary: Malformed request body
              value:
                id: invalid_payload
                message: "Invalid JSON"
            validation_error:
              summary: Field-level validation failure
              value:
                id: invalid_payload
                message: "elements.root.background_color: Must be a 6 or 8 hexadecimal color (given: #zzz)"
                errors:
                  - path: "elements.root.background_color"
                    code: invalid_payload
                    message: "Must be a 6 or 8 hexadecimal color (given: #zzz)"
    Unauthorized:
      description: Unauthorized — missing or invalid API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            missing_or_invalid_key:
              summary: Missing or invalid API key
              value:
                message: "Unauthorized: missing or invalid API key."
                id: unauthorized
            plan_no_api_access:
              summary: Subscription plan does not include API access
              value:
                message: "Unauthorized: Your subscription plan does not include API access."
                id: api_access_denied
    NotFound:
      description: Not Found — the requested resource does not exist.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            resource_not_found:
              summary: Design not found — never existed, or deleted
              value:
                message: "Template UUID 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' not found"
                id: template_not_found
            format_not_found:
              summary: Format name not found on the design
              description: >-
                Returned by the generation endpoints when the design exists but a requested
                `template_format_name(s)` entry does not match any of its formats. This is a
                404 by contract, not a 400 — the format is addressed as a resource.
              value:
                message: "Format name 'Missing formats: facebook-feed' not found"
                id: format_not_found
            endpoint_not_found:
              summary: Endpoint does not exist
              value:
                message: "The endpoint 'GET /unknown/path' does not exist."
                id: endpoint_not_found
    GenerationRateLimited:
      description: |
        Rate limit exceeded (10 req/s) **or** a plan / credit restriction — inspect `message`
        to tell them apart. Both answer `id: rate_limit_exceeded`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            rate_limit:
              summary: Request rate limit exceeded
              value:
                id: rate_limit_exceeded
                message: "Too Many Requests"
            not_enough_credits:
              summary: Insufficient generation credits
              value:
                id: rate_limit_exceeded
                message: "Not enough credits, please update your plan."
            format_not_in_plan:
              summary: Output format not included in plan
              value:
                id: rate_limit_exceeded
                message: "GIF is not included in your plan, please update your plan."
    TooManyRequests:
      description: |
        Too Many Requests. Two different limits answer here, and the `id` tells them apart:

        - `request_rate_limited` — the per-workspace request rate limit for this class of
          route. Wait `Retry-After` seconds and retry; `X-RateLimit-Remaining` on every
          response reports how much budget is left, so this is avoidable.
        - `rate_limit_exceeded` — the gateway's global ceiling, or a plan / credit
          restriction. Retrying does not help until the plan or the credit balance changes.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            request_rate_limited:
              summary: Too many requests for this route class
              value:
                id: request_rate_limited
                message: "Too many requests. Retry in 27 seconds."
            rate_limit_exceeded:
              summary: Gateway ceiling or plan restriction
              value:
                id: rate_limit_exceeded
                message: "Too Many Requests"
    InternalServerError:
      description: Internal Server Error — an unexpected error occurred on our side.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            unhandled:
              summary: An unhandled failure in this API
              value:
                id: internal_error
                message: An unexpected error occurred.
                errors:
                  - code: internal_error
                    message: An unexpected error occurred.
            downstream:
              summary: A downstream service failed
              value:
                id: internal_server_error
                message: Internal server error
  securitySchemes:
    api_key:
      description: All of your Abyssale requests must contain a `x-api-key header` with your API Key.
      type: apiKey
      name: x-api-key
      in: header
  schemas:
    ErrorResponse:
      type: object
      description: |
        **The error shape for this entire API.** Every failure, on every endpoint, at every
        status, is this object — there is no second envelope to detect and no per-endpoint
        variant. `id` and `message` are always present; `errors` appears only when there is
        field-level detail to give.
      example:
        id: out_of_range
        message: "formats[0].width: Must be greater than or equal to 1 and less than or equal to 5000."
        errors:
          - path: "formats[0].width"
            code: out_of_range
            message: Must be greater than or equal to 1 and less than or equal to 5000.
            expected: { min: 1, max: 5000 }
            received: 99999999
      properties:
        message:
          type: string
          description: |
            Human-readable error message. Prose, not a contract — branch on `id`, never on this.

            It is meant to stand on its own, so when the failure comes down to a SINGLE problem
            the message is that problem's own text prefixed with its `path`
            (`name: Missing data for required field.`) — you do not have to read `errors` to
            learn which field was rejected. When several problems disagree it falls back to a
            generic sentence and `errors` carries the detail.
        id:
          type: string
          description: |
            Machine-readable error code. Branch on this rather than on `message`, which is prose
            and may change. **Present on every error this API returns**, on every endpoint, at
            every status — there is no second error shape to detect.

            When `errors` is present, `id` is the response-level code: the shared code if every
            entry agrees, otherwise `invalid_payload`, meaning "read `errors`".

            Codes are added over time. Treat one you do not recognise as generic and fall back to
            `message`; that keeps a new code from being a breaking change.

            This covers errors the generation pipeline raises downstream and this API relays:
            they carry no code of their own, so one is derived (`format_not_found`,
            `template_not_found`, `invalid_payload`, …) and a refusal that matches none of the
            known cases is reported as `cannot_build_banner` rather than as a bare `message`.

            Every value, as of this release. The list is generated from the API's code registry
            and covered by a test, so it cannot drift — but it is a snapshot, not a closed enum:
            treat an unrecognised code as generic rather than as a parse failure.

            Grouped by **what you should do about it**, because that is the only thing that
            changes your code. The grouping is guidance; the status line is what the response
            actually carries, and a few codes appear twice because they genuinely mean two things.

            **Fix the request, then send it again.** The payload, the parameters or the headers
            were wrong: `invalid_payload`, `invalid_json`, `wrong_type`, `missing_required`,
            `unknown_field`, `unknown_enum_value`, `unknown_format_key`, `out_of_range`,
            `mutually_exclusive`, `conditional_dependency_missing`, `duplicate_format_name`,
            `duplicate_layer_name`, `reserved_format_name`, `unsupported_for_type`,
            `unknown_font`, `unreachable_src`, `invalid_query_param`, `invalid_filetype`,
            `invalid_design_type`, `template_not_static`, `more_than_one_format`,
            `missing_assets`, `not_round_trippable`, `unsupported_media_type`, `not_acceptable`,
            `method_not_allowed`.

            **Fix the identifier.** The request was well-formed, but named something that does
            not exist or does not belong to this workspace: `template_not_found`,
            `format_not_found`, `visual_not_found`, `generation_request_not_found`,
            `duplication_request_not_found`, `workspace_template_not_found`, `project_not_found`,
            `not_related_to_same_template`, `not_related_to_same_format`, `not_found`,
            `endpoint_not_found`.

            **Too late.** The job finished, but its result is no longer kept (7 days):
            `generation_request_gone`, `duplication_request_gone`. Generate again, and store the
            result this time rather than re-polling for it later.

            **Back off, then retry.** These two are the only ones worth a retry loop:
            `request_rate_limited`, `rate_limit_exceeded`.

            **Retrying never helps — something has to change first.** The plan or the credit
            balance: `feature_not_in_plan`, `api_access_denied`. Note that `rate_limit_exceeded`
            lands here too when it means "not enough credits"; the message is what tells the two
            apart, which is why both entries name it.

            **Authenticate.** `unauthorized` for a missing, unknown or revoked key;
            `api_access_denied` when the key is valid but the plan excludes API access. There is
            no 403 in this API.

            **The resource is in the wrong state for this call.** Read it back to find out which:
            `template_import_already_processed`, `project_already_exists`, `template_not_active`,
            `previous_secret_still_active`.

            **Valid request, unrenderable content.** The engine accepted the call and then
            refused the artwork — most often text that cannot fit its layer:
            `cannot_build_banner`, `image_fetching_error`.

            **Ours, not yours.** Retry once; if it persists, send us the response:
            `internal_error`, `internal_server_error`.
        errors:
          type: array
          minItems: 1
          description: |
            Field-level detail. **Present only when there is some** — its absence means the error
            is not about a particular field, not that detail was withheld. When present it is
            always a non-empty FLAT array of problem objects; it is never an object, never null
            and never empty.

            Object keys are dotted and array indices bracketed (`formats[0].layers`,
            `elements.root.background_color`), so one parser reads every API error.

            This holds for detail produced downstream too: the generation engine reports its own
            field errors in a different shape, and they are translated to this one before the
            response is written.

            Entries may carry keys beyond the three required ones (`expected`, `received`).
            Ignore ones you do not recognise — more may be added.
          items:
            $ref: '#/components/schemas/Problem'
        version:
          $ref: '#/components/schemas/ApiVersion'
      # `id` is required as well as `message`: the API guarantees both on every error, and a
      # contract that says "branch on `id`" while typing it optional just makes every caller
      # write a fallback the API never takes.
      required:
        - id
        - message
    DesignAsImportResponse:
      type: object
      description: |
        Envelope returned by `GET /designs/{designId}/as-import`. `payload` is a complete,
        valid `POST /designs/import/json` body; re-posting it recreates the design.
      required: [payload, warnings, version]
      properties:
        payload:
          $ref: '#/components/schemas/DesignImportRequest'
        warnings:
          type: array
          description: |
            Values that were clipped, converted or dropped so the payload stays valid against the contract
            (e.g. `color_converted`, `property_not_representable`, `print_settings_not_recoverable`).
            An entry with no `code` is informational and carries no stable machine identity —
            branch only on entries that have one.
          items:
            $ref: '#/components/schemas/Warning'
        version:
          allOf:
            - $ref: '#/components/schemas/ApiVersion'
            - description: Contract version of `payload`.

    SigningSecret:
      type: object
      description: |
        The workspace's webhook signing secret and the state of any rotation in progress.
      properties:
        secret:
          type: string
          description: |
            The secret to verify `X-Abyssale-Signature` with. Prefixed `whsec_` so it is
            recognisable if it turns up somewhere it should not.
          example: whsec_2f1a8c4e6b9d0a7c3e5f8b1d4a6c9e2f0b3d5a7c1e4f6b8d0a2c5e7f9b1d3a5c
        created_at_ts:
          type: integer
          description: Unix second the secret was first issued.
          example: 1755561234
        rotated_at_ts:
          type: integer
          nullable: true
          description: |
            Unix second of the most recent rotation, or `null` if the secret has never been rotated.
          example: null
        previous_secret_expires_at_ts:
          type: integer
          nullable: true
          description: |
            When the previous secret stops being honoured — 24 hours after the rotation that
            demoted it. `null` when there is no overlap in progress, either because nothing was
            rotated, because the window has lapsed, or because it was ended with
            `POST /signing-secret/revoke`.

            While this is set, deliveries carry **two** `v1` hashes and a receiver holding either
            secret verifies. The previous secret's value is never returned — only its expiry.
          example: null
      # All four keys are ALWAYS emitted — `signing_secret_response()` builds the whole dict on
      # every one of the three routes. Only the two non-nullable ones are listed as `required`:
      # `datamodel-code-generator` drops `nullable: true` for any field it sees in `required`, so
      # listing `rotated_at_ts` here produced a Python model that rejected the commonest real
      # response (a never-rotated secret, where both timestamps are null). Do not "complete" this
      # list without regenerating the Python SDK and asserting a null-bearing payload validates.
      required:
        - secret
        - created_at_ts

    ApiVersion:
      type: string
      pattern: '^v\d{4}-\d{2}-\d{2}$'
      example: v2026-08-21
      description: |
        The API version that produced this response, named by release date (`vYYYY-MM-DD`).
        Stamped as a top-level field on JSON object bodies, success and error alike, so a client
        can always tell which contract answered. There is no version-selection parameter — a
        single version is maintained at a time.

        Two kinds of body are **not** stamped. Array bodies (the listings) carry no envelope. And
        a body that already has a `version` key of its own is left alone — which in practice means
        `Banner`, whose `version` is the generated file's integer counter. So `GET
        /banners/{bannerId}` and the synchronous generate are the two responses that do not tell
        you which contract answered.

        The value changes when a new version is released. Match the `vYYYY-MM-DD` shape rather than
        pinning today's literal, or your client breaks on the next release.

    DesignImportRequest:
      type: object
      description: |
        Import payload. `static` / `printer` / `animated` designs carry `formats` + top-level
        `layers` (both required). `printer_multipage` designs instead carry a top-level `pages`
        array (and no `formats`/`layers`) — see the `pages` and `type` fields.
      required:
        - name
        - type
      # marshmallow `unknown=RAISE`: any unlisted key is `unknown_field`.
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          example: "My YouTube Template"
        type:
          type: string
          enum: [static, printer, animated, printer_multipage]
          description: |
            Design type. `static` = single image; `printer` = single-page print PDF;
            `animated` = animated/video design; `printer_multipage` = multi-page print PDF.
            Printer payloads are authored in physical units — the design declares `unit`
            (`mm` | `in`) **once at the top level**, geometry is floats in that unit, font sizes are pt,
            and colors are stored as cmyk (a solid hex is converted with a
            `color_converted` warning; gradients are rejected).
            Animated payloads are px-authored like static and require a top-level
            `animation` object; they may use `video`/`audio` layers and per-layer
            `animation` timing/tweens.

            **Muting is spelled differently on each of the three surfaces, and the type changes
            too.** Importing takes `muted` (a boolean, on the `video`/`audio` layer properties);
            reading a design returns it as the attributes `video_muted` / `audio_muted`
            (booleans); overriding it at generation time takes `audio_muted` as a **number** 0–1,
            where `true`/`false` answer `400`. Convert deliberately when moving a value between
            them.
            Multipage payloads (`printer_multipage`) drop `formats`/`layers`, declare the print setup once at the top level (`unit`/`width`/`height` required, `bleed_size`/`safe_size`/`dpi` optional), and carry a
            top-level ordered `pages` array (1–30 pages); each page is a self-contained print
            page (own `background_color` + `layers`, inheriting the document-wide print
            setup) that inherits every per-layer `printer` rule. No shared layers exist — a repeated element is included
            per page.
          example: "static"
        target:
          type: object
          description: |
            Where the import lands. An import creates a **design** — the thing you can generate
            from — so this defaults to `{ "kind": "design" }` and `project_uuid` is required:
            a design lives in a project, and one without a project is not returned by
            `GET /designs`. Omitting `target` entirely therefore fails with `missing_required`
            at `target.project_uuid`.

            Pass `kind: workspace_template` for the other variant: a reusable blueprint in the
            workspace library, which has no project and must be duplicated into one before you
            can generate from it. Only designs are addressable via
            `GET /designs/{uuid}/as-import`.
          required: [kind]
          additionalProperties: false
          properties:
            kind:
              type: string
              enum: [design, workspace_template]
              description: >-
                Required WHENEVER `target` is present. The `design` default applies only to an
                entirely absent `target`; sending `{"project_uuid": "..."}` with no `kind` is
                `missing_required` at `target.kind`.
            project_uuid:
              type: string
              format: uuid
              description: Required for `kind=design`; forbidden for `kind=workspace_template`.
        formats:
          type: array
          minItems: 1
          maxItems: 20
          description: List of formats (1-20). **Required on `static`/`printer`/`animated`; omit on `printer_multipage` (use `pages` instead).** A count outside 1-20 is rejected with `out_of_range` at `formats`. `background_color` is required on each format. There is no preview-image field — thumbnails are rendered for you (a `preview_image` key is rejected as `unknown_field`).
          items:
            $ref: '#/components/schemas/DesignImportFormat'
        layers:
          type: array
          minItems: 1
          maxItems: 80
          description: "**Required on `static`/`printer`/`animated`; omit on `printer_multipage` (each page carries its own flat `layers`).** 1-80 layers - an empty array is rejected with `out_of_range` at `layers` (`expected: {min: 1, max: 80}`), since `required` enforces key presence and not non-emptiness. Layers defined once at the top level. Each layer contains `layout` and `properties` keyed by format name. A `\"default\"` key may be used in either dict — it is merged under each per-format entry before validation, so overrides only need to specify the fields that differ."
          items:
            $ref: '#/components/schemas/DesignImportLayer'
        unit:
          type: string
          enum: [mm, in]
          description: |
            **Print designs only (`printer` and `printer_multipage`) — required.** Physical
            authoring unit for **everything** in the document: format/page size, bleed and safe
            sizes, and every layer's geometry in every format. Declared exactly once, because a
            print design has one ruler — it does not change between its formats. Missing →
            `missing_required` at `unit`; present on `static`/`animated` →
            `unsupported_for_type`. A format carrying its own `unit` is rejected with
            `unknown_field`.
          example: "mm"
        width:
          type: number
          minimum: 0
          exclusiveMinimum: true
          description: |
            **`printer_multipage` designs only — required.** Page width as a float in `unit`,
            shared by every page. Its pixel equivalent at 300 DPI must stay within 1–20000
            (`out_of_range` at `width`).
          example: 210.0
        height:
          type: number
          minimum: 0
          exclusiveMinimum: true
          description: |
            **`printer_multipage` designs only — required.** Page height as a float in `unit`,
            shared by every page. Same 1–20000 px-equivalence bound, reported at `height`.
          example: 297.0
        bleed_size:
          type: number
          minimum: 0
          description: |
            **`printer_multipage` designs only.** Optional bleed size as a float in `unit`;
            presence enables bleed on every page.
        safe_size:
          type: number
          minimum: 0
          description: |
            **`printer_multipage` designs only.** Optional safe-zone size as a float in `unit`;
            presence enables the safe zone on every page.
        dpi:
          type: integer
          minimum: 10
          maximum: 300
          description: |
            **`printer_multipage` designs only.** Optional global render DPI for the whole
            document. Because the document has a single page size there is exactly one area-based
            ceiling `min(300, floor(72*sqrt(225000000/(w_pt*h_pt))))`; a higher value is rejected
            with `out_of_range` at `dpi` (carrying `expected: {max: <ceiling>}` and `received`).
            Omitted ⇒ the backend computes that same ceiling as the default, identically for every
            page. On `printer` designs `dpi` is a per-format field instead.
          example: 300
        pages:
          type: array
          minItems: 1
          maxItems: 30
          description: |
            **`printer_multipage` designs only** (replaces `formats` + top-level `layers`;
            forbidden on other types). Ordered array of 1–30 self-contained print pages — array
            order is the page order (the backend names them `page_1 … page_N`). A count outside
            1–30 → `out_of_range` at `pages`. All pages share one size, so the print setup
            (`unit`/`width`/`height`/`bleed_size`/`safe_size`/`dpi`) is declared once at the top
            level; a page object carries only `background_color` and a flat `layers` array (one
            format per page).
          items:
            $ref: '#/components/schemas/DesignImportPage'
        animation:
          type: object
          description: |
            Animated designs only — **required** when `type` is `animated`, rejected with
            `unsupported_for_type` on `static`/`printer`. The design's timeline.
          required: [duration]
          properties:
            duration:
              type: number
              minimum: 1
              maximum: 60
              description: Timeline length in seconds (1–60). The authoritative animation duration.
              example: 8.0
            screenshot_at_s:
              type: number
              minimum: 0
              description: |
                The moment (seconds, 0 ≤ value ≤ `duration` — `out_of_range` beyond) at which the
                HTML5 backup screenshot is taken. Defaults to `duration` when omitted.
              example: 8.0

    DesignImportFormat:
      type: object
      required:
        - name
        - width
        - height
        - background_color
      # marshmallow `unknown=RAISE`: any unlisted key is `unknown_field`.
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          description: |
            Format name, 1–100 characters, matching `^[a-z0-9][a-z0-9_-]*$` — lowercase
            letters, digits, `-` and `_`, starting with a letter or digit. Anything else
            (spaces, capitals) is `invalid_payload`. Must be unique within `formats`
            (`duplicate_format_name`) and must not be a platform-reserved name
            (`reserved_format_name`).
          example: "youtube-thumbnail"
        width:
          type: number
          minimum: 0
          exclusiveMinimum: true
          description: |
            The upper bound depends on the design type, so the schema declares none.
            `static`: width in pixels, integer 1–5000.
            `printer`: a float in the design's `unit` (`mm` | `in`), no fixed upper bound of its
            own — its pixel equivalent at 300 DPI must stay within 1–20000, else `out_of_range`.
            On `animated`: integer pixels, and the format must additionally fit within a
            1920×1200 bounding box in either orientation (max(width, height) ≤ 1920 and
            min(width, height) ≤ 1200 — `out_of_range` otherwise).
          example: 1280
        height:
          type: number
          minimum: 0
          exclusiveMinimum: true
          description: |
            The upper bound depends on the design type, so the schema declares none.
            `static`: height in pixels, integer 1–5000.
            `printer`: a float in the design's `unit` (`mm` | `in`), no fixed upper bound of its
            own — its pixel equivalent at 300 DPI must stay within 1–20000, else `out_of_range`.
            On `animated`: integer pixels, and the format must additionally fit within a
            1920×1200 bounding box in either orientation (max(width, height) ≤ 1920 and
            min(width, height) ≤ 1200 — `out_of_range` otherwise).
          example: 720
        background_color:
          type: string
          description: |
            On `static`/`animated`: hex (`#RRGGBB` / `#RRGGBBAA`) or a gradient; a cmyk token
            is converted to hex with a `color_converted` warning. On `type: "printer"`: a
            `cmyk(c,m,y,k)` / `cmyka(c,m,y,k[,a])` token (integers 0–100) is what gets stored —
            a solid hex value is accepted and converted to cmyk with the same warning, while a
            gradient is rejected with `unsupported_for_type`.
          example: "#ffffff"
        bleed_size:
          type: number
          minimum: 0
          description: |
            Printer only. Bleed size as a float in the design's `unit`; presence enables bleed
            on the created design. Forbidden on `static` (`unsupported_for_type`).
        safe_size:
          type: number
          minimum: 0
          description: |
            Printer only. Safe-zone size as a float in the design's `unit`; presence enables the
            safe zone on the created design. Forbidden on `static` (`unsupported_for_type`).
        dpi:
          type: integer
          minimum: 10
          maximum: 300
          description: |
            Printer only, optional. Render DPI for this format. Beyond the 10–300 field range, the
            value is capped by an area-based ceiling computed from the format size, so a large
            format rejects a high DPI with `out_of_range`; omitted, the backend computes the
            ceiling itself. Forbidden on `static`/`animated` (`unsupported_for_type`). On
            `printer_multipage` `dpi` is a single document-level value instead — a page carries no
            print settings of its own.
          example: 300

    DesignImportPage:
      type: object
      description: |
        A single page of a `printer_multipage` design (one entry of the top-level `pages` array).

        All pages of a multipage design have the same dimensions, so the print setup (`unit`,
        `width`, `height`, `bleed_size`, `safe_size`, `dpi`) is declared **once at the top level
        of the payload**, never per page. A page
        object accepts **exactly two fields** — `background_color` and `layers`. Any other key,
        including the six print settings and `name`, is rejected with `unknown_field` at
        `pages[i].<field>`.

        A page is **single-format**, so its layer `layout`/`properties` may be flat or keyed
        under `default` (any other key → `unknown_format_key`). Layers are authored in the
        document-level `unit` and follow every `type: "printer"` layer rule.
      additionalProperties: false
      required:
        - background_color
        - layers
      properties:
        background_color:
          type: string
          description: |
            A `cmyk(c,m,y,k)` / `cmyka(c,m,y,k[,a])` token (integers 0-100) - **required**. A
            solid hex value is accepted and converted to cmyk with a `color_converted` warning;
            a gradient is rejected with `unsupported_for_type` — same as `type: "printer"`. This
            is the one setting that stays per page, because a cover legitimately differs from an
            inside page.
          example: "cmyk(0,0,0,0)"
        layers:
          type: array
          minItems: 1
          maxItems: 80
          description: |
            This page's layers, authored in the document-level `unit`. A page is single-format, so
            each layer's `layout`/`properties` may be given flat or keyed under `default` — any
            other key is rejected with `unknown_format_key`. Either way the stored structure is
            flat. Same layer shapes and per-layer rules as `type: "printer"`.
            An empty array is rejected with `out_of_range` at `pages[i].layers`
            (`expected: {min: 1, max: 80}`).
          items:
            $ref: '#/components/schemas/DesignImportLayer'

    DesignImportLayer:
      type: object
      required:
        - name
        - type
        - layout
        - properties
      # marshmallow `unknown=RAISE`: any unlisted key is `unknown_field`.
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 40
          description: >-
            Layer name, unique within the design (`duplicate_layer_name`). On
            `printer_multipage` uniqueness is scoped to the page, so two pages may reuse a name.
          example: "title"
        type:
          type: string
          enum: [image, logo, text, shape, group, button, rating, qrcode, illustration, video, audio]
          description: |
            `video` and `audio` are accepted on `type: "animated"` designs only — on
            static/printer the enum simply doesn't include them (`unknown_enum_value`).
            The order above is the contractual order surfaced in an `unknown_enum_value`
            problem's `expected` list.
        hidden:
          description: |
            Hide the layer. Either a single boolean applying to every format, or an object
            keyed by format name when the layer is hidden in some formats and visible in
            others — the editor allows that, and collapsing it to one boolean would remove
            the layer from a format where it is meant to show.

            A multi-format design MAY still send the flat form: "hidden everywhere" is a
            complete answer, unlike a flat `layout`. A missing format key means `false`; a
            `default` key covers the formats not named.
          default: false
          anyOf:
            - type: boolean
            - type: object
              additionalProperties:
                type: boolean
              example: {"a4-horizontal": false, "medium-rectangle": true}
        locked:
          description: |
            Lock the layer against editing. Same flat-or-format-keyed shape as `hidden`.
          default: false
          anyOf:
            - type: boolean
            - type: object
              additionalProperties:
                type: boolean
        layer_ids:
          type: array
          description: |
            **`group` layers only — required there, rejected anywhere else.** Omitting it on a
            group is `missing_required`; sending it on any other layer type is
            `unsupported_for_type`. Names of the layers this group contains.
            Declared here beside `layout`/`properties` rather than inside the group's property
            object, because a group's membership is one list for the whole design — the same
            children in every format, so per-format membership is unrepresentable by design.
            Each entry must name a layer declared in the same format, otherwise
            `conditional_dependency_missing`.
          items:
            type: string
            minLength: 1
            maxLength: 100
          example: ["headline", "subtitle"]
        layout:
          description: |
            Keyed by format name. Each value is `{ x, y, width, height }`. A `"default"` key is allowed and will be merged under each per-format entry.
            Single-format designs may instead pass the `{ x, y, width, height }` object directly (flat form, no format key). The same applies to `properties`.

            On `static` designs all four values are integer pixels (bounds below). On `printer`
            designs they are floats in the design's `unit` (`mm` | `in`); each value's pixel
            equivalent at the fixed 300 DPI must stay within the same bounds (`out_of_range`
            otherwise).
          anyOf:
            - title: Flat (single-format designs only)
              allOf:
                - $ref: "#/components/schemas/DesignImportLayoutBox"
            - title: Keyed by format name
              type: object
              additionalProperties:
                allOf:
                  - $ref: "#/components/schemas/DesignImportLayoutBox"
                x-additionalPropertiesName: format-name
          example:
            youtube-thumbnail:
              x: 57
              y: 67
              width: 715
              height: 383
        properties:
          description: |
            The layer's property set. Either **flat** (`{ "payload": "Hi", ... }`) or **keyed by
            format name** (`{ "youtube-thumbnail": { ... } }`, plus an optional `"default"` key
            merged under every per-format entry). A **single-format design may use either shape**;
            a multi-format design must key, and must cover every format. The two are told apart by
            key name: a top-level key that is a declared property of the layer's type means the
            flat form. `layout` follows exactly the same rule.

            Which property object is valid depends on the layer's `type` **and** on the design
            `type`. The three `anyOf` branches below are the generated, per-design-type unions —
            `DesignImportStaticLayerProperties`,
            `DesignImportPrinterLayerProperties` and
            `DesignImportAnimatedLayerProperties` — each fanning out to one schema per
            layer type. They are generated from the schemas that validate the
            request, so their fields, bounds, enums and defaults are exact. The fourth branch is
            the format-keyed envelope, whose values are those same objects.

            **A generic JSON-Schema validator cannot enforce the pairing, so do not trust a green
            result here.** The branch is selected by the layer's own `type`, and OpenAPI 3.0 has no
            way to tie an `anyOf` to a sibling field. A property set that is legal for *some* layer
            type therefore validates for *every* layer type — `alignment` on a `text` layer passes
            offline validation and then answers `400 unknown_field`, because `alignment` belongs to
            button/image/logo/illustration/group and a text layer takes `text_align` +
            `vertical_align`. Check a payload against the layer type you are actually sending, or
            against the API with `?validate_only=true`.

            **Printer designs (`type: "printer"` / `printer_multipage`):** every `*_color`
            field, `stroke.color` included, is a `cmyk(c,m,y,k)` / `cmyka(c,m,y,k[,a])` token
            (integers 0–100, no `%`). A solid hex is accepted and converted, reported as
            `color_converted`; inline markup inside a text `payload` is exempt and stays hex.
            Conversion is approximate — no ICC profile — so author `cmyk(...)` directly when the
            exact print colour matters. **Gradients are rejected** (`unsupported_for_type`).
            Geometric values (`stroke.width`, `radius`, `padding`, `gap`, `star_dimension`,
            `star_margin`) are floats in the design's `unit` with **no upper bound**; only the
            format size and the layer `layout` are checked against the pixel bounds at 300 DPI
            (`out_of_range`). `font_size`/`min_font_size` are floats in pt, 0.48–240.

            `shadow`, `text_shadow`, `pattern_name`, `pattern_color` and `overlay` are
            **accepted and dropped** — validated in full, then removed, one
            `property_dropped_for_type` warning per occurrence naming the layer path and the
            property. `overlay` is one nested object, so it drops as a unit and reports a single
            warning. Because they are still validated, a gradient in
            `pattern_color` is `invalid_payload`. `filter_name` stays allowed.

            **Animated designs (`type: "animated"`):** px/hex like static (a cmyk token is
            converted to hex with a `color_converted` warning). Group `auto_layout`, `direction`,
            `alignment` and `gap` are rejected (`unsupported_for_type`). A `video` `src` is a
            filename or URL ending `.mp4`/`.webm` or a `data:video/mp4`|`data:video/webm` URI;
            an `audio` `src` is `.mp3`/`.wav` or `data:audio/mpeg`|`data:audio/wav`. On both, an
            extension is checked only when present: an extensionless `https://` URL is accepted
            and the worker probes the content type. A media extension in an image/logo `src` is
            rejected (`unsupported_for_type`).

            **Asset fields:** `src` (and qrcode `icon_src`) accepts a filename — which returns a presigned S3 POST in `uploads[]` — an `https://` URL, or a data URI decoded and uploaded server-side at POST time (≤ 2 MB decoded on every design type). A qrcode's centre icon is `icon_src` and accepts the same three forms as `src`, with a data URI capped at 1 MB decoded rather than 2 MB. There is no `icon` property. The QR error-correction level is chosen for you (Q with an icon, M without) and is not a payload field.

            **Conditional dependencies** the flat schemas cannot express: `mask_name: "rounded_corners"` requires `mask_properties.radius`; `filter_name: "duotone"` requires `filter_properties.name`; each entry of a group's layer-level `layer_ids` must name a layer declared in the same format. Violations are reported as `conditional_dependency_missing`.

            `filter_properties.name` must additionally be one of the named duotone presets — `blue_green`, `blue_orange`, `deep_green_light_green`, `BrightRed_light_yellow`, `brown_pale_green`, `brown_beige`, `deep_blue_green`, `deep_blue_red`, `deep_purple_orange`, `deep_purple_beige`, `deep_purple_pale_green`, `deep_blue_pale_green`. An unlisted name is rejected with `unknown_enum_value`.

            **Groups** carry their per-format geometry like every other layer type, but their MEMBERSHIP is not per-format: `layer_ids` sits on the layer itself and names the same children in every format. A group may also carry the common layer-level `hidden`/`locked` attributes: the platform keeps a group consistent with its members automatically — setting them on import propagates the flag down to the members (per format), and on reads (`GET /designs/{designId}/formats/{formatSpecifier}`, as-import) a group's `hidden`/`locked` are computed from its members (all members locked ⇒ group locked; all hidden ⇒ group hidden).
          anyOf:
            - $ref: '#/components/schemas/DesignImportStaticLayerProperties'
            - $ref: '#/components/schemas/DesignImportPrinterLayerProperties'
            - $ref: '#/components/schemas/DesignImportAnimatedLayerProperties'
            - type: object
              description: |
                Multi-format form: keys are format names (plus the optional `"default"`), values
                are the per-design-type property objects of the three branches above.
              additionalProperties:
                anyOf:
                  - $ref: '#/components/schemas/DesignImportStaticLayerProperties'
                  - $ref: '#/components/schemas/DesignImportPrinterLayerProperties'
                  - $ref: '#/components/schemas/DesignImportAnimatedLayerProperties'
          example:
            default:
              payload: "Hello World"
              color: "#ffffff"
              font_size: 108
            youtube-thumbnail:
              font_size: 80
        animation:
          type: object
          description: |
            Animated designs only (`unsupported_for_type` on static/printer). Per-layer timeline
            timing and tweens — format-global (not keyed by format name). Layers without it span
            the whole timeline.
          properties:
            start_at_s:
              type: number
              minimum: 0
              default: 0
              description: When the layer appears on the timeline (seconds).
            end_at_s:
              type: number
              minimum: 0
              description: |
                When the layer disappears (seconds). Defaults to `animation.duration`; must be
                ≤ the timeline duration and strictly greater than `start_at_s` (`out_of_range`).
            tweens:
              type: array
              description: |
                Animation tweens. `type` is one of `slide` | `fade` | `scale` | `rotate` |
                `audioFade` | `textEffect`; `keyframes` is an array of at least two raw keyframe
                objects — deliberately untyped (several keyframe schema generations coexist);
                the only enforced invariant is that every item carries a numeric `data.time`
                (seconds). Tween ids are assigned by the API and are never part of the payload.
                Each type animates specific keyframe `attr` keys, and a tween whose (dict-era)
                keyframes carry none of them is rejected with `missing_required`: `slide` needs
                **both** `left` and `top`, `fade` needs `opacity`, **`scale` needs `scale`**,
                `rotate` needs `angle`, `audioFade` needs `volumeEffect`, `textEffect` needs
                `typewriting`. `scale` accepts no other spelling — `width`, `height` and
                `animationScale` exist on some older stored designs but animate nothing.
              items:
                type: object
                required: [type, keyframes]
                properties:
                  type:
                    type: string
                    enum: [slide, fade, scale, rotate, audioFade, textEffect]
                  keyframes:
                    type: array
                    minItems: 2
                    items:
                      type: object
                      description: >-
                        One keyframe. Several keyframe generations coexist in stored designs, so
                        extra keys are permitted rather than rejected.
                      properties:
                        attr:
                          type: object
                          additionalProperties: true
                          description: >-
                            The properties this keyframe sets, as a map of property name to value:
                            `{"opacity": 0}` (fade), `{"left": 1021, "top": 347}` (slide — always
                            both), `{"scale": 120}` (scale), `{"angle": -100}` (rotate),
                            `{"volumeEffect": 0}` (audioFade), `{"typewriting": 100,
                            "textEffectType": "classic"}` (textEffect). Values are numbers, except
                            `textEffectType` which is a string.
                          example:
                            left: 1021
                            top: 347
                        data:
                          type: object
                          properties:
                            type:
                              type: string
                              example: start
                            time:
                              type: number
                              example: 0
                      additionalProperties: true
                  scale_origin_x:
                    type: string
                    enum: [left, center, right]
                    description: |
                      **`scale` tweens only.** Horizontal anchor the layer grows/shrinks FROM,
                      relative to its own box. Must be given together with `scale_origin_y`
                      (`conditional_dependency_missing` otherwise) — the renderer applies the
                      origin only when both axes are set, so one alone would be ignored. On any
                      other tween type: `unsupported_for_type`. Omit both to scale from the
                      layer's own placement.
                    example: left
                  scale_origin_y:
                    type: string
                    enum: [top, middle, bottom]
                    description: |
                      **`scale` tweens only.** Vertical anchor the layer grows/shrinks FROM. Must
                      be given together with `scale_origin_x` — see that field.
                    example: top
              example:
                - type: fade
                  keyframes:
                    - attr: { opacity: 0 }
                      data: { type: start, time: 0 }
                    - attr: { opacity: 1 }
                      data: { type: end, time: 1.5 }

    DesignImportLayoutBox:
      type: object
      description: One layer box. Integer pixels on `static`/`animated`; floats in the design's `unit` on print.
      required: [x, y, width, height]
      properties:
        x:
          type: number
          minimum: -19999
          maximum: 19999
        y:
          type: number
          minimum: -19999
          maximum: 19999
        width:
          type: number
          minimum: 0
          exclusiveMinimum: true
          maximum: 19999
          description: Integer >= 1 (px) on static; float > 0 (unit) on printer.
        height:
          type: number
          minimum: 0
          exclusiveMinimum: true
          maximum: 19999
          description: Integer >= 1 (px) on static; float > 0 (unit) on printer.
      additionalProperties: false

    DesignImportLinks:
      type: object
      description: Hypermedia links the client should use instead of constructing URLs by hand.
      properties:
        validate:
          type: string
          format: uri
          description: URL to PUT once all `uploads[]` have been completed.
          example: "https://api.abyssale.com/designs/import/json/64238d01-d402-474b-8c2d-fbc957e9d290"
        status:
          type: string
          format: uri
          description: URL to GET for polling import status.
          example: "https://api.abyssale.com/designs/import/json/64238d01-d402-474b-8c2d-fbc957e9d290"
        dashboard:
          type: string
          format: uri
          description: |
            Human-facing link to the result. **Not always present.**

            A **workspace template** import (`target.kind: "workspace_template"`) carries it
            from creation, pointing at the Workspace Templates section keyed by import id.

            A **design** import (`target.kind: "design"`, the default) has no destination until it succeeds —
            the design does not exist before then — so the link is absent until `status` is
            `DONE`, at which point it points at the design itself.

            `PUT` never returns it: at that moment neither target exists yet.
          example: "https://app.abyssale.com/designs/9c2f31a0-8810-4c4b-bbf5-44a02e1f1d2f"

    DesignImportSummary:
      type: object
      description: |
        One row of `GET /designs/import/json`. A summary: `warnings[]` and `error` are omitted
        because they are unbounded — fetch them from `links.status` when you need them.
      properties:
        id:
          type: string
          format: uuid
          example: "64238d01-d402-474b-8c2d-fbc957e9d290"
          description: Unique identifier of the import.
        name:
          type: string
          example: "Autumn card"
          description: Name the import was created with.
        status:
          type: string
          enum: [WAITING_FOR_VALIDATION, QUEUED, PROCESSING, DONE, FAILED]
          description: |
            Effective status. An import past its presigned upload window reports `FAILED` here even
            though the import still reads `WAITING_FOR_VALIDATION`, matching the item endpoint.
        created_at_ts:
          type: integer
          example: 1649942114
          description: Unix timestamp of creation.
        validated_at_ts:
          type: integer
          nullable: true
          example: 1649942200
          description: Unix timestamp of the PUT that queued the import, or `null` if not yet validated.
        warning_count:
          type: integer
          example: 3
          description: Number of warnings accumulated so far. Read them from `links.status`.
        links:
          $ref: '#/components/schemas/DesignImportLinks'
        result:
          type: object
          nullable: true
          description: What the import produced. Present only once `status` is `DONE`.
          properties:
            kind:
              type: string
              enum: [workspace_template, design]
              description: '`workspace_template` for a workspace template, `design` for a design in a project.'
            uuid:
              type: string
              format: uuid
              description: Identifier of the created template or design.
            project_uuid:
              type: string
              format: uuid
              description: The project the design landed in. Only present when `kind` is `design`.
      required:
        - id
        - name
        - status
        - created_at_ts
        # Emitted unconditionally by `_import_summary`. `validated_at_ts` and `result` are also
        # always present but nullable, and are declared so above.
        - warning_count
        - links

    DesignImportUpload:
      type: object
      description: |
        A single S3 presigned POST. To upload, send a `multipart/form-data` request to `url`
        with every entry of `fields` as a form field, plus the file itself as the final `file` field.
      required: [target, url, fields, max_bytes, expires_at_ts]
      properties:
        target:
          type: string
          enum: [layer]
          description: What the upload is for. Always `layer` (an image/logo/video/audio `src` filename) — JSON imports have no preview uploads — thumbnails are rendered for you.
        name:
          type: string
          description: |
            **The layer's name — not a filename.** An upload entry carries no path and no
            original filename, so keep your own map from layer name to local file while you build
            the payload; it cannot be recovered from this response.
          example: "hero"
        page:
          type: string
          description: |
            **`printer_multipage` imports only** — the page this layer belongs to
            (`page_1 … page_N`). Layer names are unique per *page* on a multipage design, not per
            design, so two pages may each have a `hero`; this is what tells their uploads apart.
            Absent on every other design type.
          example: "page_1"
        url:
          type: string
          format: uri
          example: "https://s3.amazonaws.com/abyssale-template-import"
        fields:
          type: object
          description: S3 presigned POST form fields. Send all of these alongside the file.
          additionalProperties:
            type: string
        max_bytes:
          type: integer
          description: |
            Server-enforced upper bound on the upload size, in bytes. `20000000` for `static`
            layer assets; `500000000` on `printer` and `printer_multipage`; on `animated` it is
            per layer type — `100000000` for `video`, `25000000` for `audio`, `20000000` for
            `image`/`logo`. A `qrcode` layer's `icon_src` is capped at `1000000` on every design
            type.
          example: 20000000
        expires_at_ts:
          type: integer
          description: Unix timestamp when this presigned URL stops accepting uploads.
          example: 1749831334

    DesignImportCreateResponse:
      type: object
      required: [id, status, links, uploads]
      properties:
        id:
          type: string
          format: uuid
          example: "64238d01-d402-474b-8c2d-fbc957e9d290"
        status:
          type: string
          enum: [WAITING_FOR_VALIDATION, QUEUED]
          description: |
            `WAITING_FOR_VALIDATION` if any uploads are required.
            `QUEUED` (sync short-circuit) when every `src` is a public URL or data URI — `uploads[]` is empty and no PUT call is needed.
          example: "WAITING_FOR_VALIDATION"
        links:
          $ref: '#/components/schemas/DesignImportLinks'
        uploads:
          type: array
          description: Flat list of presigned uploads. Empty when no uploads are needed.
          items:
            $ref: '#/components/schemas/DesignImportUpload'
        version:
          $ref: '#/components/schemas/ApiVersion'

    DesignImportValidateResponse:
      type: object
      required: [id, status, next_check_after_ms, links]
      properties:
        id:
          type: string
          format: uuid
          example: "64238d01-d402-474b-8c2d-fbc957e9d290"
        status:
          type: string
          enum: [QUEUED]
          example: "QUEUED"
        next_check_after_ms:
          type: integer
          nullable: true
          example: 2000
        links:
          $ref: '#/components/schemas/DesignImportLinks'
        version:
          $ref: '#/components/schemas/ApiVersion'

    DesignImportStatusResponse:
      type: object
      # Every key is emitted unconditionally; the ones that can be empty are declared nullable.
      required:
        - id
        - name
        - status
        - created_at_ts
        - validated_at_ts
        - next_check_after_ms
        - links
        - warnings
        - error
        - result
      properties:
        id:
          type: string
          format: uuid
          example: "64238d01-d402-474b-8c2d-fbc957e9d290"
        name:
          type: string
          example: "Holiday Campaign"
        status:
          type: string
          enum: [WAITING_FOR_VALIDATION, QUEUED, PROCESSING, DONE, FAILED]
          example: "PROCESSING"
        created_at_ts:
          type: integer
          example: 1234567890
        validated_at_ts:
          type: integer
          nullable: true
          description: Unix timestamp when PUT was called. `null` until validated.
          example: null
        next_check_after_ms:
          type: integer
          nullable: true
          description: Suggested delay before polling again. `null` on terminal states (`DONE`, `FAILED`).
          example: 2000
        links:
          $ref: '#/components/schemas/DesignImportLinks'
        warnings:
          type: array
          description: |
            Stable append-only list of non-fatal backend decisions (font fallback, image scaled
            down, etc.).

            Warning codes: `font_weight_adjusted`, `text_fit_failed` (non-strict),
            `preview_generation_failed`, `property_dropped_for_type` (a property the design type
            cannot render — `shadow`, `text_shadow`, `pattern_name`, `pattern_color`,
            `overlay` on print — was accepted and removed from the imported design; one entry per
            occurrence),
            `print_settings_not_recoverable`, `animation_duration_derived`,
            `color_converted` (a solid color was converted into the design type's color space —
            hex → cmyk on printer, cmyk → hex on static/animated; emitted by the edge at POST time
            and by `as-import`),
            `property_not_representable` (`as-import` only — the design stores a property the
            import schema does not model, so it was omitted from the exported payload).

            `preview_generation_failed` is reported at `formats` on
            `static`/`printer`/`animated`; on `printer_multipage` it names the failing page —
            one warning per failing page at `pages[i]` (bare `pages` when the page index cannot
            be resolved).
          items:
            $ref: '#/components/schemas/Warning'
        error:
          # NOT `allOf: [$ref Problem]` with `nullable: true`: in OpenAPI 3.0 the
          # nullability does not reach inside `allOf`, so the `null` this field actually returns
          # failed validation against its own schema. Same shape, declared directly — as `result`
          # below already does.
          type: object
          nullable: true
          description: |
            The failure, when `status` is `FAILED`; `null` otherwise. Same shape as an entry of
            the `errors[]` array — see `Problem`.
          properties:
            path:
              type: string
              description: |
                Where the failure occurred, e.g. `layers[3].properties.font_size`. When the
                failure came from a layer, this is how you identify it — there is no separate
                layer field here, unlike a `Warning`.
            code:
              type: string
              description: Stable, machine-readable code — branch on this, not on `message`.
            message:
              type: string
              description: Human-readable explanation.
        result:
          type: object
          nullable: true
          description: |
            What the import produced. `null` until `status` is `DONE`, then polymorphic on the
            requested `target.kind`: `{ kind: "workspace_template", uuid }` or
            `{ kind: "design", uuid, project_uuid }`.
          properties:
            kind:
              type: string
              enum: [workspace_template, design]
              example: "design"
            uuid:
              type: string
              format: uuid
              description: UUID of the created template or design.
            project_uuid:
              type: string
              format: uuid
              description: Project UUID (present when `kind` is `design`).
        version:
          $ref: '#/components/schemas/ApiVersion'

    Warning:
      type: object
      description: |
        One non-fatal note attached to an otherwise successful response — the `warnings` array on
        `GET /designs/{designId}/as-import` and on the import status endpoint. Only `message` is
        guaranteed: an entry with no `code` is informational and carries no stable machine
        identity, so branch only on entries that have one.

        Same field meanings as `Problem`; split from it because `code` is required there.
      required: [message]
      properties:
        message:
          type: string
          description: Human-readable explanation.
        code:
          type: string
          description: |
            Stable, machine-readable code, when the warning has one. Absent on purely
            informational entries.
          example: color_converted
        path:
          type: string
          description: Where in the payload the warning applies, same syntax as `Problem.path`.
          example: "layers[2].properties.color"
        layer:
          type: string
          description: |
            Name of the layer the entry belongs to, when it was raised while transforming a
            layer. An index in `path` identifies a position in the emitted array, which is not
            the name the caller sees in the editor — group on this rather than parsing `path`.
          example: headline

    Problem:
      type: object
      description: |
        One field-level error inside `ErrorResponse.errors`. `code` and `message` are always
        present; `path` is omitted when the error is not about a particular field (an unhandled
        `500`, for instance). Informational warnings are a DIFFERENT shape — see `Warning` —
        because a warning may legitimately carry no `code`, and one schema cannot say
        "required here, optional there".
      required: [code, message]
      properties:
        path:
          type: string
          description: |
            Path into the request body where the problem applies. **Syntax (normative):** object
            keys are dotted, array indices are bracketed — `formats[0].width`,
            `pages[2].layers[7].layout.x`, `target.project_uuid`. A whole array/object is named
            bare (`layers`, `pages`, `body`, `uploads`), as are the document-level
            `printer_multipage` print settings (`unit`, `width`, `height`, `dpi`).
          example: "layers[2].properties.font_size"
        code:
          type: string
          description: |
            Stable, machine-readable code. Agents should branch on `code`, not on `message`.

            Payload/validation codes: `missing_required`, `unknown_field`, `wrong_type`,
            `out_of_range`, `unknown_enum_value`, `unknown_format_key`, `duplicate_layer_name`,
            `duplicate_format_name`, `reserved_format_name`, `project_not_found` (**400**),
            `conditional_dependency_missing`, `invalid_payload`, `unsupported_for_type`,
            `unknown_font`, `unreachable_src`, `mutually_exclusive` (two fields that cannot be
            combined were both supplied).
            Upload/lifecycle codes: `missing_assets`, `template_import_already_processed`,
            `not_found` (**404** — the import does not exist for this company).
            Export code: `not_round_trippable`.
            Transport codes: `invalid_query_param` (an unparseable query-parameter value; `path`
            is the parameter name), `internal_error` (an unhandled server-side error, returned in
            this same envelope with a 500 status).
            Build-phase failure codes (surfaced in the status response's `error`):
            `text_fit_failed`, `image_processing_failed` (genuine image failures only),
            `media_processing_failed` (a video/audio container could not be resolved or
            processed), `invalid_asset` (an asset arrived but could not be decoded — truncated or
            corrupt), `font_resolution_failed`, `import_failed` (fallback for a processing failure
            with no more specific code).
          example: "out_of_range"
        message:
          type: string
          example: "font_size must be between 2 and 1000"
        expected:
          oneOf:
            - type: array
              items:
                type: string
              description: Allowed values, for an enum problem.
            - type: object
              description: "Bounds, for a range problem — `{ min, max }`."
              properties:
                min: {type: number}
                max: {type: number}
            - type: string
          description: |
            Type-dependent. For enums an array of allowed values; for ranges `{ min, max }`; for
            `missing_assets` the list of unfulfilled upload targets. Present on `out_of_range` and
            `unknown_enum_value` for schema-derived problems as well as hand-written ones.
        received:
          oneOf:
            - type: string
            - type: number
            - type: boolean
            - type: object
              additionalProperties: true
            - type: array
              items: {}
          description: >-
            The offending value, echoed back as it arrived — any JSON type. Omitted when not
            meaningful.

    # <<< BEGIN GENERATED: template-import layer property schemas >>>
    # Generated — do not edit by hand. Run the generator; CI fails when this block is stale.
    #
    # One component per (design-type variant, layer type). `unknown_field` is rejected by the
    # import validator, hence `additionalProperties: false` throughout.

    DesignImportFilterProperties:
      type: object
      description: 'The `filter_name` companion blob. `name` selects a duotone preset.'
      required: [name]
      properties:
        name:
          type: string
          enum: [blue_green, blue_orange, deep_green_light_green, BrightRed_light_yellow, brown_pale_green, brown_beige, deep_blue_green, deep_blue_red, deep_purple_orange, deep_purple_beige, deep_purple_pale_green, deep_blue_pale_green]
      additionalProperties: false

    DesignImportIllustrationProperties:
      type: object
      description: 'Recolouring for a library illustration.'
      required: [primary_color]
      properties:
        primary_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
      additionalProperties: false

    DesignImportLayerMaskProperties:
      type: object
      description: 'The `mask_name` companion blob. `radius` is its ONLY field.'
      properties:
        radius:
          description: 'One number applying to every side, or an object with `tl`, `tr`, `bl`, `br`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: integer
              minimum: 0
              maximum: 1000
            - $ref: '#/components/schemas/DesignImportRadiusProperties'
      additionalProperties: false

    DesignImportMaskProperties:
      type: object
      description: 'The mask a `group` clips its members with. Its presence is what makes the group masked.'
      required: [shape, width, height, center_x, center_y]
      properties:
        shape:
          type: string
          enum: [rectangle, circle]
        width:
          type: integer
          minimum: 0
          maximum: 20000
        height:
          type: integer
          minimum: 0
          maximum: 20000
        center_x:
          type: integer
          minimum: -20000
          maximum: 20000
        center_y:
          type: integer
          minimum: -20000
          maximum: 20000
        radius:
          description: 'One number applying to every side, or an object with `tl`, `tr`, `bl`, `br`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: integer
              minimum: 0
              maximum: 1000
            - $ref: '#/components/schemas/DesignImportRadiusProperties'
        rx:
          type: integer
          minimum: 0
          maximum: 20000
        ry:
          type: integer
          minimum: 0
          maximum: 20000
        rotation:
          type: number
          minimum: 0
          maximum: 360
      additionalProperties: false

    DesignImportOverlayProperties:
      type: object
      description: 'A two-colour gradient overlay on an image.'
      required: [direction, color_1, color_2]
      properties:
        direction:
          type: string
          enum: [horizontal, vertical, diagonal]
        color_1:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        color_2:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
      additionalProperties: false

    DesignImportPaddingProperties:
      type: object
      description: 'Text/button padding. The editor stores ONE vertical and ONE horizontal value — top and bottom cannot differ, nor left and right. A four-sided object was accepted until now and silently mirrored (`5,6,7,8` came back `5,6,5,6`), which is worse than refusing it.'
      required: [vertical, horizontal]
      properties:
        vertical:
          type: integer
          minimum: -1000
          maximum: 1000
        horizontal:
          type: integer
          minimum: -1000
          maximum: 1000
      additionalProperties: false

    DesignImportPrinterLayerMaskProperties:
      type: object
      description: 'Unit-domain floats, so the px corner caps come off — as everywhere else on printer.'
      properties:
        radius:
          description: 'One number applying to every side, or an object with `tl`, `tr`, `bl`, `br`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: number
              minimum: 0
            - $ref: '#/components/schemas/DesignImportPrinterRadiusProperties'
      additionalProperties: false

    DesignImportPrinterMaskProperties:
      type: object
      description: 'Unit-domain floats, so the px caps come off — only the sign constraints survive.'
      required: [shape, width, height, center_x, center_y]
      properties:
        shape:
          type: string
          enum: [rectangle, circle]
        width:
          type: number
          minimum: 0
        height:
          type: number
          minimum: 0
        center_x:
          type: number
        center_y:
          type: number
        radius:
          description: 'One number applying to every side, or an object with `tl`, `tr`, `bl`, `br`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: number
              minimum: 0
            - $ref: '#/components/schemas/DesignImportPrinterRadiusProperties'
        rx:
          type: number
          minimum: 0
        ry:
          type: number
          minimum: 0
        rotation:
          type: number
          minimum: 0
          maximum: 360
      additionalProperties: false

    DesignImportPrinterPaddingProperties:
      type: object
      description: 'Reusable nested printer padding object of a template-import layer property set.'
      required: [vertical, horizontal]
      properties:
        vertical:
          type: number
        horizontal:
          type: number
      additionalProperties: false

    DesignImportPrinterRadiusProperties:
      type: object
      description: 'Reusable nested printer radius object of a template-import layer property set.'
      required: [tl, tr, bl, br]
      properties:
        tl:
          type: number
          minimum: 0
        tr:
          type: number
          minimum: 0
        bl:
          type: number
          minimum: 0
        br:
          type: number
          minimum: 0
      additionalProperties: false

    DesignImportPrinterSideBorderProperties:
      type: object
      description: 'Unit-domain floats, so the px caps come off — only the sign constraints survive.'
      required: [side]
      properties:
        side:
          type: string
          enum: [left, right, top, bottom, none]
        thickness:
          type: number
          minimum: 0
        color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        rounded:
          type: boolean
        padding:
          type: number
          minimum: 0
        offset:
          type: number
        spread:
          type: number
          minimum: 0
      additionalProperties: false

    DesignImportPrinterStrokeProperties:
      type: object
      description: 'Reusable nested printer stroke object of a template-import layer property set.'
      required: [color, width]
      properties:
        color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        width:
          type: number
          minimum: 0
      additionalProperties: false

    DesignImportRadiusProperties:
      type: object
      description: 'Per-corner radius — image, logo and shape only. A button''s radius is a single number: the editor stores one scalar for it and cannot round its corners independently.'
      required: [tl, tr, bl, br]
      properties:
        tl:
          type: integer
          minimum: 0
          maximum: 1000
        tr:
          type: integer
          minimum: 0
          maximum: 1000
        bl:
          type: integer
          minimum: 0
          maximum: 1000
        br:
          type: integer
          minimum: 0
          maximum: 1000
      additionalProperties: false

    DesignImportShadowProperties:
      type: object
      description: 'Reusable nested shadow object of a template-import layer property set.'
      required: [blur, offset_x, offset_y]
      properties:
        blur:
          type: integer
          minimum: 0
          maximum: 200
        offset_x:
          type: integer
          minimum: -200
          maximum: 200
        offset_y:
          type: integer
          minimum: -200
          maximum: 200
        color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          default: '#000000'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
      additionalProperties: false

    DesignImportSideBorderProperties:
      type: object
      description: 'A rule along one edge of a text box.'
      required: [side]
      properties:
        side:
          type: string
          enum: [left, right, top, bottom, none]
        thickness:
          type: number
          minimum: 0
          maximum: 1000
        color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        rounded:
          type: boolean
        padding:
          type: number
          minimum: 0
          maximum: 1000
        offset:
          type: number
          minimum: -1000
          maximum: 1000
        spread:
          type: number
          minimum: 0
          maximum: 1000
      additionalProperties: false

    DesignImportStrokeProperties:
      type: object
      description: 'Reusable nested stroke object of a template-import layer property set.'
      required: [color, width]
      properties:
        color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        width:
          type: integer
          minimum: 0
          maximum: 1000
      additionalProperties: false

    DesignImportTextLayerProperties:
      type: object
      description: 'Properties of a `text` layer on `type: "static"` designs, and the shared layer types of `type: "animated"`.'
      required: [font_size]
      properties:
        payload:
          type: string
          maxLength: 10000
          default: ''
        color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^linear-gradient\(([0-9]{1,3}% [0-9]{1,3}% [0-9]{1,3}% [0-9]{1,3}%,[0-9]{1,3}% #[0-9A-F]{6} \d+(\.\d+)?,[0-9]{1,3}% #[0-9A-F]{6} \d+(\.\d+)?)\)$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          default: '#000000'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        font:
          type: string
          minLength: 36
          maxLength: 36
          default: '6156629c-33c5-11ea-9877-92672c1b8195'
        font_size:
          type: integer
          minimum: 2
          maximum: 1000
        min_font_size:
          type: number
          minimum: 2
          maximum: 1000
        font_weight:
          type: integer
          enum: [100, 200, 300, 400, 500, 600, 700, 800, 900]
          default: 400
        font_style:
          type: string
          enum: [normal, italic]
          default: normal
        text_align:
          type: string
          enum: [left, center, right]
          default: left
        vertical_align:
          type: string
          enum: [top, middle, bottom]
          default: top
        text_decoration:
          type: string
          enum: [underline, strikethrough, diagonal-strikethrough]
          nullable: true
        text_decoration_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        text_transform:
          type: string
          enum: [uppercase, lowercase, titlecase]
          nullable: true
        line_height:
          type: integer
          minimum: 1
          maximum: 500
          default: 130
        char_spacing:
          type: integer
          minimum: -100
          maximum: 1000
          default: 0
        skew_y:
          type: number
          minimum: -20
          maximum: 20
        background_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        padding:
          description: 'One number applying to every side, or an object with `vertical`, `horizontal`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: integer
              minimum: -1000
              maximum: 1000
            - $ref: '#/components/schemas/DesignImportPaddingProperties'
        background_fill:
          type: string
          enum: [normal, width]
        background_mode:
          type: string
          enum: [highlight, paragraph]
        background_radius:
          type: integer
          minimum: 0
          maximum: 1000
        background_offset_x:
          type: integer
          minimum: -1000
          maximum: 1000
        background_offset_y:
          type: integer
          minimum: -1000
          maximum: 1000
        rotation:
          type: number
          minimum: 0
          maximum: 360
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        stroke:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportStrokeProperties'
          nullable: true
        shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
        side_border:
          $ref: '#/components/schemas/DesignImportSideBorderProperties'
      additionalProperties: false

    DesignImportImageLayerProperties:
      type: object
      description: 'Properties of a `image` layer on `type: "static"` designs, and the shared layer types of `type: "animated"`.'
      properties:
        alignment:
          type: string
          enum: ['top left', 'top center', 'top right', 'middle left', 'middle center', 'middle right', 'bottom left', 'bottom center', 'bottom right', custom, top, middle, bottom, left, center, right]
          default: 'middle center'
        position_offset_x:
          type: number
          minimum: -0.5
          maximum: 0.5
        position_offset_y:
          type: number
          minimum: -0.5
          maximum: 0.5
        zoom:
          type: number
          minimum: 1.0
          maximum: 20.0
          default: 1.0
        src:
          type: string
          minLength: 1
          maxLength: 3000000
        fitting_type:
          type: string
          enum: [cover, fill]
          default: fill
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
        mask_name:
          type: string
          enum: [circle, rounded_corners, blob, squircle, pentagon, hexagon, parallelogram, window]
        mask_properties:
          $ref: '#/components/schemas/DesignImportLayerMaskProperties'
        filter_name:
          type: string
          enum: [grayscale, duotone]
        filter_properties:
          $ref: '#/components/schemas/DesignImportFilterProperties'
        overlay:
          $ref: '#/components/schemas/DesignImportOverlayProperties'
        remove_bg:
          type: boolean
        remove_bg_properties:
          type: object
          additionalProperties: true
        radius:
          description: 'One number applying to every side, or an object with `tl`, `tr`, `bl`, `br`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: integer
              minimum: 0
              maximum: 1000
              nullable: true
            - $ref: '#/components/schemas/DesignImportRadiusProperties'
        shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
      additionalProperties: false

    DesignImportLogoLayerProperties:
      type: object
      description: 'Properties of a `logo` layer on `type: "static"` designs, and the shared layer types of `type: "animated"`.'
      properties:
        alignment:
          type: string
          enum: ['top left', 'top center', 'top right', 'middle left', 'middle center', 'middle right', 'bottom left', 'bottom center', 'bottom right', custom, top, middle, bottom, left, center, right]
          default: 'middle center'
        position_offset_x:
          type: number
          minimum: -0.5
          maximum: 0.5
        position_offset_y:
          type: number
          minimum: -0.5
          maximum: 0.5
        zoom:
          type: number
          minimum: 1.0
          maximum: 20.0
          default: 1.0
        src:
          type: string
          minLength: 1
          maxLength: 3000000
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
        radius:
          description: 'One number applying to every side, or an object with `tl`, `tr`, `bl`, `br`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: integer
              minimum: 0
              maximum: 1000
              nullable: true
            - $ref: '#/components/schemas/DesignImportRadiusProperties'
        shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
      additionalProperties: false

    DesignImportShapeLayerProperties:
      type: object
      description: 'Properties of a `shape` layer on `type: "static"` designs, and the shared layer types of `type: "animated"`.'
      properties:
        shape:
          type: string
          enum: [rectangle, triangle, right_triangle, circle, half_circle, star_1, star_2, pentagon, hexagon, hexagram, rhombus, parallelogram, trapeze, squircle, curved_diamond, window, frame, arrow, cross, gear, rounded_gear, blob, cloud, heart]
          default: rectangle
        background_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^linear-gradient\(([0-9]{1,3}% [0-9]{1,3}% [0-9]{1,3}% [0-9]{1,3}%,[0-9]{1,3}% #[0-9A-F]{6} \d+(\.\d+)?,[0-9]{1,3}% #[0-9A-F]{6} \d+(\.\d+)?)\)$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          default: '#000000'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        pattern_name:
          type: string
          minLength: 1
          maxLength: 100
        pattern_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
        stroke:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportStrokeProperties'
          nullable: true
        radius:
          description: 'One number applying to every side, or an object with `tl`, `tr`, `bl`, `br`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: integer
              minimum: 0
              maximum: 1000
              nullable: true
            - $ref: '#/components/schemas/DesignImportRadiusProperties'
        shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
      additionalProperties: false

    DesignImportButtonLayerProperties:
      type: object
      description: 'Properties of a `button` layer on `type: "static"` designs, and the shared layer types of `type: "animated"`.'
      required: [font_size]
      properties:
        payload:
          type: string
          maxLength: 2048
          default: ''
        background_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^linear-gradient\(([0-9]{1,3}% [0-9]{1,3}% [0-9]{1,3}% [0-9]{1,3}%,[0-9]{1,3}% #[0-9A-F]{6} \d+(\.\d+)?,[0-9]{1,3}% #[0-9A-F]{6} \d+(\.\d+)?)\)$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          default: '#000000'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^linear-gradient\(([0-9]{1,3}% [0-9]{1,3}% [0-9]{1,3}% [0-9]{1,3}%,[0-9]{1,3}% #[0-9A-F]{6} \d+(\.\d+)?,[0-9]{1,3}% #[0-9A-F]{6} \d+(\.\d+)?)\)$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          default: '#ffffff'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        font:
          type: string
          minLength: 36
          maxLength: 36
          default: '6156629c-33c5-11ea-9877-92672c1b8195'
        font_size:
          type: integer
          minimum: 2
          maximum: 1000
        min_font_size:
          type: number
          minimum: 2
          maximum: 1000
        font_weight:
          type: integer
          enum: [100, 200, 300, 400, 500, 600, 700, 800, 900]
          default: 400
        font_style:
          type: string
          enum: [normal, italic]
          default: normal
        text_decoration:
          type: string
          enum: [underline, strikethrough, diagonal-strikethrough]
          nullable: true
        text_decoration_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        text_transform:
          type: string
          enum: [uppercase, lowercase, titlecase]
          nullable: true
        text_align:
          type: string
          enum: [left, center, right]
          default: center
        alignment:
          type: string
          enum: ['top left', 'top center', 'top right', 'middle left', 'middle center', 'middle right', 'bottom left', 'bottom center', 'bottom right']
          default: 'middle center'
        line_height:
          type: integer
          minimum: 1
          maximum: 500
          default: 130
        char_spacing:
          type: integer
          minimum: -100
          maximum: 1000
          default: 0
        padding:
          description: 'One number applying to every side, or an object with `vertical`, `horizontal`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: integer
              minimum: -1000
              maximum: 1000
            - $ref: '#/components/schemas/DesignImportPaddingProperties'
        radius:
          type: integer
          minimum: 0
          maximum: 1000
          nullable: true
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
        stroke:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportStrokeProperties'
          nullable: true
        text_shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
        shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
      additionalProperties: false

    DesignImportRatingLayerProperties:
      type: object
      description: 'Properties of a `rating` layer on `type: "static"` designs, and the shared layer types of `type: "animated"`.'
      properties:
        rating_score:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        star_dimension:
          type: number
          minimum: 4
          maximum: 400
        star_margin:
          type: number
          minimum: 0
          maximum: 100
        star_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^linear-gradient\(([0-9]{1,3}% [0-9]{1,3}% [0-9]{1,3}% [0-9]{1,3}%,[0-9]{1,3}% #[0-9A-F]{6} \d+(\.\d+)?,[0-9]{1,3}% #[0-9A-F]{6} \d+(\.\d+)?)\)$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        background_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^linear-gradient\(([0-9]{1,3}% [0-9]{1,3}% [0-9]{1,3}% [0-9]{1,3}%,[0-9]{1,3}% #[0-9A-F]{6} \d+(\.\d+)?,[0-9]{1,3}% #[0-9A-F]{6} \d+(\.\d+)?)\)$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
        shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
      additionalProperties: false

    DesignImportQrCodeLayerProperties:
      type: object
      description: 'Properties of a `qrcode` layer on `type: "static"` designs, and the shared layer types of `type: "animated"`.'
      properties:
        payload:
          type: string
          maxLength: 2048
          default: ''
        foreground_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          default: '#000000'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        background_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        icon_src:
          type: string
          minLength: 1
          maxLength: 3000000
        margin:
          type: integer
          minimum: 0
          maximum: 100
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
      additionalProperties: false

    DesignImportGroupLayerProperties:
      type: object
      description: 'Properties of a `group` layer on `type: "static"` designs, and the shared layer types of `type: "animated"`.'
      properties:
        auto_layout:
          type: boolean
          default: false
        direction:
          type: string
          enum: [horizontal, vertical]
          default: horizontal
        alignment:
          type: string
          enum: ['top left', 'top center', 'top right', 'middle left', 'middle center', 'middle right', 'bottom left', 'bottom center', 'bottom right', top, middle, bottom, left, center, right]
        gap:
          type: integer
          minimum: 0
          maximum: 1000
          default: 10
        mask:
          $ref: '#/components/schemas/DesignImportMaskProperties'
      additionalProperties: false

    DesignImportIllustrationLayerProperties:
      type: object
      description: 'Properties of a `illustration` layer on `type: "static"` designs, and the shared layer types of `type: "animated"`.'
      required: [illustration_type, illustration_file]
      properties:
        illustration_type:
          type: string
          enum: [undraw, feather, twemoji, material_icons]
        illustration_file:
          type: string
          minLength: 1
          maxLength: 255
        illustration_properties:
          $ref: '#/components/schemas/DesignImportIllustrationProperties'
        alignment:
          type: string
          enum: ['top left', 'top center', 'top right', 'middle left', 'middle center', 'middle right', 'bottom left', 'bottom center', 'bottom right', top, middle, bottom, left, center, right]
          default: 'middle center'
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
      additionalProperties: false

    DesignImportPrinterTextLayerProperties:
      type: object
      description: 'Properties of a `text` layer on `type: "printer"` and `type: "printer_multipage"` designs. Accepted but DROPPED: `shadow` — accepted, then removed from the imported design with a `property_dropped_for_type` warning. The import succeeds.'
      required: [font_size]
      properties:
        payload:
          type: string
          maxLength: 10000
          default: ''
        color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          default: 'cmyk(0,0,0,100)'
          description: 'A `linear-gradient(...)` value is rejected with `unsupported_for_type` on this design type: the print pipeline collapses a gradient to its first stop, so it would ship different artwork. Author `cmyk(...)` directly, or a solid hex (converted, with a `color_converted` warning). Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        font:
          type: string
          minLength: 36
          maxLength: 36
          default: '6156629c-33c5-11ea-9877-92672c1b8195'
        font_size:
          type: number
          minimum: 0.48
          maximum: 240.0
        min_font_size:
          type: number
          minimum: 0.48
          maximum: 240.0
        font_weight:
          type: integer
          enum: [100, 200, 300, 400, 500, 600, 700, 800, 900]
          default: 400
        font_style:
          type: string
          enum: [normal, italic]
          default: normal
        text_align:
          type: string
          enum: [left, center, right]
          default: left
        vertical_align:
          type: string
          enum: [top, middle, bottom]
          default: top
        text_decoration:
          type: string
          enum: [underline, strikethrough, diagonal-strikethrough]
          nullable: true
        text_decoration_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        text_transform:
          type: string
          enum: [uppercase, lowercase, titlecase]
          nullable: true
        line_height:
          type: integer
          minimum: 1
          maximum: 500
          default: 130
        char_spacing:
          type: integer
          minimum: -100
          maximum: 1000
          default: 0
        skew_y:
          type: number
          minimum: -20
          maximum: 20
        background_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        padding:
          description: 'One number applying to every side, or an object with `vertical`, `horizontal`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: number
            - $ref: '#/components/schemas/DesignImportPrinterPaddingProperties'
        background_fill:
          type: string
          enum: [normal, width]
        background_mode:
          type: string
          enum: [highlight, paragraph]
        background_radius:
          type: number
          minimum: 0
        background_offset_x:
          type: number
        background_offset_y:
          type: number
        rotation:
          type: number
          minimum: 0
          maximum: 360
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        stroke:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportPrinterStrokeProperties'
          nullable: true
        shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
        side_border:
          $ref: '#/components/schemas/DesignImportPrinterSideBorderProperties'
      additionalProperties: false

    DesignImportPrinterImageLayerProperties:
      type: object
      description: 'Properties of a `image` layer on `type: "printer"` and `type: "printer_multipage"` designs. Accepted but DROPPED: `overlay`, `shadow` — accepted, then removed from the imported design with a `property_dropped_for_type` warning. The import succeeds.'
      properties:
        alignment:
          type: string
          enum: ['top left', 'top center', 'top right', 'middle left', 'middle center', 'middle right', 'bottom left', 'bottom center', 'bottom right', custom, top, middle, bottom, left, center, right]
          default: 'middle center'
        position_offset_x:
          type: number
          minimum: -0.5
          maximum: 0.5
        position_offset_y:
          type: number
          minimum: -0.5
          maximum: 0.5
        zoom:
          type: number
          minimum: 1.0
          maximum: 20.0
          default: 1.0
        src:
          type: string
          minLength: 1
          maxLength: 3000000
        fitting_type:
          type: string
          enum: [cover, fill]
          default: fill
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
        mask_name:
          type: string
          enum: [circle, rounded_corners, blob, squircle, pentagon, hexagon, parallelogram, window]
        mask_properties:
          $ref: '#/components/schemas/DesignImportPrinterLayerMaskProperties'
        filter_name:
          type: string
          enum: [grayscale, duotone]
        filter_properties:
          $ref: '#/components/schemas/DesignImportFilterProperties'
        overlay:
          $ref: '#/components/schemas/DesignImportOverlayProperties'
        remove_bg:
          type: boolean
        remove_bg_properties:
          type: object
          additionalProperties: true
        radius:
          description: 'One number applying to every side, or an object with `tl`, `tr`, `bl`, `br`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: number
              minimum: 0
              nullable: true
            - $ref: '#/components/schemas/DesignImportPrinterRadiusProperties'
        shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
      additionalProperties: false

    DesignImportPrinterLogoLayerProperties:
      type: object
      description: 'Properties of a `logo` layer on `type: "printer"` and `type: "printer_multipage"` designs. Accepted but DROPPED: `shadow` — accepted, then removed from the imported design with a `property_dropped_for_type` warning. The import succeeds.'
      properties:
        alignment:
          type: string
          enum: ['top left', 'top center', 'top right', 'middle left', 'middle center', 'middle right', 'bottom left', 'bottom center', 'bottom right', custom, top, middle, bottom, left, center, right]
          default: 'middle center'
        position_offset_x:
          type: number
          minimum: -0.5
          maximum: 0.5
        position_offset_y:
          type: number
          minimum: -0.5
          maximum: 0.5
        zoom:
          type: number
          minimum: 1.0
          maximum: 20.0
          default: 1.0
        src:
          type: string
          minLength: 1
          maxLength: 3000000
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
        radius:
          description: 'One number applying to every side, or an object with `tl`, `tr`, `bl`, `br`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: number
              minimum: 0
              nullable: true
            - $ref: '#/components/schemas/DesignImportPrinterRadiusProperties'
        shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
      additionalProperties: false

    DesignImportPrinterShapeLayerProperties:
      type: object
      description: 'Properties of a `shape` layer on `type: "printer"` and `type: "printer_multipage"` designs. Accepted but DROPPED: `pattern_color`, `pattern_name`, `shadow` — accepted, then removed from the imported design with a `property_dropped_for_type` warning. The import succeeds.'
      properties:
        shape:
          type: string
          enum: [rectangle, triangle, right_triangle, circle, half_circle, star_1, star_2, pentagon, hexagon, hexagram, rhombus, parallelogram, trapeze, squircle, curved_diamond, window, frame, arrow, cross, gear, rounded_gear, blob, cloud, heart]
          default: rectangle
        background_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          default: 'cmyk(0,0,0,100)'
          description: 'A `linear-gradient(...)` value is rejected with `unsupported_for_type` on this design type: the print pipeline collapses a gradient to its first stop, so it would ship different artwork. Author `cmyk(...)` directly, or a solid hex (converted, with a `color_converted` warning). Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        pattern_name:
          type: string
          minLength: 1
          maxLength: 100
        pattern_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
        stroke:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportPrinterStrokeProperties'
          nullable: true
        radius:
          description: 'One number applying to every side, or an object with `tl`, `tr`, `bl`, `br`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: number
              minimum: 0
              nullable: true
            - $ref: '#/components/schemas/DesignImportPrinterRadiusProperties'
        shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
      additionalProperties: false

    DesignImportPrinterButtonLayerProperties:
      type: object
      description: 'Properties of a `button` layer on `type: "printer"` and `type: "printer_multipage"` designs. Accepted but DROPPED: `shadow`, `text_shadow` — accepted, then removed from the imported design with a `property_dropped_for_type` warning. The import succeeds.'
      required: [font_size]
      properties:
        payload:
          type: string
          maxLength: 2048
          default: ''
        background_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          default: 'cmyk(0,0,0,100)'
          description: 'A `linear-gradient(...)` value is rejected with `unsupported_for_type` on this design type: the print pipeline collapses a gradient to its first stop, so it would ship different artwork. Author `cmyk(...)` directly, or a solid hex (converted, with a `color_converted` warning). Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          default: 'cmyk(0,0,0,0)'
          description: 'A `linear-gradient(...)` value is rejected with `unsupported_for_type` on this design type: the print pipeline collapses a gradient to its first stop, so it would ship different artwork. Author `cmyk(...)` directly, or a solid hex (converted, with a `color_converted` warning). Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        font:
          type: string
          minLength: 36
          maxLength: 36
          default: '6156629c-33c5-11ea-9877-92672c1b8195'
        font_size:
          type: number
          minimum: 0.48
          maximum: 240.0
        min_font_size:
          type: number
          minimum: 0.48
          maximum: 240.0
        font_weight:
          type: integer
          enum: [100, 200, 300, 400, 500, 600, 700, 800, 900]
          default: 400
        font_style:
          type: string
          enum: [normal, italic]
          default: normal
        text_decoration:
          type: string
          enum: [underline, strikethrough, diagonal-strikethrough]
          nullable: true
        text_decoration_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        text_transform:
          type: string
          enum: [uppercase, lowercase, titlecase]
          nullable: true
        text_align:
          type: string
          enum: [left, center, right]
          default: center
        alignment:
          type: string
          enum: ['top left', 'top center', 'top right', 'middle left', 'middle center', 'middle right', 'bottom left', 'bottom center', 'bottom right']
          default: 'middle center'
        line_height:
          type: integer
          minimum: 1
          maximum: 500
          default: 130
        char_spacing:
          type: integer
          minimum: -100
          maximum: 1000
          default: 0
        padding:
          description: 'One number applying to every side, or an object with `vertical`, `horizontal`. The export emits the number whenever the sides agree.'
          anyOf:
            - type: number
            - $ref: '#/components/schemas/DesignImportPrinterPaddingProperties'
        radius:
          type: number
          minimum: 0
          nullable: true
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
        stroke:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportPrinterStrokeProperties'
          nullable: true
        text_shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
        shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
      additionalProperties: false

    DesignImportPrinterRatingLayerProperties:
      type: object
      description: 'Properties of a `rating` layer on `type: "printer"` and `type: "printer_multipage"` designs. Accepted but DROPPED: `shadow` — accepted, then removed from the imported design with a `property_dropped_for_type` warning. The import succeeds.'
      properties:
        rating_score:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        star_dimension:
          type: number
          minimum: 0
          exclusiveMinimum: true
        star_margin:
          type: number
          minimum: 0
        star_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'A `linear-gradient(...)` value is rejected with `unsupported_for_type` on this design type: the print pipeline collapses a gradient to its first stop, so it would ship different artwork. Author `cmyk(...)` directly, or a solid hex (converted, with a `color_converted` warning). Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        background_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'A `linear-gradient(...)` value is rejected with `unsupported_for_type` on this design type: the print pipeline collapses a gradient to its first stop, so it would ship different artwork. Author `cmyk(...)` directly, or a solid hex (converted, with a `color_converted` warning). Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
        shadow:
          type: object
          allOf:
            - $ref: '#/components/schemas/DesignImportShadowProperties'
          nullable: true
      additionalProperties: false

    DesignImportPrinterQrCodeLayerProperties:
      type: object
      description: 'Properties of a `qrcode` layer on `type: "printer"` and `type: "printer_multipage"` designs.'
      properties:
        payload:
          type: string
          maxLength: 2048
          default: ''
        foreground_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          default: 'cmyk(0,0,0,100)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        background_color:
          type: string
          pattern: '(?:^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$)|(?:^cmyk\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*\)$|^cmyka\(\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*,\s*(?:100|\d{1,2})\s*(?:,\s*(?:100|\d{1,2})\s*)?\)$)'
          description: 'Matched case-insensitively (JSON Schema `pattern` cannot express that flag).'
        icon_src:
          type: string
          minLength: 1
          maxLength: 3000000
        margin:
          type: integer
          minimum: 0
          maximum: 100
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
      additionalProperties: false

    DesignImportPrinterGroupLayerProperties:
      type: object
      description: 'Properties of a `group` layer on `type: "printer"` and `type: "printer_multipage"` designs.'
      properties:
        auto_layout:
          type: boolean
          default: false
        direction:
          type: string
          enum: [horizontal, vertical]
          default: horizontal
        alignment:
          type: string
          enum: ['top left', 'top center', 'top right', 'middle left', 'middle center', 'middle right', 'bottom left', 'bottom center', 'bottom right', top, middle, bottom, left, center, right]
        gap:
          type: number
          minimum: 0
          default: 0.0
        mask:
          $ref: '#/components/schemas/DesignImportPrinterMaskProperties'
      additionalProperties: false

    DesignImportPrinterIllustrationLayerProperties:
      type: object
      description: 'Properties of a `illustration` layer on `type: "printer"` and `type: "printer_multipage"` designs.'
      required: [illustration_type, illustration_file]
      properties:
        illustration_type:
          type: string
          enum: [undraw, feather, twemoji, material_icons]
        illustration_file:
          type: string
          minLength: 1
          maxLength: 255
        illustration_properties:
          $ref: '#/components/schemas/DesignImportIllustrationProperties'
        alignment:
          type: string
          enum: ['top left', 'top center', 'top right', 'middle left', 'middle center', 'middle right', 'bottom left', 'bottom center', 'bottom right', top, middle, bottom, left, center, right]
          default: 'middle center'
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
        rotation:
          type: number
          minimum: 0
          maximum: 360
      additionalProperties: false

    DesignImportVideoLayerProperties:
      type: object
      description: 'Properties of a `video` layer on `type: "animated"` designs only.'
      required: [src]
      properties:
        src:
          type: string
          minLength: 1
          maxLength: 3000000
        muted:
          type: boolean
          default: false
        opacity:
          type: integer
          minimum: 0
          maximum: 100
          default: 100
      additionalProperties: false

    DesignImportAudioLayerProperties:
      type: object
      description: 'Properties of a `audio` layer on `type: "animated"` designs only.'
      required: [src]
      properties:
        src:
          type: string
          minLength: 1
          maxLength: 3000000
        muted:
          type: boolean
          default: false
        max_volume:
          type: number
          minimum: 0
          maximum: 1
      additionalProperties: false

    DesignImportStaticLayerProperties:
      description: 'Property set of one layer of a `type: "static"` design, selected by the layer''s `type`.'
      anyOf:
        - $ref: '#/components/schemas/DesignImportTextLayerProperties'
        - $ref: '#/components/schemas/DesignImportImageLayerProperties'
        - $ref: '#/components/schemas/DesignImportLogoLayerProperties'
        - $ref: '#/components/schemas/DesignImportShapeLayerProperties'
        - $ref: '#/components/schemas/DesignImportButtonLayerProperties'
        - $ref: '#/components/schemas/DesignImportRatingLayerProperties'
        - $ref: '#/components/schemas/DesignImportQrCodeLayerProperties'
        - $ref: '#/components/schemas/DesignImportGroupLayerProperties'
        - $ref: '#/components/schemas/DesignImportIllustrationLayerProperties'

    DesignImportPrinterLayerProperties:
      description: 'Property set of one layer of a `type: "printer"` / `type: "printer_multipage"` design, selected by the layer''s `type`.'
      anyOf:
        - $ref: '#/components/schemas/DesignImportPrinterTextLayerProperties'
        - $ref: '#/components/schemas/DesignImportPrinterImageLayerProperties'
        - $ref: '#/components/schemas/DesignImportPrinterLogoLayerProperties'
        - $ref: '#/components/schemas/DesignImportPrinterShapeLayerProperties'
        - $ref: '#/components/schemas/DesignImportPrinterButtonLayerProperties'
        - $ref: '#/components/schemas/DesignImportPrinterRatingLayerProperties'
        - $ref: '#/components/schemas/DesignImportPrinterQrCodeLayerProperties'
        - $ref: '#/components/schemas/DesignImportPrinterGroupLayerProperties'
        - $ref: '#/components/schemas/DesignImportPrinterIllustrationLayerProperties'

    DesignImportAnimatedLayerProperties:
      description: 'Property set of one layer of a `type: "animated"` design, selected by the layer''s `type`.'
      anyOf:
        - $ref: '#/components/schemas/DesignImportTextLayerProperties'
        - $ref: '#/components/schemas/DesignImportImageLayerProperties'
        - $ref: '#/components/schemas/DesignImportLogoLayerProperties'
        - $ref: '#/components/schemas/DesignImportShapeLayerProperties'
        - $ref: '#/components/schemas/DesignImportButtonLayerProperties'
        - $ref: '#/components/schemas/DesignImportRatingLayerProperties'
        - $ref: '#/components/schemas/DesignImportQrCodeLayerProperties'
        - $ref: '#/components/schemas/DesignImportGroupLayerProperties'
        - $ref: '#/components/schemas/DesignImportIllustrationLayerProperties'
        - $ref: '#/components/schemas/DesignImportVideoLayerProperties'
        - $ref: '#/components/schemas/DesignImportAudioLayerProperties'

    # <<< END GENERATED: template-import layer property schemas >>>

    Banner:
      type: object
      properties:
        id:
          type: string
          format: uuid
          example: "64238d01-d402-474b-8c2d-fbc957e9d290"
        version:
          type: integer
          example: 1
          description: |
            Version number of the generated file — an integer counter, NOT the API version.
            A banner response reports this counter; the `vYYYY-MM-DD` stamp other responses carry
            never appears here.
        sharing_id:
          type: string
          format: uuid
          example: "5fcec999-2bfb-4dd7-ba38-2d9e16c49149"
          description: Identifier used for sharing this generated file.
        file:
          $ref: '#/components/schemas/File'
        format:
          $ref: '#/components/schemas/Format'
        template:
          allOf:
            - $ref: '#/components/schemas/DesignSummary'
          description: The design this file was generated from.
        project:
          type: object
          description: The design's project — present when the design has one.
          properties:
            id: {type: string, format: uuid}
            name: {type: string}
            created_at_ts: {type: integer}
        image:
          type: object
          deprecated: true
          description: Deprecated — same information as `file.type` / `file.url`.
          properties:
            type: {type: string}
            url: {type: string, format: uri}
        edit_url:
          type: string
          description: Platform edit URL — only for visuals bookmarked or downloaded in the platform.
        view_url:
          type: string
          description: Platform view URL — only for visuals bookmarked or downloaded in the platform.
        visual_status:
          type: object
          description: Present when the visual carries a review status.
          properties:
            status: {type: string}
            status_updated_at_ts: {type: integer}
            reason: {type: string}
            content_to_replace:
              type: string
              description: Review feedback — content the reviewer asked to be replaced.
            content_to_hide:
              type: string
              description: Review feedback — content the reviewer asked to be hidden.
      required:
        - id
        - file
    File:
      type: object
      properties:
        type:
          type: string
          example: jpeg
          enum: ["jpeg", "png", "webp", "avif", "pdf", "gif", "mp4", "zip"]
          description: >-
            File type. `webp` and `avif` are returned when `image_file_type` asked for them;
            an `html5` generation is delivered as `zip`.
        url:
          type: string
          example: "https://production-banners.s3.eu-west-1.amazonaws.com/demo/996739f4-b563-428a-a6e8-ec3cb8bd03d4.jpeg"
          description: URL of the banner (useful to download the image).
        cdn_url:
          type: string
          example: "https://cdn.abyssale.com/demo/996739f4-b563-428a-a6e8-ec3cb8bd03d4.jpeg"
          description: The CDN URL of the banner (useful to host the image; on a website for instance). (A bandwidth usage limit applies, related to your plan). (Not available for zip)
        filename:
          type: string
          example: "996739f4-b563-428a-a6e8-ec3cb8bd03d4.jpeg"
          description:  Name of the file. If the related design contains a custom naming scheme, the custom name will be available from this property.
        fallback_image_url:
          type: string
          description: Backup JPEG URL — present only when `type` is `zip` (HTML5 output).
      required:
        - type
        - url
    Format:
      type: object
      properties:
        id:
          type: string
          example: "facebook-post"
          description: Identifier/name of the format. Absent on multi-page print visuals.
        unit:
          type: string
          example: "px"
          description: Unit of `width`/`height` — `px`, or `mm`/`in` on print designs.
        width:
          type: number
          example: 1200
          description: Width of the format, in `unit`. A float on print designs (mm/in).
        height:
          type: number
          example: 1200
          description: Height of the format, in `unit`. A float on print designs (mm/in).
      required:
        - width
        - height
    Design:
      type: object
      properties:
        id:
          type: string
          format: uuid
          example: "64238d01-d402-474b-8c2d-fbc957e9d290"
          description: Unique identifier (UUID) of the design.
        template_id:
          type: string
          format: uuid
          example: "64238d01-d402-474b-8c2d-fbc957e9d290"
          description: >-
            Deprecated duplicate of `id`, equal to it. Present on the single-design read; **not**
            emitted by the `GET /designs` listing. Kept for existing clients — read `id`.
        name:
          type: string
          example: "Ad campaign fall 2025"
          description: Name of the design.
        type:
          type: string
          enum: ["static", "printer", "animated", "printer_multipage"]
          example: "static"
          description: Type of the design
        created_at:
          type: integer
          example: 1649942114
          description: Timestamp of when the design has been created.
        updated_at:
          type: integer
          example: 1649942114
          description: Timestamp of when the design has been updated for the last time.
        project_id:
          type: string
          format: uuid
          nullable: true
          example: "9d1f2b7c-5a44-4c3e-9f21-0b8e6d4a1c73"
          description: >-
            Unique identifier (UUID) of the project the design belongs to. `null` when the project
            cannot be resolved.
        project_name:
          type: string
          nullable: true
          example: "Fall campaigns"
          description: Name of the project the design belongs to. `null` when the project cannot be resolved.
        category_id:
          type: string
          format: uuid
          nullable: true
          deprecated: true
          example: "9d1f2b7c-5a44-4c3e-9f21-0b8e6d4a1c73"
          description: >-
            Deprecated, superseded by `project_id`, and always equal to it on a design.
            `category_id` properly names the grouping of a WORKSPACE TEMPLATE — a design belongs
            to a project, so read `project_id`.
        category_name:
          type: string
          nullable: true
          deprecated: true
          example: "Fall campaigns"
          description: >-
            Deprecated, superseded by `project_name`, which it mirrors on every read — the
            platform reads both from the same `company_template_category` row, the table that
            holds projects. `null` when the design is in no project. `category_*` names the
            grouping of a WORKSPACE TEMPLATE — a design belongs to a project, so read
            `project_name`.
        version:
          allOf:
            - $ref: '#/components/schemas/ApiVersion'
            - description: >-
                Present when a `Design` is returned as a whole response body (the single-design
                read). Absent on items nested inside an array, such as the `GET /designs` listing,
                which stamps nothing.
      required:
        - id
        - name
        - type
        - created_at
        - updated_at
        - project_id
        - project_name

    WorkspaceTemplate:
      type: object
      description: |
        A workspace template — an organisation-level master design. Same underlying object
        as a Design, but grouped into a *category* rather than a *project*, and not
        addressable through the Designs API until duplicated into a project.
      properties:
        id:
          type: string
          format: uuid
          example: "64238d01-d402-474b-8c2d-fbc957e9d290"
          description: Unique identifier (UUID) of the workspace template.
        name:
          type: string
          example: "Brand master"
          description: Name of the workspace template.
        type:
          type: string
          enum: ["static", "printer", "animated", "printer_multipage"]
          example: "static"
          description: Type of the workspace template
        created_at:
          type: integer
          example: 1649942114
          description: Timestamp of when the workspace template has been created.
        updated_at:
          type: integer
          example: 1649942114
          description: Timestamp of when the workspace template has been updated for the last time.
        preview_url:
          type: string
          format: uri
          description: Preview Image URL of the first format
        category_id:
          type: string
          format: uuid
          nullable: true
          example: "1c7a9e35-0b62-4d18-8f4a-2e5c7b90d146"
          description: |
            Unique identifier (UUID) of the workspace category it belongs to, or `null` when the
            template sits at the root of the workspace. Unlike a design's project, a category is
            **optional** — most workspace templates have none.
        category_name:
          type: string
          nullable: true
          example: "Brand assets"
          description: Name of the workspace category, or `null` when the template is at the root.
      required:
        - id
        - name
        - type
        - created_at
        - updated_at

    WorkspaceTemplateCategory:
      type: object
      properties:
        id:
          type: string
          format: uuid
          example: "1c7a9e35-0b62-4d18-8f4a-2e5c7b90d146"
          description: Unique identifier (UUID) of the workspace category.
        name:
          type: string
          example: "Brand assets"
          description: Name of the workspace category.
        color:
          type: string
          example: "#4F46E5"
          description: Display colour of the category.
        icon:
          type: string
          example: "star"
          description: Display icon of the category.
        created_at:
          type: integer
          example: 1649942114
          description: Timestamp of when the category has been created.
      required:
        - id
        - name

    DesignSummary:
      type: object
      description: >-
        A short reference to a design, nested inside another resource. Deliberately narrower than
        `Design` — only the fields the parent resource carries.
      properties:
        id:
          type: string
          format: uuid
          example: "64238d01-d402-474b-8c2d-fbc957e9d290"
          description: Unique identifier (UUID) of the design.
        name:
          type: string
          example: "Ad campaign fall 2025"
          description: Name of the design.
        created_at:
          type: integer
          example: 1649942114
        updated_at:
          type: integer
          example: 1649942114
      required:
        - id
        - name

    DesignFormatSummary:
      description: >-
        The design a format belongs to, as returned nested in
        `GET /designs/{designId}/formats/{formatSpecifier}`.
      allOf:
        - $ref: "#/components/schemas/DesignSummary"
        - type: object
          properties:
            type:
              type: string
              enum: ["static", "printer", "animated", "printer_multipage"]
              example: "static"
            category_name:
              type: string
              nullable: true
              deprecated: true
              description: Deprecated alias of the project name — see `Design.category_name`.

    DesignFormat:
      type: object
      properties:
        id:
          type: string
          example: "facebook-post"
          description: Unique identifier (name) of the format.
        uid:
          type: string
          format: uuid
          example: "9b57d65e-eb2c-4a74-a51e-4482917c248a"
          description: Unique UUID of the format
        width:
          type: number
          example: 1200
          description: Width of the format
        height:
          type: number
          example: 1200
          description: Height of the format
        unit:
          type: string
          example: "px"
          description: Unit of measurement for dimensions
        preview_url:
          type: string
          format: uri
          example: https://production-banners.s3-eu-west-1.amazonaws.com/templates/e0d292f2-ec21-11e9-a539-3c408bf94155/a9b3c668-7b84-4924-adf0-815dae727d32.png
          description: Preview Image URL of the format
        dynamic_image_url:
          type: string
          format: uri
          example: https://img.abyssale.com/ecf1fe8c-5392-48c2-b6d2-665183a18fe5/9b57d65e-eb2c-4a74-a51e-4482917c248a
          description: URL of the existing dynamic image for this format.
        dpi:
          type: integer
          example: 300
          description: Printer designs only. Render DPI of the format, computed at import time (capped at 300; large formats degrade to stay within the renderer's pixel budget). Read-only — not an import field.
        bleed_size:
          type: number
          example: 3.5
          description: Printer designs only. Bleed size as a float in the design's unit (mm/in). Always present on a printer format; `0` means the zone is off.
        safe_size:
          type: number
          example: 5
          description: Printer designs only. Safe-zone size as a float in the design's unit (mm/in). Always present on a printer format; `0` means the zone is off.
      required:
        - id
        - uid
        - width
        - height
    DesignPage:
      type: object
      description: |
        One page of a `printer_multipage` design, as returned in `pages[]`.

        Deliberately **not** `DesignFormat`. A multipage design is one document with no formats —
        each page is addressed by `id` (`page_1 … page_N`), has no format `uid`, and cannot carry
        a dynamic image, so `GET /designs/{designId}/formats/{formatSpecifier}` answers
        `404 format_not_found` for every specifier.

        **A page carries no print settings.** `dpi`, `bleed_size` and `safe_size` belong to the
        document and are returned once, at the root of the design read. `width` / `height` /
        `unit` appear here because a page is a thing with dimensions, but every page of the
        document has the same ones.
      properties:
        id:
          type: string
          example: "page_1"
          description: Page identifier, `page_1 … page_N`, in document order.
        width:
          type: number
          example: 210
          description: Page width as a float in `unit`. Every page of the document shares it.
        height:
          type: number
          example: 297
          description: Page height as a float in `unit`. Every page of the document shares it.
        unit:
          type: string
          enum: [mm, in]
          example: "mm"
          description: The document's physical authoring unit.
        preview_url:
          type: string
          format: uri
          description: Preview image URL of this page.
      required:
        - id
        - width
        - height

    DesignAnimation:
      type: object
      description: |
        Animated designs only. The design's timeline, read-only (all values in seconds).
      properties:
        duration:
          type: number
          nullable: true
          example: 8.0
          description: Timeline length in seconds.
        screenshot_at_s:
          type: number
          nullable: true
          example: 8.0
          description: |
            The moment the HTML5 backup screenshot is taken (seconds). Null on designs created before it was recorded
            that never stored it; always set on imported designs.
    DesignElement:
      type: object
      properties:
        name:
          type: string
          example: "element-name"
          description: Layer name (`root` is a special element that allows to customize the image background color.)
        type:
          type: string
          enum: ["container", "text", "button", "image", "logo", "shape", "illustration", "rating", "qrcode", "video", "audio", "group", "code"]
          example: "text"
          description: >-
            Layer type. `container` is the design's own root wrapper, not a layer you can author or override — skip it when walking the tree. `group` elements are injected only on the platform
            advanced view (`i=advanced`); a masked group reports `group` too, with a `mask` block.
            `code` is listed last because it is marginal: a custom HTML/JS layer that exists only
            on `animated` designs, read-only like `container` — it carries no customisable
            attributes, so it can never be targeted in a generation request.
        layout:
          type: object
          description: The element's box, **keyed by format name**.
          additionalProperties:
            allOf:
              - $ref: "#/components/schemas/ElementLayout"
            x-additionalPropertiesName: format-id
        layer_ids:
          type: array
          description: "`group` layers only — the names of the layers this group contains."
          items:
            type: string
        hidden:
          type: object
          description: "`group` layers only — computed visibility, keyed by format name."
          additionalProperties:
            type: boolean
            x-additionalPropertiesName: format-id
        locked:
          type: object
          description: "`group` layers only — computed lock state, keyed by format name."
          additionalProperties:
            type: boolean
            x-additionalPropertiesName: format-id
        group:
          type: object
          description: "`group` layers only — auto-layout settings, keyed by format name."
          additionalProperties:
            allOf:
              - $ref: "#/components/schemas/GroupLayout"
            x-additionalPropertiesName: format-id
        mask:
          type: object
          description: >-
            Masked `group` layers only — the mask geometry, keyed by format name. Presence is a
            layer-level fact: a layer is a masked group in every format or in none.
          additionalProperties:
            allOf:
              - $ref: "#/components/schemas/GroupMask"
            x-additionalPropertiesName: format-id
        animation:
          type: object
          description: |
            Animated designs only; present when the element carries timeline timing or tweens.
            An injected `group` layer carries `start_at_s` / `end_at_s` only — never `tweens`.
          properties:
            start_at_s:
              type: number
              nullable: true
              example: 0.79
            end_at_s:
              type: number
              nullable: true
              example: 8.0
            tweens:
              type: array
              items:
                type: object
                properties:
                  id:
                    type: string
                    example: "tb-text_0-slide_2"
                  type:
                    type: string
                    enum: [slide, fade, scale, rotate, audioFade, textEffect]
                  keyframes:
                    type: array
                    items:
                      type: object
                      description: >-
                        One keyframe. Several keyframe generations coexist in stored designs, so
                        extra keys are permitted rather than rejected.
                      properties:
                        attr:
                          type: object
                          additionalProperties: true
                          description: >-
                            The properties this keyframe sets, as a map of property name to value:
                            `{"opacity": 0}` (fade), `{"left": 1021, "top": 347}` (slide — always
                            both), `{"scale": 120}` (scale), `{"angle": -100}` (rotate),
                            `{"volumeEffect": 0}` (audioFade), `{"typewriting": 100,
                            "textEffectType": "classic"}` (textEffect). Values are numbers, except
                            `textEffectType` which is a string.
                          example:
                            left: 1021
                            top: 347
                        data:
                          type: object
                          properties:
                            type:
                              type: string
                              example: start
                            time:
                              type: number
                              example: 0
                      additionalProperties: true
        attributes:
          type: array
          description: List of all attributes
          items:
            type: object
            properties:
              id:
                type: string
                example: payload
                description: The attribute name
              help:
                type: string
                example: Text content (i.e. Lorem ipsum)
                description: An helper to understand what is this attribute
              values:
                type: object
                example:
                  facebook-post: "My image title"
                additionalProperties:
                  oneOf:
                    - type: string
                    - type: number
                    - type: boolean
                    - type: object
                      additionalProperties: true
                  description: >-
                    The value of the attribute in that format. Usually a string; the media
                    attributes of a `video`/`audio` layer are typed — `video_duration` and
                    `max_volume` are numbers, `video_muted` and `audio_muted` booleans.
                    Two attributes carry an **object** instead: `mask_properties` (e.g.
                    `{"radius": 12, "skew_y": -2}`) and `filter_properties` (e.g.
                    `{"name": "blue_green"}`). Their inner keys vary by mask and filter and are
                    not enumerated here.
                  x-additionalPropertiesName: format-id
            required:
              - id
              - values
      required:
        - name
        - type
        - attributes

    ElementLayout:
      type: object
      description: >-
        An element's box. Integers in pixels on `static`/`animated`; floats in the design's
        physical unit (mm/in) on `printer`.
      properties:
        x: {type: number, example: 0}
        y: {type: number, example: 0}
        width: {type: number, example: 3333}
        height: {type: number, example: 666}

    GroupLayout:
      type: object
      description: >-
        Auto-layout settings of a `group` layer. **Empty on animated designs** — the platform
        stores no auto-layout there. `direction`, `placement` and `gap` appear only when
        `auto_layout` is on.
      properties:
        auto_layout:
          type: boolean
        direction:
          type: string
        placement:
          type: string
        gap:
          type: number
          description: Pixels on static/animated; a physical-unit float on printer.

    GroupMask:
      type: object
      description: Mask geometry of a masked `group` layer.
      properties:
        shape: {type: string}
        width: {type: number}
        height: {type: number}
        center_x: {type: number}
        center_y: {type: number}
        rx: {type: number}
        ry: {type: number}
        rotation: {type: number}
        radius:
          oneOf:
            - type: number
            - type: object
              properties:
                tl: {type: number}
                tr: {type: number}
                br: {type: number}
                bl: {type: number}
      required:
        - shape

    ElementAnimationTiming:
      type: object
      description: "Animated designs only — the element's own timeline window, in seconds."
      properties:
        start_at_s:
          type: number
          nullable: true
        end_at_s:
          type: number
          nullable: true

    DesignPageElement:
      type: object
      description: |
        One element of a `printer_multipage` page, as returned in `elements_per_page`.

        A page is a single format, so the per-format maps are collapsed exactly as they are on
        `GET /designs/{designId}/formats/{formatSpecifier}`: `layout` is that page's box and an
        attribute carries one `value`, not `values` keyed by format name.

        **`attributes` is an object here, keyed by attribute id — every other read returns an
        array.** That is the one difference from `DesignFormatElement`, which this schema
        otherwise matches field for field. It is an inconsistency, not a feature: the two
        single-format reads convert the platform's attribute map into an array and this path does
        not. It is documented rather than corrected because the current shape is live and in use.
        Expect it to be unified with `DesignFormatElement` in a future dated version, announced
        in the changelog — write your attribute lookup so it can tolerate both, e.g. by
        normalising with `Object.values(attributes)` / `list(attributes.values())` when it is not
        already an array.
      properties:
        name:
          type: string
          example: "headline"
          description: Layer name (`root` is the special element carrying the page background color).
        type:
          type: string
          enum: ["container", "text", "button", "image", "logo", "shape", "illustration", "rating", "qrcode", "group"]
          example: "text"
          description: >-
            Layer type. Narrower than the other reads: a page belongs to a print document, so
            `video` and `audio` — which exist only on `animated` designs — never appear here.
            `container` is the design's own root wrapper, not a layer you can author or override — skip it when walking the tree.
            `group` elements are injected only on the advanced view (`i=advanced`).
        settings:
          type: object
          properties:
            is_mandatory:
              type: boolean
              example: false
        attributes:
          type: object
          description: |
            The element's customisable attributes, **keyed by attribute id**. Each value carries
            that attribute's `id`, an optional `help` string, and its single `value` on this page.
          additionalProperties:
            type: object
            x-additionalPropertiesName: attribute-id
            properties:
              id:
                type: string
                example: "payload"
              help:
                type: string
                example: "Text content (i.e. Lorem ipsum)"
              value:
                oneOf:
                  - type: string
                  - type: number
                  - type: boolean
                  - type: object
                    additionalProperties: true
                description: >-
                  The attribute's single value on this page. `mask_properties` and
                  `filter_properties` carry an **object** rather than a scalar (e.g.
                  `{"radius": {"tl": 1000, "tr": 1000, "bl": 1000, "br": 1000}}`); their inner
                  keys vary by mask and filter and are not enumerated here.
                example: "Spring Catalogue"
          example:
            payload: { id: payload, help: "Text content", value: "Spring Catalogue" }
            font_size: { id: font_size, help: "Font size in pt", value: 36 }
        layout:
          allOf:
            - $ref: "#/components/schemas/ElementLayout"
          description: The element's box on this page, in the document's unit.
        layer_ids:
          type: array
          description: "`group` layers only — the names of the layers this group contains."
          items:
            type: string
        hidden:
          type: boolean
          description: "`group` layers only — computed visibility on this page."
        locked:
          type: boolean
          description: "`group` layers only — computed lock state on this page."
        group:
          allOf:
            - $ref: "#/components/schemas/GroupLayout"
          description: "`group` layers only — auto-layout settings on this page."
        mask:
          allOf:
            - $ref: "#/components/schemas/GroupMask"
          description: Masked `group` layers only — the mask geometry on this page.
      required:
        - name
        - type

    DesignFormatElement:
      type: object
      description: |
        An element as `GET /designs/{designId}/formats/{formatSpecifier}` returns it — the
        **single-format projection** of `DesignElement`.

        The endpoint answers for one format, so the per-format maps are collapsed: an attribute
        carries a single `value` instead of `values` keyed by format name, and `layout` is that
        format's box instead of a map. This is deliberate, not drift — do not "align" it with
        `DesignElement`. An attribute the requested format does not define is omitted entirely.
      properties:
        name:
          type: string
          example: "headline"
          description: Layer name (`root` is a special element that allows to customize the image background color.)
        type:
          type: string
          enum: ["container", "text", "button", "image", "logo", "shape", "illustration", "rating", "qrcode", "video", "audio", "group", "code"]
          example: "text"
          description: >-
            Layer type. `container` is the design's own root wrapper, not a layer you can author or override — skip it when walking the tree.
            `code`, listed last, is a marginal `animated`-only custom HTML/JS layer, read-only in
            the same way.
        settings:
          type: object
          properties:
            is_mandatory:
              type: boolean
              example: false
              description: Whether the element is mandatory
        attributes:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                example: "background_color"
                description: Attribute identifier
              help:
                type: string
                example: "6 or 8 digits hexadecimal background color (i.e. #F3F3F3) of the banner"
                description: Help text for the attribute
              value:
                oneOf:
                  - type: string
                  - type: number
                  - type: boolean
                example: "#FFFFFF"
                description: >-
                  The attribute's value in the requested format. Usually a string; a `video`/`audio`
                  layer's media attributes are typed (`video_duration`/`max_volume` numbers,
                  `video_muted`/`audio_muted` booleans).
        layout:
          allOf:
            - $ref: "#/components/schemas/ElementLayout"
          description: The element's box in the requested format.
        layer_ids:
          type: array
          description: "`group` layers only — the names of the layers this group contains."
          items:
            type: string
        hidden:
          type: boolean
          description: "`group` layers only — computed visibility in this format."
        locked:
          type: boolean
          description: "`group` layers only — computed lock state in this format."
        group:
          allOf:
            - $ref: "#/components/schemas/GroupLayout"
          description: "`group` layers only — auto-layout settings in this format."
        mask:
          allOf:
            - $ref: "#/components/schemas/GroupMask"
          description: Masked `group` layers only — the mask geometry in this format.
        animation:
          allOf:
            - $ref: "#/components/schemas/ElementAnimationTiming"
          description: "`group` layers on animated designs only."
      required:
        - name
        - type

    DesignVariables:
      type: object
      description: |
        Variables used within the text layers of the design. Keys are variable names (without
        braces), values are the placeholder as written in the design (e.g. `"{name}"`).
      additionalProperties:
        type: string
        x-additionalPropertiesName: variable-name
      example:
        name: "{name}"
        title: "{title}"
    Font:
      type: object
      properties:
        id:
          type: string
          format: uuid
          example: "61568e7c-33c5-11ea-9877-92672c1b8195"
          description: Font unique ID, this parameter can be used to force a specific font on a text or a button element.
        name:
          type: string
          example: "Ubuntu Mono"
          description: The font name
        available_weights:
          type: array
          items:
            oneOf:
              - type: number
                enum: [100, 200, 300, 400, 500, 600, 700, 800, 900]
              - type: string
                enum:
                  - "100-italic"
                  - "200-italic"
                  - "300-italic"
                  - "400-italic"
                  - "500-italic"
                  - "600-italic"
                  - "700-italic"
                  - "800-italic"
                  - "900-italic"
            description: The list of weights supported by this font
        type:
          type: string
          enum: [google, custom]
          description: Either `google` for the Google fonts or `custom` for your uploaded fonts.
      required:
        - id
        - name
        - available_weights
        - type

    RootElement:
      type: object
      properties:
        background_color:
          $ref: '#/components/schemas/backgroundColor'
    SharedElementProperties:
      type: object
      description: >-
        Properties every layer type accepts, whatever its type. Composed into `Element` and
        `AsyncElement` with `allOf` so they survive the per-type `anyOf` — declared as a sibling of
        `anyOf` they would be dropped from every branch.
      properties:
        hidden:
          type: boolean
          description: "`true`, `false`. If true it hides the current element"
        shadow_color:
          type: string
          description: "6-8 digits hexadecimal color"
        shadow_blur:
          type: number
          minimum: 0
          description: "Blur in pixels"
        shadow_offset_x:
          type: number
          minimum: -200
          maximum: 200
          description: "Horizontal offset in pixels (can be negative)"
        shadow_offset_y:
          type: number
          minimum: -200
          maximum: 200
          description: "Vertical offset in pixels (can be negative)"

    Element:
      # `anyOf`, not `oneOf`: these branches overlap by design. An element payload carries no type
      # field — the layer's type comes from the design — so `{"payload": "Hi"}` matches text,
      # button and qrcode at once, which `oneOf` rejects. Same reason there is no discriminator.
      allOf:
      - $ref: "#/components/schemas/SharedElementProperties"
      - anyOf:
        - $ref: "#/components/schemas/TextElement"
        - $ref: "#/components/schemas/ImageElement"
        - $ref: "#/components/schemas/ButtonElement"
        - $ref: "#/components/schemas/LogoElement"
        - $ref: "#/components/schemas/ShapeElement"
        - $ref: "#/components/schemas/RatingElement"
        - $ref: "#/components/schemas/IllustrationElement"
        - $ref: "#/components/schemas/QRCodeElement"
        - $ref: "#/components/schemas/VideoElement"
        - $ref: "#/components/schemas/AudioElement"

    AsyncElement:
      description: "Same as `Element`, but its image element also exposes AI generation properties (`text_to_image`, inpainting, background removal model) that are only available for asynchronous generation."
      # `anyOf` for the same reason as `Element` above — overlapping branches, no type field.
      allOf:
      - $ref: "#/components/schemas/SharedElementProperties"
      - anyOf:
        - $ref: "#/components/schemas/TextElement"
        - $ref: "#/components/schemas/AsyncImageElement"
        - $ref: "#/components/schemas/ButtonElement"
        - $ref: "#/components/schemas/LogoElement"
        - $ref: "#/components/schemas/ShapeElement"
        - $ref: "#/components/schemas/RatingElement"
        - $ref: "#/components/schemas/IllustrationElement"
        - $ref: "#/components/schemas/QRCodeElement"
        - $ref: "#/components/schemas/VideoElement"
        - $ref: "#/components/schemas/AudioElement"

    TextElement:
      type: object
      properties:
        payload:
          $ref: '#/components/schemas/payload'
        color:
          $ref: '#/components/schemas/color'
        background_color:
          $ref: '#/components/schemas/backgroundColor'
        background_padding:
          $ref: '#/components/schemas/backgroundPadding'
        font_size:
          $ref: '#/components/schemas/fontSize'
        font:
          $ref: '#/components/schemas/fontId'
        font_weight:
          $ref: '#/components/schemas/fontWeight'
        line_height:
          $ref: '#/components/schemas/lineHeight'
        skew_y:
          $ref: '#/components/schemas/skewY'
        alignment:
          $ref: '#/components/schemas/textAlignment'
        stroke_width:
          $ref: '#/components/schemas/strokeWidth'
        stroke_color:
          $ref: '#/components/schemas/strokeColor'
        text_transform:
          $ref: '#/components/schemas/textTransform'
        auto_resize:
          type: boolean
          description: Automatically adjusts the text size to fit within its container. When true, `min_font_size` must also be defined.
        min_font_size:
          type: number
          minimum: 0
          description: Minimum font size allowed when `auto_resize` is enabled.
        max_lines:
          type: number
          minimum: 1
          description: Maximum number of lines allowed.
        text_harmony:
          type: boolean
          description: Attempts to balance line lengths with a maximum variance of 20% between lines. Adjusts character spacing (±20), then reduces font size (up to -10px) if needed.
        text_truncation:
          type: boolean
          description: If the text does not fit, it is truncated and an ellipsis (...) is appended.
        side_border:
          type: string
          enum: [left, right, top, bottom, none]
          description: Defines the side on which a border is rendered. Use `none` to disable an existing side border.
        side_border_thickness:
          type: number
          minimum: 0
          description: Thickness of the side border in pixels.
        side_border_color:
          type: string
          description: "Color of the side border. 6-8 digits hexadecimal or cmyka (for print)."
        side_border_rounded:
          type: boolean
          description: Whether the border corners are rounded (true) or square (false). Default is false.
        side_border_padding:
          type: number
          minimum: 0
          description: Distance in pixels between the text content and the side border. Default is 0.
        side_border_offset:
          type: number
          description: Moves the border along the perpendicular axis. Positive and negative values shift in opposite directions. Default is 0.
        side_border_spread:
          type: number
          minimum: 0
          description: Extends the length of the border beyond the text bounding box by the specified number of pixels. Default is 0.
    ButtonElement:
      type: object
      properties:
        payload:
          $ref: '#/components/schemas/buttonPayload'
        color:
          $ref: '#/components/schemas/color'
        background_color:
          $ref: '#/components/schemas/backgroundColor'
        background_padding:
          $ref: '#/components/schemas/backgroundPadding'
        font_size:
          $ref: '#/components/schemas/fontSize'
        font:
          $ref: '#/components/schemas/fontId'
        font_weight:
          $ref: '#/components/schemas/fontWeight'
        line_height:
          $ref: '#/components/schemas/lineHeight'
        text_transform:
          $ref: '#/components/schemas/textTransform'
        alignment:
          allOf:
            - $ref: '#/components/schemas/alignment'
          description: >-
            Placement of the **button box** — not of its label. A button keeps the two apart: this
            moves the box, `text_align` moves the label inside it.
        text_align:
          type: string
          enum: [left, center, right]
          description: >-
            Alignment of the **label** inside the button box. Default `center`. A button is the
            one layer type where these are two separate settings — on a `text` layer the alignment
            *is* the position.
        stroke_color:
          $ref: '#/components/schemas/strokeColor'
        stroke_width:
          $ref: '#/components/schemas/strokeWidth'
        min_font_size:
          type: number
          minimum: 0
          description: Minimum font size allowed when `auto_resize` is enabled.
        auto_resize:
          type: boolean
          description: Automatically adjusts the label size to fit the button. When true, `min_font_size` must also be defined.
    ImageElement:
      type: object
      properties:
        image_url:
          $ref: '#/components/schemas/imageUrl'
        image_encoded:
          $ref: '#/components/schemas/imageEncoded'
        opacity:
          $ref: '#/components/schemas/opacity'
        fitting_type:
          $ref: '#/components/schemas/fittingType'
        alignment:
          $ref: '#/components/schemas/alignment'
        mask_name:
          $ref: '#/components/schemas/maskName'
        mask_properties:
          $ref: '#/components/schemas/maskProperties'
        filter_name:
          $ref: '#/components/schemas/filterName'
        filter_properties:
          $ref: '#/components/schemas/filterProperties'
        overlay_direction:
          $ref: '#/components/schemas/overlayDirection'
        overlay_color_1:
          $ref: '#/components/schemas/overlayColor1'
        overlay_color_2:
          $ref: '#/components/schemas/overlayColor2'
        remove_bg:
          type: boolean
          deprecated: true
          description: |
            Activates AI background removal when set to true.

            **Deprecated on this endpoint, and not recommended.** It works and existing
            integrations keep working, but removing a background is an extra AI round-trip on
            top of the render, and this endpoint is hard-capped at 10 seconds — a large or slow
            source image can push the whole call past the cap and fail with
            `500 internal_server_error` instead of returning your asset. Use asynchronous
            generation, which has no such bound.
        remove_bg_properties:
          type: object
          deprecated: true
          description: Additional settings for background removal. Deprecated here for the same reason as `remove_bg`.
          properties:
            remove_bg_crop:
              type: boolean
              description: Trims the edges of the image after background removal. Default is false.
        auto_focus:
          type: boolean
          description: Activates AI-powered auto-focus to detect and focus on specified objects or people within the image.
        auto_focus_properties:
          type: object
          description: Additional settings for auto-focus.
          properties:
            model:
              type: string
              enum: [generic, people, face]
              description: |
                Model used for focusing. `generic` for objects, `people` for human subjects.
                Default is `generic`.

                **`face` is deprecated** — use `people` with `focus_framing: "face"`, which
                detects faces with the same model and additionally honours `focus_zoom` and
                `focus_target`. `face` ignores all three. `face` keeps working; no removal date
                is set.
            focus_objects:
              type: array
              items:
                type: string
              description: List of object labels to focus on (generic model only). Uses Open Images Dataset labels.
            focus_framing:
              type: string
              enum: [face, head, shoulders, full_body]
              description: "Specific to `people` model. Defines which part of the subject to frame. Default is `face`, which is why `people` alone replaces the deprecated `face` model."
            focus_zoom:
              type: string
              # `off` MUST stay quoted: bare `off` is a YAML 1.1 boolean, so PyYAML reads it as
              # `False` while js-yaml (YAML 1.2) reads the string. Unquoted, it generated a Python
              # enum member `False_ = False` inside a `type: string` enum.
              enum: ["off", low, medium, max]
              description: "Specific to `people` model. Controls the zoom level applied. Default is `max`."
            focus_target:
              type: string
              enum: [largest, left, middle, right, all]
              description: "Specific to `people` model. When multiple subjects are detected, defines which one to target. Default is `all`."

    AsyncImageElement:
      type: object
      description: Image element properties available for asynchronous generation, including AI image generation, inpainting, and background removal model selection.
      properties:
        image_url:
          $ref: '#/components/schemas/imageUrl'
        image_encoded:
          $ref: '#/components/schemas/imageEncoded'
        opacity:
          $ref: '#/components/schemas/opacity'
        fitting_type:
          $ref: '#/components/schemas/fittingType'
        alignment:
          $ref: '#/components/schemas/alignment'
        mask_name:
          $ref: '#/components/schemas/maskName'
        mask_properties:
          $ref: '#/components/schemas/maskProperties'
        filter_name:
          $ref: '#/components/schemas/filterName'
        filter_properties:
          $ref: '#/components/schemas/filterProperties'
        overlay_direction:
          $ref: '#/components/schemas/overlayDirection'
        overlay_color_1:
          $ref: '#/components/schemas/overlayColor1'
        overlay_color_2:
          $ref: '#/components/schemas/overlayColor2'
        remove_bg:
          type: boolean
          description: Activates AI background removal when set to true.
        remove_bg_properties:
          type: object
          description: Additional settings for background removal.
          properties:
            remove_bg_crop:
              type: boolean
              description: Trims the edges of the image after background removal. Default is false.
            model:
              type: string
              enum: [bria-rmbg-2-0, birefnet, pixelcut, imageUtils, ideogram]
              description: Background removal model to use. Default is `bria-rmbg-2-0`.
        auto_focus:
          type: boolean
          description: Activates AI-powered auto-focus to detect and focus on specified objects or people within the image.
        auto_focus_properties:
          type: object
          description: Additional settings for auto-focus.
          properties:
            model:
              type: string
              enum: [generic, people, face]
              description: |
                Model used for focusing. `generic` for objects, `people` for human subjects.
                Default is `generic`.

                **`face` is deprecated** — use `people` with `focus_framing: "face"`, which
                detects faces with the same model and additionally honours `focus_zoom` and
                `focus_target`. `face` ignores all three. `face` keeps working; no removal date
                is set.
            focus_objects:
              type: array
              items:
                type: string
              description: List of object labels to focus on (generic model only). Uses Open Images Dataset labels.
            focus_framing:
              type: string
              enum: [face, head, shoulders, full_body]
              description: "Specific to `people` model. Defines which part of the subject to frame. Default is `face`, which is why `people` alone replaces the deprecated `face` model."
            focus_zoom:
              type: string
              # `off` MUST stay quoted: bare `off` is a YAML 1.1 boolean, so PyYAML reads it as
              # `False` while js-yaml (YAML 1.2) reads the string. Unquoted, it generated a Python
              # enum member `False_ = False` inside a `type: string` enum.
              enum: ["off", low, medium, max]
              description: "Specific to `people` model. Controls the zoom level applied. Default is `max`."
            focus_target:
              type: string
              enum: [largest, left, middle, right, all]
              description: "Specific to `people` model. When multiple subjects are detected, defines which one to target. Default is `all`."
        text_to_image:
          anyOf:
            - type: boolean
            - type: string
          description: |
            Activates AI image generation (text-to-image) or AI-assisted image editing (inpainting).
            If `image_url` (except default image) or `image_encoded` is provided, this property is ignored.

            `true` uses `text_to_image_properties`. As a shorthand you may instead pass the prompt
            **as a string** — optionally `"prompt,url1[,url2]"` to supply inpainting sources — which
            is expanded into `text_to_image_properties` before validation.
        text_to_image_properties:
          $ref: '#/components/schemas/TextToImageProperties'

    TextToImageProperties:
      type: object
      description: Settings for AI image generation or inpainting.
      properties:
        prompt:
          type: string
          description: >-
            Description of the image to generate, or of the edit to apply when `inpaint_images` is
            provided. **At least 3 whitespace-separated words** — a shorter prompt is rejected.
          example: "A sleek, modern glass villa situated in the middle of a minimalist lavender field."
        model:
          type: string
          enum: [gemini-3-pro, gemini-2.5-flash, gemini-3.1-flash, kling-image-o3, wan-2.7, gpt-image-1.5, flux-2-pro, qwen-2511, nano-banana, nano-banana-2, nano-banana-pro, seedream-4.5, gpt-image-2, grok-imagine, flux-2-klein-9b]
          description: |
            Model used for generation. Default is `nano-banana-2`.
            Allowed `ratio` and `quality` values depend on the selected model — see the
            [Text to Image & Inpainting guide](https://developers.abyssale.com/rest-api/generation/element-properties/image#text-to-image-inpainting) for the full table.
        ratio:
          type: string
          description: |
            Aspect ratio or size of the output (e.g. `16:9`, `square_hd`, `1024x1024`).
            Allowed values depend on the selected `model` — see the
            [Text to Image & Inpainting guide](https://developers.abyssale.com/rest-api/generation/element-properties/image#text-to-image-inpainting).
        quality:
          type: string
          description: |
            Output quality/resolution (e.g. `1K`, `high`). Only supported by some models — see the
            [Text to Image & Inpainting guide](https://developers.abyssale.com/rest-api/generation/element-properties/image#text-to-image-inpainting).
            Ignored if the selected model does not support it.
        inpaint_images:
          type: array
          items:
            type: string
            format: uri
          description: URL(s) of the image(s) to edit. When provided, switches generation to inpainting mode instead of pure text-to-image.
      required:
        - prompt

    LogoElement:
      type: object
      properties:
        image_url:
          $ref: '#/components/schemas/imageUrl'
        image_encoded:
          $ref: '#/components/schemas/imageEncoded'
        alignment:
          $ref: '#/components/schemas/alignment'
    ShapeElement:
      type: object
      properties:
        background_color:
          $ref: '#/components/schemas/backgroundColor'
        pattern_name:
          $ref: '#/components/schemas/patternName'
        pattern_color:
          $ref: '#/components/schemas/patternColor'
        stroke_width:
          $ref: '#/components/schemas/shapeStrokeWidth'
        stroke_color:
          $ref: '#/components/schemas/strokeColor'
    RatingElement:
      type: object
      properties:
        rating_score:
          $ref: '#/components/schemas/ratingScore'
        star_dimension:
          $ref: '#/components/schemas/starDimension'
        star_margin:
          $ref: '#/components/schemas/starMargin'
        star_color:
          $ref: '#/components/schemas/starColor'
        background_color:
          $ref: '#/components/schemas/ratingBackgroundColor'
    IllustrationElement:
      type: object
      properties:
        illustration_type:
          $ref: '#/components/schemas/illustrationType'
        illustration_file:
          $ref: '#/components/schemas/illustrationFile'
        illustration_properties:
          $ref: '#/components/schemas/illustrationProperties'
        alignment:
          $ref: '#/components/schemas/alignment'
        opacity:
          $ref: '#/components/schemas/opacity'

    QRCodeElement:
      type: object
      properties:
        background_color:
          $ref: '#/components/schemas/qrcodeBackgroundColor'
        foreground_color:
          $ref: '#/components/schemas/qrcodeForegroundColor'
        payload:
          $ref: '#/components/schemas/qrcodePayload'
        image_url:
          $ref: '#/components/schemas/qrcodeImageUrl'
        hide_icon:
          $ref: '#/components/schemas/qrcodeHideIcon'

    VideoElement:
      type: object
      description: "This element is only available for animated design"
      properties:
        video_url:
          $ref: '#/components/schemas/videoUrl'
        audio_muted:
          type: number
          minimum: 0
          maximum: 1
          example: 1
          description: >-
            Mute the video's audio track. `1` mutes it, `0` keeps it; values in between scale it.
            Default `0`. **A number, not a boolean** — `true`/`false` answer `400 invalid_payload`.
            The import spells this `muted` and takes a boolean; a design read returns
            `video_muted` / `audio_muted`, also booleans.
    AudioElement:
      type: object
      description: "Audio layer element. Only available for animated designs with MP4 output."
      properties:
        audio_url:
          type: string
          format: uri
          description: |
            **HTTP(s) URL of the audio file.**

            It must be a publicly accessible link with a filesize of 25 MB maximum.

            Supported files: mp3, wav
        audio_encoded:
          type: string
          description: >-
            **Base64-encoded audio**, as a data URI or raw base64. Ignored when `audio_url` is
            given. Same 25 MB ceiling.
        max_volume:
          type: number
          minimum: 0
          maximum: 1
          description: Volume level. 1 = default volume, 0.5 = half volume, 0 = silent. Default is 1.
          example: 1
        speed:
          type: number
          minimum: 0.25
          maximum: 2
          description: Playback speed. 1 = 100%, 0.5 = 50%. Default is 1.
          example: 1
    Elements:
      type: object
      description: |
        A `dictionary` containing all elements with properties you would like to override from
        the default design (keys correspond to layer names). The reserved key `vars` is not a
        layer: it carries the design-wide text variable values.

        **Unknown names are accepted, not rejected — and this is the one thing to know before
        you generate this object programmatically.** The API does not check element names or
        property names against the design: a key naming a layer that does not exist, or a
        property that layer does not have, passes validation and simply does not change the
        output. There is no error and no warning, so a typo shows up as an asset that renders
        with the design's saved content instead of yours.

        This leniency is deliberate and long-standing — live integrations depend on it, so it
        will not be tightened. Two consequences worth designing for:

        - **Check names against `GET /designs/{designId}`** rather than against a `400`. That
          response lists every element and every attribute it accepts.
        - **Offline schema validation cannot help either.** The branches below overlap by design
          (an element payload carries no type field — the layer's type comes from the design), so
          a generic JSON-Schema validator accepts any object here.
      additionalProperties:
        x-additionalPropertiesName: element-name
        anyOf:
          - $ref: "#/components/schemas/RootElement"
          - $ref: "#/components/schemas/Element"
          - $ref: "#/components/schemas/VideoElement"
          - $ref: "#/components/schemas/AudioElement"
          - $ref: "#/components/schemas/ElementVars"
    AsyncElements:
      type: object
      description: "Same as `Elements`, but its image element also exposes AI generation properties (`text_to_image`, inpainting, background removal model) that are only available for asynchronous generation."
      additionalProperties:
        x-additionalPropertiesName: element-name
        anyOf:
          - $ref: "#/components/schemas/RootElement"
          - $ref: "#/components/schemas/AsyncElement"
          - $ref: "#/components/schemas/VideoElement"
          - $ref: "#/components/schemas/AudioElement"
          - $ref: "#/components/schemas/ElementVars"
    ElementVars:
      type: object
      description: |
        Design-wide text variable values. This is a reserved key of `elements`, a sibling of the
        layer names — not a property of a text layer. Variables are authored in the design as
        `{variableName}` placeholders; pass the variable name (without braces) as the key and the
        replacement value as the value. Every text layer of the design using that placeholder is
        substituted.
      additionalProperties:
        type: string
        x-additionalPropertiesName: variable-name
      example:
        username: "Alice"
        title: "Spring Sale"

    Pages:
      type: object
      description: >-
        Per-page element overrides, keyed by page identifier (`page_1 … page_N`). Each value is a
        dictionary of element overrides for that page, in the same shape as `elements` on every
        other generation endpoint — `root` plus any layer of that page, keyed by layer name.
        This endpoint is asynchronous, so its image layers accept the AI properties too.
      additionalProperties:
        allOf:
          - $ref: "#/components/schemas/AsyncElements"
        x-additionalPropertiesName: page-id

    DuplicationRequest:
      type: object
      properties:
        duplication_request_id:
          type: string
          format: uuid
          example: "40c32a4e-4869-11f0-96f2-0a00d9eb8f78"
          description: Unique identifier for tracking the duplication process
        version:
          $ref: '#/components/schemas/ApiVersion'
      required:
        - duplication_request_id

    DuplicationRequestStatus:
      type: object
      properties:
        request_id:
          type: string
          format: uuid
          example: "40c32a4e-4869-11f0-96f2-0a00d9eb8f78"
          description: Unique identifier of the duplication request
        status:
          type: string
          enum: [INIT, IN_PROGRESS, ERROR, COMPLETED]
          example: "COMPLETED"
          description: Current status of the duplication request
        created_at_ts:
          type: integer
          example: 1749827734
          description: Unix timestamp when the duplication request was created
        completed_at_ts:
          type: integer
          nullable: true
          example: 1749827736
          description: Unix timestamp when the duplication was completed (null if not completed)
        errored_at_ts:
          type: integer
          nullable: true
          example: null
          description: Unix timestamp when the duplication failed (null if not failed)
        target_project:
          $ref: "#/components/schemas/ProjectSummary"
        designs:
          type: array
          items:
            $ref: "#/components/schemas/DuplicatedDesign"
          description: List of duplicated designs (empty if not completed)
        version:
          $ref: '#/components/schemas/ApiVersion'
      required:
        - request_id
        - status
        - created_at_ts
        - target_project
        - designs

    ProjectSummary:
      type: object
      properties:
        id:
          type: string
          format: uuid
          example: "d59adee9-4867-11f0-96f2-0a00d9eb8f78"
          description: Unique identifier of the project
        name:
          type: string
          example: "HTML5 Tests"
          description: Name of the project
        created_at_ts:
          type: integer
          example: 1749827125
          description: Unix timestamp when the project was created
        category_name:
          type: string
          nullable: true
          deprecated: true
          example: "HTML5 Tests"
          description: >-
            Deprecated alias of `name` — the same string, under the old noun. It does **not**
            describe a category the project belongs to: a project has no parent category. Returned
            by the `GET /projects` listing only; the `POST /projects` read-back omits it. Read
            `name`.
      required:
        - id
        - name
        - created_at_ts

    DuplicatedDesign:
      type: object
      properties:
        original_design_id:
          type: string
          format: uuid
          example: "0c967bd0-4137-4690-ad70-249aa021c68b"
          description: ID of the original workspace template
        target_design_id:
          type: string
          format: uuid
          example: "afb1a61a-6c50-4bc3-a49b-3381822d4e81"
          description: ID of the newly created template in your project
        target_design_name:
          type: string
          example: "Design Name"
          description: Name of the duplicated template
      required:
        - original_design_id
        - target_design_id
        - target_design_name

    GenerationRequestStatus:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Unique identifier of the generation request
        is_finalized:
          type: boolean
          description: Whether the generation is complete. `false` when the request returns 202, `true` when it returns 200.
        banners:
          type: array
          items:
            $ref: "#/components/schemas/Banner"
          description: List of generated files (populated once finalized)
        errors:
          type: array
          items:
            type: object
            properties:
              template_format_name:
                type: string
                description: The format name that failed to generate
              reason:
                type: string
                description: The error reason
          description: Per-format errors, if any occurred during generation
        version:
          $ref: '#/components/schemas/ApiVersion'
      required:
        - id
        - is_finalized
        - banners

    DynamicImageFormat:
      type: object
      description: >-
        One format of a dynamic image. Deliberately **not** `DesignFormat`: this endpoint is
        `static`-only (anything else answers `400 template_not_static`), so it never carries
        `preview_url` or the printer settings, and every field below is always present.
      properties:
        id:
          type: string
          example: "facebook-post"
          description: Identifier (name) of the format.
        uid:
          type: string
          format: uuid
          example: "9b57d65e-eb2c-4a74-a51e-4482917c248a"
          description: Unique UUID of the format — the last path segment of `dynamic_image_url`.
        width:
          type: number
          example: 1200
        height:
          type: number
          example: 1200
        unit:
          type: string
          example: "px"
          description: Always `px` here; a dynamic image is only available on static designs.
        dynamic_image_url:
          type: string
          format: uri
          example: https://img.abyssale.com/ecf1fe8c-5392-48c2-b6d2-665183a18fe5/9b57d65e-eb2c-4a74-a51e-4482917c248a
          description: The dynamic image URL for this format.
      required:
        - id
        - uid
        - width
        - height
        - unit
        - dynamic_image_url

    DynamicImageResponse:
      type: object
      properties:
        id:
          type: string
          format: uuid
          description: Unique ID of the dynamic image
        design_id:
          type: string
          format: uuid
          description: Design ID associated with this dynamic image
        formats:
          type: array
          items:
            $ref: "#/components/schemas/DynamicImageFormat"
          description: List of formats available for this dynamic image
        version:
          $ref: '#/components/schemas/ApiVersion'
      required:
      - id
      - design_id
      - formats
      example:
        id: a12b3c4d-5e6f-7890-abcd-ef1234567890
        design_id: b2c3d4e5-f6a7-8901-bcde-f12345678901
        formats:
          - id: one-format
            uid: 11111111-2222-3333-4444-555555555555
            width: 3333
            unit: px
            height: 666
            dynamic_image_url: https://img.abyssale.com/a12b3c4d-5e6f-7890-abcd-ef1234567890/one-format
          - id: another-format
            uid: 66666666-7777-8888-9999-000000000000
            width: 1920
            unit: px
            height: 1080
            dynamic_image_url: https://img.abyssale.com/a12b3c4d-5e6f-7890-abcd-ef1234567890/another-format

    backgroundColor:
      type: string
      description:  |
        **The background color displayed behind the element.**

        3 filling modes are available:
        - `Monochrome`: 6 or 8 hexadecimal colors starting with a **#**. _i.e. #EAEAEA or #FF00FF55_
        - `Linear Gradient`: `linear-gradient(x1% y1% x2% y2%,offset1% #color1 opacity1,offset2% #color2 opacity2)` _i.e. linear-gradient(0% 0% 100% 0%,0% #1a47ff 1,100% #b65151 1)_
        - `Cmyka` (print only): `cmyka(c,m,y,k)` or `cmyka(c,m,y,k,alpha)` where each value is 0–100. _i.e. cmyka(0,100,100,0,100)_
      example: "#FF0000"

    payload:
      type: string
      description: |
        **The text content**. *Example: Lorem Ipsum*

        - [How can I force specific line breaks?](https://developers.abyssale.com/image-generation-properties.html#color)
        - [How can I add decorations to specific parts of a text?](https://developers.abyssale.com/image-generation-properties.html#color)
      minLength: 1
      maxLength: 10000

    buttonPayload:
      type: string
      description: |
        **The button's label.** Same content rules as a text `payload`, but capped at 2 048
        characters rather than 10 000 — the validator enforces the two separately.
      minLength: 1
      maxLength: 2048

    color:
      type: string
      description:  |
        **The text color.**

        3 filling modes are available:
        - `Monochrome`: 6 or 8 hexadecimal colors starting with a **#**. _i.e. #EAEAEA or #FF00FF55_
        - `Linear Gradient`: `linear-gradient(x1% y1% x2% y2%,offset1% #color1 opacity1,offset2% #color2 opacity2)`
        - `Cmyka` (print only): `cmyka(c,m,y,k)` or `cmyka(c,m,y,k,alpha)` where each value is 0–100.

    backgroundPadding:
      type: string
      description: |
        **The padding of the background color around the text.** *Example: 10*

        _This parameter will only be used if a background color is defined._

        - String: Two numbers separated by a space: First number represents the vertical padding in pixels & the second the vertical padding. For instance: 0 10 : 0 as vertical padding & 10 as horizontal
        - Number: Paddings (Horizontal & vertical) in pixels. _i.e. 10_

    fontSize:
      type: number
      minimum: 1
      description: "**Font size in pixels** *Example: 20*"

    # Named `fontId`, not `font`: a component named `font` differs from the `Font` model of
    # GET /fonts by case alone, and a generator writing one file per model overwrites one with
    # the other on a case-insensitive filesystem (macOS, Windows). The two are different things —
    # this is the id you send, `Font` is the object you read.
    fontId:
      type: string
      format: uuid
      description: |
        **Force a specific font by ID**. *Example: 6156907e-33c5-11ea-9877-92672c1b8195*

        The fonts list is available by calling the [GET /fonts](/api-reference/#fonts) API route.

    fontWeight:
      type: number
      enum: [100, 200, 300, 400, 500, 600, 700, 800, 900]
      description: |
        **Force a font weight**. *Example: 500*

        | Value | Corresponding name |
        |-----|-----|
        | 100   | Thin   |
        | 200   | Extra Light   |
        | 300   | Light   |
        | 400   | Regular   |
        | 500   | Medium   |
        | 600   | Semi Bold   |
        | 700   | Bold   |
        | 800   | Extra Bold   |
        | 900   | Black   |

        _If the font does not contain the given font weight, the nearest weight will be used._

    lineHeight:
      type: number
      minimum: 1
      description: |
        **Force line height in percentage** *Example: 130*

        __This parameter is only applied when the text is a multiline one.__

    skewY:
      type: number
      minimum: -20
      maximum: 20
      description: "**Slant text on the y axis** *Example: 20*"

    textAlignment:
      type: string
      enum: [top, middle, bottom, left, center, right, top left, top center, top right, middle left, middle center, middle right, bottom left, bottom right, bottom center, top custom, middle custom, bottom custom, custom left, custom center, custom right, custom custom]
      description: |
        **The text alignment.** *Example: left*

        __If given, the text position will be computed from the text bounding box defined within the design.__

    textTransform:
      type: string
      description: >
        **Text transformation style.**
        Force the text to be transformed to one of the following options:
        - `uppercase`: All letters become uppercase (e.g., `EXAMPLE`)
        - `lowercase`: All letters become lowercase (e.g., `example`)
        - `titlecase`: The first letter of each word is capitalized (e.g., `Example Text`)
        - `capitalize`: Only the first letter of the entire text is capitalized (e.g., `Example text`)
        - `none`: No transformation — send it to clear one set on the design
      enum:
        - none
        - uppercase
        - lowercase
        - titlecase
        - capitalize

    strokeWidth:
      type: number
      minimum: 0
      maximum: 40
      description: >-
        **Width of the stroke** *Example: 10*. Text and button top out at 40; a shape layer accepts up to 60
        (see `shapeStrokeWidth`). The design **import** allows up to 1000, so a design can hold a
        stroke this endpoint cannot reproduce — the import bound is the design's, this one is the
        override's.

    shapeStrokeWidth:
      type: number
      minimum: 0
      maximum: 60
      description: >-
        **Width of the shape's stroke** *Example: 10*. The design **import** allows up to 1000 —
        see `strokeWidth`.

    strokeColor:
      type: string
      description: |
        **Stroke Color. 6-8 digits Hexa color.**. *Example: #FF0000*

        __If your design does not contain any stroke, this color won't be visible__

    imageUrl:
      type: string
      format: uri
      description: |
        **HTTP(s) URL of the image** *Example: https://www.abyssale.com/imge/abyssale_logo.png*

        __It must be publicly accessible and at most 20 MB__ (500 MB on `printer` and
        `printer_multipage` designs).

        Supported files: jpeg, jpg, png, webp, svg, gif, tiff, tif, avif

    imageEncoded:
      type: string
      description: |
        **Base64 encoded image as value.** *Example: /9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAIBAQE...*

        __If the image_url is given, this parameter will not be used.__

    opacity:
      type: number
      minimum: 0
      maximum: 100
      description: "**Opacity of the image** *Example: 60*"

    fittingType:
      type: string
      enum: [cover, fill]
      description: |
        **Defines the way the image will be inserted in the bounding box**

        Two properties are supported:

        - `cover`: It will force the image to fill entirely the area without changing the aspect ratio (hence the image will be cropped if its ratio is not the same as the container)
        - `fill`: The image will be displayed entirely within the box.

    alignment:
      type: string
      enum: [top, middle, bottom, left, center, right, top left, top center, top right, middle left, middle center, middle right, bottom left, bottom right, bottom center, top custom, middle custom, bottom custom, custom left, custom center, custom right, custom custom]
      description: >-
        **The image alignment.** *Example: left*. A `custom` component resolves to the **centre** of
        that axis: `custom custom`, `custom center` and `middle custom` all place at the box
        centre, while `custom left` is the middle of the left edge. It is the value the editor
        stores for a hand-positioned layer, not a way to preserve one.

    maskName:
      type: string
      enum: [circle, rounded_corners, blob, squircle, pentagon, hexagon, parallelogram, window]
      description: |
        **A mask can be added to the image**.

        8 masks are available. Only `rounded_corners` takes a `mask_properties`; the other
        seven are shapes with no additional property.

        - `circle`: The image will be rendered as a circle.
        - `rounded_corners`: Corners of the image will be rounded. It requires another property: `mask_properties`.
        - `blob`: The image will be rendered as a blob.
        - `squircle`: The image will be rendered as a squircle.
        - `pentagon`: The image will be rendered as a pentagon.
        - `hexagon`: The image will be rendered as a hexagon.
        - `parallelogram`: The image will be rendered as a parallelogram.
        - `window`: The image will be rendered as a window (an arch — flat base, rounded top).

    maskProperties:
      type: object
      properties:
        radius:
          oneOf:
            - type: number
              minimum: 0
            - type: object
              properties:
                tl: {type: number, minimum: 0}
                tr: {type: number, minimum: 0}
                br: {type: number, minimum: 0}
                bl: {type: number, minimum: 0}
        skew_y:
          type: number
          minimum: -22
          maximum: 22
          description: Slant the mask on the y axis.
      description: |
        **When the rounded_corners mask is applied, this parameter allows to define the radius**.
        
        Two types of radius is supported:
        - A number, that allows to define all corners' radius at once. *Example: {"radius": 20}*
        - An object, that allows to customize each corner's radius individually.
          4 properties are available: (`tl` = top left, `tr` = top right, `br` = bottom right, `bl` = bottom left)
        
        *Example: {"radius": {
          "tl": 0,
          "tr": 56,
          "br": 56,
          "bl": 20
        }}*

    filterName:
      type: string
      enum: [grayscale, duotone]
      description: |
        **A filter can be added to the image.** *Example: grayscale*

        2 filters are available:
        - `grayscale`: It converts the input image to grayscale. No additional property is available.
        - `duotone`: Apply a duotone filter to the image. It requires another property: `filter_properties`

    filterProperties:
      type: object
      properties:
        name:
          type: string
          enum: [blue_green,blue_orange,deep_green_light_green,BrightRed_light_yellow,brown_pale_green,brown_beige,deep_blue_green,deep_blue_red,deep_purple_orange,deep_purple_beige,deep_purple_pale_green,deep_blue_pale_green]
      description: |
        **When the duotone mask is applied, this parameter allows to define the duotone filter to apply**.
        *Example: { "name": "blue_orange" }*

    patternName:
      type: string
      enum: [bubbles,cage,cross,doubleCircle,drops,parkay,pills,plus,star,ticTac,ticTacFilled,triangle,wiggle]
      description: |
        **A pattern can be added to the shape**.

        (List of patterns)[] 

    patternColor:
      type: string
      example: "#EAEAEA"
      description: |
        **A 6 or 8 hexadecimal shape color starting with a `#`.** *Example: #EAEAEA or #FF00FF55*

        _If no pattern is applied to the shape, this property will not change anything._

    overlayDirection:
      type: string
      enum: [horizontal, vertical, diagonal]
      description: Direction of the overlay.

    overlayColor1:
      type: string
      description: First color of the overlay. 6-8 Digits Hexa color.

    overlayColor2:
      type: string
      description: Second color of the overlay. 6-8 Digits Hexa color.

    ratingScore:
      type: number
      minimum: 0
      maximum: 100
      description: |
        **Score of the rating on a scale of 100.** *Example: 50*
        
        _For instance, as rating is displayed as five-stars, 50 will give a 2.5/5 score._  

    ratingBackgroundColor:
      type: string
      description:  |
        **The background color displayed behind all the stars.**

        6 or 8 hexadecimal colors starting with a **#**. _i.e. #EAEAEA or #FF00FF55_

    starColor:
      type: string
      description:  |
        **The color of the filled stars.**

        6 or 8 hexadecimal colors starting with a **#**. _i.e. #EAEAEA or #FF00FF55_

    starMargin:
      type: number
      minimum: 0
      maximum: 100
      description: "**Margins in pixels between stars.** *Example: 60*"

    starDimension:
      type: number
      exclusiveMinimum: true
      minimum: 0
      maximum: 400
      description: >-
        **Size in pixels of one star.** *Example: 100*. The design **import** requires at least 4,
        so this endpoint accepts smaller stars than a design can be imported with.

    illustrationType:
      type: string
      example: undraw
      description: >-
        **The illustration library the illustration name is looked up in.** The public libraries
        are `undraw`, `feather`, `twemoji` and `material_icons`.

        Left as a free string rather than an enum on purpose: some workspaces have private
        libraries of their own, and an enum would make a generated client reject a value their API
        accepts.

    illustrationFile:
      type: string
      description: "**The illustration name.**."

    illustrationProperties:
      type: object
      properties:
        primary_color:
          type: string
          description: |
            **Color of the illustration** 
            
            *A 6 or 8 hexadecimal shape color starting with a **#**.** *Example: #EAEAEA or #FF00FF55_*
      description: |
         **Only `Undraw`, `feather` and `material icons` support `illustration_properties`.**

    qrcodeBackgroundColor:
      type: string
      description:  |
        **The background color displayed behind the qrcode.**

        6 or 8 hexadecimal colors starting with a **#**. _i.e. #EAEAEA or #FF00FF55_

    qrcodeForegroundColor:
      type: string
      description:  |
        **The color of the qrcode (of all squares).**

        6 or 8 hexadecimal colors starting with a **#**. _i.e. #EAEAEA or #FF00FF55_

    qrcodePayload:
      type: string
      description:  |
        **The content of the qrcode. i.e. Lorem Ipsum**

        This content will be displayed once the QR Code is scanned.

    qrcodeImageUrl:
      type: string
      description:  |
        **HTTP(s) URL of the icon displayed in the middle of the QR Code** *Example: https://www.abyssale.com/imge/abyssale_logo.png*

        __It must be a public accessible link and have a filesize of 10 mo maximum.__

        Supported files: jpeg, png, webp

    qrcodeHideIcon:
      type: boolean
      description: "`true`, `false`. If true it hides the icon in the middle of the QRCode."

    videoUrl:
      type: string
      format: uri
      description: |
        **HTTP(s) URL of the video** *Example: https://www.abyssale.com/imge/this_an_example.mp4*

        __It must be a public accessible link and have a filesize of 100 mo maximum.__

        Supported files: mp4

x-webhooks:
  newBanner:
    post:
      security: []
      summary: File generated
      description: |
        A new file (image, video, GIF, HTML5 banner, or PDF) has been generated and is ready to
        download.

        **Subscribed in the dashboard, not per request.** Unlike the other two events, this one
        has no `callback_url` field on any endpoint — it is configured once for the workspace and
        then fires for every file generated, including files produced outside the API.

        Delivery, retries and the `event_type` convention are described under
        [Webhooks](/webhooks/overview-webhooks).
      operationId: newImage
      tags:
        - Files
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  properties:
                    event_type:
                      type: string
                      enum: [NEW_BANNER]
                      description: Identifies the event — every webhook payload carries this field.
                - $ref: "#/components/schemas/Banner"
      responses:
        "200":
          description: Return a 200 status to indicate that the data was received successfully

  newBannerBatch:
    post:
      security: []
      summary: Batch generation completed
      description: |
        An asynchronous batch generation request has completed. Contains the full list
        of generated files and any per-format errors. Triggered by
        `POST /async/banner-builder/{designId}/generate`.
      operationId: newBannerBatch
      tags:
        - Files
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event_type:
                  type: string
                  enum: [NEW_BANNER_BATCH]
                  description: Identifies the event — every webhook payload carries this field.
                generation_request_id:
                  type: string
                  format: uuid
                  description: Unique identifier (UUID) of the batch generation
                banners:
                  type: array
                  items:
                    $ref: "#/components/schemas/Banner"
                  description: List of generated images
                errors:
                  type: array
                  items:
                    type: object
                    properties:
                      template_format_name:
                        type: string
                        description: The image related to this format name cannot be generated
                      reason:
                        type: string
                        description: The error reason
                  description: If any errors occur, they will be listed under that property
              required:
                - generation_request_id
                - banners
      responses:
        "200":
          description: Return a 200 status to indicate that the data was received successfully

  newExport:
    post:
      security: []
      summary: Banner export archive ready
      description: |
        An asynchronous banner export has completed. The ZIP archive containing all
        requested banners is available at the provided `archive_url`.
      operationId: newExport
      tags:
        - Exports
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event_type:
                  type: string
                  enum: [NEW_EXPORT]
                  description: Identifies the event — every webhook payload carries this field.
                export_id:
                  type: string
                  format: uuid
                  example: "38cb7df3-1160-4824-8531-2bacde2b6517"
                  description: ID of the export
                archive_url:
                  type: string
                  format: uri
                  example: "https://production-banners.s3.eu-west-1.amazonaws.com/demo/export_38cb7df3-1160-4824-8531-2bacde2b6517.zip"
                  description: The zip public URL
                requested_at:
                  type: number
                  example: 1642150000
                  description: Timestamp at which the export has been requested
                generated_at:
                  type: number
                  example: 1642150002
                  description: Timestamp at which the export has been done
      responses:
        "200":
          description: Return a 200 status to indicate that the data was received successfully
  templateStatus:
    post:
      security: []
      summary: Design status changed
      description: |
        A design moved through the approval workflow — submitted for review, approved, rejected,
        put on hold, or reset. Use it to tell an internal tool that a creative is cleared for use.

        **Configured in the dashboard, and filterable.** Like `newBanner`, this event has no
        `callback_url` on any endpoint: you subscribe once for the workspace. The subscription can
        be narrowed to specific designs, in which case changes to any other design are not
        delivered.

        The payload is the design plus the status that triggered the event. That status is
        **frozen at the moment of the change**, not read at delivery time — if a design is
        approved and then rejected, you receive two events carrying `APPROVED` and `REJECTED`
        respectively, rather than two copies of the latest value.

        Delivery, retries and the `event_type` convention are described under
        [Webhooks](/webhooks/overview-webhooks).
      operationId: templateStatus
      tags:
        - Designs
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                event_type:
                  type: string
                  enum: [TEMPLATE_STATUS]
                  description: Identifies the event — every webhook payload carries this field.
                id:
                  type: string
                  format: uuid
                  description: Unique identifier (UUID) of the design whose status changed.
                  example: "873608a1-e498-47dd-a36d-bd065e3e2b8e"
                name:
                  type: string
                  description: Name of the design.
                  example: "Ad campaign fall 2025"
                created_at:
                  type: integer
                  description: Unix timestamp of the design's creation.
                  example: 1623229458
                updated_at:
                  type: integer
                  description: Unix timestamp of the last change to the design's content.
                  example: 1649837900
                status:
                  type: string
                  enum: [NONE, IN_REVIEW, ON_HOLD, REJECTED, APPROVED, COMPLETED]
                  description: |
                    The workflow status this event reports. `NONE` is the default for a design
                    that has never entered the workflow, and is also what a reset reports.
                    `COMPLETED` reports that work on the design is finished.
                  example: "APPROVED"
                status_updated_at:
                  type: integer
                  description: Unix timestamp at which that status was set.
                  example: 1649837939
              required:
                - event_type
                - id
                - status
                - status_updated_at
      responses:
        "200":
          description: Return a 200 status to indicate that the data was received successfully
