Skip to content

Quickstart B - server-side

Submit try-ons from your own server with the secret key, then read the result by polling or from a webhook. Use this when the shopper's photo reaches your backend (an app, a form, a message) rather than a product page. For a product page, see Quickstart A - bare HTML.

Base URL: https://api.ziex-tryon.com. Every path below starts with /api/v1/tryon.

Secret key stays on the server

Authenticate with X-API-Key: tryon_YOUR_SECRET_KEY. Never ship this key in page source, in a mobile app or in a public repository: it also signs in to the client portal, where your shoppers' photos are visible. A page uses the publishable key pk_… instead (see Overview & concepts).

Always send your own User-Agent

Cloudflare's Browser Integrity Check answers 403 with error 1010 (an HTML page, not our JSON) to the default User-Agent of Python's standard library, Python-urllib/3.x. curl, requests, PHP's curl and browsers pass as they are. Whatever you use, set an explicit User-Agent that names your shop and version, such as shop-example-tryon/1.0.

The flow

  1. POST /api/v1/tryon with an Idempotency-Key header. Answer: 201 with job_id, and the headers X-Job-Id and Location.
  2. Poll GET /api/v1/tryon/status/{job_id} until status is completed, failed or interrupted - or receive a webhook.
  3. If the answer to the POST is lost, find the job with GET /api/v1/tryon/attempts/{idempotency_key}.

1. Submit a try-on

POST https://api.ziex-tryon.com/api/v1/tryon, Content-Type: application/json.

Field Required Meaning
model_image yes The shopper's photo: an http(s) URL or a data URI (data:image/jpeg;base64,…).
garment_image yes The garment: a URL or a data URI.
category no tops (default), outerwear, bottoms, one-pieces or auto. The current list is GET /api/v1/tryon/capabilities. An id the service cannot try on is 422 CATEGORY_UNSUPPORTED.
mode no quality (default, higher fidelity) or balanced (faster).
product_title no The product name, kept with the job. Up to 1000 characters are accepted; the stored text is cleaned and cut to 200.
product_url no The product page. Up to 4096 characters are accepted; stored only if it is a plain http(s) URL of up to 2048 characters, otherwise dropped - the try-on still goes ahead.
webhook_url no A public HTTPS URL to notify when the job ends. Secret key only.
integration no A label for your integration, name/version, for example custom/acme-shop. Never a reason to refuse a request.

Each decoded image must be at most 10 MB, at most 16 megapixels and at least 256 x 256 pixels, or the answer is 422 VALIDATION_ERROR. The whole request body is at most 12 MB; a larger one is 413 PAYLOAD_TOO_LARGE from nginx. A URL is fetched by the service, so it must be reachable from the internet.

Test keys need an upload

A live secret key may send both images as URLs. A test key (tryon_test_…) must send model_image as a data URI, or the answer is 422 PHOTO_UPLOAD_REQUIRED. See Local development.

The Idempotency-Key header

Send one with every submit. It is a string matching ^[A-Za-z0-9_-]{22,64}$ - a UUID with or without dashes fits - new for every try-on, and reused on every retry of that same try-on. Store it with the order or request on your side before you send.

  • The first request with a key creates the job: 201.
  • A repeat with the same key never creates a second job and never charges a second time. It answers 200 with the same job_id, the job's current status and the header Idempotent-Replayed: true. The body of the repeat is not compared with the first, so a different photo under a reused key gets the first job back.
  • While the first request is still running, a repeat is 409 IDEMPOTENT_IN_PROGRESS with Retry-After: wait and repeat with the same key.
  • The key is scoped to the API key and to its kind: the same string under another key is another attempt.

A malformed key is 422 VALIDATION_ERROR.

curl -sS -i https://api.ziex-tryon.com/api/v1/tryon \
  -H 'X-API-Key: tryon_YOUR_SECRET_KEY' \
  -H 'User-Agent: shop-example-tryon/1.0' \
  -H "Idempotency-Key: $(uuidgen)" \
  -H 'Content-Type: application/json' \
  -d '{
    "model_image": "https://shop.example/uploads/shopper-1234.jpg",
    "garment_image": "https://shop.example/images/linen-shirt.jpg",
    "category": "tops",
    "mode": "quality",
    "product_title": "Linen shirt",
    "product_url": "https://shop.example/p/linen-shirt",
    "integration": "custom/acme-shop"
  }'

A shell cannot pass a multi-megabyte data URI as an argument; to upload a photo, use the Python or PHP example.

import base64
import time
import uuid

import requests

API = "https://api.ziex-tryon.com/api/v1/tryon"
HEADERS = {
    "X-API-Key": "tryon_YOUR_SECRET_KEY",
    "User-Agent": "shop-example-tryon/1.0",
}


