On this page
Introduction
The Perseworks API gives you Seedance video, Seedream image, and Doubao Seed chat models through one key and one prepaid balance. It is organised around REST: JSON request bodies, JSON responses, standard HTTP status codes, and Bearer-token authentication.
Chat is OpenAI-compatible — if you already use an OpenAI SDK, change only base_url and the API key. Video and image endpoints follow the same conventions.
All requests must use HTTPS. Requests over plain HTTP, or without a key, fail.
https://api.perseworks.hk/v1 from openai import OpenAI
client = OpenAI(
api_key="sk-...",
base_url="https://api.perseworks.hk/v1", # the only change
) Before your first request
Most "my key doesn't work" reports are one of these five steps. Do them in order:
- Sign in at app.perseworks.hk with your email code.
- Add points. New accounts start with a balance of 0. Every request reserves points before it runs, so with an empty balance every call returns
402 insufficient_points— even with a valid key. Buy a point pack on Billing, or redeem a code there. - Create a key on API Keys. The full
sk-…value is shown once — copy it then. Leave Allowed models and IP allowlist empty unless you need them. - Use the right base URL:
https://api.perseworks.hk/v1. Requests must go toapi.perseworks.hk— notperseworks.hk(the website) orapp.perseworks.hk(the dashboard). A key sent to the wrong host never reaches the API, so you'll see no error from us and no usage in the dashboard. - Run the smoke test on the right. The first call checks connectivity; the second checks your key and balance, and costs a fraction of a point.
Still failing? Send the full HTTP status and JSON error body to support@perseworks.hk, together with the first 11 characters of your key (sk- plus 8) — never the whole key.
# 1 · reachability — no key needed
curl https://api.perseworks.hk/v1/models
# 2 · your key + balance — the cheapest possible call
curl https://api.perseworks.hk/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model":"seed-2.0-mini","max_tokens":16,
"messages":[{"role":"user","content":"ping"}]}'import requests
r = requests.post(
"https://api.perseworks.hk/v1/chat/completions",
headers={"Authorization": "Bearer sk-..."},
json={"model": "seed-2.0-mini",
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 16},
)
print(r.status_code, r.json())const r = await fetch("https://api.perseworks.hk/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": "Bearer sk-...",
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seed-2.0-mini",
messages: [{ role: "user", content: "ping" }],
max_tokens: 16,
}),
});
console.log(r.status, await r.json()); HTTP/1.1 402 Payment Required
{
"error": {
"code": "insufficient_points",
"message": "Not enough points for this request",
"required": 1060
}
} Authentication
Authenticate every request with an API key in the Authorization header:
Authorization: Bearer sk-…
Keys carry the full spending power of your balance. Keep them server-side — never in a browser, mobile app, or public repo. If a key leaks, revoke it on the API Keys page and create a new one; revocation is immediate.
Each key can optionally be restricted:
- Allowed models — calls to any other model return
403 model_not_allowed. - IP allowlist — matched against the public egress IP of the machine making the call (not your laptop's LAN address). Other sources get
403 ip_not_allowed.
GET /v1/models is public and needs no key.
curl https://api.perseworks.hk/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model":"seed-2.0-mini","messages":[{"role":"user","content":"ping"}]}'import requests
r = requests.post(
"https://api.perseworks.hk/v1/chat/completions",
headers={"Authorization": "Bearer sk-..."},
json={"model": "seed-2.0-mini",
"messages": [{"role": "user", "content": "ping"}]},
)
print(r.status_code, r.json())const r = await fetch("https://api.perseworks.hk/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": "Bearer sk-...",
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seed-2.0-mini",
messages: [{ role: "user", content: "ping" }],
}),
});
console.log(r.status, await r.json()); HTTP/1.1 401 Unauthorized
{
"error": {
"code": "invalid_api_key",
"message": "Unknown or revoked API key"
}
} Errors
Perseworks uses conventional HTTP status codes: 2xx success, 4xx a problem with the request (fix it before retrying), 5xx a problem on our side or the model provider's (safe to retry with backoff). Every error has the same JSON shape.
A request that fails is never charged: any points reserved for it are returned.
| Status | Code | Meaning |
|---|---|---|
400 | invalid_request | The body is not valid JSON or a required field is missing / out of range. |
400 | unknown_model | The model id doesn't exist or doesn't fit this endpoint (e.g. a video model on /chat/completions). |
400 | unsupported_resolution | The model does not offer that resolution. |
400 | invalid_duration | durationSec exceeds the model's maximum. |
400 | too_many_references too_many_reference_videos too_many_reference_audios too_many_images | More reference media than the model accepts. |
400 | input_rejected | The model provider refused an input (e.g. a reference image showing a real person). Change the input — retrying won't help. Not charged. |
401 | invalid_api_key | Missing, malformed, unknown, or revoked API key. |
402 | insufficient_points | Your balance can't cover the reservation. required is the amount needed. Top up and retry. |
403 | model_not_allowed | This key is restricted to other models. |
403 | ip_not_allowed | This key has an IP allowlist and the request came from another address. |
404 | not_found | The task doesn't exist or belongs to another account. |
413 | payload_too_large | Body over the size cap (chat 8 MB · video 100 KB). |
422 | content_rejected | The prompt failed content moderation. Not charged. |
429 | rate_limited | Too many requests for this key. Wait for Retry-After seconds. |
500 | internal_error | Something went wrong on our side. Any reservation is refunded. |
502 | upstream_error | The model provider failed or timed out. Any reservation is refunded; safe to retry. |
Attributes
-
error.codestringMachine-readable code from the table below. Branch on this, not on the message. -
error.messagestringHuman-readable explanation. May change; do not parse. -
error.requirednumberOnly oninsufficient_points: points needed to reserve the request.
{
"error": {
"code": "model_not_allowed",
"message": "API key not scoped for model \"seedance-2.0\""
}
} Points & billing
Usage is paid from a prepaid points balance — 13,800 points = US$1. One balance covers every model.
- Reserve. Before running, the request reserves its maximum possible cost (for chat: prompt +
max_tokens; for video:durationSec× per-second rate). If the balance can't cover it you get402. - Settle. On success you are charged the actual usage — real tokens, the real video length — and the rest of the reservation is released.
- Refund. On any failure the full reservation is returned.
Successful responses report the charge in the X-Credits-Charged header and in usage.credits (both in points). Live per-model prices are always in GET /v1/models; see also the pricing page.
Tip: a large max_tokens on chat raises the reservation, not the bill — but it can trigger 402 on a low balance. Set it to what you actually need.
HTTP/1.1 200 OK
X-Credits-Charged: 1485
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 119 HTTP/1.1 402 Payment Required
{
"error": {
"code": "insufficient_points",
"message": "Not enough points for this request",
"required": 1060
}
} Rate limits
Limits are applied per API key, separately for each endpoint family: 120 requests per minute each for /v1/chat, /v1/images, and /v1/video (polling counts against the video budget — poll every 5–10 s, not in a tight loop).
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (seconds). Over the limit you receive 429 rate_limited with a Retry-After header — wait that many seconds, then retry.
Need more throughput? Contact support@perseworks.hk.
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
{ "error": { "code": "rate_limited", "message": "too many requests" } } Idempotent requests
Video jobs are expensive, so POST /v1/video/generations supports idempotency: send an Idempotency-Key header and a retry with the same key returns the original task instead of starting (and charging for) a second one.
Use a random value such as a UUID v4, generated once per logical job and reused on every retry of that job — for example after a network timeout where you can't tell whether the first submit landed.
curl https://api.perseworks.hk/v1/video/generations \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 5f1d6c1e-9b0a-4c36-8d1e-2a7f3c9b4e10" \
-d '{"model":"seedance-2.0-fast","prompt":"a cat surfing a wave, cinematic","resolution":"720p","durationSec":5}'import uuid, requests
key = str(uuid.uuid4()) # generate once, reuse on every retry
r = requests.post(
"https://api.perseworks.hk/v1/video/generations",
headers={"Authorization": "Bearer sk-...", "Idempotency-Key": key},
json={"model": "seedance-2.0-fast", "prompt": "a cat surfing a wave, cinematic",
"resolution": "720p", "durationSec": 5},
)const key = crypto.randomUUID(); // generate once, reuse on every retry
await fetch("https://api.perseworks.hk/v1/video/generations", {
method: "POST",
headers: {
"Authorization": "Bearer sk-...",
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({ model: "seedance-2.0-fast", prompt: "a cat surfing a wave, cinematic",
resolution: "720p", durationSec: 5 }),
}); The model object
Describes one callable model and its live price. Exactly one of the pricing fields is present, depending on category.
Attributes
-
idstringThe value you pass asmodel. -
namestringDisplay name. -
categoryenumvideo·image·chat -
statusstringactivefor callable models. -
billing_unitenumper_second·per_image·per_1m_token -
pricingarrayVideo only. One entry per supported resolution.pricing ▸
-
resolutionstring720p·1080p·4k -
points_per_secnumberPoints per output second. -
usd_per_secnumberSame price in USD.
-
-
image_pricingobjectImage only.image_pricing ▸
-
points_per_imagenumberPoints per generated image. -
usd_per_imagenumberSame price in USD. -
extra_input_image_pointsnumberSurcharge per reference image beyond the first.
-
-
token_pricingobjectChat only.token_pricing ▸
-
points_per_1m_inputnumberPoints per million input tokens. -
points_per_1m_outputnumberPoints per million output tokens. -
points_per_1m_cached_inputnumberPoints per million cached input tokens. -
context_windownumberMaximum context in tokens.
-
{
"id": "seedance-2.0",
"name": "Seedance 2.0",
"category": "video",
"status": "active",
"billing_unit": "per_second",
"pricing": [
{ "resolution": "720p", "points_per_sec": 2760, "usd_per_sec": 0.2 },
{ "resolution": "1080p", "points_per_sec": 6210, "usd_per_sec": 0.45 },
{ "resolution": "4k", "points_per_sec": 13800, "usd_per_sec": 1 }
]
} List models
Returns every model you can call, with live prices. Public — no API key required. Current models:
| Model id | Type | Notes |
|---|---|---|
seedance-2.5 | video | Flagship. 720p · 4–30 s · text / image / video / audio references · edit & extend. |
seedance-2.0 | video | 720p · 1080p · 4k · up to 15 s · native audio. |
seedance-2.0-fast | video | 720p · faster turnaround. |
seedance-2.0-mini | video | 720p · lowest cost. |
seedream-5.0 | image | Seedream 5.0 Pro · text-to-image + editing · flat per-image rate. |
seed-2.1-pro | chat | Strongest chat model · vision · 262K context. |
seed-2.0-mini | chat | Fast and low cost · 262K context. |
Returns
curl https://api.perseworks.hk/v1/modelsimport requests
for m in requests.get("https://api.perseworks.hk/v1/models").json()["data"]:
print(m["id"], m["category"])const { data } = await (await fetch("https://api.perseworks.hk/v1/models")).json();
data.forEach((m) => console.log(m.id, m.category)); {
"object": "list",
"data": [
{ "id": "seedance-2.5", "category": "video", ... },
{ "id": "seedance-2.0", "category": "video", ... },
{ "id": "seedream-5.0", "category": "image",
"billing_unit": "per_image",
"image_pricing": { "points_per_image": 1485, "usd_per_image": 0.1076,
"extra_input_image_points": 49 } },
{ "id": "seed-2.1-pro", "category": "chat",
"billing_unit": "per_1m_token",
"token_pricing": { "points_per_1m_input": 15220,
"points_per_1m_output": 76103,
"points_per_1m_cached_input": 3044,
"context_window": 262144, ... } },
...
]
} Create a chat completion
Generates a model response for a conversation. Request and response follow the OpenAI Chat Completions format, so OpenAI SDKs work unchanged.
Parameters
-
modelstring Requiredseed-2.1-proorseed-2.0-mini. -
messagesarray RequiredThe conversation so far, oldest first. 1–256 messages.messages ▸
-
roleenum Requiredsystem·user·assistant -
contentstring | array RequiredText (up to 128K characters), or an array of content parts to send images — see Send images.
-
-
streambooleanIftrue, the response is streamed as server-sent events, ending withdata: [DONE]. Defaultfalse. -
max_tokensintegerMaximum tokens to generate, 1–8192. Default1024. Also sizes the point reservation. -
response_formatobject{"type": "json_object"}forces valid JSON output;{"type": "text"}is the default. -
thinkingobject{"type": "disabled"}turns off deep thinking for lower latency;enabled/autoalso accepted.
Returns
chat.completion object. With stream: true, a sequence of chat.completion.chunk events. usage.credits is the number of points charged.curl https://api.perseworks.hk/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{
"model": "seed-2.1-pro",
"messages": [{"role": "user", "content": "Hello!"}],
"stream": true
}'from openai import OpenAI
client = OpenAI(api_key="sk-...", base_url="https://api.perseworks.hk/v1")
stream = client.chat.completions.create(
model="seed-2.1-pro",
messages=[{"role": "user", "content": "Hello!"}],
stream=True,
)
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="")import OpenAI from "openai";
const client = new OpenAI({ apiKey: "sk-...", baseURL: "https://api.perseworks.hk/v1" });
const stream = await client.chat.completions.create({
model: "seed-2.1-pro",
messages: [{ role: "user", content: "Hello!" }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0].delta.content || "");
} {
"id": "chatcmpl-x2Lq9ZtR0bWk4mNv8sYe",
"object": "chat.completion",
"created": 1785000000,
"model": "seed-2.1-pro",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "Hi! How can I help?" },
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 9,
"completion_tokens": 8,
"total_tokens": 17,
"credits": 1
}
} data: {"id":"chatcmpl-…","object":"chat.completion.chunk",
"choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}
data: {"id":"chatcmpl-…","object":"chat.completion.chunk",
"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE] Send images
seed-2.1-pro can read images. Pass content as an array of parts instead of a string.
Content parts
-
{ type: "text" }objectA text part:{"type": "text", "text": "…"}. -
{ type: "image_url" }objectAn image part.{ type: "image_url" } ▸
-
image_url.urlstring RequiredAn HTTPS URL, or a base64 data URI (data:image/jpeg;base64,…). -
image_url.detailenumlowbills each image as 512 input tokens;high(default) as 1,600.
-
Returns
curl https://api.perseworks.hk/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{
"model": "seed-2.1-pro",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.jpg", "detail": "low"}}
]
}]
}'from openai import OpenAI
client = OpenAI(api_key="sk-...", base_url="https://api.perseworks.hk/v1")
r = client.chat.completions.create(
model="seed-2.1-pro",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url",
"image_url": {"url": "https://example.com/cat.jpg", "detail": "low"}},
],
}],
)
print(r.choices[0].message.content)import OpenAI from "openai";
const client = new OpenAI({ apiKey: "sk-...", baseURL: "https://api.perseworks.hk/v1" });
const r = await client.chat.completions.create({
model: "seed-2.1-pro",
messages: [{
role: "user",
content: [
{ type: "text", text: "What is in this image?" },
{ type: "image_url",
image_url: { url: "https://example.com/cat.jpg", detail: "low" } },
],
}],
});
console.log(r.choices[0].message.content); Create an image
Generates images from a prompt, optionally guided by reference images. The call is synchronous and typically takes 70–90 seconds — set your HTTP client timeout to at least 120 s. (If your own server sits behind a proxy with a ~100 s limit, such as Cloudflare, call this from a background job.)
Parameters
-
modelstring Requiredseedream-5.0 -
promptstring RequiredWhat to generate. Up to 4,000 characters. -
sizeenum1Kor2K. Default2K. Same price for both. -
imagestring | arrayReference image(s) for editing or multi-reference generation: URL or base64, up to 10. -
nintegerNumber of images, 1–4. Default1. Billed per image. -
output_formatenumpng·jpeg -
seedintegerFix for reproducible output.
Returns
data is an array of { url }. Download and store the images you want to keep.curl https://api.perseworks.hk/v1/images/generations \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
--max-time 150 \
-d '{
"model": "seedream-5.0",
"prompt": "a red panda barista, cinematic lighting",
"size": "2K"
}'import requests
r = requests.post(
"https://api.perseworks.hk/v1/images/generations",
headers={"Authorization": "Bearer sk-..."},
json={"model": "seedream-5.0",
"prompt": "a red panda barista, cinematic lighting",
"size": "2K"},
timeout=150, # generation takes ~70–90 s
)
print(r.json()["data"][0]["url"])const res = await fetch("https://api.perseworks.hk/v1/images/generations", {
method: "POST",
headers: {
"Authorization": "Bearer sk-...",
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seedream-5.0",
prompt: "a red panda barista, cinematic lighting",
size: "2K",
}),
signal: AbortSignal.timeout(150_000), // generation takes ~70–90 s
});
console.log((await res.json()).data[0].url); {
"id": "img_4nT8vQ1sXw9cR2kL",
"created": 1785000000,
"model": "seedream-5.0",
"data": [ { "url": "https://.../out.png" } ],
"usage": { "images": 1, "credits": 1485 }
} The video task object
Video generation is asynchronous. Submitting returns a task in queued state; poll it until it reaches succeeded or failed.
Attributes
-
idstringTask id, prefixedvid_. -
statusenumqueued→running→succeeded|failed -
modelstringReturned on submit. -
video_urlstringWhensucceeded: the MP4. Download promptly. -
thumbnail_urlstringWhensucceeded: a cover image. -
durationnumberWhensucceeded: actual length in seconds — what you are billed for. -
errorobjectWhenfailed:{ code, message }. The reservation is refunded.
{
"id": "vid_8KqZ2mR4tY7wP1xN",
"status": "succeeded",
"video_url": "https://.../vid_8KqZ2mR4tY7wP1xN.mp4",
"thumbnail_url": "https://.../vid_8KqZ2mR4tY7wP1xN.jpg",
"duration": 5
} {
"id": "vid_8KqZ2mR4tY7wP1xN",
"status": "failed",
"error": { "code": "upstream_failed", "message": "Video generation failed" }
} Create a video
Starts a video job and returns immediately with 202 Accepted. Points for durationSec are reserved now and settled against the actual output length. Supports the Idempotency-Key header.
Parameters
-
modelstring Requiredseedance-2.5·seedance-2.0·seedance-2.0-fast·seedance-2.0-mini -
promptstring RequiredUp to 2,000 characters. -
resolutionenum Required720p·1080p·4k(lower-case).1080pand4konseedance-2.0only; every other model is720p. -
durationSecinteger RequiredSeconds.seedance-2.5: 4–30. Others: 1–15. -
aspectRatioenum16:9·4:3·1:1·3:4·9:16·21:9·adaptive -
imagestringImage URL to animate (image-to-video). -
firstFramestringImage URL to use as the first frame. -
lastFramestringImage URL to use as the last frame. -
referencesarrayReference image URLs.seedance-2.5: up to 30. Others: up to 9. -
referenceVideosarrayReference video URLs.seedance-2.5: up to 4. Others: up to 3. -
referenceAudiosarrayReference audio URLs, up to 2. -
seedintegerFix for reproducible output.
Returns
curl https://api.perseworks.hk/v1/video/generations \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{
"model": "seedance-2.0",
"prompt": "a cat surfing a wave, cinematic",
"resolution": "1080p",
"durationSec": 5,
"aspectRatio": "16:9"
}'import requests
r = requests.post(
"https://api.perseworks.hk/v1/video/generations",
headers={"Authorization": "Bearer sk-..."},
json={
"model": "seedance-2.0",
"prompt": "a cat surfing a wave, cinematic",
"resolution": "1080p",
"durationSec": 5,
"aspectRatio": "16:9",
},
)
task = r.json()
print(task["id"], task["status"]) # vid_... queuedconst res = await fetch("https://api.perseworks.hk/v1/video/generations", {
method: "POST",
headers: {
"Authorization": "Bearer sk-...",
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "seedance-2.0",
prompt: "a cat surfing a wave, cinematic",
resolution: "1080p",
durationSec: 5,
aspectRatio: "16:9",
}),
});
const task = await res.json();
console.log(task.id, task.status); // vid_... queued HTTP/1.1 202 Accepted
{
"id": "vid_8KqZ2mR4tY7wP1xN",
"status": "queued",
"model": "seedance-2.0"
} Retrieve a video
Returns the current state of a task you created. Poll every 5–10 seconds until status is succeeded or failed.
Path parameters
-
idstring RequiredThevid_…id returned on create.
Returns
404 not_found if the id is unknown or owned by another account.curl https://api.perseworks.hk/v1/video/generations/vid_8KqZ2mR4tY7wP1xN \
-H "Authorization: Bearer sk-..."import time, requests
while True:
t = requests.get(
f"https://api.perseworks.hk/v1/video/generations/{task_id}",
headers={"Authorization": "Bearer sk-..."},
).json()
if t["status"] in ("succeeded", "failed"):
break
time.sleep(10)
print(t.get("video_url") or t["error"])let t;
do {
await new Promise((r) => setTimeout(r, 10_000));
t = await (await fetch(`https://api.perseworks.hk/v1/video/generations/${taskId}`, {
headers: { "Authorization": "Bearer sk-..." },
})).json();
} while (t.status === "queued" || t.status === "running");
console.log(t.video_url ?? t.error); {
"id": "vid_8KqZ2mR4tY7wP1xN",
"status": "succeeded",
"video_url": "https://.../vid_8KqZ2mR4tY7wP1xN.mp4",
"thumbnail_url": "https://.../vid_8KqZ2mR4tY7wP1xN.jpg",
"duration": 5
}