Aqua REST API Reference
Add easy, simple screenshot generation and app icon generation into your SaaS or vibe coding platform using our REST API and zero-dependency SDKs.
Please note that the Aqua API is currently in alpha and is not ready for production use. Endpoints, authorization methods, and response schemas are subject to change.
Get notified when production-ready
Sign up to get notified when we launch the production-ready API and SDK!
No spam. We only send critical product updates. You can unsubscribe at any time.
Agent Quick Start
Get your AI coding assistant integrated in three simple steps.
Create a Free Account
Create free account →Sign up on the Aqua platform to access your dashboard, generate developer API keys, and manage your assets.
Add Credits
Go to Credits page →Purchase API credits to enable programmatic screenshot and icon generation for your workspace.
Copy Agent Prompt
Provide this prompt to your AI developer agent to enable it to write the integration code for you automatically.
You are an AI coding assistant integrating the Aqua Developer API to generate App Store icons and polished screenshots programmatically.
## Primary references (read these first)
- TypeScript SDK: sdk/typescript/client.ts (zero dependencies)
- Python SDK: sdk/python/client.py (zero dependencies)
- OpenAPI spec: public/openapi.json
- Human docs: src/routes/_marketing/docs.tsx (examples for cURL, TypeScript, Python)
## Authentication & client setup
- Production base URL: https://api.aqua-app.com
- Authenticate every request with header x-api-key (developer API key from the Aqua dashboard).
- Read the key from env (e.g. AQUA_API_KEY). Never hardcode secrets.
- TypeScript: new Aqua({ apiKey, baseUrl? })
- Python: Aqua(api_key, base_url="https://api.aqua-app.com")
- Optional baseUrl/base_url for local dev (http://localhost:3000).
## Credits
- Icon set or icon PNG: 1 credit per request.
- Screenshots: 1 credit per screenshot, max 5 per request.
- Failed requests are refunded automatically.
- 400 = invalid request; 401 = missing/invalid API key; 402 = insufficient credits; 500/502/503 = server/render failures (refunded).
## Endpoints
### POST /api/v1/icons/generate — App Store icon set (.icon ZIP)
- JSON body: { "prompt": string } — max 500 chars, no URLs/links in prompt.
- TS: await client.generateIconSet({ prompt }) → Blob (application/zip)
- Python: client.generate_icon_set(prompt) → bytes
### POST /api/v1/icons/png — single master icon PNG
- Same prompt rules as above.
- TS: await client.generateIconPng({ prompt }) → Blob (image/png)
- Python: client.generate_icon_png(prompt) → bytes
### POST /api/v1/screenshots/generate — polished App Store screenshots (ZIP)
- multipart/form-data: manifest (JSON string) + capture_{slot} PNG files per manifest slot.
- Returns application/zip with screenshot-{slot}.png files.
- TS: await client.screenshots.generate({ appDisplayName, screenshots }) → Blob
(alias: client.generateScreenshots)
- Python: client.screenshots.generate(app_display_name=..., screenshots=[...]) → bytes
(alias: client.generate_screenshots)
#### Manifest shape
{
"appDisplayName": string, // required, max 120 chars
"screenshots": [ // 1–5 entries, unique slot values
{
"slot": 1-5,
"position": "iphone_full_with_text_top" | "iphone_bottom_with_text_top" | "iphone_top_with_text_bottom",
"copy": "auto" | { "title": string, "subtitle": string },
"backgroundColor": "auto" | <explicit background>
}
]
}
#### Position constants (SDK convenience; API accepts canonical strings only)
- TS/Python: Aqua.Position.iPhoneFullTextTop → iphone_full_with_text_top
- Aqua.Position.iPhoneBottomTextTop → iphone_bottom_with_text_top
- Aqua.Position.iPhoneTopTextBottom → iphone_top_with_text_bottom
#### Copy length limits (explicit title/subtitle only; "auto" skips these)
- iphone_full_with_text_top: title 1–20, subtitle 1–20
- iphone_bottom_with_text_top: title 22–50, subtitle 20–80
- iphone_top_with_text_bottom: title 22–50, subtitle 20–80
- TS: Aqua.copyLimitsForPosition(position) or ScreenshotCopyLimits
- Python: Aqua.copy_limits_for_position(position)
#### backgroundColor
- "auto" — AI-picked background
- Or explicit: #RRGGBB hex, CSS linear-gradient(...), or Unsplash url(https://images.unsplash.com/...)
- TS preflight: isValidScreenshotBackground(value)
- Python preflight: is_valid_screenshot_background(value)
#### Raw captures
- One PNG per manifest slot as capture_{slot} (multipart) or capture File/Blob (SDK).
- Required size: 1206×2622 px (iPhone portrait). JPEG/WebP rejected.
- TS: each screenshot entry needs capture: File | Blob (.png filename)
- Python: each entry needs capture_path pointing to a .png file
#### SDK preflight validation (runs before HTTP)
- TS: validateGenerateScreenshotsOptions(options)
- Python: validate_generate_screenshots_options(app_display_name=..., screenshots=...)
## Implementation guidance
- Copy the SDK file(s) into the project or import from sdk/ paths shown above.
- Use client.screenshots.generate for screenshots (documented entry point).
- Handle API errors: SDK throws on non-2xx with server error message when available.
- See docs.tsx code snippets for full working examples (icons + 5-slot screenshot batch).
- Do not invent endpoints or fields beyond the SDK types and OpenAPI spec.Developer SDK Quick Start
Easy SetupInstantiate the client class in your preferred language to start generating app store icons with just a few lines of code.
import { Aqua } from './sdk/typescript/client';
const client = new Aqua({
apiKey: 'aqua_your_developer_key_here'
});
// Generate and get Blob of the .icon ZIP bundle
const zipBlob = await client.generateIconSet({
prompt: 'neon ice cream'
});Authentication
The API uses secret tokens called API keys. You can generate and revoke developer API keys directly on your dashboard under the API Keys page.
import { Aqua } from './sdk/typescript/client';
const client = new Aqua({
apiKey: 'aqua_your_developer_key_here',
});/api/v1/screenshots/generate
Cost: 1 Credit per screenshot (max 5)Upload up to five raw iPhone captures (1206×2622 px) and receive a ZIP of polished App Store screenshots directly in the response. Set copy: "auto" for AI-generated title and subtitle, or pass explicit { title, subtitle } within the per-position character limits below. Set backgroundColor: "auto" for an AI-picked background, or pass an explicit hex, gradient, or Unsplash url(). Captures must be PNG files. The SDK validates copy, background, and PNG uploads before sending the request.
| position | title | subtitle |
|---|---|---|
| iphone_full_with_text_top | 1–20 | 1–20 |
| iphone_bottom_with_text_top | 22–50 | 20–80 |
| iphone_top_with_text_bottom | 22–50 | 20–80 |
| Field | Type | Required | Description |
|---|---|---|---|
| manifest | JSON string | Yes | JSON object with appDisplayName (string, max 120 chars) and a screenshots array (1–5 entries, unique slot values). Each entry needs slot (1–5), position (iphone_full_with_text_top, iphone_bottom_with_text_top, or iphone_top_with_text_bottom), copy ("auto" or { title, subtitle } within per-position character limits), and backgroundColor ("auto" or explicit hex, CSS linear-gradient, or Unsplash url()). |
| capture_1 … capture_5 | file | Per slot | Raw capture PNG for each manifest slot (1206×2622 px). JPEG and WebP are rejected. |
Returns a ZIP archive with screenshot-{slot}.png files. Content-Type is application/zip.
| Status | Description |
|---|---|
| 400 | Bad Request. Invalid manifest or capture uploads. |
| 401 | Unauthorized. Missing or invalid API key. |
| 402 | Payment Required. Insufficient credit balance. |
| 500 | Server failure (e.g. credit transaction failure). Failed requests are refunded automatically. |
| 502 | Screenshot render service failure. Failed requests are refunded automatically. |
| 503 | Screenshot render service is not configured. Failed requests are refunded automatically. |
const zipBlob = await client.screenshots.generate({
appDisplayName: 'TaskFlow',
screenshots: [
{
slot: 1,
position: Aqua.Position.iPhoneFullTextTop,
capture: homeCaptureFile,
copy: 'auto',
backgroundColor: 'auto',
},
{
slot: 2,
position: Aqua.Position.iPhoneBottomTextTop,
capture: statsCaptureFile,
copy: {
title: 'Track progress over time.',
subtitle: 'Beautiful charts for habits and goals you care about.',
},
backgroundColor: 'auto',
},
{
slot: 3,
position: Aqua.Position.iPhoneTopTextBottom,
capture: profileCaptureFile,
copy: 'auto',
backgroundColor: '#1a1a2e',
},
{
slot: 4,
position: Aqua.Position.iPhoneTopTextBottom,
capture: settingsCaptureFile,
copy: {
title: 'Stay on top of your day.',
subtitle: 'Smart reminders and focus tools built for deep work.',
},
backgroundColor: 'auto',
},
{
slot: 5,
position: Aqua.Position.iPhoneBottomTextTop,
capture: shareCaptureFile,
copy: 'auto',
backgroundColor: 'auto',
},
],
});/api/v1/icons/generate
Cost: 1 CreditGenerates a high-quality App Store icon set (.icon ZIP bundle) synchronously from a description prompt. Returns the binary asset directly in the response.
| Field | Type | Required | Description |
|---|---|---|---|
| prompt | string | Yes | Description of the icon visual concept (max 500 characters). Must not contain HTTP links or URLs. |
Returns the binary content of the .icon ZIP bundle. Content-Type is application/zip.
| Status | Description |
|---|---|
| 400 | Bad Request. Invalid JSON body or invalid prompt (empty, over 500 characters, or contains URLs/links). |
| 401 | Unauthorized. Missing or invalid API key. |
| 402 | Payment Required. Insufficient credit balance. |
| 500 | Server or image generation failure. Failed requests are refunded automatically. |
const zipBlob = await client.generateIconSet({
prompt: 'Retro 80s neon synthwave bucket of ice cream',
});
console.log('ZIP Blob size:', zipBlob.size);/api/v1/icons/png
Cost: 1 CreditGenerates a single high-quality App Store master icon PNG synchronously from a description prompt. Returns the binary asset directly in the response.
| Field | Type | Required | Description |
|---|---|---|---|
| prompt | string | Yes | Description of the icon visual concept (max 500 characters). Must not contain HTTP links or URLs. |
Returns the binary content of the generated master icon PNG file. Content-Type is image/png.
| Status | Description |
|---|---|
| 400 | Bad Request. Invalid JSON body or invalid prompt (empty, over 500 characters, or contains URLs/links). |
| 401 | Unauthorized. Missing or invalid API key. |
| 402 | Payment Required. Insufficient credit balance. |
| 500 | Server or image generation failure. Failed requests are refunded automatically. |
const pngBlob = await client.generateIconPng({
prompt: 'Retro 80s neon synthwave bucket of ice cream',
});
console.log('PNG Blob size:', pngBlob.size);TypeScript SDK
Save this zero-dependency TypeScript wrapper file to your codebase. It supports autocompletion and compiles directly in browser, server-side Node.js, and edge environments.
/**
* Aqua API client (zero dependencies).
*
* Public surface: `Aqua`, `AquaScreenshotsClient`, screenshot/icon types,
* `isValidScreenshotBackground`, `validateGenerateScreenshotsOptions`,
* `copyLimitsForPosition`, `ScreenshotPosition`, `ScreenshotCopyLimits`.
*/
export interface AquaConfig {
apiKey: string
baseUrl?: string
}
export interface GenerateIconSetOptions {
prompt: string
}
/** Screenshot positions — also exposed as `Aqua.Position` for named constants. */
export const ScreenshotPosition = {
iPhoneFullTextTop: 'iphone_full_with_text_top',
iPhoneBottomTextTop: 'iphone_bottom_with_text_top',
iPhoneTopTextBottom: 'iphone_top_with_text_bottom',
} as const
export type ScreenshotPosition =
(typeof ScreenshotPosition)[keyof typeof ScreenshotPosition]
export type ScreenshotCopyFieldLimits = {
min: number
max: number
}
/** Per-position title/subtitle length limits enforced by the API. */
export const ScreenshotCopyLimits: Record<
ScreenshotPosition,
{
title: ScreenshotCopyFieldLimits
subtitle: ScreenshotCopyFieldLimits
}
> = {
[ScreenshotPosition.iPhoneFullTextTop]: {
title: { min: 1, max: 20 },
subtitle: { min: 1, max: 20 },
},
[ScreenshotPosition.iPhoneBottomTextTop]: {
title: { min: 22, max: 50 },
subtitle: { min: 20, max: 80 },
},
[ScreenshotPosition.iPhoneTopTextBottom]: {
title: { min: 22, max: 50 },
subtitle: { min: 20, max: 80 },
},
}
export function copyLimitsForPosition(position: ScreenshotPosition) {
return ScreenshotCopyLimits[position]
}
export type ScreenshotCopy =
| 'auto'
| {
title: string
subtitle: string
}
/** Explicit background per API rules — not `'auto'`. */
export type ScreenshotBackgroundValue = string
export type ScreenshotBackground = 'auto' | ScreenshotBackgroundValue
export interface ScreenshotGenerateInput {
slot: number
capture: Blob | File
copy: ScreenshotCopy
position?: ScreenshotPosition | string
backgroundColor: ScreenshotBackground
}
export interface GenerateScreenshotsOptions {
appDisplayName: string
screenshots: ScreenshotGenerateInput[]
}
// --- Private validation helpers ---
/** Raw capture file header check. */
const _CAPTURE_SIGNATURE = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
])
const _MAX_BACKGROUND_LENGTH = 2048
const _SOLID_COLOR_RE = /^#[0-9A-Fa-f]{6}$/
const _POSITION_ALIASES: Record<string, ScreenshotPosition> = {
iphone_full_text_top: ScreenshotPosition.iPhoneFullTextTop,
iphone_bottom_text_top: ScreenshotPosition.iPhoneBottomTextTop,
iphone_top_text_bottom: ScreenshotPosition.iPhoneTopTextBottom,
}
const _CANONICAL_POSITIONS = new Set<string>(Object.values(ScreenshotPosition))
function _isScreenshotPosition(value: string): value is ScreenshotPosition {
return _CANONICAL_POSITIONS.has(value)
}
function _countColorStops(value: string) {
return value.match(/#[0-9A-Fa-f]{6}/g)?.length ?? 0
}
function _isRemoteImageBackground(value: string) {
const match = /^url\s*\(\s*['"]?(https:\/\/[^'")]+?)['"]?\s*\)$/i.exec(value)
if (!match) {
return false
}
try {
const url = new URL(match[1])
return url.protocol === 'https:' && url.hostname === 'images.unsplash.com'
} catch {
return false
}
}
function _isGradientBackground(value: string) {
if (!/^linear-gradient\s*\(/i.test(value)) {
return false
}
if (!value.trim().endsWith(')')) {
return false
}
return _countColorStops(value) >= 2
}
export function isValidScreenshotBackground(value: string) {
const trimmed = value.trim()
if (!trimmed || trimmed.length > _MAX_BACKGROUND_LENGTH) {
return false
}
if (_SOLID_COLOR_RE.test(trimmed)) {
return true
}
if (_isGradientBackground(trimmed)) {
return true
}
return _isRemoteImageBackground(trimmed)
}
function _validateScreenshotBackground(
backgroundColor: ScreenshotBackground,
slot: number,
) {
if (backgroundColor === 'auto') {
return
}
if (!isValidScreenshotBackground(backgroundColor)) {
throw new Error(
`Slot ${slot}: backgroundColor must be 'auto' or a valid hex, linear-gradient, or Unsplash url() value.`,
)
}
}
function _resolveScreenshotPosition(
position: ScreenshotPosition | string | undefined,
): ScreenshotPosition {
if (!position) {
return ScreenshotPosition.iPhoneFullTextTop
}
const resolved = _POSITION_ALIASES[position] ?? position
if (!_isScreenshotPosition(resolved)) {
throw new Error(`Unknown screenshot position: ${position}`)
}
return resolved
}
function _validateScreenshotSlot(slot: number) {
if (!Number.isInteger(slot) || slot < 1 || slot > 5) {
throw new Error(`Slot ${slot} must be an integer between 1 and 5.`)
}
}
function _validateExplicitScreenshotCopy(
position: ScreenshotPosition,
copy: { title: string; subtitle: string },
slot: number,
) {
const limits = ScreenshotCopyLimits[position]
const title = copy.title.trim()
const subtitle = copy.subtitle.trim()
if (title.length < limits.title.min || title.length > limits.title.max) {
throw new Error(
`Slot ${slot}: title length ${title.length} must be between ${limits.title.min} and ${limits.title.max} for ${position}.`,
)
}
if (
subtitle.length < limits.subtitle.min ||
subtitle.length > limits.subtitle.max
) {
throw new Error(
`Slot ${slot}: subtitle length ${subtitle.length} must be between ${limits.subtitle.min} and ${limits.subtitle.max} for ${position}.`,
)
}
}
async function _validateCaptureFile(
capture: Blob,
slot: number,
filename: string,
) {
if (capture.type && capture.type !== 'image/png') {
throw new Error(`Slot ${slot}: capture must be PNG (got ${capture.type}).`)
}
if (!filename.toLowerCase().endsWith('.png')) {
throw new Error(`Slot ${slot}: capture filename must end with .png.`)
}
const header = new Uint8Array(await capture.slice(0, 8).arrayBuffer())
if (
header.length < _CAPTURE_SIGNATURE.length ||
!_CAPTURE_SIGNATURE.every((byte, index) => header[index] === byte)
) {
throw new Error(`Slot ${slot}: capture is not a valid PNG file.`)
}
}
export async function validateGenerateScreenshotsOptions(
options: GenerateScreenshotsOptions,
) {
const appDisplayName = options.appDisplayName.trim()
if (!appDisplayName) {
throw new Error('appDisplayName is required.')
}
if (appDisplayName.length > 120) {
throw new Error('appDisplayName must be 120 characters or fewer.')
}
if (options.screenshots.length < 1 || options.screenshots.length > 5) {
throw new Error('screenshots must include between 1 and 5 entries.')
}
const slots = new Set<number>()
for (const shot of options.screenshots) {
_validateScreenshotSlot(shot.slot)
if (slots.has(shot.slot)) {
throw new Error(`Duplicate slot ${shot.slot} in screenshots.`)
}
slots.add(shot.slot)
const position = _resolveScreenshotPosition(shot.position)
if (shot.copy !== 'auto') {
_validateExplicitScreenshotCopy(position, shot.copy, shot.slot)
}
_validateScreenshotBackground(shot.backgroundColor, shot.slot)
const filename =
shot.capture instanceof File
? shot.capture.name
: `capture-${shot.slot}.png`
await _validateCaptureFile(shot.capture, shot.slot, filename)
}
}
// --- Private HTTP helpers ---
async function _parseApiError(response: Response): Promise<never> {
const errorData = (await response.json().catch(() => ({}))) as {
error?: string
}
throw new Error(errorData.error || `HTTP error! status: ${response.status}`)
}
async function _postJson(
baseUrl: string,
apiKey: string,
path: string,
body: unknown,
): Promise<Blob> {
const response = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
},
body: JSON.stringify(body),
})
if (!response.ok) {
await _parseApiError(response)
}
return response.blob()
}
async function _postMultipart(
baseUrl: string,
apiKey: string,
path: string,
formData: FormData,
): Promise<Blob> {
const response = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: {
'x-api-key': apiKey,
},
body: formData,
})
if (!response.ok) {
await _parseApiError(response)
}
return response.blob()
}
// --- Public client ---
export class AquaScreenshotsClient {
constructor(private readonly aqua: Aqua) {}
async generate(options: GenerateScreenshotsOptions): Promise<Blob> {
return this.aqua.generateScreenshots(options)
}
}
export class Aqua {
/** Named position constants — values match `ScreenshotPosition` type. */
static readonly Position = ScreenshotPosition
private apiKey: string
private baseUrl: string
readonly screenshots: AquaScreenshotsClient
constructor(config: AquaConfig) {
if (!config.apiKey) {
throw new Error('API Key is required to initialize the Aqua client.')
}
this.apiKey = config.apiKey
this.baseUrl = (config.baseUrl || 'https://api.aqua-app.com').replace(
/\/$/,
'',
)
this.screenshots = new AquaScreenshotsClient(this)
}
static copyLimitsForPosition(position: ScreenshotPosition) {
return copyLimitsForPosition(position)
}
/**
* Generates a high-quality App Store icon set synchronously.
* Deducts 1 credit from your balance.
* Returns a Blob containing the .icon ZIP file.
*/
async generateIconSet(options: GenerateIconSetOptions): Promise<Blob> {
return _postJson(
this.baseUrl,
this.apiKey,
'/api/v1/icons/generate',
options,
)
}
/**
* Generates a high-quality App Store icon PNG synchronously.
* Deducts 1 credit from your balance.
* Returns a Blob containing the PNG image.
*/
async generateIconPng(options: GenerateIconSetOptions): Promise<Blob> {
return _postJson(this.baseUrl, this.apiKey, '/api/v1/icons/png', options)
}
/**
* Generates polished App Store screenshots synchronously.
* Deducts 1 credit per screenshot (max 5 per request).
* Returns a ZIP blob containing screenshot-{slot}.png files.
*/
async generateScreenshots(
options: GenerateScreenshotsOptions,
): Promise<Blob> {
await validateGenerateScreenshotsOptions(options)
const manifest = {
appDisplayName: options.appDisplayName,
screenshots: options.screenshots.map((shot) => ({
slot: shot.slot,
position: _resolveScreenshotPosition(shot.position),
copy: shot.copy,
backgroundColor: shot.backgroundColor,
})),
}
const formData = new FormData()
formData.set('manifest', JSON.stringify(manifest))
for (const shot of options.screenshots) {
const file =
shot.capture instanceof File
? shot.capture
: new File([shot.capture], `capture-${shot.slot}.png`, {
type: 'image/png',
})
formData.set(`capture_${shot.slot}`, file, file.name)
}
return _postMultipart(
this.baseUrl,
this.apiKey,
'/api/v1/screenshots/generate',
formData,
)
}
}
Python SDK
Save this lightweight Python client using Python's standard library urllib. There are no third-party package dependencies like requests required.
"""
Aqua API client (zero dependencies).
Public surface: Aqua, screenshot/icon helpers, is_valid_screenshot_background,
validate_generate_screenshots_options, copy_limits_for_position, Position.
"""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Literal, TypedDict, Union
from urllib.parse import urlparse
__all__ = [
"Aqua",
"Position",
"ScreenshotBackground",
"ScreenshotCopy",
"ScreenshotCopyObject",
"ScreenshotPosition",
"copy_limits_for_position",
"is_valid_screenshot_background",
"validate_generate_screenshots_options",
]
ScreenshotPosition = Literal[
"iphone_full_with_text_top",
"iphone_bottom_with_text_top",
"iphone_top_with_text_bottom",
]
SCREENSHOT_COPY_LIMITS: dict[
ScreenshotPosition,
dict[str, dict[str, int]],
] = {
"iphone_full_with_text_top": {
"title": {"min": 1, "max": 20},
"subtitle": {"min": 1, "max": 20},
},
"iphone_bottom_with_text_top": {
"title": {"min": 22, "max": 50},
"subtitle": {"min": 20, "max": 80},
},
"iphone_top_with_text_bottom": {
"title": {"min": 22, "max": 50},
"subtitle": {"min": 20, "max": 80},
},
}
class Position:
"""Named position constants — values match API manifest strings."""
iPhoneFullTextTop = "iphone_full_with_text_top"
iPhoneBottomTextTop = "iphone_bottom_with_text_top"
iPhoneTopTextBottom = "iphone_top_with_text_bottom"
def copy_limits_for_position(position: ScreenshotPosition) -> dict[str, dict[str, int]]:
return SCREENSHOT_COPY_LIMITS[position]
class ScreenshotCopyObject(TypedDict):
title: str
subtitle: str
ScreenshotCopy = Union[Literal["auto"], ScreenshotCopyObject]
ScreenshotBackground = Union[Literal["auto"], str]
# --- Private validation helpers ---
_CAPTURE_SIGNATURE = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
_MAX_BACKGROUND_LENGTH = 2048
_SOLID_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
_REMOTE_IMAGE_RE = re.compile(
r"^url\s*\(\s*['\"]?(https://[^'\"()]+)['\"]?\s*\)\s*$",
re.IGNORECASE,
)
_GRADIENT_PREFIX_RE = re.compile(r"^linear-gradient\s*\(", re.IGNORECASE)
_POSITION_ALIASES: dict[str, ScreenshotPosition] = {
"iphone_full_text_top": "iphone_full_with_text_top",
"iphone_bottom_text_top": "iphone_bottom_with_text_top",
"iphone_top_text_bottom": "iphone_top_with_text_bottom",
}
def is_valid_screenshot_background(value: str) -> bool:
trimmed = value.strip()
if not trimmed or len(trimmed) > _MAX_BACKGROUND_LENGTH:
return False
if _SOLID_COLOR_RE.match(trimmed):
return True
if _GRADIENT_PREFIX_RE.match(trimmed) and trimmed.endswith(")"):
return len(re.findall(r"#[0-9A-Fa-f]{6}", trimmed)) >= 2
match = _REMOTE_IMAGE_RE.match(trimmed)
if not match:
return False
parsed = urlparse(match.group(1))
return parsed.scheme == "https" and parsed.hostname == "images.unsplash.com"
def _resolve_screenshot_position(position: str | None) -> ScreenshotPosition:
if not position:
return "iphone_full_with_text_top"
resolved = _POSITION_ALIASES.get(position, position) # type: ignore[arg-type]
if resolved not in SCREENSHOT_COPY_LIMITS:
raise ValueError(f"Unknown screenshot position: {position}")
return resolved
def _validate_screenshot_slot(slot: int) -> None:
if not isinstance(slot, int) or slot < 1 or slot > 5:
raise ValueError(f"Slot {slot} must be an integer between 1 and 5.")
def _validate_explicit_screenshot_copy(
position: ScreenshotPosition,
copy: ScreenshotCopyObject,
slot: int,
) -> None:
limits = SCREENSHOT_COPY_LIMITS[position]
title = copy["title"].strip()
subtitle = copy["subtitle"].strip()
title_limits = limits["title"]
if title_limits["min"] > len(title) or len(title) > title_limits["max"]:
raise ValueError(
f"Slot {slot}: title length {len(title)} must be between "
f"{title_limits['min']} and {title_limits['max']} for {position}."
)
subtitle_limits = limits["subtitle"]
if (
subtitle_limits["min"] > len(subtitle)
or len(subtitle) > subtitle_limits["max"]
):
raise ValueError(
f"Slot {slot}: subtitle length {len(subtitle)} must be between "
f"{subtitle_limits['min']} and {subtitle_limits['max']} for {position}."
)
def _validate_screenshot_background(background_color: ScreenshotBackground, slot: int) -> None:
if background_color == "auto":
return
if not isinstance(background_color, str) or not is_valid_screenshot_background(
background_color
):
raise ValueError(
f"Slot {slot}: background_color must be 'auto' or a valid hex, "
"linear-gradient, or Unsplash url() value."
)
def _validate_capture_file(capture_path: Path, slot: int) -> None:
if capture_path.suffix.lower() != ".png":
raise ValueError(f"Slot {slot}: capture must use a .png filename.")
data = capture_path.read_bytes()
if len(data) < len(_CAPTURE_SIGNATURE) or not data.startswith(_CAPTURE_SIGNATURE):
raise ValueError(f"Slot {slot}: capture is not a valid PNG file.")
def validate_generate_screenshots_options(
*,
app_display_name: str,
screenshots: list[dict[str, Any]],
) -> None:
app_name = app_display_name.strip()
if not app_name:
raise ValueError("app_display_name is required.")
if len(app_name) > 120:
raise ValueError("app_display_name must be 120 characters or fewer.")
if not screenshots or len(screenshots) > 5:
raise ValueError("screenshots must include between 1 and 5 entries.")
slots: set[int] = set()
for shot in screenshots:
slot = int(shot["slot"])
_validate_screenshot_slot(slot)
if slot in slots:
raise ValueError(f"Duplicate slot {slot} in screenshots.")
slots.add(slot)
position = _resolve_screenshot_position(shot.get("position"))
copy = shot["copy"]
if copy != "auto":
if not isinstance(copy, dict) or not copy.get("title") or not copy.get("subtitle"):
raise ValueError(
f"copy for slot {slot} must be 'auto' or an object with title and subtitle"
)
_validate_explicit_screenshot_copy(position, copy, slot)
background_color = shot.get("background_color") or shot.get("backgroundColor")
if background_color is None:
raise ValueError(f"background_color is required for slot {slot}.")
_validate_screenshot_background(background_color, slot)
capture_path = Path(shot["capture_path"])
if not capture_path.is_file():
raise FileNotFoundError(
f"Capture not found for slot {slot}: {capture_path}"
)
_validate_capture_file(capture_path, slot)
# --- Private HTTP helpers ---
def _raise_http_error(error: urllib.error.HTTPError) -> None:
try:
error_body = json.loads(error.read().decode("utf-8"))
error_message = error_body.get("error", f"HTTP {error.code}")
except Exception:
error_message = f"HTTP {error.code}"
raise Exception(f"API Error: {error_message}") from error
def _post_json(base_url: str, api_key: str, path: str, payload: dict[str, Any]) -> bytes:
url = f"{base_url}{path}"
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
}
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req) as res:
return res.read()
except urllib.error.HTTPError as e:
_raise_http_error(e)
raise # unreachable — satisfies type checker
def _post_multipart(
base_url: str,
api_key: str,
path: str,
body: bytes,
content_type: str,
) -> bytes:
url = f"{base_url}{path}"
headers = {
"Content-Type": content_type,
"x-api-key": api_key,
}
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
try:
with urllib.request.urlopen(req) as res:
return res.read()
except urllib.error.HTTPError as e:
_raise_http_error(e)
raise
def _encode_multipart(
fields: dict[str, str],
files: list[tuple[str, bytes, str, str]],
) -> tuple[bytes, str]:
boundary = "----AquaScreenshotBoundary7MA4YWxkTrZu0gW"
chunks: list[bytes] = []
for name, value in fields.items():
chunks.append(f"--{boundary}\r\n".encode())
chunks.append(
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
)
chunks.append(value.encode())
chunks.append(b"\r\n")
for field_name, content, filename, mime_type in files:
chunks.append(f"--{boundary}\r\n".encode())
chunks.append(
(
f'Content-Disposition: form-data; name="{field_name}"; '
f'filename="{filename}"\r\n'
).encode()
)
chunks.append(f"Content-Type: {mime_type}\r\n\r\n".encode())
chunks.append(content)
chunks.append(b"\r\n")
chunks.append(f"--{boundary}--\r\n".encode())
encoded = b"".join(chunks)
return encoded, f"multipart/form-data; boundary={boundary}"
# --- Public client ---
class Aqua:
Position = Position
def __init__(self, api_key: str, base_url: str = "https://api.aqua-app.com"):
if not api_key:
raise ValueError("API Key is required to initialize the Aqua client.")
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.screenshots = _AquaScreenshotsClient(self)
@staticmethod
def copy_limits_for_position(position: ScreenshotPosition) -> dict[str, dict[str, int]]:
return copy_limits_for_position(position)
def generate_icon_set(self, prompt: str) -> bytes:
"""
Generates a high-quality App Store icon set synchronously.
Deducts 1 credit from your balance.
Returns bytes of the .icon ZIP bundle.
"""
return _post_json(
self.base_url,
self.api_key,
"/api/v1/icons/generate",
{"prompt": prompt},
)
def generate_icon_png(self, prompt: str) -> bytes:
"""
Generates a high-quality App Store icon PNG synchronously.
Deducts 1 credit from your balance.
Returns bytes of the PNG image.
"""
return _post_json(
self.base_url,
self.api_key,
"/api/v1/icons/png",
{"prompt": prompt},
)
def generate_screenshots(
self,
*,
app_display_name: str,
screenshots: list[dict[str, Any]],
) -> bytes:
"""
Generates polished App Store screenshots synchronously.
Deducts 1 credit per screenshot (max 5 per request).
Returns bytes of a ZIP containing screenshot-{slot}.png files.
Each screenshot dict expects:
- slot: int (1-5)
- capture_path: str path to a raw 1206x2622 PNG capture
- copy: "auto" or {"title": "...", "subtitle": "..."}
- position: optional position string (e.g. Aqua.Position.iPhoneFullTextTop)
- background_color: "auto" or explicit CSS background (hex, linear-gradient, Unsplash url())
"""
return self.screenshots.generate(
app_display_name=app_display_name,
screenshots=screenshots,
)
class _AquaScreenshotsClient:
def __init__(self, aqua: Aqua):
self._aqua = aqua
def generate(
self,
*,
app_display_name: str,
screenshots: list[dict[str, Any]],
) -> bytes:
validate_generate_screenshots_options(
app_display_name=app_display_name,
screenshots=screenshots,
)
manifest_screenshots = []
files: list[tuple[str, bytes, str, str]] = []
for shot in screenshots:
slot = int(shot["slot"])
capture_path = Path(shot["capture_path"])
copy = shot["copy"]
position = _resolve_screenshot_position(shot.get("position"))
background_color = shot.get("background_color") or shot.get(
"backgroundColor"
)
entry: dict[str, Any] = {
"slot": slot,
"position": position,
"copy": copy,
"backgroundColor": background_color,
}
manifest_screenshots.append(entry)
files.append(
(
f"capture_{slot}",
capture_path.read_bytes(),
capture_path.name,
"image/png",
)
)
manifest = {
"appDisplayName": app_display_name,
"screenshots": manifest_screenshots,
}
body, content_type = _encode_multipart(
{"manifest": json.dumps(manifest)},
files,
)
return _post_multipart(
self._aqua.base_url,
self._aqua.api_key,
"/api/v1/screenshots/generate",
body,
content_type,
)
Credits & Billing Information
API icon generation costs 1 credit per icon. Screenshot generation costs 1 credit per screenshot (up to 5 per request). Failed requests are refunded automatically. Insufficient balances return a 402 Payment Required response. Purchase credits from the billing portal.
