Webhooks¶
A webhook is an HTTPS request that Ziex Try-On sends to your server when a try-on job ends. It saves you from polling
GET /api/v1/tryon/status/{job_id} for every job. There are two ways to receive it:
| Registered endpoint | Per-request webhook_url |
|
|---|---|---|
| Set where | The client portal, once | In the body of each POST /api/v1/tryon |
| Key allowed | - | Secret key only |
| Attempts | Up to 5 (retry_count) |
One |
| Signed with | The endpoint's secret | A key derived per job |
| Body | 18 fields | 5 fields |
Use a registered endpoint unless you need a different address for each job. Both kinds are best effort: a webhook can arrive late, twice, or (after every attempt has failed) never. See Reconcile by polling.
Register an endpoint¶
- Sign in to the client portal at
https://app.ziex-tryon.comwith your live secret key. - Open Webhooks and add an endpoint.
- Fill in:
- Address - a public
https://orhttp://URL, for examplehttps://shop.example/ziex/webhook. - Events -
job.completed,job.failed, or both. - Mode -
liveortest. It is chosen when the endpoint is created and cannot be changed later: to switch, delete the endpoint and create another. Aliveendpoint receives the events of live jobs only, atestendpoint those of test-key jobs only (see Test keys). - Delivery attempts (
retry_count) - the number of attempts in all, the first one included: 1 to 5, 3 by default.
- Address - a public
- Copy the secret. It is shown once, when the endpoint is created. Store it on your server like a password.
The portal also shows the deliveries of each endpoint with the attempt number, the status and the response time. The
number of endpoints per account is capped at 10 (live and test together); the 11th answers 409 WEBHOOK_LIMIT with no
Retry-After.
Public addresses only
The address must resolve to a public IP address. A private or local address (localhost, 192.168.x.x, 10.x.x.x,
a cloud metadata address and the like) is refused when you register it and is never delivered to, to protect your
network from forged requests. To receive webhooks on a developer machine use a tunnel that gives you a public
address, or poll instead; see Local development.
Events¶
| Event | Sent when |
|---|---|
job.completed |
A job ended completed. result_url is set. |
job.failed |
A job ended failed or interrupted. error_code and error_message are set. |
The request¶
Each delivery is a POST with Content-Type: application/json and these headers:
| Header | Value |
|---|---|
X-TryOn-Event |
job.completed or job.failed. |
X-TryOn-Timestamp |
Unix time in seconds of this attempt. It is renewed on every attempt. |
X-TryOn-Signature-V2 |
sha256= + hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the endpoint's secret. Verify this one. |
X-TryOn-Signature |
sha256= + hex HMAC-SHA256 of the raw body alone. Legacy: kept for older receivers, it does not protect against replay. |
Payload¶
The body is one JSON object with exactly these 18 fields, the same for both events. Every field is always present;
a value that does not apply is null.
| Field | Type | Meaning |
|---|---|---|
event |
string | job.completed or job.failed. |
job_id |
string | The job's id (a UUID), as returned by POST /api/v1/tryon. |
status |
string | completed, failed or interrupted. |
result_url |
string or null | The result image. null unless status is completed. |
error_code |
string or null | GENERATION_FAILED, INVALID_IMAGE, CONTENT_BLOCKED, TIMEOUT or INTERRUPTED. null on success. |
error_message |
string or null | A safe text for the code. It never carries the provider's raw error. |
processing_time_ms |
integer or null | How long the job took, in milliseconds. |
category |
string or null | The platform category the job ran with. |
product_title |
string or null | The product title the submit carried. |
product_url |
string or null | The product link the submit carried. |
integration |
string or null | The name/version label the submit carried; see Identification. |
api_key_id |
string | The id of the API key the job was submitted with. |
key_kind |
string or null | secret or publishable: which key of that account submitted the job. null when the submit had no Idempotency-Key. |
idempotency_key |
string or null | The Idempotency-Key exactly as the client sent it. null when none was sent. It is unique only together with api_key_id and key_kind. |
test |
boolean | true for a job of a test key. |
created_at |
string | When the job was created, ISO 8601. |
occurred_at |
string | When the job reached this state, ISO 8601. |
delivery_id |
string | One id for the whole delivery, the same on every attempt. Deduplicate by it. |
A job.completed body:
{
"event": "job.completed",
"job_id": "0b9c7f5e-6a1d-4e0e-9a39-3c1d2f6b8a10",
"status": "completed",
"result_url": "https://media.ziex-tryon.com/3f2b0a9c-9d1e-4f3a-8c52-7b6e1d0a4c11.webp",
"error_code": null,
"error_message": null,
"processing_time_ms": 21840,
"category": "tops",
"product_title": "Linen shirt",
"product_url": "https://shop.example/p/linen-shirt",
"integration": "woocommerce/1.4.0",
"api_key_id": "5d8a1c42-0b7e-4c0d-b6a4-2f1e9c7d3a55",
"key_kind": "secret",
"idempotency_key": "b1f6c0e2a4d84c7e9a3b5d7f1e2c4a60",
"test": false,
"created_at": "2026-10-26T09:14:02.511Z",
"occurred_at": "2026-10-26T09:14:24.370Z",
"delivery_id": "e4a1c8d2-7b3f-4a56-9c0e-1d2f3a4b5c6d"
}
Read the body as JSON only after the signature has been verified. Do not rely on the order of the fields or on the exact spacing: parse the JSON.
Verify the signature¶
Anyone can post to your address. Before you believe a delivery, check that it comes from Ziex Try-On.
Verify X-TryOn-Signature-V2 and ignore the legacy header. The rules:
- Use the raw bytes of the body, exactly as received, before any JSON parsing. Never rebuild the body from parsed JSON: key order, spacing and escaping are not part of the contract, and a different byte string gives a different signature.
- Compute
sha256=+ hex HMAC-SHA256, keyed with the endpoint's secret (its text as is), over<value of X-TryOn-Timestamp>+.+<raw body>. - Compare with
X-TryOn-Signature-V2in constant time (hash_equals,hmac.compare_digest,crypto.timingSafeEqual), never with==. - Reject a stale or future timestamp: if
X-TryOn-Timestampis more than 5 minutes away from your clock, answer with an error. This is what stops a captured delivery from being replayed later. Keep your server clock correct (NTP).
<?php
const ZIEX_WEBHOOK_SECRET = 'YOUR_WEBHOOK_SECRET';
const ZIEX_TOLERANCE_SECONDS = 300;
function ziex_verify(string $secret, string $rawBody, string $timestamp, string $signature): bool
{
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > ZIEX_TOLERANCE_SECONDS) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
$rawBody = file_get_contents('php://input'); // the bytes as received
$ok = ziex_verify(
ZIEX_WEBHOOK_SECRET,
$rawBody,
$_SERVER['HTTP_X_TRYON_TIMESTAMP'] ?? '',
$_SERVER['HTTP_X_TRYON_SIGNATURE_V2'] ?? ''
);
if (!$ok) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
// Store $event['delivery_id'] and $event['job_id'], then answer quickly.
http_response_code(204);
import hashlib
import hmac
import time
WEBHOOK_SECRET = "YOUR_WEBHOOK_SECRET"
TOLERANCE_SECONDS = 300
def ziex_verify(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
try:
sent_at = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), (signature or "").encode())
# In your web framework's handler, take the body as bytes, not as parsed JSON:
# raw_body = request.get_data() # Flask
# raw_body = await request.body() # FastAPI / Starlette
# and the headers X-TryOn-Timestamp and X-TryOn-Signature-V2.
ok = ziex_verify(WEBHOOK_SECRET, raw_body, timestamp_header, signature_v2_header)
const crypto = require('node:crypto');
const express = require('express');
const WEBHOOK_SECRET = 'YOUR_WEBHOOK_SECRET';
const TOLERANCE_SECONDS = 300;
function ziexVerify(secret, rawBody, timestamp, signature) {
if (!/^\d+$/.test(timestamp || '')) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex')
);
const got = Buffer.from(signature || '');
// timingSafeEqual throws on different lengths, so compare lengths first.
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
}
const app = express();
// express.raw keeps the body as a Buffer: do NOT use express.json() on this route.
app.post('/ziex/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ok = ziexVerify(
WEBHOOK_SECRET,
req.body,
req.get('X-TryOn-Timestamp'),
req.get('X-TryOn-Signature-V2')
);
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
// Store event.delivery_id and event.job_id, then answer quickly.
res.sendStatus(204);
});
Answer quickly¶
Answer with any 2xx status within 10 seconds. The deadline covers the whole request, not each phase. Do the real work after you have answered: write the event to a queue or a table and return, then process it. A slow handler is a failed attempt, and the event will be sent again.
Delivery and retries¶
Delivery is at least once. The first attempt goes out as soon as the job ends, normally within about 10 seconds. If it fails, the same body is sent again after a pause:
| Attempt | Pause before it (after the previous attempt failed) |
|---|---|
| 1 | none, as soon as the job ends |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
The endpoint's retry_count (1 to 5, default 3) is the number of attempts in all, so an endpoint uses the first
retry_count rows of this schedule: the default 3 gives attempts 1, 2 and 3, and there is never a sixth attempt. Each attempt has a new X-TryOn-Timestamp and so a new
X-TryOn-Signature-V2; the body and delivery_id do not change.
What your answer means:
| Your answer | What happens |
|---|---|
| Any 2xx | Delivered. No more attempts. |
| A network error, no answer within 10 s for the whole POST | Failed attempt, retried. |
5xx, 408, 429 |
Failed attempt, retried. On 429 and 503 the Retry-After header (seconds or an HTTP date) is honoured: the next attempt is at the later of the schedule's pause and Retry-After, but never later than 6 hours. The 6 hours is only this ceiling, not a step of the schedule. |
Any other 4xx (400, 401, 403, 404, 410...) |
Final. The delivery stops. |
Because a delivery can be sent more than once (a slow answer that arrived after the timeout, a retry after a network
error), deduplicate by delivery_id: keep the ids you have processed and acknowledge a repeat with a 2xx without
doing the work again.
Reconcile by polling¶
Webhooks are best effort. If every attempt fails, or your server was down for longer than the schedule, the event is not sent again. A job that the platform has to close itself (it never started, or it was interrupted) may also end without a webhook. Every job reaches a final state within 45 minutes of being created, so a job that has no webhook by then should be checked directly:
GET /api/v1/tryon/status/{job_id}- one job. See the API reference.GET /api/v1/tryon/jobs?since=<ISO 8601 time>- the jobs of your secret key since a moment, page by page. Use it on a schedule (for example every few minutes) to find anything you missed.
A sound integration treats the webhook as a fast path and the poll as the source of truth.
A webhook for one request: webhook_url¶
POST /api/v1/tryon accepts a webhook_url in its body. Ziex Try-On then also calls that address for this one job.
- Secret key only. A request with a publishable key that carries
webhook_urlis refused with403 WEBHOOK_NOT_ALLOWED. The address must be public andhttp(s), as above; otherwise the request is a422 VALIDATION_ERROR. - One attempt. There is no retry and no schedule. If the call fails, read the job with
GET /api/v1/tryon/status/{job_id}. - A smaller body: only
event,job_id,status,result_urlanddelivery_id. - A different signature. Nothing is registered, so there is no endpoint secret. The call is signed with a job
key that you derive yourself from your secret API key and the job's id, and the request says so with
X-TryOn-Signing-Key: job-v1.
job_key = hex( HMAC_SHA256( key = <your secret key, tryon_…>, message = "tryon-webhook-v1:" + job_id ) )
signature = "sha256=" + hex( HMAC_SHA256( key = job_key, message = <raw body> ) )
The signature arrives in X-TryOn-Signature. Use the 64-character hex job_key as text (its ASCII bytes) for the second
HMAC. Read only job_id from the body before the check, derive the key from it, compare in constant time over
the raw bytes, and believe the rest of the body only after the comparison passes. This kind of call carries no
X-TryOn-Timestamp, so it has no age check: a copy of a delivery can only repeat what already happened. Drop repeats
by job_id.
<?php
$apiKey = 'tryon_YOUR_SECRET_KEY'; // the key the job was submitted with
$rawBody = file_get_contents('php://input');
$jobId = json_decode($rawBody, true)['job_id'] ?? ''; // the only field read before the check
$jobKey = hash_hmac('sha256', 'tryon-webhook-v1:' . $jobId, $apiKey);
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $jobKey);
$ok = hash_equals($expected, $_SERVER['HTTP_X_TRYON_SIGNATURE'] ?? '');
import hashlib
import hmac
import json
API_KEY = "tryon_YOUR_SECRET_KEY" # the key the job was submitted with
body = json.loads(raw_body)
job_id = body.get("job_id") if isinstance(body, dict) else None # the only field read before the check
job_key = hmac.new(
API_KEY.encode(), ("tryon-webhook-v1:" + str(job_id)).encode(), hashlib.sha256
).hexdigest()
expected = "sha256=" + hmac.new(job_key.encode(), raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected.encode(), signature_header.encode()) # X-TryOn-Signature
const crypto = require('node:crypto');
const API_KEY = 'tryon_YOUR_SECRET_KEY'; // the key the job was submitted with
// rawBody is a Buffer (express.raw)
const body = JSON.parse(rawBody.toString('utf8'));
const jobId = String((body && body.job_id) || ''); // the only field read before the check
const jobKey = crypto.createHmac('sha256', API_KEY).update('tryon-webhook-v1:' + jobId).digest('hex');
const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', jobKey).update(rawBody).digest('hex'));
const got = Buffer.from(req.get('X-TryOn-Signature') || '');
const ok = got.length === expected.length && crypto.timingSafeEqual(got, expected);
Verify with the secret key the job was submitted with: the callback is signed with the key it started with, so rotating or deleting a key while its job runs changes nothing.
Checklist¶
- The endpoint address is public and answers
2xxwithin 10 seconds. - You verify
X-TryOn-Signature-V2over the raw bytes and reject a timestamp more than 5 minutes off. - You deduplicate by
delivery_id. - You reconcile with
GET /api/v1/tryon/status/{job_id}orGET /api/v1/tryon/jobs?since=. - Test endpoints use mode
test, live endpoints modelive; see Test keys. - Typical failures are listed on the Errors page.