Вебхуки
Магазин сам отправляет POST на ваш адрес, когда что-то меняется: заказ, оплата, товар, остаток.
Подписка
Панель магазина: «Настройки» → «API и вебхуки» → «Вебхуки». Укажите адрес https://…, отметьте события и нажмите «Добавить». Секрет whsec_… показывает кнопка «Секрет», «Проверить» отправляет событие ping — результат на вкладке «Журнал».
Что приходит
POST с JSON: номер события, тип, время, магазин и объект в том же виде, что в API. Здесь заказ сокращён.
{
"id": "evt_1042",
"type": "order.paid",
"created_at": "2026-10-30T09:31:07+00:00",
"shop": {"site": "myshop", "host": "myshop.okeysait.ru"},
"data": {"id": 518, "number": "518", "paid": true, "total": "2580.00"}
}| Заголовок | Значение |
|---|---|
X-Okeano-Event | Тип события, например order.paid |
X-Okeano-Delivery | Номер доставки |
X-Okeano-Signature | Подпись t=<unix>,v1=<hex> — как проверить |
User-Agent | Okeano-Webhooks/1.0 |
Проверка подписи
Подпись v1 — HMAC-SHA256 от строки <t>.<тело> с секретом whsec_…, в hex. Считайте её по сырому телу, а не по разобранному JSON, и отклоняйте запросы, у которых t старше 5 минут.
# Проверка подписи в оболочке (bash): сырое тело запроса — в файле body.json,
# заголовок X-Okeano-Signature — в $SIG, секрет whsec_… — в $WHSEC
# Секрет виден в списке процессов — запускайте на своём сервере.
# Сравнение не за постоянное время: в языке нет такой функции.
[[ $SIG =~ ^t=([0-9]+),v1=([0-9a-f]{64})$ ]] || { echo invalid; exit; }
t=${BASH_REMATCH[1]}; v1=${BASH_REMATCH[2]}
want=$( { printf '%s.' "$t"; cat body.json; } | openssl dgst -sha256 -hmac "$WHSEC" | sed 's/^.*= //')
age=$(( $(date +%s) - t )); age=${age#-}
if [ "$want" = "$v1" ] && [ "$age" -le 300 ]; then echo valid; else echo invalid; fi<?php
// PHP: проверка подписи вебхука Океано
function okeano_webhook_valid($body, $header, $secret, $tolerance = 300) {
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) return false;
if (abs(time() - (int)$m[1]) > $tolerance) return false;
return hash_equals(hash_hmac('sha256', $m[1] . '.' . $body, $secret), $m[2]);
}
$ok = okeano_webhook_valid(
file_get_contents('php://input'),
$_SERVER['HTTP_X_OKEANO_SIGNATURE'] ?? '',
'whsec_…'
);# Python: проверка подписи вебхука Океано; body — сырые байты тела запроса
import hashlib
import hmac
import re
import time
def okeano_webhook_valid(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
if not m or abs(time.time() - int(m[1])) > tolerance:
return False
want = hmac.new(secret.encode(), m[1].encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, m[2])// Node.js: проверка подписи вебхука Океано; rawBody — сырые байты тела (Buffer), не разобранный JSON
const crypto = require("crypto");
function okeanoWebhookValid(rawBody, header, secret, tolerance = 300) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || "");
if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > tolerance) return false;
const want = crypto.createHmac("sha256", secret).update(m[1] + ".").update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(want), Buffer.from(m[2]));
}// 1С:Предприятие 8.3.11 и новее, HTTP-сервис: проверка подписи вебхука Океано.
// Тело = Запрос.ПолучитьТелоКакДвоичныеДанные(); Заголовок = Запрос.Заголовки.Получить("X-Okeano-Signature").
// Сравнение не за постоянное время: в языке нет такой функции.
Функция ПодписьОкеаноВерна(Тело, Заголовок, Секрет, Допуск = 300) Экспорт
Части = СтрРазделить(Заголовок, ",");
Если Части.Количество() <> 2 Тогда
Возврат Ложь;
КонецЕсли;
Если Не СтрНачинаетсяС(Части[0], "t=") Тогда
Возврат Ложь;
КонецЕсли;
Если Не СтрНачинаетсяС(Части[1], "v1=") Тогда
Возврат Ложь;
КонецЕсли;
Метка = Сред(Части[0], 3);
Подпись = НРег(Сред(Части[1], 4));
Если СтрДлина(Метка) > 12 Тогда
Возврат Ложь;
КонецЕсли;
Если Не ТолькоСимволы(Метка, "0123456789") Тогда
Возврат Ложь;
КонецЕсли;
Если СтрДлина(Подпись) <> 64 Тогда
Возврат Ложь;
КонецЕсли;
Если Не ТолькоСимволы(Подпись, "0123456789abcdef") Тогда
Возврат Ложь;
КонецЕсли;
Сейчас = ТекущаяУниверсальнаяДата() - Дата(1970, 1, 1);
Если Макс(Сейчас - Число(Метка), Число(Метка) - Сейчас) > Допуск Тогда
Возврат Ложь;
КонецЕсли;
Начало = ПолучитьБуферДвоичныхДанныхИзСтроки(Метка + ".", КодировкаТекста.UTF8, Ложь);
Сообщение = Начало.Соединить(ПолучитьБуферДвоичныхДанныхИзДвоичныхДанных(Тело));
Ключ = ПолучитьБуферДвоичныхДанныхИзСтроки(Секрет, КодировкаТекста.UTF8, Ложь);
// платформа отдаёт hex заглавными буквами и с пробелами между байтами
Ожидаемая = НРег(СтрЗаменить(ПолучитьHexСтрокуИзБуфераДвоичныхДанных(HMAC_SHA256(Ключ, Сообщение)), " ", ""));
Возврат Ожидаемая = Подпись;
КонецФункции
// Непустая строка, целиком состоящая из допустимых символов.
Функция ТолькоСимволы(Строка, Допустимые)
Если СтрДлина(Строка) = 0 Тогда
Возврат Ложь;
КонецЕсли;
Для Позиция = 1 По СтрДлина(Строка) Цикл
Если СтрНайти(Допустимые, Сред(Строка, Позиция, 1)) = 0 Тогда
Возврат Ложь;
КонецЕсли;
КонецЦикла;
Возврат Истина;
КонецФункции
// HMAC-SHA256 по RFC 2104: готовой функции HMAC в платформе нет.
Функция HMAC_SHA256(Знач Ключ, Данные)
Блок = 64;
Если Ключ.Размер > Блок Тогда
Ключ = SHA256(Ключ);
КонецЕсли;
Внутр = Новый БуферДвоичныхДанных(Блок);
Внешн = Новый БуферДвоичныхДанных(Блок);
Для Индекс = 0 По Блок - 1 Цикл
Байт = ?(Индекс < Ключ.Размер, Ключ[Индекс], 0);
Внутр[Индекс] = ПобитовоеИсключительноеИли(Байт, 54);
Внешн[Индекс] = ПобитовоеИсключительноеИли(Байт, 92);
КонецЦикла;
Возврат SHA256(Внешн.Соединить(SHA256(Внутр.Соединить(Данные))));
КонецФункции
Функция SHA256(Буфер)
Хеш = Новый ХешированиеДанных(ХешФункция.SHA256);
Хеш.Добавить(ПолучитьДвоичныеДанныеИзБуфераДвоичныхДанных(Буфер));
Возврат ПолучитьБуферДвоичныхДанныхИзДвоичныхДанных(Хеш.ХешСумма);
КонецФункцииimport (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"time"
)
var okeanoSig = regexp.MustCompile(`^t=(\d+),v1=([0-9a-f]{64})$`)
// OkeanoWebhookValid проверяет подпись вебхука Океано; body — сырое тело запроса.
func OkeanoWebhookValid(body []byte, header, secret string) bool {
m := okeanoSig.FindStringSubmatch(header)
if m == nil {
return false
}
t, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return false
}
age := time.Now().Unix() - t
if age < 0 {
age = -age
}
if age > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(m[1] + "."))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(m[2]))
}Не сошлось — ответьте 400, доставка повторится. После проверки отвечайте сразу, а тяжёлую работу делайте в фоне.
Доставка и повторы
- Ответьте 2xx за 10 секунд — доставка засчитана. Другой ответ или тишина — неудача. Редиректы не выполняются.
- Повторы: через 1 минуту, 5 минут, 30 минут, 2 часа, 6 часов и 12 часов. После 7 попыток — «не доставлено»; повторить вручную можно в «Журнале».
- 50 неудач подряд выключают вебхук, владельцу приходит письмо. Включить — кнопкой «Включить» в панели.
- «Хотя бы один раз»: событие может прийти дважды — отбрасывайте повторы по
id. Порядок событий не гарантирован. - Адрес: только
https, порт 443 или 8443; внутренние адреса запрещены.
События
| Событие | Когда приходит и что в data |
|---|---|
order.created | Создан заказ. Новый заказ с сайта, через embed.js или API. В data — заказ целиком, с позициями. |
order.paid | Заказ оплачен. Заказ отмечен оплаченным (оплата на сайте, из панели или через API). При оплате order.paid приходит всегда; вместе с ним могут прийти order.updated и order.status_changed (например, при оплате картой или через Robokassa) — отсекайте повторы по id заказа и updated_at. |
order.status_changed | Сменился статус заказа. Новый статус — в data.status. Вместе с ним приходит и order.updated; при оплате (например, через Robokassa) он может прийти вместе с order.paid. |
order.updated | Заказ изменён. Изменились данные заказа: статус, покупатель, адрес, заметка, состав и другое. Если вы подписаны на order.created, для заказа, созданного в том же запросе, order.updated не приходит. При смене статуса приходят оба события — order.status_changed и order.updated; при оплате к order.paid могут добавиться order.updated и order.status_changed. Отсекайте повторы по id заказа и updated_at. |
product.created | Создан товар. В data — товар целиком, как в GET /products/{id}. |
product.updated | Изменён товар. Изменились поля товара, его варианты, разделы или фото. Несколько правок одного товара за один запрос дают одно событие. Если товар к моменту отправки уже удалён, в data будет {"id": 42, "deleted": true}. |
product.deleted | Удалён товар. В data — номер и артикулы удалённого товара. |
stock.changed | Изменились остатки или цены. Изменились остаток, цена или старая цена вариантов. Изменения за один запрос собираются в одно событие. В data.items — текущее состояние затронутых вариантов, не больше 200: если изменений за запрос больше 200, вместо stock.changed приходит один catalog.imported. Поле truncated оставлено для совместимости и всегда false. |
catalog.imported | Массовое изменение каталога. Больше 200 изменений товаров и остатков за одну операцию либо загрузка файлом: вместо события на каждый товар приходит одно catalog.imported со счётчиками. Получив его, перечитайте каталог через GET /products?updated_since=…. При подписке на события товаров и остатков это событие добавляется автоматически. |
customer.created | Зарегистрирован покупатель. В data — покупатель, как в GET /customers/{id}. Персональные данные: подписывайтесь, только если они нужны. |
feedback.created | Новое сообщение из формы обратной связи. Посетитель написал через форму магазина. |
ping | Проверка из панели. В data — {"message": "…"}. |