Optracy

Turn images into print files from your own software

The Optracy API takes one image and gives back one file: SVG, PDF, EPS or a 300 dpi print PNG. You send the image, ask for the job until it is done, then download the file. Each image that succeeds costs 1 credit.

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.

  1. 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
    }
  2. 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"
  3. 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.

StatusMeaning
queuedWaiting for its turn. Ask again after Retry-After (5 s).
runningBeing made. phase and progress (0 to 100) say how far it is. Ask again after Retry-After (3 s).
succeededThe file is ready: result gives its address, size and number of colors. 1 credit was charged.
failedNo file could be made: error says why. Nothing was charged.
canceledYou canceled it, or the account was disabled. Nothing was charged.

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.

Name RequiredDefault ValuesWhat you get
image yes file The image: PNG, JPEG, GIF, BMP or WebP, at most 3 megapixels, each side at least 16 px and the long side at most 20 times the short one. The whole request is at most 30 MB.
format yes svg, pdf, eps, png The file you get: svg, pdf, eps, or png, a print PNG at 300 dpi.
size no original string original, or <width>x<height> in pixels: each side 1 to 20,000 and at most 40 megapixels in all. The design is fitted inside with its proportions kept. A png of size original is the print canvas, 4,500 × 5,400 px at 300 dpi.
dpi no 300 1..10000 Only with an explicit size: the resolution the file states, 1 to 10,000.
colors no automatic 1..1000 Empty for automatic, or the number of flat colors you want, 1 to 1,000. An image with fewer colors keeps the ones it has; result.colors says how many the file has.
background no keep keep, remove keep, or remove: the background color is left out of a vector file and transparent in a PNG. When removing it would leave nothing, nothing is removed; result.background_removed says what happened.

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.

ValueLimit
1credits for each image that succeeds
3unfinished jobs at a time, queued or running (each account's default)
10images submitted a minute
120other requests a minute
30wrong keys a minute from one network
30MB for a whole request
3megapixels for an image
16px at least, for each side of an image
24hours a file is kept after its job succeeds
24hours an Idempotency-Key is remembered

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"
}
CodeHTTP MeaningWhat to do
unauthenticated401 Authentication requiredSend a valid key as Authorization: Bearer <key>, and check that it was not revoked.
account_disabled403 Account disabledThe account is disabled. Write to [email protected].
insufficient_credits402 Not enough creditsGet more credits, or wait for an unfinished job to end.
invalid_parameter400 Invalid parameterFix the field that param names; detail says what it accepts.
image_too_large413 Image too largeSend a smaller image: at most 3 megapixels, and at most 30 MB for the whole request.
unsupported_image422 Unsupported imageSend a PNG, JPEG, GIF, BMP or WebP file that opens, each side at least 16 px.
rate_limited429 Too many requestsWait the seconds in Retry-After, then send again.
too_many_active_jobs429 Too many unfinished jobsWait until one of your jobs ends, then submit the next one. detail says how many your account may have at once.
server_busy503 Server busyWait the seconds in Retry-After, then submit again with the same Idempotency-Key.
idempotency_key_reused422 Idempotency key reusedUse a new key for a new image or new options.
not_found404 Not foundCheck the address and the job id. A job of another account is not found either.
method_not_allowed405 Method not allowedUse a method the Allow header names.
result_not_ready409 Result not readyAsk for the job until it succeeds. A failed or canceled job has no file.
job_not_cancelable409 Job cannot be canceledNothing to do: the job has already ended.
result_expired410 Result expiredThe file was deleted 24 hours after the job. Submit the image again.
internal_error500 Internal errorTry again. If it keeps happening, write to [email protected] with the time of the request.

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

NameSent as RequiredDefault ValuesWhat you get
Idempotency-Keyheader no string Optional. 1 to 255 letters, digits and . _ : - (a UUID works). The same image with the same options and the same key within 24 hours returns the first job instead of starting a new one.
imageform field yes file The image: PNG, JPEG, GIF, BMP or WebP, at most 3 megapixels, each side at least 16 px and the long side at most 20 times the short one. The whole request is at most 30 MB.
formatform field yes svg, pdf, eps, png The file you get: svg, pdf, eps, or png, a print PNG at 300 dpi.
sizeform field no original string original, or <width>x<height> in pixels: each side 1 to 20,000 and at most 40 megapixels in all. The design is fitted inside with its proportions kept. A png of size original is the print canvas, 4,500 × 5,400 px at 300 dpi.
dpiform field no 300 1..10000 Only with an explicit size: the resolution the file states, 1 to 10,000.
colorsform field no automatic 1..1000 Empty for automatic, or the number of flat colors you want, 1 to 1,000. An image with fewer colors keeps the ones it has; result.colors says how many the file has.
backgroundform field no keep keep, remove keep, or remove: the background color is left out of a vector file and transparent in a PNG. When removing it would leave nothing, nothing is removed; result.background_removed says what happened.

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

NameSent as RequiredDefault ValuesWhat you get
job_idaddress yes string The job's id, as the submission returned it.

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

NameSent as RequiredDefault ValuesWhat you get
job_idaddress yes string The job's id, as the submission returned it.

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/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.