Zapnoty — Webhooks — incoming events

API Documentation

REST API for notifications via Telegram and Max. Subscribers, OTP, broadcasts, forms, helpdesk.

Webhooks

Zapnoty sends HTTP POST to your URL when events occur. One webhook per project. Configured in the dashboard or via API.

How to set up a webhook

Webhooks are configured as independent endpoints — up to 5 per project. Each has its own URL, its own set of events and its own signing secret. You can create an endpoint via the API or on the "Webhook" tab in the project dashboard — both manage the same endpoints.

Creating an endpoint — POST /v1/webhooks. Fields: url (required), events (list of events; an empty array subscribes to all events), description (optional note).

# Создать webhook-эндпоинт (до 5 на проект)
POST https://api.zapnoty.com/v1/webhooks
Authorization: Bearer zn_live_...
Content-Type: application/json
{
"url": "https://example.com/zapnoty/hook",
"events": ["delivery.failed", "subscription.created"],
"description": "Прод-обработчик"
}
# Ответ — поле secret показывается ОДИН раз:
{
"id": "6f1c2e8a-...",
"url": "https://example.com/zapnoty/hook",
"events": ["delivery.failed", "subscription.created"],
"description": "Прод-обработчик",
"status": "active",
"secret": "whsec_..."
}

Managing existing endpoints — GET for the list, PUT to change (url, events, description, status active/disabled), DELETE to remove. The secret can be rotated without downtime: POST /v1/webhooks/{id}/rotate-secret issues a new secret while the old one stays valid for another 24 hours.

# Список эндпоинтов проекта
GET https://api.zapnoty.com/v1/webhooks
# Обновить эндпоинт (любое поле опционально)
PUT https://api.zapnoty.com/v1/webhooks/{id}
{ "url": "...", "events": [...], "description": "...", "status": "disabled" }
# Удалить эндпоинт
DELETE https://api.zapnoty.com/v1/webhooks/{id}
# Ротация секрета — старый валиден ещё 24 ч (grace-период)
POST https://api.zapnoty.com/v1/webhooks/{id}/rotate-secret

The secret field is returned only once — on endpoint creation and on rotation. Save it immediately: the secret cannot be retrieved again, only rotated.

Delivery format

A webhook arrives as a POST request. The body is always an { event, data } object: event — the event name, data — the event payload. Metadata (timestamp, delivery_id, event name, signature) is passed via HTTP headers, not in the body.

POST {ваш webhook URL}
Content-Type: application/json
# HTTP-заголовки запроса:
X-Zapnoty-Event: subscription.created
X-Zapnoty-Timestamp: 1747676400
X-Zapnoty-Delivery-Id: 6f1c2e8a-... (UUID — для дедупликации)
X-Zapnoty-Signature: t=1747676400,v1=9a3f...
# Тело — всегда { event, data }:
{
"event": "subscription.created",
"data": { ... }
}

X-Zapnoty-Delivery-Id is identical across all retry attempts of one delivery — use it for deduplication.

Signature verification

The X-Zapnoty-Signature header has the format t=<unix_timestamp>,v1=<hex>. The signed string is "timestamp + dot + raw request body":

# Псевдокод проверки подписи (любой язык)
ts, v1_list = parse(header "X-Zapnoty-Signature") # t=<ts>, один+ v1=<hex>
signed_payload = ts + "." + raw_request_body # raw_body — байты как пришли
expected = hex( HMAC_SHA256(webhook_secret, signed_payload) )
valid = any( constant_time_eq(v1, expected) for v1 in v1_list )
  • signed_payload = "<timestamp>" + "." + raw_body — hex signature, lowercase
  • Sign the RAW request body, not the result of JSON.parse → JSON.stringify (key order / whitespace would change).
  • For verification, take the timestamp from t= INSIDE X-Zapnoty-Signature — the signature was computed with exactly that value. The separate X-Zapnoty-Timestamp header just duplicates it for convenience; use t= for verification.
  • During secret rotation (grace period) multiple v1= values arrive — a match with any is valid.

⚠️ The timestamp in the signature is for integrity only, NOT a replay window. A webhook may arrive as a retry hours later (retries last up to 24h), so do NOT reject "stale" webhooks by timestamp — you would lose deliveries. To guard against double-processing, deduplicate by X-Zapnoty-Delivery-Id (identical across all attempts of one delivery).

