Python SDK
abyssale is the official Python client. It ships a synchronous client and an asynchronous one with identical surfaces — same method names, same arguments, same return types.
pip install abyssale
export ABYSSALE_API_KEY="{YOUR-API-KEY}"Requires Python 3.10+. Built on httpx, with response models generated from the OpenAPI spec.
This release models API version v2026-08-20
The API is versioned by release date and keeps one version at a time; the SDK version is independent of it. Every SDK release names the API version it was generated from, and abyssale.__api_version__ reads it at runtime — compare it against the version field on any response to see whether the two agree.
| SDK | API version |
|---|---|
| 1.0.0 | v2026-08-20 |
from abyssale import Abyssale
with Abyssale() as client:
banner = client.generate_image("{designId}", {
"template_format_name": "facebook-feed",
"elements": {
"text_title": {"payload": "Summer sale — 40% off"},
},
})
print(banner.file.cdn_url)New to the API? Quickstart walks the same path in four calls.
Configuration
Every setting can be passed to the constructor or left to the environment. The argument wins.
| Variable | Required | Default | Description |
|---|---|---|---|
ABYSSALE_API_KEY | Yes | — | Your API key. Sent as x-api-key on every request. |
ABYSSALE_TIMEOUT_MS | No | 30000 | Per-attempt timeout in milliseconds. |
ABYSSALE_MAX_RETRIES | No | 3 | Maximum automatic retries — see Retries and timeouts. |
ABYSSALE_MAX_RETRY_WAIT_MS | No | 30000 | Longest Retry-After the SDK waits out before giving up. inf to never give up. |
client = Abyssale(api_key="{YOUR-API-KEY}", timeout=60, max_retries=0)The constructor's timeout and max_retry_wait are in seconds; their environment variables are in milliseconds, to match the API's own millisecond fields. With no key in either place the constructor raises AbyssaleConfigError — nothing is read at import time, so one process can hold clients for two different workspaces.
Both clients are context managers, and both accept an http_client= of your own when you need a proxy, a custom transport, or a shared connection pool. Outside a with block, call close() (aclose() on the async client).
Async
import asyncio
from abyssale import AsyncAbyssale
async def main():
async with AsyncAbyssale() as client:
accepted = await client.generate_multi_format_media("{designId}", {
"elements": {"text_title": {"payload": "New product launch"}},
})
result = await client.wait_for_generation_request(accepted.generation_request_id)
for banner in result.banners:
print(banner.file.cdn_url)
asyncio.run(main())Every method
One method per operation in the OpenAPI spec, named after that operation's operationId snake_cased — listDesigns → list_designs, generateMultiPagePdf → generate_multi_page_pdf. If you can find the operation in the reference, you can predict the method.
Every method below exists on both Abyssale and AsyncAbyssale, with the same name and signature; the async ones are coroutines. Each returns the parsed result and raises on failure — see Errors.
Authentication
| Method | Endpoint | Guide |
|---|---|---|
verify_api_key() | POST /auth | Authentication |
Designs
| Method | Endpoint | Guide |
|---|---|---|
list_designs(project_id=None, type=None) | GET /designs | List Designs |
get_design(design_id, advanced=False) | GET /designs/{designId} | Design Details |
get_design_format(design_id, format_specifier) | GET /designs/{designId}/formats/{formatSpecifier} | Design Format Details |
get_design takes advanced=True to include group layers, which the default response omits; get_design_format is always the advanced view and accepts either a format name or its UUID.
Generation
| Method | Endpoint | Guide |
|---|---|---|
generate_image(design_id, body) | POST /banner-builder/{designId}/generate | Generate Single Image |
generate_multi_format_media(design_id, body) | POST /async/banner-builder/{designId}/generate | Asynchronous Generation |
generate_multi_page_pdf(design_id, body) | POST /async/banner-builder/{designId}/generate-multipage-pdf | Multi-Page PDF |
get_generation_request(generation_request_id) | GET /generation-request/{generationRequestId} | Polling |
wait_for_generation_request(id, ...) | polls the above | Polling helpers |
Files
| Method | Endpoint | Guide |
|---|---|---|
get_file(banner_id) | GET /banners/{bannerId} | Endpoint Catalog |
Fonts
| Method | Endpoint | Guide |
|---|---|---|
list_fonts() | GET /fonts | Fonts |
Projects
| Method | Endpoint | Guide |
|---|---|---|
list_projects() | GET /projects | Projects |
create_project(body) | POST /projects | Projects |
Exports
| Method | Endpoint | Guide |
|---|---|---|
export_banners(body) | POST /async/banners/export | Asset Export |
Dynamic images
| Method | Endpoint | Guide |
|---|---|---|
create_dynamic_image_url(design_id, body=None) | POST /designs/{designId}/dynamic-image-url | Create a Dynamic Image |
Workspace templates
| Method | Endpoint | Guide |
|---|---|---|
list_workspace_templates(category_id=None, type=None) | GET /workspace-templates | Workspace Templates |
list_workspace_template_categories() | GET /workspace-template-categories | Workspace Templates |
duplicate_workspace_template(company_template_id, body) | POST /workspace-templates/{companyTemplateId}/use | Use a Workspace Template |
get_duplication_request(duplicate_request_id) | GET /design-duplication-requests/{duplicateRequestId} | Use a Workspace Template |
wait_for_duplication_request(id, ...) | polls the above | Polling helpers |
Request bodies and responses
Request bodies are plain dictionaries, passed through untouched. Responses are typed models.
banner = client.generate_image(design_id, {
"template_format_name": "facebook-feed",
"elements": {"text_title": {"payload": "Hello"}},
})
banner.file.cdn_url # typed
banner.format.id # typedA misspelled element name is silently ignored
The API accepts unknown element and property names — a key naming a layer your design does not have passes validation and simply changes nothing, with no error and no warning. The SDK does not add a check of its own, because that leniency is deliberate and long-standing. Verify names against get_design(design_id), which lists every element and the attributes it accepts, rather than against a 400.
Why bodies are not modelled
An element payload carries no type field — the layer's type comes from the design — so the schema is a union of ten deliberately overlapping shapes with nothing to tell them apart. Nothing can validate that offline, and a model would only mis-coerce your payload into the wrong branch.
A successful response never fails to parse. Unknown fields are preserved, and a value the reference does not describe — an older design's layer type, say — is kept as-is rather than raising, so a documentation lag can never break a working integration. Use getattr(obj, "field", None) for anything you are not certain of.
Only the part that does not match degrades. Everything else on the response is still a typed model, so one unrecognised value in a list of layers does not turn the whole design into dictionaries — design.formats[0].id keeps working.
Errors
Every failure raises. All exceptions descend from AbyssaleError:
| Exception | Raised when |
|---|---|
AbyssaleAPIError | Any non-2xx. The base for the four below. |
AbyssaleAuthError | 401 — unknown key, revoked key, or a plan without API access (api_access_denied). |
AbyssaleNotFoundError | 404 — no such design, format, file or request in this workspace. |
AbyssaleRateLimitError | 429 — see Retries and timeouts. Carries retry_after. |
AbyssaleConnectionError | The request never got a response: DNS, TLS, connection reset, timeout. |
AbyssaleConfigError | A missing or invalid setting, raised by the constructor. |
AbyssalePollingError | A wait_for_* helper gave up — see Polling helpers. |
AbyssaleAPIError carries the API's error envelope, unflattened:
| Attribute | Contents |
|---|---|
id | The machine-readable error code — branch on this, never on the message. |
message | The human-readable explanation. |
errors | The field-level problems as {path, code, message} dicts, or None when the failure is not field-scoped. |
status | The HTTP status code. |
response | The raw httpx.Response, for headers. |
from abyssale import AbyssaleAPIError, AbyssaleNotFoundError
try:
banner = client.generate_image(design_id, {"elements": {}})
except AbyssaleNotFoundError:
raise SystemExit("Unknown design")
except AbyssaleAPIError as err:
print(err.status, err.id, err.message)
for problem in err.errors or []:
print(problem["path"], problem["code"], problem["message"])The envelope is the same one the REST API sends, so the full id catalogue on Errors applies unchanged.
Rate-limit headers are not parsed for you
The SDK does not surface X-RateLimit-*. Read them off err.response.headers when you want to pace against your remaining budget — see Rate limits.
Retries and timeouts
Failed requests are retried up to max_retries times (default 3), with exponential backoff of 1 s, 2 s, 4 s plus up to 100 ms of jitter. When the response carries Retry-After, that value is honoured instead of the backoff — but only up to max_retry_wait (default 30 s). Each attempt gets its own fresh timeout window.
The rules are deliberately narrower than "retry every 429 and 5xx":
| Response | Retried? |
|---|---|
500, 502, 503, 504 on a GET/HEAD/OPTIONS | Yes |
500, 502, 503, 504 on a POST | Never |
429 with Retry-After under max_retry_wait | Yes, after exactly that long |
429 with Retry-After over max_retry_wait | No — raises immediately, retry_after set |
429 without Retry-After | Once, after 1 s |
429 with id: feature_not_in_plan | No, headers regardless |
| Anything else | No |
A POST is never retried for you
Every POST on this API generates an asset, queues a batch or duplicates a template — all of which spend credits. A 504 from the gateway does not mean the generation didn't happen, so repeating it can bill you twice. If you retry a write yourself, verify first: poll the generation request, or list what exists.
Why a bare 429 is retried exactly once
On this API 429 means three different things, and two of them share an id.
request_rate_limited is a genuine endpoint throttle. It carries Retry-After, so the SDK waits exactly that long and tries again — nothing to guess.
feature_not_in_plan means your plan excludes that design type. Unambiguous, and permanent until the plan changes, so it is never retried — even when the response carries Retry-After. A named window is a claim that waiting helps, and it cannot be: the feature will not appear in five seconds. A header on that refusal is generic rate-limit middleware stamping every 429 it passes, not a statement about this one.
rate_limit_exceeded is the hard one: it answers both a spent credit balance, which no amount of waiting fixes, and the global 10 requests/second ceiling, which clears in under a second. Only message distinguishes them, and the ceiling is enforced in front of the API, so its refusal carries neither Retry-After nor reliably the error envelope at all.
So the SDK does not try to classify it. It retries once, after a fixed second — the ceiling is per-second, so one second is what clearing it takes. Guessing wrong costs one second on a call that was already failing; not guessing at all costs the whole call, and generation endpoints have no endpoint budget, which makes the ceiling the only limit a burst of parallel generation calls can hit.
Set max_retries=0 to switch this off with everything else. Note that a refused request still counts against your budget. Full taxonomy on Rate limits.
Why a long Retry-After is not waited out
Once a credit balance is spent the rate limiter can name a very long cool-off — cool-offs of around 28 minutes have been observed — and max_retries multiplies it. Honouring that blindly turns a single call into over an hour of silence, inside a library, with no way to intervene and nothing logged.
Whether that wait is acceptable is not something the SDK can know. A nightly batch may well want to sit out a quota; a request with a user waiting on it never does. So past max_retry_wait the SDK stops and raises the AbyssaleRateLimitError it would have raised anyway, with retry_after carrying the server's own figure — you keep the information and the decision.
import math
# Wait however long the server asks — for a batch job with nobody waiting.
client = Abyssale(max_retry_wait=math.inf)
# Or refuse to block for more than a second.
client = Abyssale(max_retry_wait=1)The bound applies to any server-named wait, including a 5xx that carries Retry-After. The SDK's own backoff ladder and the one-second probe are already short and are unaffected.
Polling helpers
wait_for_generation_request and wait_for_duplication_request run the polling loop for you — call and wait, instead of writing the backoff yourself.
accepted = client.generate_multi_format_media(design_id, {
"elements": {"text_title": {"payload": "New product launch"}},
})
# Polls with exponential backoff until is_finalized is True
result = client.wait_for_generation_request(accepted.generation_request_id)
for banner in result.banners:
print(banner.format.id, banner.file.cdn_url)Both take the same keyword options, in seconds. Values below the minimum are raised to it, not honoured:
| Option | Default | Minimum | Description |
|---|---|---|---|
interval | 3 | 2 | Delay before the first re-check. Doubles after each poll, up to max_interval. |
max_interval | 30 | 5 | Ceiling for the backoff interval. |
timeout | 1800 (30 min) | 60 | Total budget. Raises once the next wait would exceed it. |
result = client.wait_for_generation_request(request_id, interval=5, timeout=300)A poll that fails is retried only when retrying could help — the same rule the HTTP layer uses. A 5xx, a dropped connection, or a 429 carrying Retry-After is treated as a blip and absorbed, up to three in a row; the streak resets on any successful poll. A bare 429 is probed once, for the reason above — but only once per wait, not once in a row: a second one answers the question, so a spent credit balance ends the wait rather than being re-asked for half an hour. Anything else is a verdict and ends the wait immediately, so a generation_request_not_found raises on the first poll.
Failures raise AbyssalePollingError, which carries id (the API's error code, when the failure had one) and body (the parsed envelope). The original exception is always on __cause__.
from abyssale import AbyssalePollingError
try:
result = client.wait_for_generation_request(request_id)
except AbyssalePollingError as err:
if err.id == "generation_request_gone":
... # The request expired — generation requests are kept 7 days
raiseA timeout is not a failed generation: the message reads no result after 1800s — the request may still complete. For batches that outlive your process, prefer a callback_url and a webhook over polling.
Partial success resolves
A finalized generation can carry both banners and per-format errors — one format failing does not invalidate the others, so check result.errors when you need every format you asked for:
result = client.wait_for_generation_request(request_id)
for banner in result.banners:
print(banner.file.cdn_url)
for error in result.errors or []:
print("failed:", error.template_format_name, error.reason)Only a request that finalized with no banners at all and at least one error raises — returning that as a success would leave you iterating an empty list with nothing to say why. The status object is still reachable on err.__cause__.args[0] if you want to read errors programmatically.
Duplication resolves on failure too
wait_for_duplication_request returns when the request reaches COMPLETED or ERROR — reaching a terminal state is not the same as succeeding. Always check result.status before using result.designs.
Type checking
The package ships py.typed, so mypy and pyright see every signature and response model. The public models are importable from the package root:
from abyssale import (
Banner, Design, DesignDetail, DesignElement, DesignFormat, DesignFormatDetail,
DesignListItem, DesignPage, DesignPageElement, DuplicationRequest,
DuplicationRequestStatus, DuplicatedDesign, DynamicImageResponse, ErrorResponse,
ExportAccepted, Font, GenerationRequestAccepted, GenerationRequestStatus,
Project, ProjectSummary, WorkspaceTemplate, WorkspaceTemplateCategory,
)abyssale.models holds the rest, including the nested schemas that have no top-level alias.
What the SDK does not cover
- Design Import — the import surface is in Alpha and its schema can still change, so it is deliberately excluded rather than shipped and broken. Call
POST /designs/import/jsondirectly until it stabilises. - Webhook receiving — webhooks arrive at your server; there is nothing for a client to call. Payloads are on Events.
Related
- Quickstart — first image in four calls
- OpenAPI spec — the contract this SDK is generated from
- Node.js SDK — the same API, in JavaScript/TypeScript
- Errors — every
idan exception can carry - Rate limits — the budgets behind the retry rules
- Authentication — where
ABYSSALE_API_KEYcomes from - PyPI package · GitHub repository
