Перейти к содержанию

Вебхуки

Вебхук — это HTTP-запрос, который Ziex Try-On отправляет на ваш сервер, когда задача примерки завершилась. Он избавляет от опроса GET /api/v1/tryon/status/{job_id} по каждой задаче. Получать его можно двумя способами:

Зарегистрированный эндпоинт webhook_url в запросе
Где задаётся В портале клиента, один раз В теле каждого POST /api/v1/tryon
Какой ключ можно — Только секретный
Попыток До 5 (retry_count) Одна
Чем подписан Секретом эндпоинта Ключом, выведенным для задачи
Тело 18 полей 5 полей

Используйте зарегистрированный эндпоинт, если вам не нужен свой адрес для каждой задачи. Оба вида работают по принципу best effort: вебхук может прийти с опозданием, дважды или (если все попытки не удались) не прийти совсем. См. раздел «Сверка опросом» ниже.

Регистрация эндпоинта

  1. Войдите в портал клиента https://app.ziex-tryon.com вашим живым секретным ключом.
  2. Откройте Webhooks и добавьте эндпоинт.
  3. Заполните: — Адрес — публичный URL https:// или http://, например https://shop.example/ziex/webhook. — События — job.completed, job.failed или оба. — Режим — live или test. Он выбирается при создании эндпоинта и позже не меняется: чтобы сменить режим, удалите эндпоинт и создайте другой. Эндпоинт live получает события только живых задач, эндпоинт test — только задач тестовых ключей (см. Тестовые ключи). — Число попыток доставки (retry_count) — число попыток всего, включая первую: от 1 до 5, по умолчанию 3.
  4. Скопируйте секрет. Он показывается один раз, при создании эндпоинта. Храните его на сервере как пароль.

Портал также показывает доставки каждого эндпоинта: номер попытки, статус и время ответа. Число эндпоинтов на аккаунт ограничено: до 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, устаревший заголовок игнорируйте. Правила:

  1. Берите сырые байты тела, ровно как они пришли, до любого разбора JSON. Никогда не собирайте тело заново из разобранного JSON: порядок ключей, пробелы и экранирование не входят в контракт, а другая последовательность байт даёт другую подпись.
  2. Вычислите sha256= + hex HMAC-SHA256 с ключом — секретом эндпоинта (его текст как есть) от строки <значение X-TryOn-Timestamp> + . + <сырое тело>.
  3. Сравните с X-TryOn-Signature-V2 за постоянное время (hash_equals, hmac.compare_digest, crypto.timingSafeEqual), но не через ==.
  4. Отклоняйте слишком старое и слишком новое время: если 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; см. Тестовые ключи.
  • Типичные сбои перечислены на странице Ошибки.