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).
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.
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.
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":
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).
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.
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).
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.
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.