# Peckney API

> Generate images, animated GIFs and SVG vectors from your own code. Every result lands in your Peckney library and is paid from your balance.

The base URL is `https://peckney.com/v1`. Requests and answers are JSON, times are UTC in ISO 8601, and money is in whole micro dollars: 1,000,000 micros make $1.00. AI agents can read this page as plain Markdown at [https://peckney.com/docs/api.md](https://peckney.com/docs/api.md).

## Quick start

**1. Create a key.** Open [Settings](https://peckney.com/app/settings) and choose Create key under API keys. Copy the key at once, because it shows only once.

**2. Start a generation.**

```bash
export PECKNEY_API_KEY="peck_..."

curl https://peckney.com/v1/generations \
  -H "Authorization: Bearer $PECKNEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "A ceramic espresso cup on an oak table, morning light", "model": "klein"}'
```

It answers `202 Accepted` at once with the new generation and its `id`, while the image renders in the background.

**3. Poll it** every 2 seconds, or after as many seconds as the `Retry-After` header says, until `status` is `completed`, `failed` or `cancelled`.

```bash
curl https://peckney.com/v1/generations/745 \
  -H "Authorization: Bearer $PECKNEY_API_KEY"
```

**4. Download the image** from `images[0].url`.

## Authentication

Every request carries your key in the `Authorization` header:

```http
Authorization: Bearer peck_...
```

A key starts with `peck_` and has 45 characters. It acts as your account: it spends your balance, its images land in your library and its work shows in your Tasks. Create, name and revoke keys in [Settings](https://peckney.com/app/settings), at most 10 per account. Keep a key on your server, away from browsers, apps and public repositories. A request without a valid key answers `401`.

## Models and prices

`GET /v1/models` lists every model with its price per image at each size, and the GIF and SVG prices of the models that make them. The prices today:

- `openai_low`: GPT Image 2.6 (OpenAI, Low). $0.0307 at 1K, $0.056 at 2K, $0.089 at 4K. Up to 16 references. Makes GIFs and SVGs too.
- `openai_medium`: GPT Image 2.6 (OpenAI, Medium). $0.0495 at 1K, $0.0943 at 2K, $0.1528 at 4K. Up to 16 references. Makes GIFs and SVGs too.
- `openai_high`: GPT Image 2.6 (OpenAI, High). $0.0617 at 1K, $0.116 at 2K, $0.1869 at 4K. Up to 16 references. Makes GIFs and SVGs too.
- `openai_xhigh`: GPT Image 2.6 (OpenAI, Extra high). $0.1027 at 1K, $0.1993 at 2K, $0.3253 at 4K. Up to 16 references. Makes GIFs and SVGs too.
- `openai_max`: GPT Image 2.6 (OpenAI, Max). $0.2197 at 1K, $0.4372 at 2K, $0.7206 at 4K. Up to 16 references. Makes GIFs and SVGs too.
- `google_minimal`: Nano Banana 2.1 (Google, Minimal thinking). $0.0411 at 1K, $0.0579 at 2K, $0.1209 at 4K. Up to 14 references.
- `google_medium`: Nano Banana 2.1 (Google, Medium thinking). $0.0486 at 1K, $0.0654 at 2K, $0.1284 at 4K. Up to 14 references.
- `google_high`: Nano Banana 2.1 (Google, High thinking). $0.0636 at 1K, $0.0804 at 2K, $0.1434 at 4K. Up to 14 references.
- `bfl_flux3`: FLUX 3 Image (Black Forest Labs). $0.048 at 1K, $0.10 at 2K, $0.607 at 4K. Up to 10 references.
- `gpt`: GPT Image 2.6 (Kie.ai). $0.0421 at 1K, $0.0825 at 2K, $0.1351 at 4K. Up to 16 references.
- `klein`: FLUX 3 Klein (Kie.ai). $0.008 at 1K, $0.0125 at 2K, $0.0325 at 4K. Up to 10 references and 5,000 prompt characters.
- `nano`: Nano Banana Pro (Kie.ai). $0.09 at 1K, $0.09 at 2K, $0.12 at 4K. Up to 8 references.

Choose one with `model`. Without it, a generation uses `openai_medium`.

## Create a generation

`POST /v1/generations` starts one image from your prompt. It answers `202 Accepted` at once with the generation, a `Location` header that points to it and a `Retry-After` header.

- `prompt` (string, required): what to draw. The model gets it exactly as written: unlike the studio's composer, the API never splits a prompt into several images and never adds words to it. At most 20,000 characters, or fewer where a model's `max_prompt_chars` says so. `@handle` attaches the photos of the cast member with that handle, as in the studio.
- `model` (string): a model `id`. Default `openai_medium`.
- `size` (string): `1K`, `2K` or `4K`, the resolution, where larger costs more. The exact pixels depend on the model and the shape. Default `1K`.
- `aspect_ratio` (string): `1:1`, `16:9`, `4:3`, `3:4` or `9:16`, or `auto` for the shape of the first reference. Default `1:1`.
- `format` (string): `image`, `gif` for an animated GIF, or `svg` for a vector drawing. Default `image`. GIFs and SVGs come from the `openai_` models and need a set shape, not `auto`. An SVG is `1K` and takes no references.
- `frames` (integer): only for a GIF, which needs it: `8`, `12` or `16`.
- `frame_ms` (integer): only for a GIF, which needs it: how long each frame shows, `100`, `200` or `400` milliseconds.
- `references` (array of strings): images to work from, each a data URL such as `data:image/png;base64,iVBORw0...` of a PNG, JPEG or WebP under 20 MB. A model takes at most its `max_references`, cast photos included, and the whole request must stay under 64 MB.

With a reference image, written to a file first because the encoded image is long:

```bash
printf '{"prompt": "The same cup on a marble counter", "model": "klein", "references": ["data:image/jpeg;base64,%s"]}' \
  "$(base64 < cup.jpg | tr -d '\n')" > request.json

curl https://peckney.com/v1/generations \
  -H "Authorization: Bearer $PECKNEY_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @request.json
```

The answer:

```json
{
  "id": 745,
  "status": "queued",
  "prompt": "A ceramic espresso cup on an oak table, morning light",
  "model": "klein",
  "size": "1K",
  "aspect_ratio": "1:1",
  "format": "image",
  "frames": null,
  "frame_ms": null,
  "reference_count": 0,
  "created_at": "2026-11-06T21:11:44.000Z",
  "images": [],
  "cost_micros": 8000,
  "error": null
}
```

Your balance pays the price when the image starts. A failed or cancelled image costs nothing, because its charge returns to your balance. When the balance cannot pay, the create answers `402`.

### Safe retries

To retry a create after a timeout without starting it twice, send an `Idempotency-Key` header with a unique value, such as a UUID. The same key with the same body answers the generation it already started, with the header `Idempotent-Replayed: true`. The same key with another body answers `422`. A key stays tied to its generation.

## The generation

- `id` (integer): use it to poll or cancel.
- `status` (string): `queued`, `running`, `completed`, `failed` or `cancelled`. The last three are final.
- `prompt`, `model`, `size`, `aspect_ratio`, `format`, `frames`, `frame_ms`: as requested. `frames` and `frame_ms` are `null` unless the format is `gif`.
- `reference_count` (integer): how many references you sent.
- `created_at` (string): when it was created.
- `images` (array): its finished images, each with `id`, `url`, `content_type`, `width` and `height`. Rely on `status` to know it is done. The image lives in your library, so its URL works until you delete it there and shows the edits you make in the studio.
- `cost_micros` (integer): the price while it runs, then what you paid. A generation that ends without an image costs `0`.
- `error` (object or null): set when `failed`, with a `code` and a `message`. The code `declined` means the request itself was refused, by the model's content rules or a limit, so the same request fails again. The code `failed` means the service failed, so the same request can work later.

## Get a generation

`GET /v1/generations/{id}` answers the generation. While it is `queued` or `running`, the answer carries `Retry-After: 2`, and polling faster gains nothing.

```json
{
  "id": 745,
  "status": "completed",
  "prompt": "A ceramic espresso cup on an oak table, morning light",
  "model": "klein",
  "size": "1K",
  "aspect_ratio": "1:1",
  "format": "image",
  "frames": null,
  "frame_ms": null,
  "reference_count": 0,
  "created_at": "2026-11-06T21:11:44.000Z",
  "images": [
    {
      "id": 3199,
      "url": "https://media.example.com/img-106403bbff80aef7.jpg",
      "content_type": "image/jpeg",
      "width": 1024,
      "height": 1024
    }
  ],
  "cost_micros": 8000,
  "error": null
}
```

## List generations

`GET /v1/generations` answers the generations you made through the API, newest first, as `{"data": [...], "next_url": ...}`. The query parameter `limit` takes 1 to 100, default 20, and `before` takes a generation `id` to list only older ones. `next_url` fetches the next, older page, and is `null` on the last one.

## Cancel a generation

`POST /v1/generations/{id}/cancel` stops a `queued` or `running` generation and answers it. A queued generation is cancelled at once. A running one stops within seconds, so poll it until it is `cancelled`. An image it had not finished costs nothing. A final generation answers `409`.

## Account

`GET /v1/account` answers the account the key belongs to and its balance:

```json
{
  "email": "you@example.com",
  "balance_micros": 4950000
}
```

## Limits

- 100 requests every 10 seconds per account. Past that, requests answer `429` with `Retry-After`.
- 40 tasks running at once per account, studio work included. Past that, a create answers `429` until one finishes.
- One image per generation. For several images, start several generations.

## Errors

An error answers with its HTTP status and a JSON body of the type `application/problem+json`, as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) defines it. Branch on `code`, which never changes. `detail` explains this case in words, and `param` names the field at fault when there is one.

```json
{
  "type": "https://peckney.com/docs/api#invalid_request",
  "title": "Invalid request",
  "status": 400,
  "detail": "Model klein makes 1K, 2K, 4K, not \"8K\".",
  "code": "invalid_request",
  "param": "size"
}
```

### invalid_request

`400` Invalid request. A field, header or query parameter is missing or wrong. `param` names it when one is at fault. Fix the request before sending it again.

### unauthorized

`401` Missing or invalid API key. The `Authorization` header is missing or malformed, or its key was revoked. Send `Authorization: Bearer <key>` with a key from Settings.

### insufficient_balance

`402` Insufficient balance. Your balance cannot pay for this image. [Add funds](https://peckney.com/app/billing), then send the request again.

### forbidden

`403` Account blocked. This account cannot use the API. Write to support.

### not_found

`404` Not found. No endpoint answers at this path, or no generation with this id belongs to your account.

### method_not_allowed

`405` Method not allowed. This endpoint takes another HTTP method, which the `Allow` header names.

### conflict

`409` Already finished. The generation already finished, so it cannot be cancelled.

### idempotency_key_reused

`422` Idempotency key reused. This `Idempotency-Key` already started a generation from a different request. Send a new key with a new request.

### rate_limited

`429` Too many requests. Your account sent more than 100 requests in 10 seconds. Wait as many seconds as `Retry-After` says.

### too_many_active

`429` Too many running. Your account already runs 40 tasks, studio work included. Wait as many seconds as `Retry-After` says, or until one finishes.

### server_error

`500` Server error. Something failed on our side and was reported. Try again in a minute.

## Python example

Starts one image, polls it and saves it. It needs the `requests` package.

```python
import os
import time
import uuid

import requests

API = "https://peckney.com/v1"
HEADERS = {"Authorization": "Bearer " + os.environ["PECKNEY_API_KEY"]}


def answer(response):
    if not response.ok:
        raise SystemExit(response.text)

    return response.json()


response = requests.post(
    API + "/generations",
    headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
    json={"prompt": "A ceramic espresso cup on an oak table, morning light", "model": "klein"},
    timeout=60,
)
generation = answer(response)

while generation["status"] in ("queued", "running"):
    time.sleep(int(response.headers.get("Retry-After", "2")))
    response = requests.get(API + "/generations/" + str(generation["id"]), headers=HEADERS, timeout=60)
    generation = answer(response)

if generation["status"] != "completed":
    raise SystemExit(generation["status"] + ": " + str(generation["error"]))

image = generation["images"][0]

with open("image." + image["url"].rsplit(".", 1)[1], "wb") as file:
    file.write(requests.get(image["url"], timeout=60).content)
```