// Node.js. ВАЖНО: подписывается timestamp + "." + СЫРОЕ тело,
// а не голое тело и не JSON.stringify(распарсенного).
const crypto = require('crypto');
function verifyZapnoty(rawBody, sigHeader, secret) {
// sigHeader: "t=1747676400,v1=<hex>[,v1=<hex>]"
const parts = sigHeader.split(',');
const ts = parts.find(p => p.startsWith('t='))?.slice(2);
const sigs = parts.filter(p => p.startsWith('v1=')).map(p => p.slice(3));
if (!ts || sigs.length === 0) return false;
const expected = crypto.createHmac('sha256', secret)
.update(ts + '.' + rawBody)
.digest('hex');
// При ротации секрета приходит несколько v1= — подходит любое совпадение.
return sigs.some(s => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}

Events

When configuring a webhook endpoint you can subscribe to specific events or to all of them (empty list). The full set:

  • subscription.created / .deleted / .permission_changed
  • delivery.success / delivery.failed
  • broadcast.completed / .cancelled · batch.completed
  • otp.sent / .verified / .failed_attempt / .max_attempts_reached
  • auth.completed · button.clicked
  • ticket.created / .replied / .status_changed / .assigned / .closed / .csat_received / .unanswered_24h
  • scheduled.sent / .failed / .skipped · drip.step_sent / .completed · recurring.sent
  • event.tracked · form.submitted

Subscription events

subscription.created, subscription.deleted and auth.completed carry the same subscriber fields. subscriber_id is a stable UUID (no prefix), the only canonical identifier.

  • subscription.created — the user subscribed by any path (deep link, Mini App, authorization, etc.).
  • auth.completed — an auth session (/v1/auth/session) specifically completed; only this event carries your state and auth_time. To map subscriber_id to your own user_id, listen to auth.completed.
{
"event": "subscription.created",
"data": {
"subscriber_id": "550e8400-e29b-41d4-a716-446655440000",
"channel": "telegram",
"external_id": "user-42",
"first_name": "Иван",
"username": "ivan",
"lang": "ru",
"permissions": ["news"],
"tags": [],
"active": true,
"blocked": false,
"created_at": "2026-05-21T12:00:00+00:00"
}
}

Delivery failure — delivery.failed

The reason field is a machine-readable cause. retryable — whether to retry sending. should_unsubscribe — whether to remove the recipient from your audience (use this flag instead of parsing the error text).

{
"event": "delivery.failed",
"data": {
"subscriber_id": "550e8400-e29b-41d4-a716-446655440000",
"channel": "telegram",
"external_id": "user-42",
"reason": "user_blocked_bot",
"retryable": false,
"should_unsubscribe": true,
"error": "Forbidden: bot was blocked by the user",
"attempted_at": "2026-05-21T12:00:00+00:00"
}
}

user_blocked_bot — User blocked the bot. should_unsubscribe: true

chat_not_found — Chat not found / account deleted. should_unsubscribe: true

bot_banned — Bot banned by the platform. retryable: false

invalid_message — Invalid message payload (media, markup). retryable: false

rate_limited — Platform rate limit (429). retryable: true

platform_unavailable — Platform 5xx / network error. retryable: true

unknown — Unclassified error — needs manual review.

Broadcast finished — broadcast.completed

Sent when a mass broadcast (/v1/broadcast) is fully processed. job_id matches the one returned by POST /v1/broadcast. sent — delivered successfully, failed — delivery errors, skipped — skipped (inactive/unsubscribed), total — the sum of all three. The broadcast.cancelled event carries the same fields for a cancelled broadcast.

{
"event": "broadcast.completed",
"data": {
"job_id": "a1b2c3d4-5e6f-7890-abcd-ef1234567890",
"sent": 980,
"failed": 12,
"skipped": 8,
"total": 1000
}
}

Retry policy

On delivery failure, webhooks are automatically retried up to 3 times with exponential backoff (1s → 2s → 4s). Each attempt has a 10-second timeout. 4xx responses from your server are not retried. We recommend implementing idempotent processing on your side.

Related sections