def data_uri(path, mime="image/jpeg"):
    with open(path, "rb") as f:
        return f"data:{mime};base64," + base64.b64encode(f.read()).decode("ascii")


def submit(idempotency_key):
    body = {
        "model_image": data_uri("shopper.jpg"),
        "garment_image": "https://shop.example/images/linen-shirt.jpg",
        "category": "tops",
        "mode": "quality",
        "product_title": "Linen shirt",
        "product_url": "https://shop.example/p/linen-shirt",
        "integration": "custom/acme-shop",
    }
    return requests.post(
        API,
        json=body,
        headers={**HEADERS, "Idempotency-Key": idempotency_key},
        timeout=60,
    )


def wait_for_result(job_id):
    deadline = time.time() + 50 * 60          # every job is final within 45 minutes
    while time.time() < deadline:
        r = requests.get(f"{API}/status/{job_id}", headers=HEADERS, timeout=30)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "1")))
            continue
        r.raise_for_status()
        job = r.json()
        if job["status"] in ("completed", "failed", "interrupted"):
            return job
        time.sleep(2)
    raise TimeoutError(job_id)


key = uuid.uuid4().hex                          # 32 characters; keep it for retries
response = submit(key)
if response.status_code in (200, 201):
    job = wait_for_result(response.json()["job_id"])
    print(job["status"], job.get("result_url"), job.get("error_code"))
else:
    print(response.status_code, response.json()["error"]["code"])
import json
import urllib.error
import urllib.request
import uuid

API = "https://api.ziex-tryon.com/api/v1/tryon"

body = json.dumps({
    "model_image": "https://shop.example/uploads/shopper-1234.jpg",
    "garment_image": "https://shop.example/images/linen-shirt.jpg",
    "category": "tops",
}).encode("utf-8")

request = urllib.request.Request(
    API,
    data=body,
    method="POST",
    headers={
        "X-API-Key": "tryon_YOUR_SECRET_KEY",
        "Content-Type": "application/json",
        "Idempotency-Key": uuid.uuid4().hex,
        # Without this line urllib sends "Python-urllib/3.x" and Cloudflare answers 403, error 1010.
        "User-Agent": "shop-example-tryon/1.0",
    },
)
try:
    with urllib.request.urlopen(request, timeout=60) as response:
        print(response.status, response.headers["X-Job-Id"], json.load(response))
except urllib.error.HTTPError as err:
    print(err.code, err.headers.get("Retry-After"), json.load(err))
<?php
const ZIEX_API = 'https://api.ziex-tryon.com/api/v1/tryon';
const ZIEX_KEY = 'tryon_YOUR_SECRET_KEY';

