Вебхуки¶
Вебхук — это HTTP-запрос, который Ziex Try-On отправляет на ваш сервер, когда задача примерки завершилась. Он избавляет
от опроса GET /api/v1/tryon/status/{job_id} по каждой задаче. Получать его можно двумя способами:
| Зарегистрированный эндпоинт | webhook_url в запросе |
|
|---|---|---|
| Где задаётся | В портале клиента, один раз | В теле каждого POST /api/v1/tryon |
| Какой ключ можно | — | Только секретный |
| Попыток | До 5 (retry_count) |
Одна |
| Чем подписан | Секретом эндпоинта | Ключом, выведенным для задачи |
| Тело | 18 полей | 5 полей |
Используйте зарегистрированный эндпоинт, если вам не нужен свой адрес для каждой задачи. Оба вида работают по принципу best effort: вебхук может прийти с опозданием, дважды или (если все попытки не удались) не прийти совсем. См. раздел «Сверка опросом» ниже.
Регистрация эндпоинта¶
- Войдите в портал клиента
https://app.ziex-tryon.comвашим живым секретным ключом. - Откройте Webhooks и добавьте эндпоинт.
- Заполните:
— Адрес — публичный URL
https://илиhttp://, напримерhttps://shop.example/ziex/webhook. — События —job.completed,job.failedили оба. — Режим —liveилиtest. Он выбирается при создании эндпоинта и позже не меняется: чтобы сменить режим, удалите эндпоинт и создайте другой. Эндпоинтliveполучает события только живых задач, эндпоинтtest— только задач тестовых ключей (см. Тестовые ключи). — Число попыток доставки (retry_count) — число попыток всего, включая первую: от 1 до 5, по умолчанию 3. - Скопируйте секрет. Он показывается один раз, при создании эндпоинта. Храните его на сервере как пароль.
Портал также показывает доставки каждого эндпоинта: номер попытки, статус и время ответа. Число эндпоинтов на аккаунт
ограничено: до 10 эндпоинтов на аккаунт (live и test вместе); 11-й отвечает 409 WEBHOOK_LIMIT без Retry-After.
Только публичные адреса
Адрес должен указывать на публичный IP. Приватный или локальный адрес (localhost, 192.168.x.x, 10.x.x.x,
адрес метаданных облака и подобные) отклоняется при регистрации и никогда не вызывается — так ваша сеть защищена от
подложных запросов. Чтобы принимать вебхуки на машине разработчика, используйте туннель с публичным адресом или
опрашивайте задачи; см. Локальная разработка.
События¶
| Событие | Когда отправляется |
|---|---|
job.completed |
Задача завершилась со статусом completed. Поле result_url заполнено. |
job.failed |
Задача завершилась со статусом failed или interrupted. Поля error_code и error_message заполнены. |
Запрос¶
Каждая доставка — это POST с Content-Type: application/json и такими заголовками:
| Заголовок | Значение |
|---|---|
X-TryOn-Event |
job.completed или job.failed. |
X-TryOn-Timestamp |
Unix-время в секундах этой попытки. Обновляется в каждой попытке. |
X-TryOn-Signature-V2 |
sha256= + hex HMAC-SHA256 от <timestamp>.<сырое тело> с ключом — секретом эндпоинта. Проверяйте именно его. |
X-TryOn-Signature |
sha256= + hex HMAC-SHA256 только от сырого тела. Устаревший: оставлен для старых получателей, от повтора запроса не защищает. |
Тело запроса¶
Тело — один JSON-объект ровно с такими 18 полями, одинаковый для обоих событий. Каждое поле присутствует всегда; если
значение неприменимо, оно равно null.
| Поле | Тип | Смысл |
|---|---|---|
event |
string | job.completed или job.failed. |
job_id |
string | Идентификатор задачи (UUID), тот же, что вернул POST /api/v1/tryon. |
status |
string | completed, failed или interrupted. |
result_url |
string или null | Изображение результата. null, если status не completed. |
error_code |
string или null | GENERATION_FAILED, INVALID_IMAGE, CONTENT_BLOCKED, TIMEOUT или INTERRUPTED. При успехе null. |
error_message |
string или null | Безопасный текст для кода. Сырую ошибку провайдера не содержит никогда. |
processing_time_ms |
integer или null | Сколько заняла задача, в миллисекундах. |
category |
string или null | Категория платформы, с которой выполнялась задача. |
product_title |
string или null | Название товара, присланное в запросе. |
product_url |
string или null | Ссылка на товар, присланная в запросе. |
integration |
string или null | Метка name/version, присланная в запросе; см. Идентификация. |
api_key_id |
string | Идентификатор API-ключа, которым отправлена задача. |
key_kind |
string или null | secret или publishable: каким из ключей аккаунта отправлена задача. null, если в запросе не было Idempotency-Key. |
idempotency_key |
string или null | Idempotency-Key в точности как его прислал клиент. null, если не присылал. Уникален только вместе с api_key_id и key_kind. |
test |
boolean | true для задачи тестового ключа. |
created_at |
string | Когда задача создана, ISO 8601. |
occurred_at |
string | Когда задача пришла в это состояние, ISO 8601. |
delivery_id |
string | Один идентификатор на всю доставку, одинаковый во всех попытках. Дедуплицируйте по нему. |
Тело job.completed:
{
"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": "Льняная рубашка",
"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"
}
Разбирайте тело как JSON только после проверки подписи. Не полагайтесь на порядок полей и на точные пробелы: парсите JSON.
Проверка подписи¶
На ваш адрес может написать кто угодно. Прежде чем доверять доставке, убедитесь, что она пришла от Ziex Try-On.
Проверяйте X-TryOn-Signature-V2, устаревший заголовок игнорируйте. Правила:
- Берите сырые байты тела, ровно как они пришли, до любого разбора JSON. Никогда не собирайте тело заново из разобранного JSON: порядок ключей, пробелы и экранирование не входят в контракт, а другая последовательность байт даёт другую подпись.
- Вычислите
sha256=+ hex HMAC-SHA256 с ключом — секретом эндпоинта (его текст как есть) от строки<значение X-TryOn-Timestamp>+.+<сырое тело>. - Сравните с
X-TryOn-Signature-V2за постоянное время (hash_equals,hmac.compare_digest,crypto.timingSafeEqual), но не через==. - Отклоняйте слишком старое и слишком новое время: если
X-TryOn-Timestampотличается от часов вашего сервера больше чем на 5 минут, ответьте ошибкой. Именно это не даёт повторно воспроизвести перехваченную доставку позже. Следите, чтобы часы сервера были точными (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'); // байты как пришли
$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);
// Сохраните $event['delivery_id'] и $event['job_id'] и быстро ответьте.
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())
# В обработчике вашего веб-фреймворка берите тело как bytes, а не как разобранный JSON:
# raw_body = request.get_data() # Flask
# raw_body = await request.body() # FastAPI / Starlette
# и заголовки X-TryOn-Timestamp и 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 бросает исключение при разной длине, поэтому сначала сравниваем длины.
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
}
const app = express();
// express.raw оставляет тело Buffer'ом: НЕ используйте express.json() на этом маршруте.
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'));
// Сохраните event.delivery_id и event.job_id и быстро ответьте.
res.sendStatus(204);
});
Отвечайте быстро¶
Отвечайте любым статусом 2xx в течение 10 секунд. Срок отсчитывается на весь запрос целиком, а не по фазам. Настоящую работу делайте после ответа: запишите событие в очередь или таблицу и верните ответ, а обработайте потом. Медленный обработчик — это неудачная попытка, и событие будет отправлено снова.
Доставка и повторы¶
Доставка — как минимум один раз (at least once). Первая попытка уходит сразу после завершения задачи, обычно в пределах примерно 10 секунд. Если она не удалась, то же самое тело отправляется снова после паузы:
| Попытка | Пауза перед ней (после неудачи предыдущей) |
|---|---|
| 1 | нет, сразу после завершения задачи |
| 2 | 1 минута |
| 3 | 5 минут |
| 4 | 30 минут |
| 5 | 2 часа |
retry_count эндпоинта (от 1 до 5, по умолчанию 3) — число попыток всего, поэтому эндпоинт использует первые
retry_count строк этого расписания: по умолчанию 3 даёт попытки 1, 2 и 3, а шестой попытки не бывает. В каждой попытке новый X-TryOn-Timestamp, а значит и новый
X-TryOn-Signature-V2; тело и delivery_id не меняются.
Что означает ваш ответ:
| Ваш ответ | Что происходит |
|---|---|
| Любой 2xx | Доставлено. Больше попыток нет. |
| Сетевая ошибка, нет ответа за 10 с на весь POST | Неудачная попытка, будет повтор. |
5xx, 408, 429 |
Неудачная попытка, будет повтор. На 429 и 503 учитывается заголовок Retry-After (секунды или HTTP-дата): следующая попытка — не раньше большего из паузы расписания и Retry-After, но не позже чем через 6 часов. 6 часов — только этот потолок, а не шаг расписания. |
Любой другой 4xx (400, 401, 403, 404, 410...) |
Финал. Доставка прекращается. |
Поскольку доставка может прийти больше одного раза (медленный ответ, дошедший после тайм-аута; повтор после сетевой
ошибки), дедуплицируйте по delivery_id: храните идентификаторы обработанных доставок и на повтор отвечайте 2xx, не
выполняя работу заново.
Сверка опросом¶
Вебхуки работают по принципу best effort. Если все попытки не удались или ваш сервер был недоступен дольше, чем длится расписание, событие повторно не отправляется. Задача, которую платформа вынуждена закрыть сама (она не стартовала или была прервана), тоже может завершиться без вебхука. Любая задача приходит к финальному состоянию не позже чем через 45 минут после создания, поэтому задачу, по которой к этому сроку нет вебхука, проверьте напрямую:
GET /api/v1/tryon/status/{job_id}— одна задача. См. Справочник API.GET /api/v1/tryon/jobs?since=<время ISO 8601>— задачи вашего секретного ключа с некоторого момента, постранично. Запускайте по расписанию (например, раз в несколько минут), чтобы найти пропущенное.
Надёжная интеграция считает вебхук быстрым путём, а опрос — источником истины.
Вебхук для одного запроса: webhook_url¶
POST /api/v1/tryon принимает в теле webhook_url. Тогда Ziex Try-On вызывает этот адрес ещё и для этой одной задачи.
- Только секретный ключ. Запрос с публичным ключом, в котором есть
webhook_url, отклоняется с403 WEBHOOK_NOT_ALLOWED. Адрес должен быть публичным иhttp(s), как выше; иначе запрос получит422 VALIDATION_ERROR. - Одна попытка. Повторов и расписания нет. Если вызов не удался, прочитайте задачу через
GET /api/v1/tryon/status/{job_id}. - Тело меньше: только
event,job_id,status,result_urlиdelivery_id. - Другая подпись. Ничего не зарегистрировано, поэтому секрета эндпоинта нет. Вызов подписан ключом задачи,
который вы выводите сами из своего секретного API-ключа и идентификатора задачи; запрос сообщает об этом заголовком
X-TryOn-Signing-Key: job-v1.
job_key = hex( HMAC_SHA256( key = <ваш секретный ключ, tryon_…>, message = "tryon-webhook-v1:" + job_id ) )
signature = "sha256=" + hex( HMAC_SHA256( key = job_key, message = <сырое тело> ) )
Подпись приходит в X-TryOn-Signature. 64-символьный hex job_key используется как текст (его ASCII-байты) в качестве
ключа второго HMAC. До проверки читайте из тела только job_id, выводите по нему ключ, сравнивайте за постоянное
время по сырым байтам, а остальному телу верьте только после успешного сравнения. У такого вызова нет
X-TryOn-Timestamp, поэтому проверки возраста нет: копия доставки может лишь повторить то, что уже случилось.
Отбрасывайте повторы по job_id.
<?php
$apiKey = 'tryon_YOUR_SECRET_KEY'; // ключ, которым отправлена задача
$rawBody = file_get_contents('php://input');
$jobId = json_decode($rawBody, true)['job_id'] ?? ''; // единственное поле, читаемое до проверки
$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" # ключ, которым отправлена задача
body = json.loads(raw_body)
job_id = body.get("job_id") if isinstance(body, dict) else None # единственное поле, читаемое до проверки
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'; // ключ, которым отправлена задача
// rawBody - Buffer (express.raw)
const body = JSON.parse(rawBody.toString('utf8'));
const jobId = String((body && body.job_id) || ''); // единственное поле, читаемое до проверки
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);
Проверяйте тем секретным ключом, которым задача была отправлена: вызов подписан ключом, с которого задача началась, поэтому смена или удаление ключа, пока задача выполняется, ничего не меняет.
Чек-лист¶
- Адрес эндпоинта публичный и отвечает
2xxза 10 секунд. - Вы проверяете
X-TryOn-Signature-V2по сырым байтам и отклоняете время, отличающееся более чем на 5 минут. - Вы дедуплицируете по
delivery_id. - Вы сверяетесь через
GET /api/v1/tryon/status/{job_id}илиGET /api/v1/tryon/jobs?since=. - Тестовые эндпоинты имеют режим
test, живые —live; см. Тестовые ключи. - Типичные сбои перечислены на странице Ошибки.