> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ycloud.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Реализация получателя Webhook

> Создайте эндпоинт, проверяйте подписи, надежно сохраняйте события и обрабатывайте повторные попытки и приостановку Webhook.

Принимайте события YCloud на общедоступном эндпоинте и обрабатывайте их без потери или повторного выполнения бизнес-действий. Используйте HTTPS для продакшена, сохраняйте исходное тело запроса и проверяйте каждую подпись перед приемом события.

## Регистрация эндпоинта

В [YCloud Console](https://www.ycloud.com/console) перейдите в раздел **Developers > Webhook**, выберите **Add Endpoints**, укажите **Endpoints URL**, выберите **Events** и сохраните с помощью **Confirm**. Вы также можете использовать `POST /v2/webhookEndpoints`; формат запроса и ответа см. в разделе [Настройка Webhook](/ru/api-reference/guides/api-fundamentals/configure-webhooks).

* Вы можете настроить до 20 эндпоинтов на одну учетную запись.
* URL должен быть общедоступным и не должен разрешаться в приватный IP-адрес.
* URL поддерживает до 500 символов; необязательное описание поддерживает до 400 символов.
* Надежно сохраните возвращенный секрет подписи `secret`.

## Чтение запроса события

```http theme={"theme":{"light":"github-light","dark":"github-dark"}}
Content-Type: application/json
YCloud-Signature: t=1762224357,s=SIGNATURE_HEX
X-Webhook-Endpoint-ID: WEBHOOK_ENDPOINT_ID
```

| Поле | Описание |
| - | - |
| `id` | Идентификатор события. Используется для дедупликации доставки. |
| `type` | Тип события, например `whatsapp.message.updated`. |
| `apiVersion` | Версия API, используемая событием. |
| `createTime` | Временная метка создания события. |
| Объект, зависящий от типа события | Полезная нагрузка, например `whatsappMessage`, `whatsappInboundMessage` или `contactCreated`. |

Полные примеры см. в разделе [Полезная нагрузка Webhook](/ru/api-reference/guides/examples/webhook-examples/webhook-payload-examples).

## Проверка подписи

Заголовок `YCloud-Signature` имеет формат `t=TIMESTAMP,s=SIGNATURE`.
Временная метка представляет собой Unix-время в секундах.

1. Извлеките `t` и `s` из заголовка.
2. Объедините временную метку, точку и точные байты исходного тела запроса.
3. Вычислите HMAC-SHA256, используя секретный ключ подписи эндпоинта.
4. Сравните шестнадцатеричный результат с помощью сравнения с постоянным временем выполнения (constant-time).

Не сериализуйте распарсенный JSON для повторной сборки тела. Пробелы, порядок ключей или экранирование Unicode меняют входные данные для подписи.

В приведенном ниже примере также используется настраиваемый пятиминутный допуск по времени для снижения риска атак повторного воспроизведения. Этот допуск является политикой приложения, а не крайним сроком повторных попыток YCloud. Следите за синхронизацией часов вашего сервера.

```javascript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import crypto from "node:crypto";

export function verifySignature(rawBody, header, secret, now = Date.now()) {
  if (!Buffer.isBuffer(rawBody) || typeof header !== "string" || !secret) return false;
  const fields = header.split(",").map(part => part.trim().split("="));
  const timestamps = fields.filter(([key]) => key === "t").map(([, value]) => value);
  const signatures = fields.filter(([key]) => key === "s").map(([, value]) => value);
  if (timestamps.length !== 1 || !/^\d+$/.test(timestamps[0])) return false;
  const timestamp = timestamps[0];
  if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(timestamp + ".")
    .update(rawBody)
    .digest();
  return signatures.some(signature =>
    typeof signature === "string" && /^[a-fA-F0-9]{64}$/.test(signature) &&
    crypto.timingSafeEqual(expected, Buffer.from(signature, "hex"))
  );
}
```

## Сохранение перед подтверждением

Сохраните валидированное событие в надежную очередь или транзакционный inbox перед возвратом `2xx`. Если хранилище недоступно, верните ошибку, чтобы доставку можно было повторить. После надежного сохранения передайте обработку бизнес-ошибок вашему воркеру с собственными повторными попытками.

Этот обработчик Express использует операцию `persistEvent`, предоставляемую приложением. Реализуйте ее как атомарную вставку с ключом по `id` события; уже сохраненное событие должно считаться успешным. Не помечайте событие как обработанное до фиксации его бизнес-транзакции.

```javascript theme={"theme":{"light":"github-light","dark":"github-dark"}}
import express from "express";
import { verifySignature } from "./signature.js";
import { persistEvent } from "./event-inbox.js";

const app = express();
const secret = process.env.YCLOUD_WEBHOOK_SECRET;
if (!secret) throw new Error("YCLOUD_WEBHOOK_SECRET is required");

// Register the raw-body route before any JSON-parsing middleware.
app.post("/webhook", express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => {
  if (!verifySignature(req.body, req.get("YCloud-Signature"), secret)) {
    return res.sendStatus(401);
  }
  let event;
  try {
    event = JSON.parse(req.body.toString("utf8"));
  } catch {
    return res.sendStatus(400);
  }
  if (!event || typeof event.id !== "string" || typeof event.type !== "string") {
    return res.sendStatus(400);
  }
  try {
    await persistEvent(event.id, event);
    return res.sendStatus(204);
  } catch {
    return res.sendStatus(503);
  }
});

app.listen(3000);
```

### Пример для Java и Spring

Этот пример на Java 17 применяет тот же порядок проверки и надежного сохранения. Предоставьте бин `EventInbox`, поддерживаемый транзакционным хранилищем с ограничением уникальности по ID события. Метод `insertIfAbsent` должен зафиксировать событие целиком перед возвратом; дублирующиеся ID возвращают успешный результат. Затем ваш воркер сможет обработать и пометить сохраненные события в рамках собственной транзакции.

```java theme={"theme":{"light":"github-light","dark":"github-dark"}}
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;

@RestController
public class WebhookController {
    private final String secret;
    private final ObjectMapper mapper;
    private final EventInbox inbox;

    public WebhookController(@Value("${YCLOUD_WEBHOOK_SECRET}") String secret,
                             ObjectMapper mapper, EventInbox inbox) {
        if (secret.isBlank()) throw new IllegalArgumentException("Missing webhook secret");
        this.secret = secret;
        this.mapper = mapper;
        this.inbox = inbox;
    }

    @PostMapping("/webhook")
    public ResponseEntity<Void> receive(
            @RequestHeader(value = "YCloud-Signature", required = false) String header,
            @RequestBody byte[] body) {
        if (!verify(body, header)) return ResponseEntity.status(401).build();
        JsonNode event;
        try {
            event = mapper.readTree(body);
            if (event == null || !event.path("id").isTextual() || !event.path("type").isTextual()) {
                return ResponseEntity.badRequest().build();
            }
        } catch (Exception invalidJson) {
            return ResponseEntity.badRequest().build();
        }
        try {
            inbox.insertIfAbsent(event.path("id").asText(), body);
            return ResponseEntity.noContent().build();
        } catch (Exception storageFailure) {
            return ResponseEntity.status(503).build();
        }
    }

    private boolean verify(byte[] body, String header) {
        if (header == null) return false;
        String timestamp = null;
        java.util.List<String> signatures = new java.util.ArrayList<>();
        for (String part : header.split(",")) {
            String[] pair = part.trim().split("=", 2);
            if (pair.length != 2) continue;
            if (pair[0].equals("t")) {
                if (timestamp != null) return false;
                timestamp = pair[1];
            }
            if (pair[0].equals("s")) signatures.add(pair[1]);
        }
        if (timestamp == null || !timestamp.matches("[0-9]{1,12}")) return false;
        try {
            long age = Instant.now().getEpochSecond() - Long.parseLong(timestamp);
            if (Math.abs(age) > 300) return false;
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
            byte[] expected = mac.doFinal(body);
            for (String signature : signatures) {
                if (signature.matches("[a-fA-F0-9]{64}") &&
                    MessageDigest.isEqual(expected, HexFormat.of().parseHex(signature))) return true;
            }
            return false;
        } catch (Exception invalidSignature) {
            return false;
        }
    }

    public interface EventInbox {
        void insertIfAbsent(String eventId, byte[] eventBody) throws Exception;
    }
}
```

Не используйте отдельную запись «already processed» в Redis перед постановкой события в очередь: если постановка в очередь завершится сбоем после этой записи, повторная попытка может быть отброшена. Используйте атомарный, надежный inbox или очередь, где прием и обработка дубликатов выполняются атомарно.

## Тайминги, повторные попытки и приостановка

Возвращайте ответ `2xx` незамедлительно; стремитесь к времени менее 6 секунд. Медленные ответы длительностью более 10 секунд могут снизить приоритет доставки. Не выполняйте длительные бизнес-операции внутри HTTP-обработчика.

Для ответа, отличного от `2xx`, или при отсутствии ответа интервалы повторных попыток по умолчанию составляют:

| Попытка | Задержка |
| - | - |
| 1 | 10 секунд |
| 2 | 30 секунд |
| 3 | 5 минут |
| 4 | 30 минут |
| 5 | 1 час |
| 6 | 2 часа |
| 7 | 2 часа |

YCloud прекращает повторные попытки для этого события после достижения настроенного лимита. При настройках по умолчанию URL может быть приостановлен на 3 минуты при достижении 200 сбоев в минуту или суммарного времени сбоев в 10 минут в течение одной минуты по параллельным запросам. Запросы приостанавливаются на это время и возобновляются после.

Также отслеживайте `status` эндпоинта. Эндпоинт со статусом `pending` не получает события; см. раздел [конфигурация эндпоинта](/ru/api-reference/guides/api-fundamentals/configure-webhooks).

## Проверка вашего приемника

* Действительная подпись и надежно сохраненное событие возвращают `2xx`.
* Измененные тела запросов, некорректные подписи и устаревшие временные метки отклоняются.
* Повторяющееся событие принимается без повторного выполнения его бизнес-действия.
* Сбой хранилища возвращает ошибку и позволяет выполнить повторную доставку.
* Неизвестные типы событий не приводят к сбою в работе приемника.
* Ошибки обработки повторно отрабатываются вашим воркером после успешного принятия запроса.
* Секреты и полные полезные нагрузки клиентов не записываются в логи приложения.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.