События и вебхуки

Всё, что меняется у заказов, активаций и аванса, приходит событиями — лентой или вебхуком, с одним содержимым.

Обновлено 26 сентября 2026 г. · 6 мин чтения
Markdown

Два канала

Лента
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": { … }
  }
}
  1. id — ключ дедупа. Он же в заголовке X-Astrum-Event-Id, но заголовок подписью не покрыт: дедуп — по id из проверенного тела
  2. seq — порядок в ленте и курсор after
  3. type — что случилось, список ниже
  4. livemode — false у событий песочницы и тестовых
  5. data — ссылки на объект и его снимок: у активаций — data.activation, та же форма, что GET /v1/activations/{id}. Товара и данных клиента в событиях нет

Типы событий

ждём ваш шагв работеготовоне вышлоразбираем мыдля сведения
Заказ6видно ключу с orders:read
order.paidЗаказ оплачен — аванс списан
order.deliveredТовар выдан — забирайте из GET /v1/orders/{id}
order.delivery_incompleteВыдан не весь товар — разбираемся сами
order.canceledЗаказ отменён
order.goods.reissuedТовар заменён — перечитайте goods
order.refund.completedВозврат на аванс проведён
Активация11видно ключу с orders:read
activation.needs_inputНужны данные клиента — в событии, что просить
activation.submittedДанные поданы поставщику
activation.in_progressПоставщик включает подписку
activation.verification_requiredСервис просит клиента пройти проверку по ссылке
activation.activatedПодписка включена
activation.failedНе вышло — причина в reason_code
activation.manualРазбирает человек с нашей стороны
activation.canceledАктивация отменена
activation.login_link_requestedОплата нашей картой: перешлите ссылку входа
activation.noteМы оставили сообщение для клиента
activation.reissuedКлюч заменён — нужны данные клиента заново
Аванс4видно ключу с wallet:read
deposit.confirmedПополнение зачислено
deposit_address.changedОткрылся новый адрес пополнения
wallet.adjustedРучная корректировка аванса
wallet.low_balanceАванс ниже вашего порога

Настройка вебхука

  1. 1. Адрес
    PUT /v1/webhook с {"url": "https://…", "events": [...]}, без events — все, что видит ключ. Первый секрет подписи показывается ОДИН раз — в ответе первого PUT.
  2. 2. Проверка
    POST /v1/webhook/test шлёт тестовое событие с livemode: false.
  3. 3. Ротация
    POST /v1/webhook/rotate-secret: новый секрет показывается один раз, прежний действует ещё 24 ч.

Доставка и повторы

Не меньше одного раза

Событие может прийти дважды — дедуп по 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"
    }
  }
}
Страница помогла?