Your website and API share the same credit balance.
2Run the complete example
Download the Node.js 24+ example, set your environment variables, and run it with a local image. The default upscales to 2K. It streams uploads and downloads, polls jobs, and saves every result variant. To use another tool, pass a quote.json file as shown in Tools & parameters.
// Node 24+. Keep ARTPLAYER_API_KEY on your server. Never log upload tickets.
import { readFile, open, stat } from 'node:fs/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { basename } from 'node:path';
import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
const base = (process.env.ARTPLAYER_API_BASE || 'https://artplayer.org/ai/api/v1').replace(/\/$/, '');
const key = process.env.ARTPLAYER_API_KEY;
const [input, output = 'upscaled.png', quoteFile, audioInput] = process.argv.slice(2);
if (!key || !input) throw new Error('Set ARTPLAYER_API_KEY, then run: node developer-api.mjs input output [quote.json] [audio]');
const parameters = quoteFile ? JSON.parse(await readFile(quoteFile, 'utf8')) : { serviceId: 'image-upscale', preset: '2k' };
if (parameters.serviceId === 'video-lip-sync' && !audioInput) throw new Error('Lip sync requires the fourth argument: an audio file');
const deadline = Date.now() + 6 * 3600000;
async function call(path, method = 'GET', body, headers = {}) {
const response = await fetch(base + path, { method, headers: { authorization: 'Bearer ' + key, ...headers }, body, redirect: 'error', signal: AbortSignal.timeout(60000) });
if (!response.ok) {
const failure = await response.json().catch(() => null);
throw new Error('HTTP ' + response.status + ': ' + (failure?.error?.code || 'request_failed') + ' (' + (failure?.error?.requestId || 'no request id') + ')');
}
return response;
}
const json = async (path, body, headers) => (await call(path, 'POST', JSON.stringify(body), { 'content-type': 'application/json', ...headers })).json();
async function ready(id) {
for (;;) {
if (Date.now() >= deadline) throw new Error('Polling deadline reached; keep the asset/job IDs');
const asset = await (await call('/uploads/' + id)).json();
if (asset.error || ['rejected', 'delete_requested', 'deleted'].includes(asset.state)) throw new Error('Input validation failed');
if (asset.state === 'ready') return asset;
await sleep(3000);
}
}
async function uploadFile(path, mediaType) {
const { size } = await stat(path);
const upload = await json('/uploads', { name: basename(path), size, serviceId: parameters.serviceId, ...(mediaType ? { mediaType } : {}) });
console.log(JSON.stringify({ assetId: upload.id }));
if (upload.direct) {
// The temporary ticket is a separate destination. Do not send the ArtPlayer key.
if (new URL(upload.direct.url).protocol !== 'https:') throw new Error('Invalid direct upload destination');
const response = await fetch(upload.direct.url, { method: 'PUT', headers: { ...upload.direct.headers, 'content-length': String(size) }, body: createReadStream(path), duplex: 'half', redirect: 'error', signal: AbortSignal.timeout(3600000) });
if (!response.ok) throw new Error('Direct upload failed: HTTP ' + response.status);
await response.body?.cancel();
} else {
const file = await open(path, 'r');
try {
for (let part = 1; part <= upload.partCount; part++) {
const start = (part - 1) * upload.partSize;
const buffer = Buffer.alloc(Math.min(upload.partSize, size - start));
let offset = 0;
while (offset < buffer.length) {
const { bytesRead } = await file.read(buffer, offset, buffer.length - offset, start + offset);
if (!bytesRead) throw new Error('Input file changed during upload');
offset += bytesRead;
}
await call('/uploads/' + upload.id + '/parts/' + part, 'PUT', buffer, { 'content-type': 'application/octet-stream' });
}
} finally { await file.close(); }
}
await call('/uploads/' + upload.id + '/complete', 'POST', '');
return ready(upload.id);
}
let asset = await uploadFile(input, process.env.ARTPLAYER_MEDIA_TYPE);
if (asset.mediaType === 'gif') {
const prepared = await json('/uploads/' + asset.id + '/prepare', { background: process.env.ARTPLAYER_GIF_BACKGROUND || '#ffffff' });
asset = await ready(prepared.id);
}
if (parameters.serviceId === 'video-lip-sync') parameters.inputAssets = { audio: (await uploadFile(audioInput, 'audio')).id };
const quote = await json('/quotes', { ...parameters, assetId: asset.id });
const idempotency = randomUUID();
// Persist these non-secret values before creating the task. After an uncertain
// response, retry this quote/key; rerunning this script creates a new intent.
console.log(JSON.stringify({ quoteId: quote.id, idempotencyKey: idempotency, credits: quote.credits }));
let job = await json('/jobs', { quoteId: quote.id }, { 'idempotency-key': idempotency });
console.log(JSON.stringify({ jobId: job.id }));
while (!['succeeded', 'failed', 'canceled'].includes(job.status)) {
if (Date.now() >= deadline) throw new Error('Polling deadline reached; keep the job ID to query later');
await sleep(3000);
job = await (await call('/jobs/' + job.id)).json();
}
if (job.status !== 'succeeded') throw new Error(job.error || job.status);
if (!job.outputs.length) throw new Error('No downloadable output');
for (const file of job.outputs) {
const query = new URLSearchParams({ format: file.format, ...(file.role ? { role: file.role } : {}) });
const destination = job.outputs.length === 1 ? output : output + (file.role ? '.' + file.role : '') + '.' + file.format;
const result = await call('/jobs/' + job.id + '/result?' + query);
await pipeline(Readable.fromWeb(result.body), createWriteStream(destination, { flags: 'wx' }));
console.log('Saved ' + destination);
}
3Keep track of your job
The example prints the quote, estimated credits, idempotency key, and job ID. Save those identifiers. If job creation times out, retry with the original quote ID and idempotency key. Once a job ID is available, poll that job instead of creating another one.
The example stops on request errors. Apply the retry guidance below before using it unattended. Download the result before resultExpiresAt; repeating a download does not consume credits again.
Authentication
Send your API key in the Authorization header on every API request. Keys belong to an account and share that account’s wallet, uploads, and jobs. Browser sessions do not replace API authentication.
HTTP header
Authorization: Bearer YOUR_API_KEY
Read only
Read existing uploads and jobs, and download available results.
Read and create tasks
Includes read access, plus uploads, quotes, and job creation.
Keep keys on your server or in a secret manager. Never include them in frontend bundles, URLs, logs, or public repositories. Revoke an exposed key and create a replacement. Revocation blocks new requests immediately; accepted jobs continue processing.
Tools & parameters
Use image, video, GIF and audio tools through the same upload-to-download flow. Each tool or processing mode has its own serviceId. GET /tools lists the services and accepted input types.
Use the same serviceId when uploading and quoting. The JSON examples below are POST /quotes requests; replace assetId with your ready asset. For the Node.js example, save a payload as quote.json and run: node developer-api.mjs input output quote.json. The script replaces assetId. Lip sync also takes an audio file as the fourth argument. For audio-only ASR or vocal separation, set ARTPLAYER_MEDIA_TYPE=audio.
POST /uploads selects an upload policy using serviceId and optional mediaType. Omitting serviceId keeps the image-upscale default for existing clients. Follow the returned direct ticket when present: PUT the entire file using only the ticket headers, without your ArtPlayer API key. Otherwise upload the returned partCount parts at partSize bytes each, with a shorter final part.
Complete the upload and poll until ready before quoting. File type, size and decoded media limits are checked by the selected tool. Face blur and lip sync use direct uploads. GIFs need POST /uploads/{id}/prepare after validation, with a white or black background; poll and quote the returned video asset. Uploading and polling do not run the requested AI task.
POST/uploads
Allocate an upload for the selected service and media type
A quote binds ready inputs, tool parameters, a price and an expiry time. Choose serviceId and its parameter schema from Tools & parameters. Lip sync binds two owned assets. Estimated output dimensions are not a guarantee of actual dimensions. Submit the returned quoteId without modifying or recomputing its credits.
POST/quotes
Price validated inputs with service-specific parameters
Create a job with its quote ID and a unique Idempotency-Key. Persist both before sending the request. Reuse both after an uncertain response, including after rotating a key on the same account. Using the same key with different input returns a conflict.
Poll at a reasonable interval, such as every three seconds. pending and running are in progress. succeeded, failed, and canceled are terminal states. Check settlement separately to see whether credits are reserved, consumed, or released.
POST/jobs
Accept an asynchronous job with atomic credit reservation
A successful job lists its downloadable variants in outputs. Follow the format and optional role in each URL. Images return PNG/JPEG/WebP; videos can return MP4/WebM/MOV; GIF tools always deliver MP4 and may also deliver GIF; ASR provides JSON and SRT; vocal separation provides individual MP3/WAV tracks. resultUrl remains available for single-primary-output tools; vocal separation requires an output URL with a role. Downloads do not consume additional inference credits. Stored files support Range requests; generated SRT does not.
200 · Private result bytes. Use a URL listed in job.outputs for the exact format and track. Downloads do not consume inference credits. Stored files support Range; generated SRT does not.
Response schema · image/png
{
"type": "string",
"format": "binary"
}
Response schema · image/jpeg
{
"type": "string",
"format": "binary"
}
Response schema · image/webp
{
"type": "string",
"format": "binary"
}
Response schema · image/gif
{
"type": "string",
"format": "binary"
}
Response schema · video/mp4
{
"type": "string",
"format": "binary"
}
Response schema · video/webm
{
"type": "string",
"format": "binary"
}
Response schema · video/quicktime
{
"type": "string",
"format": "binary"
}
Response schema · audio/mpeg
{
"type": "string",
"format": "binary"
}
Response schema · audio/wav
{
"type": "string",
"format": "binary"
}
Response schema · application/json
{
"type": "string",
"format": "binary"
}
Response schema · application/x-subrip
{
"type": "string",
"format": "binary"
}
206 · Private result bytes. Use a URL listed in job.outputs for the exact format and track. Downloads do not consume inference credits. Stored files support Range; generated SRT does not.
Response schema · image/png
{
"type": "string",
"format": "binary"
}
Response schema · image/jpeg
{
"type": "string",
"format": "binary"
}
Response schema · image/webp
{
"type": "string",
"format": "binary"
}
Response schema · image/gif
{
"type": "string",
"format": "binary"
}
Response schema · video/mp4
{
"type": "string",
"format": "binary"
}
Response schema · video/webm
{
"type": "string",
"format": "binary"
}
Response schema · video/quicktime
{
"type": "string",
"format": "binary"
}
Response schema · audio/mpeg
{
"type": "string",
"format": "binary"
}
Response schema · audio/wav
{
"type": "string",
"format": "binary"
}
Response schema · application/json
{
"type": "string",
"format": "binary"
}
Response schema · application/x-subrip
{
"type": "string",
"format": "binary"
}
Errors & retries
JSON errors contain error.code, error.message, and error.requestId. Keep the request ID when reporting a problem; omit your API key and file contents. Do not assume every response is JSON: successful result downloads are binary.
400 / 411 / 413 / 415 / 416
Correct the request fields, Content-Length, file size, media type, or byte range before retrying.
401 / 403
Check the key, expiration, permissions, account access, and environment. Repeating the unchanged request will not restore access.
404 / 410
Check ownership, identifiers, and expiry. A missing or expired result cannot be recovered by polling.
409
Check asset readiness, quote validity, and idempotency conflicts. Do not generate a new key to bypass an uncertain job creation.
429
Wait for the Retry-After interval, then back off. Limits are shared by the account.
Network timeout / 5xx
Retry reads with bounded backoff. For uncertain job creation, keep the original quote ID and Idempotency-Key; creating a new key may start a second paid job.
Credits & limits
API tasks use the same credit wallet as the website. Accepting a job reserves the quoted credits. Successful delivery consumes them; a failed job releases its reservation. An uncertain submission may remain reserved while its outcome is reconciled.
The Developer API account page shows current request, active-task, and daily-credit limits. Limits apply across the account’s keys and API channels. Daily admission includes consumed credits for the UTC day and all still-reserved API tasks. Creating more keys does not increase these limits.
The management page summarizes tasks created today in UTC. Task counts are not HTTP request counts, and its usage summary is not the daily admission budget. Check the quote for each job’s credit cost.