ИнтеграцияСобытия и вебхуки
События и вебхуки
Всё, что меняется у заказов, активаций и аванса, приходит событиями — лентой или вебхуком, с одним содержимым.
Обновлено 26 сентября 2026 г. · 6 мин чтения
Два канала
Лента
GET /v1/events?after=<seq>&limit= Забираете сами — события по возрастанию seq.
- Хранятся 14 дней
- Курсор старше — 410
cursor_expired: начните безafter, с начала хранения, и сверьте состояние черезGET - Догоняет пропуски вебхука
Вебхук
PUT /v1/webhookПрисылаем сами на ваш адрес — только https, порт 443 или 8443.
- Подпись — в заголовке
X-Astrum-Signature - Ответ 2xx быстрее 5 с
- Без
events— все события, что видит ключ
Типы order.* и activation.* видны ключу с orders:read, wallet.*, deposit.* и deposit_address.* — с wallet:read.
Из чего состоит событие
{
"id": "evt_1042",
"seq": 1042,
"type": "activation.needs_input",
"created_at": "2026-09-24T10:14:05Z",
"livemode": true,
"data": {
"activation_id": "a93d6b1e-…",
"order_id": "b1e04c6e-…",
"reseller_ref": "ord-10482",
"activation": { … }
}
}id— ключ дедупа. Он же в заголовкеX-Astrum-Event-Id, но заголовок подписью не покрыт: дедуп — поidиз проверенного телаseq— порядок в ленте и курсорaftertype— что случилось, список нижеlivemode—falseу событий песочницы и тестовыхdata— ссылки на объект и его снимок: у активаций —data.activation, та же форма, чтоGET /v1/activations/{id}. Товара и данных клиента в событиях нет
Типы событий
ждём ваш шагв работеготовоне вышлоразбираем мыдля сведения
Заказ6видно ключу с
orders:readorder.paidЗаказ оплачен — аванс списанorder.deliveredТовар выдан — забирайте из GET /v1/orders/{id}order.delivery_incomplete Выдан не весь товар — разбираемся самиorder.canceledЗаказ отменёнorder.goods.reissuedТовар заменён — перечитайте goodsorder.refund.completedВозврат на аванс проведёнАктивация11видно ключу с
orders:readactivation.needs_inputНужны данные клиента — в событии, что проситьactivation.submittedДанные поданы поставщикуactivation.in_progressПоставщик включает подпискуactivation.verification_required Сервис просит клиента пройти проверку по ссылкеactivation.activatedПодписка включенаactivation.failedНе вышло — причина в reason_codeactivation.manualРазбирает человек с нашей стороныactivation.canceledАктивация отмененаactivation.login_link_requested Оплата нашей картой: перешлите ссылку входаactivation.noteМы оставили сообщение для клиентаactivation.reissuedКлюч заменён — нужны данные клиента зановоАванс4видно ключу с
wallet:readdeposit.confirmedПополнение зачисленоdeposit_address.changed Открылся новый адрес пополненияwallet.adjustedРучная корректировка авансаwallet.low_balanceАванс ниже вашего порогаНастройка вебхука
- 1. Адрес
PUT /v1/webhookс{"url": "https://…", "events": [...]}, безevents— все, что видит ключ. Первый секрет подписи показывается ОДИН раз — в ответе первогоPUT. - 2. Проверка
POST /v1/webhook/testшлёт тестовое событие сlivemode: false. - 3. Ротация
POST /v1/: новый секрет показывается один раз, прежний действует ещё 24 ч.webhook/ rotate-secret
Доставка и повторы
Не меньше одного раза
Событие может прийти дважды — дедуп по id из тела, проверенного подписью.
Порядок не гарантирован
Состояние сверяйте через GET, пропуски догоняйте лентой.
2xx быстрее 5 с
Тяжёлую работу — после ответа, иначе повтор.
Только публичный https
Порт 443 или 8443; внутренние сети и хосты наших поставщиков — 422 webhook_url_invalid.
Повторы после неудачи
сбой1 мин5 мин30 мин2 ч12 ч24 чdead
После последнего повтора событие получает статус dead: нам — алерт, вам — письмо на почту аккаунта. В ленте оно остаётся.
activation.login_link_requested — каждые 15 с, 12 раз: ссылка входа живёт минутыПодпись
X-Astrum-Signature: t=<unix>,v1=<hex>
t — время отправки, unix
v1 — HMAC-SHA256 секретом от строки
f"{t}." + сырое телоПроверяйте по СЫРЫМ байтам тела, не по пересобранному JSON
Сравнивайте в постоянном времени
Держите окно времени
24 ч после ротации подписей две (
v1=…,v1=…) — годится любаяimport hashlib
import hmac
import re
import time
_T = re.compile(r"[0-9]{1,12}")
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
pairs = [part.split("=", 1) for part in header.split(",") if "=" in part]
t = next((v for k, v in pairs if k.strip() == "t"), "")
if not _T.fullmatch(t) or abs(time.time() - int(t)) > tolerance:
return False
signed = t.encode("ascii") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest().encode("ascii")
return any(
hmac.compare_digest(expected, v.strip().encode("utf-8"))
for k, v in pairs
if k.strip() == "v1"
)
import crypto from 'node:crypto'
export function verify(rawBody: Buffer, header: string | undefined, secret: string, toleranceSec = 300): boolean {
const pairs = String(header || '')
.split(',')
.map((part) => part.split('='))
.filter((pair) => pair.length === 2)
const t = (pairs.find(([k]) => k.trim() === 't') || [])[1] || ''
if (!/^[0-9]{1,12}$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'))
return pairs.some(([k, v]) => {
if (k.trim() !== 'v1') return false
const got = Buffer.from(v.trim())
return got.length === expected.length && crypto.timingSafeEqual(got, expected)
})
}
function astrum_verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) !== 2) {
continue;
}
if ($kv[0] === 't') {
$t = $kv[1];
} elseif ($kv[0] === 'v1') {
$signatures[] = $kv[1];
}
}
if ($t === null || !preg_match('/^[0-9]{1,12}$/', $t) || abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
Пример события
Пример: Событие activation.needs_input
{
"id": "evt_1042",
"seq": 1042,
"type": "activation.needs_input",
"created_at": "2026-09-24T10:14:05Z",
"livemode": true,
"data": {
"activation_id": "a93d6b1e-7c2f-4e58-8f0a-2d5b9c3e1f47",
"order_id": "b1e04c6e-5a0b-4f6e-9a39-0b9f2f1c8d21",
"reseller_ref": "ord-10482",
"metadata": {
"customer": "c-7781"
},
"activation": {
"id": "a93d6b1e-7c2f-4e58-8f0a-2d5b9c3e1f47",
"order_id": "b1e04c6e-5a0b-4f6e-9a39-0b9f2f1c8d21",
"item_ref": "line-1",
"product_id": "3f6c0e7e-2b1d-4c1a-9a57-4d0f1b8e2c11",
"state": "awaiting_customer",
"credential_kind": "chatgpt_session",
"hint": "Данные сессии ChatGPT: откройте chatgpt.com/api/auth/session и скопируйте ВЕСЬ ответ — от «{» до «}». Одного account_id недостаточно",
"full_session": false,
"needs_input": true,
"needs_new_credential": true,
"waits_for_buyer": false,
"can_retry": false,
"can_force": false,
"reason_code": null,
"reason_text": null,
"reason_until": null,
"buyer_note": null,
"buyer_note_at": null,
"manual": false,
"verification_url": null,
"queue_position": null,
"new_account": false,
"awaiting_login_link": false,
"login_link_requested_at": null,
"login_link_received": false,
"updated_at": "2026-09-24T10:14:05Z"
}
}
}
Страница помогла?
