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¶
POST /api/v1/tryonwith anIdempotency-Keyheader. Answer:201withjob_id, and the headersX-Job-IdandLocation.- Poll
GET /api/v1/tryon/status/{job_id}untilstatusiscompleted,failedorinterrupted- or receive a webhook. - If the answer to the
POSTis lost, find the job withGET /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
200with the samejob_id, the job's current status and the headerIdempotent-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_PROGRESSwithRetry-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.