Three requests turn an image into a file
Each command reads your key from the environment variable OPTRACY_API_KEY. Create a key on your account page first.
Send the image with the format you want. The answer comes at once, with the job's id and a Retry-After header.
curl
curl -X POST https://api.optracy.com/v1/jobs \
-H "Authorization: Bearer $OPTRACY_API_KEY" \
-H "Idempotency-Key: design-001" \
-F [email protected] -F format=svg
JSON
{
"id": "job_8f14e45fceea167a5a36dedd4bea2543",
"status": "queued",
"phase": "queued",
"progress": 0,
"created_at": "2026-10-06T08:00:00Z",
"finished_at": null,
"options": {
"format": "svg",
"size": "original",
"colors": null,
"background": "keep"
},
"credits_charged": 0,
"result": null,
"error": null
}
Ask for the job after the seconds Retry-After gives, until status is succeeded.
curl
curl https://api.optracy.com/v1/jobs/job_8f14e45fceea167a5a36dedd4bea2543 \
-H "Authorization: Bearer $OPTRACY_API_KEY"
Download the file.
curl
curl -o design.svg https://api.optracy.com/v1/jobs/job_8f14e45fceea167a5a36dedd4bea2543/result \
-H "Authorization: Bearer $OPTRACY_API_KEY"
Every request carries your API key
Send the key in the Authorization header: Authorization: Bearer optr_…. A key has 43 characters and starts with optr_. You create keys on your account page, up to 5 at a time, and each one is shown only once.
Keep the key on your own server. Never put it in a web page, a mobile app or a public code repository: anyone who has it spends your credits. If a key leaks, revoke it on your account page; it stops working at once.
A missing, wrong or revoked key gets 401, and a key of a disabled account gets 403. After 30 wrong keys in a minute from one network, a further wrong key from that network gets 429 for the rest of the minute; a valid key keeps working.
Go to your account page
One credit buys one image and one file
A job that succeeds costs 1 credit and gives you one file. While a job runs, its credit is held, so you never start more jobs than you can pay for; the credit is charged when the file is ready.
A job that fails, that you cancel, or that a server restart interrupts costs nothing: its held credit comes back. Credits do not expire.
Credits are for the API only. Downloads on the website are counted apart: a free account downloads 5 images a day, the monthly plan downloads without a limit, and neither uses credits. See pricing
Credits are added by hand. To get credits, write to [email protected]
GET /v1/account shows your balance, the credits held by unfinished jobs, the credits available, and how many unfinished jobs your account may have at once (active_jobs_max).
A job waits, runs, then ends one of three ways
Every job has one of five statuses. queued and running are unfinished; the other three are final and never change again. Times are UTC, in ISO 8601.
While a job is unfinished, phase is queued, analysing, building or finishing. Use it for a progress display only; the phases can change without notice.
The error.code of a failed job is one of these:
processing_failed: The image could not be turned into a file. Try another image, or other options.
interrupted: A server restart stopped the job and it could not run again. Submit the image again.
Four options decide the file you get
Send the options as fields of the same multipart/form-data request as the image. A field the API does not know is refused, so a misspelled name never goes unnoticed.
Sending a request again never starts a second job
Network errors happen. Send an Idempotency-Key header with each submission and keep the same key when you send it again: the same image with the same options and the same key returns the first job, marked Idempotent-Replayed: true, and no credit is held twice. A key is remembered for 24 hours.
The same key with another image or other options gets 422 idempotency_key_reused. A refused request (a wrong field, too few credits, a limit) does not keep its key, so you can send it again once the problem is fixed.
Limits count per account and per minute
Over a limit, the answer is 429 with Retry-After: wait that many seconds, then send again.
The number of unfinished jobs at a time is set for each account: 3 by default, 5 or 10 on request (write to [email protected]). GET /v1/account gives your account's number as active_jobs_max, and a too_many_active_jobs refusal names it in its detail.
There is no guaranteed processing time. A job takes from a few seconds to several minutes, depending on the image and on how busy the server is.
Your files are deleted after 24 hours
The image you send is kept only while its job runs and is deleted when the job ends. The file is kept 24 hours after the job succeeds, then deleted, so download it within that time. After that, the download answers 410 result_expired; the job itself stays readable.
What the site keeps about your account: Privacy
Every error has a stable code and a next step
Errors are application/problem+json (RFC 9457): type links to the entry below, then title, status, detail, and code, the field to build on. A wrong field also has param. Every 429 and 503 has Retry-After.
JSON
{
"type": "https://optracy.com/docs/api#error-insufficient_credits",
"title": "Not enough credits",
"status": 402,
"detail": "This job costs 1 credit; 0 are available.",
"code": "insufficient_credits"
}
Six addresses make up the whole API
Every address starts with https://api.optracy.com. The full description, for your own tools, is the OpenAPI 3.1 document at /v1/openapi.json
POST /v1/jobs
Submit an image. Sends one image with the options of the file you want. The answer comes at once with the job (202); the file is made in the background. 1 credit is held while the job runs and charged only when it succeeds. An account may have 3 unfinished jobs at once unless it was given a higher number (active_jobs_max of GET /v1/account).
Parameters
Answer: 202
JSON
{
"id": "job_8f14e45fceea167a5a36dedd4bea2543",
"status": "queued",
"phase": "queued",
"progress": 0,
"created_at": "2026-10-06T08:00:00Z",
"finished_at": null,
"options": {
"format": "svg",
"size": "original",
"colors": null,
"background": "keep"
},
"credits_charged": 0,
"result": null,
"error": null
}
Errors
GET /v1/jobs/{job_id}
Read a job. The job's status, its phase and progress while it runs, and its result or error once it has ended. While it runs, ask again after the seconds in Retry-After.
Parameters
Answer: 200
JSON
{
"id": "job_8f14e45fceea167a5a36dedd4bea2543",
"status": "succeeded",
"phase": "finishing",
"progress": 100,
"created_at": "2026-10-06T08:00:00Z",
"finished_at": "2026-10-06T08:00:16Z",
"options": {
"format": "svg",
"size": "original",
"colors": null,
"background": "keep"
},
"credits_charged": 1,
"result": {
"format": "svg",
"width": 1086,
"height": 1448,
"bytes": 180651,
"colors": 13,
"background_removed": false,
"url": "https://api.optracy.com/v1/jobs/job_8f14e45fceea167a5a36dedd4bea2543/result",
"expires_at": "2026-10-07T08:00:16Z"
},
"error": null
}
Errors
POST /v1/jobs/{job_id}/cancel
Cancel a job. Stops a queued or running job; nothing is charged. Canceling a canceled job again changes nothing; a job that succeeded or failed cannot be canceled (409).
Parameters
Answer: 200
JSON
{
"id": "job_8f14e45fceea167a5a36dedd4bea2543",
"status": "canceled",
"phase": null,
"progress": null,
"created_at": "2026-10-06T08:00:00Z",
"finished_at": "2026-10-06T08:00:05Z",
"options": {
"format": "svg",
"size": "original",
"colors": null,
"background": "keep"
},
"credits_charged": 0,
"result": null,
"error": null
}
Errors
GET /v1/jobs/{job_id}/result
Download the file. The file of a job that succeeded, as an attachment. It is kept 24 hours after the job ends; after that the answer is 410.
Parameters
Answer: 200
Errors
GET /v1/account
Read your credits and limits. The credits of the key's account (balance, held by unfinished jobs, available), its unfinished jobs and how many it may have at once, and the limits that apply to it.
Answer: 200
JSON
{
"credits_balance": 120,
"credits_held": 1,
"credits_available": 119,
"active_jobs": 1,
"active_jobs_max": 3,
"limits": {
"job_cost": 1,
"active_jobs": 3,
"submissions_per_minute": 10,
"reads_per_minute": 120,
"max_upload_bytes": 31457280,
"max_upload_pixels": 3000000,
"result_ttl_seconds": 86400,
"idempotency_ttl_seconds": 86400
}
}
Errors
GET /v1/openapi.json
Read this description. The OpenAPI 3.1 description of the API, for your own tools. No key needed. No key needed.
Answer: 200
Errors
Complete programs in Python and Node.js
Each program sends one image, waits as Retry-After says, and saves the file. Neither needs a package, and both are run against the API in our tests.
Python
Python 3.8 or newer, standard library only.
Python
"""Turn one image into a print file with the Optracy processing API.
OPTRACY_API_KEY=optr_... python api_example.py design.png svg design.svg
Python 3.8 or newer, standard library only. The key is read from the environment, never written in the code.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid
API = os.environ.get('OPTRACY_API_BASE', 'https://api.optracy.com') + '/v1'
KEY = os.environ['OPTRACY_API_KEY']
def call(method, url, body=None, headers=None):
"""(status, headers, body) of one request, errors included."""
headers = dict(headers or {}, Authorization='Bearer ' + KEY)
request = urllib.request.Request(url, data=body, method=method, headers=headers)
try:
with urllib.request.urlopen(request, timeout=120) as answer:
return answer.status, answer.headers, answer.read()
except urllib.error.HTTPError as e:
return e.code, e.headers, e.read()
def form(fields, image_path):
"""A multipart/form-data body with the fields and the image."""
boundary = uuid.uuid4().hex
parts = [('--%s\r\nContent-Disposition: form-data; name="%s"\r\n\r\n%s\r\n' % (boundary, k, v)).encode()
for k, v in fields.items()]
with open(image_path, 'rb') as f:
parts.append(('--%s\r\nContent-Disposition: form-data; name="image"; filename="%s"\r\n'
'Content-Type: application/octet-stream\r\n\r\n'
% (boundary, os.path.basename(image_path))).encode() + f.read() + b'\r\n')
parts.append(('--%s--\r\n' % boundary).encode())
return b''.join(parts), 'multipart/form-data; boundary=' + boundary
def wait(headers):
time.sleep(int(headers.get('Retry-After') or 3))
def convert(image_path, file_format, out_path):
body, content_type = form({'format': file_format}, image_path)
# The same key on every try: a request sent again never starts a second job.
headers = {'Content-Type': content_type, 'Idempotency-Key': str(uuid.uuid4())}
for _ in range(5):
status, answer_headers, answer = call('POST', API + '/jobs', body, headers)
if status not in (429, 503):
break
wait(answer_headers)
job = json.loads(answer)
if status != 202:
sys.exit('refused: %s (%s)' % (job['code'], job['detail']))
while job['status'] in ('queued', 'running'):
wait(answer_headers)
status, answer_headers, answer = call('GET', API + '/jobs/' + job['id'])
if status == 200:
job = json.loads(answer)
elif status != 429:
sys.exit('could not read the job: %s' % json.loads(answer)['code'])
if job['status'] != 'succeeded':
sys.exit('job %s: %s' % (job['status'], (job['error'] or {}).get('detail', '')))
status, _, data = call('GET', job['result']['url'])
if status != 200:
sys.exit('could not download the file: %s' % json.loads(data)['code'])
with open(out_path, 'wb') as f:
f.write(data)
print('%s: %d bytes, %d colors, %d credit charged' % (out_path, len(data), job['result']['colors'],
job['credits_charged']))
if __name__ == '__main__':
if len(sys.argv) != 4:
sys.exit('usage: python api_example.py <image> <svg|pdf|eps|png> <file to write>')
convert(*sys.argv[1:])
shell
OPTRACY_API_KEY=optr_… python api_example.py design.jpg svg design.svg
Node.js
Node.js 18 or newer.
JavaScript
// Turn one image into a print file with the Optracy processing API.
//
// OPTRACY_API_KEY=optr_... node api_example.mjs design.png svg design.svg
//
// Node.js 18 or newer, no package needed. The key is read from the environment, never written in the code.
import { randomUUID } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
const API = (process.env.OPTRACY_API_BASE || 'https://api.optracy.com') + '/v1';
const auth = { Authorization: 'Bearer ' + process.env.OPTRACY_API_KEY };
const wait = (answer) => new Promise((done) => setTimeout(done, 1000 * Number(answer.headers.get('Retry-After') || 3)));
const [imagePath, format, outPath] = process.argv.slice(2);
if (!outPath) throw new Error('usage: node api_example.mjs <image> <svg|pdf|eps|png> <file to write>');
const form = new FormData();
form.append('format', format);
form.append('image', new Blob([await readFile(imagePath)]), basename(imagePath));
// The same key on every try: a request sent again never starts a second job.
const headers = { ...auth, 'Idempotency-Key': randomUUID() };
let answer;
for (let i = 0; i < 5; i++) {
answer = await fetch(API + '/jobs', { method: 'POST', headers, body: form });
if (answer.status !== 429 && answer.status !== 503) break;
await wait(answer);
}
let job = await answer.json();
if (answer.status !== 202) throw new Error(`refused: ${job.code} (${job.detail})`);
while (job.status === 'queued' || job.status === 'running') {
await wait(answer);
answer = await fetch(`${API}/jobs/${job.id}`, { headers: auth });
if (answer.status === 200) job = await answer.json();
else if (answer.status !== 429) throw new Error('could not read the job: ' + (await answer.json()).code);
}
if (job.status !== 'succeeded') throw new Error(`job ${job.status}: ${job.error ? job.error.detail : ''}`);
const file = await fetch(job.result.url, { headers: auth });
if (file.status !== 200) throw new Error('could not download the file: ' + (await file.json()).code);
const data = Buffer.from(await file.arrayBuffer());
await writeFile(outPath, data);
console.log(`${outPath}: ${data.length} bytes, ${job.result.colors} colors, ${job.credits_charged} credit charged`);
shell
OPTRACY_API_KEY=optr_… node api_example.mjs design.jpg svg design.svg
/v1 only grows
Within /v1, changes only add things: new fields in answers, new optional fields, new error codes. Ignore fields you do not know. A change that would break a working program comes as a new address, /v2.