# Webhooks

A webhook sends a POST request to your server about every new post in the MAX channels you choose — usually 1–2 minutes after we read it. No need to poll the feed every few minutes.

!!! note
    Webhooks are available from the “API.Max Growth” plan: 1 webhook with up to 500 channels. “API.Max Profi” — 5 webhooks with up to 5,000 channels each. Managing webhooks does not use requests.

## A read-and-write key

Only a key marked “read and write” can create and change webhooks. The mark is set when you create the key on the “API keys” page in your account and cannot be changed later. A key without it gets `403` with the code `read_only_key`.

## Create a webhook

```bash
# create a webhook: your server address and datool.ru channel numbers
# requires a "read and write" key; the response has the signing secret (shown once)
curl -X POST https://api.datool.ru/v1/max/webhooks/ \
  -H "Authorization: Bearer dtk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/datool", "channels": [4821, 5310]}'
```

- `url` — an `https://` address of your server (private network addresses and `localhost` are not accepted);
- `channels` — datool.ru numbers of public MAX channels (the `id` field in the catalog and channel card).

The response contains `secret`. **Save it**: it is shown only once and signs every event. To get a new one — `POST /v1/max/webhooks/{id}/rotate-secret/`.

To test the address: `POST /v1/max/webhooks/{id}/test/` sends a `ping` event without posts — one attempt with no retries, at most once a minute per webhook (more often — 429 `throttled`). The result is in the `last_success_at` and `last_error` fields.

Webhooks can be created, changed and deleted with a “read and write” key of the organization’s owner or administrator; a regular member’s key gets 403 `manager_required`. The URL may use an internationalized domain (`.рф`).

## What arrives

```json
{
  "event": "post.created",
  "webhook": 12,
  "created_at": "2026-10-18T12:00:00+03:00",
  "posts": [
    {
      "id": 9876543,
      "channel": 4821,
      "text": "Мэрия открыла новый парк на набережной…",
      "views": 18450
    }
  ]
}
```

| Field | Contents |
|---|---|
| `event` | `post.created` — new posts, `ping` — a test delivery |
| `webhook` | Webhook number |
| `created_at` | When the event was assembled (ISO 8601, Moscow time) |
| `posts` | Posts — an empty list for `ping` |

`posts` holds up to 100 posts, each in the same shape as `GET /v1/max/posts/{id}/`. We only send fresh posts (published within the last two days); edits and deletions are not sent yet.

Request headers:

| Header | Contents |
|---|---|
| `X-Datool-Event` | `post.created` or `ping` |
| `X-Datool-Delivery` | delivery number; the same on retries |
| `X-Datool-Signature` | `t=<time>,v1=<signature>` |

## Verify the signature

The signature is HMAC-SHA256 of the string `<t>.<request body>` with your secret. Check it before parsing JSON and reject events older than 5 minutes — this way nobody can forge an event or replay an old one.

```python
import hashlib
import hmac
import time


def is_valid(secret: str, body: bytes, header: str) -> bool:
    """Check an event signature: secret — the webhook secret, body — the raw request body."""
    # the X-Datool-Signature header: "t=<time>,v1=<signature>"
    try:
        parts = dict(item.split("=", 1) for item in header.split(","))
        timestamp = int(parts["t"])
        signature = parts["v1"]
    except (ValueError, KeyError):
        # no header or a broken one — the event is not from datool.ru
        return False
    # reject events older than 5 minutes so an old event can't be replayed
    if abs(time.time() - timestamp) > 300:
        return False
    # the signature is HMAC-SHA256 of "<t>.<body>" with your secret
    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
    ).hexdigest()
    # constant-time comparison — the signature can't be guessed from response timing
    return hmac.compare_digest(expected, signature)
```

## Receiver example

```python
# an event receiver on Flask: pip install flask
from flask import Flask, abort, request

app = Flask(__name__)
# the secret from the webhook creation response (keep it in settings, not in code)
SECRET = "whsec_…"


@app.post("/hooks/datool")
def datool_hook():
    # the raw body — the signature is computed over it, before parsing JSON
    body = request.get_data()
    if not is_valid(SECRET, body, request.headers.get("X-Datool-Signature", "")):
        # the signature doesn't match — the event is not from datool.ru
        abort(401)
    event = request.get_json()
    if event["event"] == "post.created":
        for post in event["posts"]:
            # store the post; the same event may arrive twice — check post["id"]
            print(post["id"], post["text"][:80])
    # respond 2xx quickly, do heavy processing after responding
    return "", 204
```

## Response and retries

Respond with a `2xx` code within 10 seconds — do heavy processing after responding. We do not follow redirects.

If there is no response or the code is different, we retry after 1, 5, 15 minutes, 1, 3, 6 and 12 hours — the last attempt comes about 22 hours later. An event that fails for a whole day is not delivered. We don’t read your response body — only the code matters. The same event may arrive twice — tell posts apart by `id`.

If not a single delivery succeeds for a day, the webhook is disabled and the organization’s owners and administrators get an email. Fix the server and enable the webhook: `PATCH /v1/max/webhooks/{id}/` with `{"is_active": true}` or the “Enable” button on the “API keys” page. Posts will be sent from the moment it is enabled. A webhook disabled by datool.ru staff (`disabled_reason`: `staff`) cannot be enabled this way — 409 `conflict`; contact support.

## If your plan has ended

Webhooks are not deleted, but events stop arriving. After renewal they arrive again, from that moment on.

All methods are in the [reference](/en/api/methods/) (the “MAX: webhooks” group).
