# JavaScript SDK

> Call the PicX Studio API from Node and TypeScript with the official picx-ai package.

The official JavaScript SDK wraps the PicX Studio REST API in a typed client: `picx.images.generate(...)`, `picx.video.create(...)`, `picx.models.list(...)`. It handles auth headers, JSON encoding, retries with backoff, timeouts, and video job polling, and it throws typed errors you can branch on. It ships its own TypeScript types, so requests and responses are checked at compile time.

> [!NOTE]
> The SDK is not the CLI. The [PicX CLI](https://github.com/Type-Think-AI/picx-cli) is a separate tool you install globally and run in your terminal for one-off jobs and scripting. The SDK is a library you add as a dependency to call the API from inside your own application. They are independent — use either, or both.

## Install

```bash
npm install picx-ai
```

```bash
pnpm add picx-ai
```

## Quickstart

Pass your API key to the constructor. Read it from an environment variable — never hardcode it.

```js
import { PicX } from "picx-ai";

const picx = new PicX(process.env.PICX_API_KEY);

const asset = await picx.images.generate({
  prompt: "a beautiful sunset over the ocean, cinematic lighting",
  size: "2K",
  aspect_ratio: "16:9",
});

console.log(asset.url);
console.log(asset.id, asset.model, asset.credits_used);
```

> [!WARNING]
> Never use an API key in a browser or any client-side bundle. `pxsk_…` keys are server-side credentials that carry your billing and full key scopes; anything shipped to a browser is public, and bundlers will inline the value. Call the SDK only from a server, a serverless function, or a background worker, and expose your own endpoint to the frontend. A key that reaches a browser must be treated as leaked and revoked.

## Generate an image

`images.generate` is synchronous — it resolves to the finished asset. Only `prompt` is required.

```js
const asset = await picx.images.generate({
  prompt: "a red leather sneaker on white marble, studio lighting",
  model: "gemini-3.1-flash-image-preview",
  size: "2K",
  aspect_ratio: "16:9",
});

console.log(asset.url);         // hosted CDN URL
console.log(asset.credits_used); // credits billed for this call
```

| Option | Type | Required | Description |
| --- | --- | --- | --- |
| `prompt` | `string` | Yes | What to generate. |
| `model` | `string` | No | Model ID. Defaults to the current image model. |
| `size` | `string` | No | Output size, for example `1K`, `2K`, `4K`. |
| `aspect_ratio` | `string` | No | Aspect ratio, for example `16:9`, `1:1`. |

## Edit an image

`images.edit` takes an instruction plus one to five source image URLs. It is also synchronous.

```js
const edited = await picx.images.edit({
  instruction: "make the colors more vibrant and add a subtle glow effect",
  image_urls: ["https://cdn.picxstudio.com/api/generated/image_abc123.png"],
  model: "gemini-3.1-flash-image-preview",
  size: "1K",
});

console.log(edited.url);
```

Pass several URLs to composite or use additional references:

```js
const composite = await picx.images.edit({
  instruction: "place the product from the second image onto the background of the first",
  image_urls: [
    "https://example.com/background.jpg",
    "https://example.com/product.png",
  ],
});
```

> [!TIP]
> `image_urls` accepts 1–5 URLs. More than five is rejected with a validation error before the request is sent, so you fail fast instead of burning a round trip.

## Upload and manage assets

Edit and video calls accept public HTTPS URLs, not files that only exist on your server or device. Upload the file through managed assets first. Creating and deleting assets requires the `uploads:write` scope; listing and fetching assets require a valid API key.

```js
import { readFile } from "node:fs/promises";

const bytes = await readFile("./portrait.jpg");
const source = await picx.assets.create({
  file: new Blob([bytes], { type: "image/jpeg" }),
  filename: "portrait.jpg",
  metadata: { source: "onboarding" },
});

const avatar = await picx.images.edit({
  instruction: "turn this into a naive doodle avatar",
  image_urls: [source.url],
});

console.log(source.id, source.url, avatar.url);
```

In a browser or Worker, pass a `File` or `Blob` directly:

```js
const source = await picx.assets.create({ file: selectedFile });
```

Managed uploads accept images up to 20 MB, video up to 50 MB, and audio up to 15 MB. Use the resource methods to list, recover, or soft-delete assets:

```js
const { assets, total } = await picx.assets.list({ kind: "image", limit: 50 });
const recovered = await picx.assets.get(source.id);
await picx.assets.delete(recovered.id);
```

## Generate a video

Video generation is asynchronous. `video.create` returns a **job**, not an asset. Await `job.wait()` to poll until the video is ready and get the finished asset back.

```js
const job = await picx.video.create({
  prompt: "a cat walking in the rain, cinematic",
  duration: 5,
  resolution: "720p",
  sound: true,
});

console.log(job.id, job.status); // "pending"

const asset = await job.wait();  // polls with backoff until terminal
console.log(asset.url);
```

`job.wait()` resolves when the generation completes and throws if it fails, so you do not need to inspect `status` yourself.

| Option | Type | Required | Description |
| --- | --- | --- | --- |
| `prompt` | `string` | Yes | What to generate. |
| `model` | `string` | No | Video model ID. |
| `duration` | `number` | No | Length in seconds. |
| `resolution` | `string` | No | For example `720p`, `1080p`. |
| `sound` | `boolean` | No | Generate an audio track. |

### Bind a webhook to a video job

Receive a notification when the job finishes instead of holding a connection open:

```js
const job = await picx.video.create({
  prompt: "a cat jumping off a table",
  duration: 5,
  resolution: "480p",
  webhook: { url: "https://your-server.com/webhook" },
});
// job.raw.webhook.mode === "inline"
```

Or bind by a registered webhook ID:

```js
const job = await picx.video.create({
  prompt: "a cat jumping",
  webhook: { id: "your-webhook-uuid" },
});
```

### Poll manually

If you would rather not hold a connection open — for example you store the job ID and check it from a queue worker or a webhook handler — poll `generations.get` yourself.

```js
const job = await picx.video.create({ prompt: "a cat walking in the rain" });

// Persist job.id, then later, from anywhere:
let generation = await picx.generations.get(job.id);

while (generation.status !== "completed" && generation.status !== "failed") {
  await new Promise((r) => setTimeout(r, 5000));
  generation = await picx.generations.get(job.id);
}

if (generation.status === "failed") throw new Error(generation.error);
console.log(generation.url);
```

## Video modes

`mode` selects how a video is sourced. `text` is the default; the other modes take existing media as input. Each mode has its own required fields, and the SDK checks them **client-side before the request goes out** — a missing `audio_url` on a lipsync call throws a `TypeError` locally with a message naming the field, so you never spend a round trip on a 422. Requires `picx-ai` 0.4.0 or later.

| `mode` | Required fields | Needs `prompt`? |
| --- | --- | --- |
| `text` (default) | — | Yes |
| `image` | `image_url` | Yes |
| `reference` | `reference_urls` | Yes |
| `frames` | `start_frame_url` (`end_frame_url` optional) | Yes |
| `extend` | `source_video_url` | Yes |
| `lipsync` | `source_video_url` **and** `audio_url` | No |
| `edit` | `source_video_url` **and** `image_url` | Yes |

### Animate between two frames (`frames`)

Give a start frame and, optionally, an end frame. The model interpolates a clip between them.

```js
const job = await picx.video.create({
  mode: "frames",
  prompt: "smooth dolly-in on the product",
  start_frame_url: "https://cdn.example.com/first.png",
  end_frame_url: "https://cdn.example.com/last.png", // optional
  duration: 5,
});

const asset = await job.wait();
console.log(asset.url);
```

### Extend an existing clip (`extend`)

Continue a video past its last frame.

```js
const job = await picx.video.create({
  mode: "extend",
  prompt: "the camera keeps pulling back to reveal the skyline",
  source_video_url: "https://cdn.example.com/clip.mp4",
});

const asset = await job.wait();
console.log(asset.url);
```

### Lip-sync a clip to audio (`lipsync`)

Drive the lips in a source video from an audio track. This is the one mode that does **not** take a prompt — the spoken content comes from `audio_url`.

```js
const job = await picx.video.create({
  mode: "lipsync",
  source_video_url: "https://cdn.example.com/face.mp4",
  audio_url: "https://cdn.example.com/voice.mp3",
});

const asset = await job.wait();
console.log(asset.url);
```

### Edit a clip with a reference image (`edit`)

Apply the content of an image to a source video.

```js
const job = await picx.video.create({
  mode: "edit",
  prompt: "restyle the jacket to match the reference",
  source_video_url: "https://cdn.example.com/clip.mp4",
  image_url: "https://cdn.example.com/reference.png",
});

const asset = await job.wait();
console.log(asset.url);
```

> [!TIP]
> Client-side validation is a real time saver here: `picx.video.create({ mode: "edit", prompt: "…", source_video_url: "…" })` throws immediately because `image_url` is missing, rather than queuing a job that the server would reject. The message names the exact field the mode needs.

## Prompt templates

The template catalogue is a searchable library of about 50,000 ready-made image and video prompts, tuned per model and tagged by topic. `templates.list` searches it and `templates.get` fetches one by id. Both need a valid API key like the rest of `/v1`, but return public catalogue content. Requires `picx-ai` 0.4.0 or later.

```js
const { templates, total } = await picx.templates.list({
  q: "sneaker",
  media_type: "video",
  featured: true,
  limit: 20,
});

for (const t of templates) {
  console.log(t.id, t.title, t.target_model);
}
```

All filters are optional and combine with AND. `tags` is matched as a set — a template must carry every tag you pass.

| Option | Type | Description |
| --- | --- | --- |
| `q` | `string` | Free-text search across the catalogue. |
| `media_type` | `"image" \| "video"` | Restrict to one output type. |
| `topic` | `string` | Restrict to a single topic (see the note below). |
| `tags` | `string[]` | Match templates carrying **every** tag. |
| `target_model` | `string` | Restrict to templates tuned for one model. |
| `featured` | `boolean` | Only editorially featured templates. |
| `trending` | `boolean` | Only currently trending templates. |
| `limit` | `number` | Page size, 1–100. Defaults to 30. |
| `offset` | `number` | Rows to skip. Defaults to 0. |

Fetch one template by id:

```js
const template = await picx.templates.get("tpl_abc123");
// Feed it straight into a generation:
if (template.prompt) {
  const asset = await picx.images.generate({
    prompt: template.prompt,
    model: template.target_model ?? undefined,
  });
  console.log(asset.url);
}
```

Three behaviours of the catalogue will bite you if you assume otherwise:

> [!WARNING]
> - **`total` is an estimate, not an exact count.** Do not page to `offset >= total` and expect the last page to line up. Page until a request returns fewer rows than `limit` — that short (or empty) page is the real end.
> - **The `topic` filter works, but the `topic` field is always `null`.** You can filter by topic, but you cannot read back which topic a returned template belongs to.
> - **A `null` `prompt` means the row is premium/gated, not that data is missing.** Skip such rows (or surface them as locked) rather than treating them as broken.

Paging correctly, given that `total` is only an estimate:

```js
let offset = 0;
const pageSize = 100;
for (;;) {
  const page = await picx.templates.list({ q: "cinematic", limit: pageSize, offset });
  for (const t of page.templates) {
    if (t.prompt === null) continue; // gated/premium row
    process(t);
  }
  if (page.templates.length < pageSize) break; // short page = real end
  offset += pageSize;
}
```

## Account tier and limits

`account.tier()` returns the rate limits and quotas for your key's plan. Read it at start-up to size your own client-side throttling, or to check which model families the key may use before you submit a generation.

```js
const tier = await picx.account.tier();

console.log(tier.plan_code);            // e.g. "free", "pro"
console.log(tier.requests_per_minute);  // per-minute request cap
console.log(tier.requests_per_day);     // per-day request cap
console.log(tier.concurrent_limit);     // max generations running at once
console.log(tier.max_credits_per_day);  // daily credit ceiling
console.log(tier.allowed_model_types);  // e.g. ["image", "video"]

// Gate a video submit on the plan before spending a request.
if (!tier.allowed_model_types.includes("video")) {
  throw new Error(`Plan ${tier.plan_code} cannot generate video.`);
}
```

## Stream generation progress

Rather than polling `generations.get` in a loop, subscribe to the live Server-Sent Events stream. `generations.events(id)` returns an **async iterable** that yields one event per frame as the server pushes status updates, then a terminal `completed`/`failed` event. It is cheaper and more responsive than polling. Requires `picx-ai` 0.4.0 or later and the `generations:read` scope.

```js
import { isTerminalStatus } from "picx-ai";

const job = await picx.video.create({ prompt: "a cat walking in the rain" });

for await (const event of picx.generations.events(job.id)) {
  const status = event.data?.status;
  console.log(event.event, status);
  if (isTerminalStatus(status)) break;
}
```

Each event carries the SSE frame's fields:

| Field | Type | Description |
| --- | --- | --- |
| `event` | `string` | SSE event name, e.g. `status`, `completed`, `failed`, `ping`. `"message"` when the frame carried no `event:` line. |
| `data` | `Record<string, unknown> \| null` | Parsed JSON payload, or `null` when the frame's `data:` text was not JSON (e.g. a `ping` keep-alive). |
| `rawData` | `string` | The undecoded `data:` text — always present, so a non-JSON frame is never lost. |
| `id` | `string?` | The SSE `id:` field, when the frame carried one. |
| `retry` | `number?` | The SSE `retry:` reconnection hint in ms, when present. |

The connection stays open while you iterate, so consume it to the end or `break` out — either releases it. Streaming requests are never retried; abort mid-stream with a `signal`, and use `timeout: 0` to disable the read-idle timeout on a long render:

```js
const controller = new AbortController();
for await (const event of picx.generations.events(job.id, {
  signal: controller.signal,
  timeout: 0,
})) {
  if (event.data?.status === "completed") {
    console.log("done:", event.data.output_url);
    break;
  }
}
```

## List and manage generations

```js
// List recent generations, optionally filtered
const { generations, total } = await picx.generations.list({
  type: "video",
  limit: 10,
});

for (const gen of generations) {
  console.log(gen.id, gen.status, gen.type);
}

// Cancel a pending generation
await picx.generations.cancel("generation-uuid");

// View webhook deliveries for a specific generation
const { deliveries } = await picx.generations.deliveries("generation-uuid");
```

## List models

`models.list` is public — it needs no API key, which makes it a convenient connectivity check.

```js
import { PicX } from "picx-ai";

const picx = new PicX(); // no key required for this call

const { models } = await picx.models.list();
const videoModels = await picx.models.list({ type: "video" });

for (const model of videoModels.models) {
  console.log(model.id, model.type, model.credits);
}
```

## Async images and webhooks

Both image calls are synchronous by default. Pass `callback_url` (or `webhook`) and the API returns `202` immediately, the SDK hands back a `GenerationJob`, and the finished image is POSTed to your URL. Requires `picx-ai` 0.3.0 or later.

```js
import { PicX, GenerationJob } from "picx-ai";

const picx = new PicX(process.env.PICX_API_KEY);

// No target -> ImageResult, exactly as before.
const image = await picx.images.generate({ prompt: "a cat" });
console.log(image.url);

// With a target -> GenerationJob. Nothing is held open.
const job = await picx.images.generate({
  prompt: "a cat",
  callback_url: "https://your-server.com/hooks/picx",
});
console.log(job instanceof GenerationJob, job.id, job.status);

// Editing takes the same fields.
const editJob = await picx.images.edit({
  instruction: "replace the sky with a sunset",
  image_urls: ["https://example.com/photo.jpg"],
  webhook: { url: "https://your-server.com/hooks/picx" },
});

// Always have a fallback — poll if a delivery is missed.
const asset = await job.wait({ pollIntervalMs: 3000, timeoutMs: 600_000 });
console.log(asset.url);
```

TypeScript picks the return type from the call: overloads keep an existing
`picx.images.generate({ prompt })` typed as `Promise<ImageResult>`, and only widen to
`Promise<GenerationJob>` when a delivery target is actually present. No casts needed.

See [Async Image Generation](/docs/developer-tools/async-image-generation) for the payload shape, signature verification and retry behaviour.

## Webhook management and deliveries

> [!NOTE]
> `create`, `list`, `test` and `delete` are session-authenticated in the console and reject an API key. The delivery-inspection methods below **do** work with an API key, from `picx-ai` 0.3.1.

```js
// Registered-webhook management — console session only, see the note above.
const wh = await picx.webhooks.create({
  url: "https://your-server.com/webhook",
  events: ["generation.completed", "generation.failed"],
});
console.log(wh.id, wh.secret); // save the secret
await picx.webhooks.list();
await picx.webhooks.test(wh.id);
await picx.webhooks.delete(wh.id);
```

Delivery inspection works with an API key — this is how you debug a webhook that never arrived:

```js
// Every delivery attempted for one generation, including inline/callback_url ones.
const { deliveries, total } = await picx.generations.deliveries(job.id);
for (const d of deliveries) {
  console.log(d.event_type, d.outcome, d.status_code, d.target_url);
  console.log(d.attempts); // per-attempt history
}

// Per-webhook history, filtered to the failures.
const failed = await picx.webhooks.deliveries(webhookId, {
  outcome: "exhausted",
  limit: 50,
});

// Replay one after fixing your endpoint. Reuses the original payload and event id.
const result = await picx.webhooks.redeliver(deliveryId);
console.log(result.outcome, result.status_code);
```

Binding a generation to an already-registered webhook by id also works with an API key:

```js
const job = await picx.images.generate({
  prompt: "a cat",
  webhook: { id: "0b9c1e42-…" },
});
```

## Check usage and account

```js
const me = await picx.account.me();
console.log(me.name, me.credits.balance);

const usage = await picx.account.usage({ period: "30d" });
console.log(usage);
```

## Handle errors

Every failure throws a subclass of `PicXError`, so you can branch on the failure mode instead of parsing status codes.

```js
import {
  PicX,
  PicXError,
  AuthenticationError,
  PermissionDeniedError,
  NotFoundError,
  ValidationError,
  RateLimitError,
  ServerError,
  NetworkError,
} from "picx-ai";

const picx = new PicX(process.env.PICX_API_KEY);

try {
  const asset = await picx.images.generate({ prompt: "a sunset" });
  console.log(asset.url);
} catch (err) {
  if (err instanceof AuthenticationError) {
    // 401 — key missing, malformed, or revoked
  } else if (err instanceof PermissionDeniedError) {
    // 403 — the key is valid but lacks the required scope
  } else if (err instanceof NotFoundError) {
    // 404 — no such generation or model
  } else if (err instanceof ValidationError) {
    // 422 — bad parameters; err.message says which
  } else if (err instanceof RateLimitError) {
    // 429 — already retried; wait err.retryAfter seconds
    console.log(`Rate limited, retry in ${err.retryAfter}s`);
  } else if (err instanceof ServerError) {
    // 5xx — already retried
  } else if (err instanceof NetworkError) {
    // connection failure or timeout
  } else if (err instanceof PicXError) {
    // anything else from the SDK
  } else {
    throw err;
  }
}
```

| Error | Status | Meaning |
| --- | --- | --- |
| `AuthenticationError` | 401 | Key missing, malformed, or revoked. |
| `PermissionDeniedError` | 403 | Key lacks the scope for this operation. |
| `NotFoundError` | 404 | Generation, model, or resource does not exist. |
| `ValidationError` | 422 | Invalid parameters. Not retried. |
| `RateLimitError` | 429 | Limit exceeded. Exposes `retryAfter` in seconds. |
| `ServerError` | 5xx | Upstream failure. Retried. |
| `NetworkError` | — | Connection failure or timeout. Retried. |

> [!NOTE]
> `RateLimitError` and `ServerError` only surface after the SDK has exhausted its retries. If you catch one, backing off further is the right move — see [Rate Limits](/docs/getting-started/rate-limits).

## Configure retries and timeouts

The constructor takes options alongside the key.

```js
const picx = new PicX(process.env.PICX_API_KEY, {
  baseUrl: "https://api.picxstudio.com/v1",
  timeout: 120_000, // ms, per request
  maxRetries: 3,
});
```

| Option | Default | Description |
| --- | --- | --- |
| `baseUrl` | `https://api.picxstudio.com/v1` | Override for staging or a proxy. |
| `timeout` | SDK default | Per-request timeout in milliseconds. |
| `maxRetries` | `3` | Retry attempts before throwing. Set `0` to disable. |

The SDK retries **only** on 429, 5xx, and network errors, with exponential backoff that honors `Retry-After`. It never retries 400, 401, 403, 404, or 422 — those will not succeed on a second attempt, so retrying only wastes time.

### Idempotency keys

Pass a per-call idempotency key on any POST so a retry — the SDK's or yours — cannot bill you twice for the same generation.

```js
const asset = await picx.images.generate(
  { prompt: "a red sneaker on marble" },
  { idempotencyKey: "order-4417-hero" },
);
```

Reusing a key returns the original result instead of generating again. Use a stable value derived from your own data, such as an order or row ID.

## Prefer raw HTTP?

The SDK is optional. Every endpoint is a plain REST call, so if you would rather not add a dependency — a tiny edge function, a language without an SDK, or a strict dependency policy — call the API directly with `fetch`.

```js
const res = await fetch("https://api.picxstudio.com/v1/images/generate", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PICX_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    prompt: "a beautiful sunset over the ocean",
    size: "2K",
    aspect_ratio: "16:9",
  }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data = await res.json();
console.log(data.url);
```

The SDK intentionally mirrors the API's snake_case field names (`aspect_ratio`, `image_urls`, `credits_used`). Going direct means handling retries, backoff, and video polling yourself. See [cURL Examples](/docs/code-examples/curl-examples) for every endpoint, and [Rate Limits](/docs/getting-started/rate-limits) for the retry rules to implement.