// Returns [HTTP status, response headers (lower-case names), decoded JSON body].
function ziex_request(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array
{
    $headers = [];
    $request = [
        'X-API-Key: ' . ZIEX_KEY,
        'Content-Type: application/json',
    ];
    if ($idempotencyKey !== null) {
        $request[] = 'Idempotency-Key: ' . $idempotencyKey;
    }

    $ch = curl_init(ZIEX_API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => $request,
        CURLOPT_USERAGENT      => 'shop-example-tryon/1.0',
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 60,
        CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
            $parts = explode(':', $line, 2);
            if (count($parts) === 2) {
                $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
            }
            return strlen($line);
        },
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_SLASHES));
    }
    $raw = curl_exec($ch);
    if ($raw === false) {
        throw new RuntimeException('transport error: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    return [$status, $headers, json_decode($raw, true)];
}

$photo = 'data:image/jpeg;base64,' . base64_encode(file_get_contents('shopper.jpg'));
$key = bin2hex(random_bytes(16));               // 32 characters; keep it for retries

[$status, $headers, $job] = ziex_request('POST', '', [
    'model_image'   => $photo,
    'garment_image' => 'https://shop.example/images/linen-shirt.jpg',
    'category'      => 'tops',
    'product_title' => 'Linen shirt',
    'product_url'   => 'https://shop.example/p/linen-shirt',
    'integration'   => 'custom/acme-shop',
], $key);

if ($status === 200 || $status === 201) {
    do {
        sleep(2);
        [$status, $headers, $job] = ziex_request('GET', '/status/' . $job['job_id']);
    } while (in_array($job['status'] ?? '', ['pending', 'processing'], true));
    echo $job['status'], ' ', $job['result_url'] ?? $job['error_code'], "\n";
} else {
    echo $status, ' ', $job['error']['code'], "\n";
}

The loop is the shortest form. In production also stop after 50 minutes and honour Retry-After on a 429.

The answer

201 Created, with the headers X-Job-Id: <job_id> and Location: /api/v1/tryon/status/<job_id>:

{
  "job_id": "3f1b8c0e-5d52-4f0a-9d0e-0c6b3f5f2a11",
  "status": "pending",
  "poll_url": "/api/v1/tryon/status/3f1b8c0e-5d52-4f0a-9d0e-0c6b3f5f2a11",
  "estimated_seconds": 20,
  "request_id": "8a6c1b2e"
}

estimated_seconds is an estimate for a progress bar, not a promise. Quote X-Request-ID (present on every answer from the application) when you contact us about a request.

2. Read the result

Poll

GET /api/v1/tryon/status/{job_id} with the same X-API-Key. Ask every 2 seconds or so. A secret key reads the live jobs of its account; an id outside that scope is 404 NOT_FOUND.

{
  "job_id": "3f1b8c0e-5d52-4f0a-9d0e-0c6b3f5f2a11",
  "status": "completed",
  "progress": 100,
  "result_url": "https://media.ziex-tryon.com/RESULT_FILE",
  "processing_time_ms": 21400,
  "error_code": null,
  "error_message": null,
  "cost_credits": null
}
status Meaning
pending Accepted, waiting for a worker. progress is 0.
processing Being generated. progress is an estimate that stops at 95.
completed Done. result_url is the image; download it from there.
failed Could not be generated. error_code is one of GENERATION_FAILED, INVALID_IMAGE, CONTENT_BLOCKED, TIMEOUT; error_message is a safe text you may show.
interrupted Stopped by a restart of the service; error_code is INTERRUPTED. Submit again under a new key.

Every job is final within 45 minutes. A failed or interrupted job is refunded to your plan automatically; a completed one is not. cost_credits is always null.

If the status endpoint answers 429, wait Retry-After seconds. Polling is limited to 20 requests a second per IP (burst 30) - one job every 2 seconds is far below it.

Webhook

Instead of polling, give webhook_url (a public HTTPS address; secret key only) and Ziex Try-On will call it when the job ends with job.completed or job.failed. The webhook is signed; the payload, the signature check and the retry schedule are described on the Webhooks page. Webhooks are best effort, so keep a poll as the safety net for a job whose webhook never arrives. A machine behind a firewall or on localhost receives no webhook - see Local development.

3. When the answer is lost

A timeout or a dropped connection after the POST was sent does not mean no job exists: the request may have reached the service and created it. Never send the same try-on again under a new key. Ask for the attempt:

curl -sS https://api.ziex-tryon.com/api/v1/tryon/attempts/YOUR_IDEMPOTENCY_KEY \
  -H 'X-API-Key: tryon_YOUR_SECRET_KEY' \
  -H 'User-Agent: shop-example-tryon/1.0'
Answer Meaning
200 The job: the same body as the status endpoint, plus X-Job-Id and Location.
409 IDEMPOTENT_IN_PROGRESS The submit is still being processed. Ask again after Retry-After.
404 NOT_FOUND No job was created with this key. This is final only about 2 minutes after you sent the POST; before that, ask again.
503 ATTEMPT_LOOKUP_UNAVAILABLE Cannot tell right now. Ask again after Retry-After.

Repeating the POST itself with the same Idempotency-Key is equivalent and also safe.

What to do with an error

The error body is always {"error": {"code": "...", "message": "..."}}. Decide by error.code and the HTTP status, never by message, which can change. The full table is on the Errors page; the cases that decide whether to retry:

Answer What happened What to do
400, 401, 403, 422 The request or the key is wrong. Fix it. Retrying the same request will not help.
429 RATE_LIMITED, LIMIT_EXCEEDED, DAILY_CAP_REACHED A limit. Nothing was created. Wait Retry-After seconds, then repeat - the same key may be reused.
409 IDEMPOTENT_IN_PROGRESS The first request with this key is still running. Wait Retry-After, repeat with the same key.
503 OVERLOADED (Retry-After: 5, no X-Job-Id) The service was busy; nothing was created or charged. Repeat the same request with the same key after Retry-After.
503 SUBMIT_UNCONFIRMED (with X-Job-Id) The job exists but may not be queued. Poll X-Job-Id, or look the attempt up. Do not resubmit under a new key.
500 Final. If a job was already created it is now failed and its quota comes back. A repeat with the same key answers 200 with that failed job. For a new try, use a new key.
502, 503, 504 SERVICE_UNAVAILABLE, a timeout, a dropped connection The service restarted or did not answer in time; the outcome is unknown. Look the attempt up (section 3), then retry with backoff.

Limits worth knowing

  • 30 submits per minute per client IP address, plus nginx's rate of 2 requests a second (burst 5) on POST. A server that forwards all its shoppers' requests from one address shares this one budget.
  • The key's hourly limit, your plan's generations and the daily caps; each refusal carries Retry-After.
  • Uploaded photos and results are kept for 180 days.

The numbers for every limit are on the Limits page; the whole API is in the API reference.