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.
The SDK is not the CLI. The 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
npm install picx-ai
pnpm add picx-ai
Quickstart
Pass your API key to the constructor. Read it from an environment variable — never hardcode it.
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);
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.
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.
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:
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",
],
});
image_urlsaccepts 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.
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:
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:
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.
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:
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:
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.
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.
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.
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.
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.
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);
Client-side validation is a real time saver here:
picx.video.create({ mode: "edit", prompt: "…", source_video_url: "…" })throws immediately becauseimage_urlis 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.
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:
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:
totalis an estimate, not an exact count. Do not page tooffset >= totaland expect the last page to line up. Page until a request returns fewer rows thanlimit— that short (or empty) page is the real end.- The
topicfilter works, but thetopicfield is alwaysnull. You can filter by topic, but you cannot read back which topic a returned template belongs to.- A
nullpromptmeans 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:
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.
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.
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:
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
// 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.
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.
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 for the payload shape, signature verification and retry behaviour.
Webhook management and deliveries
create,list,testanddeleteare session-authenticated in the console and reject an API key. The delivery-inspection methods below do work with an API key, frompicx-ai0.3.1.
// 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:
// 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:
const job = await picx.images.generate({
prompt: "a cat",
webhook: { id: "0b9c1e42-…" },
});
Check usage and account
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.
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. |
RateLimitErrorandServerErroronly surface after the SDK has exhausted its retries. If you catch one, backing off further is the right move — see Rate Limits.
Configure retries and timeouts
The constructor takes options alongside the key.
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.
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.
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 for every endpoint, and Rate Limits for the retry rules to implement.