Вебхуки
Управление вебхуками
Регистрирует публичный адрес https, который получает одно подписанное событие на вызов при изменении заказа или восполнения. Конечные точки принадлежат аккаунту, и у каждой свой секрет.
https://notpanel.com/api/v3action=webhook.add- Требуется API-ключ
- Превышен лимит запросов
- Тело:
application/x-www-form-urlencoded
Параметры
Передаётся всегда
keystringОбязательный- Ваш API-ключ.
actionstringОбязательныйwebhook.addДолжно быть буквальной строкой «webhook.add».
Параметры действия
urlstring (URL)Обязательный- Публичный адрес https, получающий события.
eventsstring (CSV)Необязательно- Типы событий через запятую, на которые подписаться, из 8. Не указывайте, чтобы получать все.order.processing, order.in_progress, order.completed, order.partial, order.canceled, order.cancel_refused, refill.completed, refill.rejected
curl -X POST https://notpanel.com/api/v3 \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "key=YOUR_API_KEY&action=webhook.add&url=https://your-server.example.com/notpanel-webhook&events=order.completed,order.partial,order.canceled"const res = await fetch("https://notpanel.com/api/v3", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
key: "YOUR_API_KEY",
action: "webhook.add",
url: "https://your-server.example.com/notpanel-webhook",
events: "order.completed,order.partial,order.canceled",
}),
});
const data = await res.json();
console.log(data);import requests
res = requests.post(
"https://notpanel.com/api/v3",
data={
"key": "YOUR_API_KEY",
"action": "webhook.add",
"url": "https://your-server.example.com/notpanel-webhook",
"events": "order.completed,order.partial,order.canceled",
},
)
print(res.json())<?php
$body = http_build_query([
'key' => 'YOUR_API_KEY',
'action' => 'webhook.add',
'url' => 'https://your-server.example.com/notpanel-webhook',
'events' => 'order.completed,order.partial,order.canceled',
]);
$response = file_get_contents('https://notpanel.com/api/v3', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => $body,
'ignore_errors' => true,
],
]));
print_r(json_decode($response, true));
Замените YOUR_API_KEY ключом со страницы API.
{
"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"url": "https://your-server.example.com/notpanel-webhook",
"events": ["order.completed", "order.partial", "order.canceled"],
"active": true,
"paused_at": null,
"created_at": "2026-10-03T09:00:00Z",
"status": "active",
"failureCount": 0,
"lastDeliveredAt": null,
"createdAt": "2026-10-03T09:00:00.000Z",
"secret": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"webhook_id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301"
}Ошибки
- 400
{"error":"Invalid webhook URL","error_code":"INVALID_WEBHOOK_URL","retryable":false}Адрес не является публичным адресом https или указывает на частный.
- 400
{"error":"Invalid events: not_allowed","error_code":"INVALID_PARAMETER","retryable":false,"details":{"param":"events","rule":"not_allowed"}}Названия события нет в списке. Проверьте написание: названия выглядят как
order.completed. - 409
{"error":"Maximum 5 webhook endpoints per account","error_code":"WEBHOOK_LIMIT","retryable":false,"details":{"limit":5}}В аккаунте уже максимум — 5 конечных точек. Сначала удалите одну.
События
Подписывайтесь на любые из них. Каждое отправляется отдельным вызовом, когда изменение сохранено, а откатанное изменение ничего не отправляет. Ping отправляется только через webhook.test.
Типы событий
order.processing- Заказ отправлен и ожидает начала выполнения.
order.in_progress- Выполнение началось.
order.completed- Заказ выполнен полностью.
order.partial- Часть заказа выполнена, остаток возвращён.
order.canceled- Заказ отменён и возвращён. Заменяет прежнее
order.refunded. order.cancel_refused- Запрос на отмену отклонён. Заказ продолжает выполняться.
refill.completed- Восполнение выполнено. Заменяет прежнее
order.refill_completed. refill.rejected- В восполнении отказано. В данных указана причина.
ping- Тестовое событие, отправляемое только через
webhook.test.
Список конечных точек
action=webhook.list возвращает все конечные точки аккаунта с их событиями, признаком активности и временем приостановки, если она была. Секрет никогда не возвращается. Также включены прежние ключи status, failureCount, lastDeliveredAt и createdAt.
https://notpanel.com/api/v3action=webhook.listkey=YOUR_API_KEY&action=webhook.list[
{
"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"url": "https://your-server.example.com/notpanel-webhook",
"events": ["order.completed", "order.partial", "order.canceled"],
"active": true,
"paused_at": null,
"created_at": "2026-10-03T09:00:00Z",
"status": "active",
"failureCount": 0,
"lastDeliveredAt": null,
"createdAt": "2026-10-03T09:00:00.000Z"
}
]Отправить тестовое событие
action=webhook.test ставит в очередь ping для конечной точки. Используйте его, чтобы проверить свой приёмник и код подписи. В ответе — идентификатор доставки.
https://notpanel.com/api/v3action=webhook.testkey=YOUR_API_KEY&action=webhook.test&id=3f2504e0-4f89-41d3-9a0c-0305e82c3301{
"queued": true,
"delivery_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}Удалить конечную точку
action=webhook.remove удаляет конечную точку и её секрет. Доставка, которая уже в пути, всё ещё может прийти; новых для неё в очередь не ставится.
https://notpanel.com/api/v3action=webhook.removekey=YOUR_API_KEY&action=webhook.remove&id=3f2504e0-4f89-41d3-9a0c-0305e82c3301{
"removed": true,
"id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
"success": true
}Содержимое доставки
Каждая доставка — одно событие в одном POST с Content-Type: application/json. Тело — это конверт, а data содержит заказ или восполнение в том же формате, что и status и refill_status. Это версия схемы 1.
Конверт
idstring- Уникальный идентификатор события. Используйте его, чтобы игнорировать дубликаты.
typestring- Тип события.
schema_versioninteger- Версия этого формата. Сейчас — 1.
created_atstring- Когда событие было создано.
sequenceinteger- Растёт с каждым событием аккаунта, чтобы вы могли упорядочить события.
dataobjectorderилиrefill, в формате соответствующего действия чтения. Пусто для ping.
Заголовки запроса
X-Webhook-Signature- HMAC-SHA256 от временной метки, точки и точного тела, записанный как
sha256=и шестнадцатеричные цифры. X-Webhook-Timestamp- Unix-время этой попытки в секундах. Повторная попытка получает новую временную метку и подпись.
X-Webhook-Event- Тип события, такой же, как
typeв теле. X-Webhook-Delivery-Id- Идентификатор этой доставки, одинаковый при каждой её повторной попытке.
{
"id": "e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e51",
"type": "order.completed",
"schema_version": 1,
"created_at": "2026-10-03T12:30:00Z",
"sequence": 1042,
"data": {
"order": {
"charge": "1.20",
"start_count": "3572",
"status": "Completed",
"remains": "0",
"currency": "USD",
"order": 1234567,
"status_key": "completed",
"refund": "0.00",
"reason_code": null,
"reason": null,
"can_cancel": false,
"cancel_available": false,
"cancel_state": null,
"can_refill": false,
"refill_available_at": "2026-10-03T18:35:00Z",
"updated_at": "2026-10-03T12:35:00Z"
}
}
}{
"id": "e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e52",
"type": "order.partial",
"schema_version": 1,
"created_at": "2026-10-03T12:30:00Z",
"sequence": 1043,
"data": {
"order": {
"charge": "0.87",
"start_count": "3572",
"status": "Partial",
"remains": "157",
"currency": "USD",
"order": 1234568,
"status_key": "partial",
"refund": "0.13",
"reason_code": "partially_delivered",
"reason": "Part of the order was delivered. The rest was refunded.",
"can_cancel": false,
"cancel_available": false,
"cancel_state": null,
"can_refill": true,
"refill_available_at": null,
"updated_at": "2026-10-03T12:00:00Z"
}
}
}{
"id": "e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e53",
"type": "order.canceled",
"schema_version": 1,
"created_at": "2026-10-03T12:30:00Z",
"sequence": 1044,
"data": {
"order": {
"charge": "0.00",
"start_count": "0",
"status": "Canceled",
"remains": "1000",
"currency": "USD",
"order": 1234569,
"status_key": "cancelled",
"refund": "1.20",
"reason_code": "customer_canceled",
"reason": "You canceled this order. The charge was refunded.",
"can_cancel": false,
"cancel_available": false,
"cancel_state": null,
"can_refill": false,
"refill_available_at": null,
"updated_at": "2026-10-03T10:00:09Z"
}
}
}{
"id": "e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e56",
"type": "refill.rejected",
"schema_version": 1,
"created_at": "2026-10-03T12:30:00Z",
"sequence": 1047,
"data": {
"refill": {
"refill": 5502,
"status": "Rejected",
"status_key": "rejected",
"order": 1234568,
"reason_code": "refill_too_early",
"reason": "This order cannot be refilled yet. Try again later.",
"created_at": "2026-10-03T12:40:00Z",
"updated_at": "2026-10-03T12:41:00Z"
}
}
}Проверка подписей
Прочитайте X-Webhook-Timestamp и точные байты тела. Вычислите HMAC-SHA256 от <timestamp>.<body bytes> с секретом конечной точки, запишите как sha256=<hex> и сравните с X-Webhook-Signature за постоянное время. У повторной попытки тот же идентификатор события, но новые временная метка и подпись.
Прочитайте необработанное тело и заголовки
Вычислите и сравните подпись
Пропускайте уже обработанные события
import { createHmac, timingSafeEqual } from "node:crypto";
/**
* @param {{rawBody: unknown, signatureHeader: unknown, timestampHeader: unknown,
* secret: unknown, nowSeconds?: number, toleranceSeconds?: number}} input
*/
function verifyNotPanelBody({
rawBody,
signatureHeader,
timestampHeader,
secret,
nowSeconds = Math.floor(Date.now() / 1000),
toleranceSeconds = 300, // Example receiver policy, not a NotPanel rule.
}) {
if (!Buffer.isBuffer(rawBody)) return false;
if (typeof secret !== "string" || secret.length === 0) return false;
if (typeof signatureHeader !== "string") return false;
if (typeof timestampHeader !== "string") return false;
if (!/^(0|[1-9]\d{0,15})$/.test(timestampHeader)) return false;
if (!Number.isSafeInteger(nowSeconds)) return false;
if (!Number.isSafeInteger(toleranceSeconds) || toleranceSeconds < 0) {
return false;
}
const timestamp = Number(timestampHeader);
if (!Number.isSafeInteger(timestamp)) return false;
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false;
const match = /^sha256=([0-9a-f]{64})$/i.exec(signatureHeader);
if (!match?.[1]) return false;
const expected = createHmac("sha256", secret)
.update(timestampHeader, "utf8")
.update(".", "utf8")
.update(rawBody)
.digest();
const received = Buffer.from(match[1], "hex");
return timingSafeEqual(expected, received);
}Разобранный пример
Используйте это для проверки вашего кода. Подпись охватывает временную метку, точку и тело ровно в том виде, как показано, без лишних пробелов и переводов строк.
secret = 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
timestamp = 1790000000
body = {"id":"e1f0c9a2-3a8d-4a0e-9d8f-6c1b2a3d4e51","type":"order.completed","schema_version":1,"created_at":"2026-10-03T12:30:00Z","sequence":1042,"data":{"order":{"charge":"1.20","start_count":"3572","status":"Completed","remains":"0","currency":"USD","order":1234567,"status_key":"completed","refund":"0.00","reason_code":null,"reason":null,"can_cancel":false,"cancel_available":false,"cancel_state":null,"can_refill":false,"refill_available_at":"2026-10-03T18:35:00Z","updated_at":"2026-10-03T12:35:00Z"}}}X-Webhook-Signature: sha256=6e8e90578f566d2e14b5c3914eba7e378c4107a5a4d4ddd52c747791f2264fffДоставка и повторные попытки
Минимум один раз
Любой ответ 2xx в течение 10 с считается доставкой. Перенаправления не выполняются, а из вашего ответа читаются только первые 4096 байт. Одно и то же событие может прийти несколько раз, поэтому игнорируйте уже обработанный идентификатор события.
Восемь попыток за сутки
Всего 8 попыток: первая сразу, затем 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h после события. Каждая попытка сохраняет тот же идентификатор события и доставки.
Конечные точки приостанавливаются сами
После 50 неудач подряд или 3 дн. без успеха конечная точка приостанавливается, а владельцу аккаунта сообщают. Исправьте приёмник и добавьте конечную точку снова.
Порядок не гарантируется
События могут приходить не по порядку. Используйте
sequence, чтобы упорядочить их, обрабатывайте каждый идентификатор события один раз и читайтеaction=status, когда нужно текущее состояние.