← Создание агентов на Cloudflare / agents / communication-channels
Webhooks
Принимайте вебхук-события от внешних сервисов и направляйте их к выделенным экземплярам агентов. У каждого источника вебхуков (репозитория, клиента, устройства) может быть собственный агент с изолированным состоянием, постоянным хранилищем и подключениями клиентов в реальном времени.
быстрый старт
import { Agent, getAgentByName, routeAgentRequest } from "agents";
export class WebhookAgent extends Agent {
async onRequest(request) {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const rawBody = await request.text();
const signature = request.headers.get("X-Hub-Signature-256");
if (
!(await verifyGitHubWebhook(rawBody, signature, this.env.WEBHOOK_SECRET))
) {
return new Response("Invalid signature", { status: 401 });
}
let payload;
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Invalid payload", { status: 400 });
}
await this.processEvent(payload);
return new Response("OK");
}
async processEvent(payload) {
// Store event, update state, trigger actions...
}
}
async function verifyGitHubWebhook(rawBody, signature, secret) {
if (!signature || !/^sha256=[0-9a-f]{64}$/i.test(signature)) return false;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
const signatureBytes = Uint8Array.from(
signature.slice("sha256=".length).match(/.{2}/g) ?? [],
(byte) => Number.parseInt(byte, 16),
);
return crypto.subtle.verify(
"HMAC",
key,
signatureBytes,
encoder.encode(rawBody),
);
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/webhooks/github" && request.method === "POST") {
const rawBody = await request.clone().text();
const signature = request.headers.get("X-Hub-Signature-256");
if (
!(await verifyGitHubWebhook(rawBody, signature, env.WEBHOOK_SECRET))
) {
return new Response("Invalid signature", { status: 401 });
}
let payload;
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Invalid payload", { status: 400 });
}
const repository = payload.repository?.full_name;
if (!repository) {
return new Response("Missing repository", { status: 400 });
}
const agentName = repository.toLowerCase().replace(/\//g, "-");
const agent = await getAgentByName(env.WebhookAgent, agentName);
return agent.fetch(request);
}
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
},
};import { Agent, getAgentByName, routeAgentRequest } from "agents";
export class WebhookAgent extends Agent<Env> {
async onRequest(request: Request): Promise<Response> {
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const rawBody = await request.text();
const signature = request.headers.get("X-Hub-Signature-256");
if (
!(await verifyGitHubWebhook(
rawBody,
signature,
this.env.WEBHOOK_SECRET,
))
) {
return new Response("Invalid signature", { status: 401 });
}
let payload: unknown;
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Invalid payload", { status: 400 });
}
await this.processEvent(payload);
return new Response("OK");
}
private async processEvent(payload: unknown) {
// Store event, update state, trigger actions...
}
}
async function verifyGitHubWebhook(
rawBody: string,
signature: string | null,
secret: string,
): Promise<boolean> {
if (!signature || !/^sha256=[0-9a-f]{64}$/i.test(signature)) return false;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
const signatureBytes = Uint8Array.from(
signature.slice("sha256=".length).match(/.{2}/g) ?? [],
(byte) => Number.parseInt(byte, 16),
);
return crypto.subtle.verify(
"HMAC",
key,
signatureBytes,
encoder.encode(rawBody),
);
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/webhooks/github" && request.method === "POST") {
const rawBody = await request.clone().text();
const signature = request.headers.get("X-Hub-Signature-256");
if (
!(await verifyGitHubWebhook(
rawBody,
signature,
env.WEBHOOK_SECRET,
))
) {
return new Response("Invalid signature", { status: 401 });
}
let payload: { repository?: { full_name?: string } };
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Invalid payload", { status: 400 });
}
const repository = payload.repository?.full_name;
if (!repository) {
return new Response("Missing repository", { status: 400 });
}
const agentName = repository.toLowerCase().replace(/\//g, "-");
const agent = await getAgentByName(env.WebhookAgent, agentName);
return agent.fetch(request);
}
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;Сценарии использования
Сочетание вебхуков с агентами позволяет реализовать паттерны, в которых каждая внешняя сущность получает собственный изолированный экземпляр агента с сохранением состояния.
Инструменты для разработчиков
| Сценарий использования | Описание |
|---|---|
| GitHub Repo Monitor | Один агент на репозиторий отслеживает коммиты, PR, issues и звёзды |
| CI/CD Pipeline Agent | Реагируйте на события сборки и развёртывания, уведомляйте о сбоях, отслеживайте историю развёртываний |
| Трекер Linear/Jira | Автоматически сортировать обращения, распределять их по содержимому, отслеживать время решения |
Электронная коммерция и платежи
| Сценарий использования | Описание |
|---|---|
| Stripe Customer Agent | Один агент на клиента отслеживает платежи, подписки и споры |
| Shopify Order Agent | Жизненный цикл заказа от создания до выполнения с синхронизацией остатков склада |
| Сверка платежей | Сопоставлять события вебхуков с внутренними записями, отмечать расхождения |
Коммуникация и уведомления
| Сценарий использования | Описание |
|---|---|
| Twilio SMS/Voice | Разговорные агенты, запускаемые входящими сообщениями или звонками |
| Slack Bot | Отвечать на слэш-команды, нажатия кнопок и интерактивные сообщения |
| Отслеживание электронной почты | События доставки SendGrid/Mailgun, обработка отказов доставки, аналитика вовлечённости |
IoT и инфраструктура
| Сценарий использования | Описание |
|---|---|
| Телеметрия устройства | Один агент на устройство обрабатывает потоки данных с датчиков |
| Агрегация оповещений | Собирайте оповещения из PagerDuty, Datadog или другой системы мониторинга |
| Автоматизация дома | Реагируйте на триггеры IFTTT/Zapier с сохранением состояния |
Интеграции SaaS
| Сценарий использования | Описание |
|---|---|
| CRM Sync | Обновления контактов и сделок в Salesforce/HubSpot |
| Calendar Agent | Уведомления о событиях Google Calendar и планирование |
| Отправки форм | Вебхуки форм Typeform, Tally или собственных форм с последующими действиями |
Маршрутизация вебхуков агентам
Ключевой паттерн: сначала проверяется подлинность исходного запроса ещё до его разбора, и только затем на основе аутентифицированных данных payload определяется идентичность Agent. Подпись тела запроса не подтверждает подлинность произвольного сегмента URL или произвольного заголовка.
Извлечение сущности из payload
Большинство вебхуков включают идентификатор в полезной нагрузке:
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/webhooks/github") {
const rawBody = await request.clone().text();
const signature = request.headers.get("X-Hub-Signature-256");
if (
!(await verifyGitHubWebhook(rawBody, signature, env.WEBHOOK_SECRET))
) {
return new Response("Invalid signature", { status: 401 });
}
let payload;
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Invalid payload", { status: 400 });
}
const repository = payload.repository?.full_name;
if (!repository) {
return new Response("Missing repository", { status: 400 });
}
const agentName = repository.toLowerCase().replace(/\//g, "-");
const agent = await getAgentByName(env.RepoAgent, agentName);
return agent.fetch(request);
}
},
};export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/webhooks/github") {
const rawBody = await request.clone().text();
const signature = request.headers.get("X-Hub-Signature-256");
if (
!(await verifyGitHubWebhook(
rawBody,
signature,
env.WEBHOOK_SECRET,
))
) {
return new Response("Invalid signature", { status: 401 });
}
let payload: { repository?: { full_name?: string } };
try {
payload = JSON.parse(rawBody);
} catch {
return new Response("Invalid payload", { status: 400 });
}
const repository = payload.repository?.full_name;
if (!repository) {
return new Response("Missing repository", { status: 400 });
}
const agentName = repository.toLowerCase().replace(/\//g, "-");
const agent = await getAgentByName(env.RepoAgent, agentName);
return agent.fetch(request);
}
},
} satisfies ExportedHandler<Env>;Валидируйте идентификаторы сущностей в URL
Подпись тела запроса от провайдера не аутентифицирует URL webhook. Если URL содержит идентификатор сущности, сравните его с соответствующей идентичностью из проверенной полезной нагрузки провайдера и отклоняйте несовпадение перед вызовом getAgentByName().
Извлеките идентификационные данные Slack из проверенного тела запроса
Slack не отправляет аутентифицированный X-Slack-Team-Id заголовок маршрутизации. Проверьте помеченную временем подпись Slack и окно replay относительно необработанного тела запроса, затем прочитайте team_id из проверенного события или тела формы.
Проверка подписи
Всегда проверяйте подписи вебхуков, прежде чем доверять полезной нагрузке или обрабатывать её.
Паттерн GitHub HMAC-SHA256
Быстрого старта verifyGitHubWebhook() хелпер проверяет добавленную GitHub sha256=<hex> подпись поверх необработанного тела запроса с crypto.subtle.verify(). Этот формат специфичен для GitHub. Другие провайдеры используют другое кодирование подписи, другие подписываемые данные, проверки временных меток и защиту от повторного воспроизведения. Обратитесь к документации провайдера по ссылке в разделе Типичные поставщики вебхуков.
Заголовки для конкретных провайдеров
| Провайдер | Заголовок подписи | Алгоритм |
|---|---|---|
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256 |
| Stripe | Stripe-Signature |
HMAC-SHA256 (с временной меткой) |
| Twilio | X-Twilio-Signature |
HMAC-SHA1 |
| Slack | X-Slack-Signature |
HMAC-SHA256 (с временной меткой) |
| Shopify | X-Shopify-Hmac-Sha256 |
HMAC-SHA256 (base64) |
Обработка вебхуков
Handler onRequest
Используйте onRequest() чтобы обрабатывать входящие вебхуки в вашем агенте. Если Worker ещё не проверил запрос, проверьте его перед разбором тела. В этом примере повторно используется код из раздела «Быстрый старт» verifyGitHubWebhook() вспомогательная функция:
export class WebhookAgent extends Agent {
async onRequest(request) {
// 1. Validate method
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
// 2. Get the GitHub event type
const eventType = request.headers.get("X-GitHub-Event") ?? "unknown";
// 3. Verify the GitHub signature
const signature = request.headers.get("X-Hub-Signature-256");
const body = await request.text();
if (
!(await verifyGitHubWebhook(body, signature, this.env.WEBHOOK_SECRET))
) {
return new Response("Invalid signature", { status: 401 });
}
// 4. Parse and process
const payload = JSON.parse(body);
await this.handleEvent(eventType, payload);
// 5. Respond quickly
return new Response("OK", { status: 200 });
}
async handleEvent(type, payload) {
// Update state (broadcasts to connected clients)
this.setState({
...this.state,
lastEventType: type,
lastEventTime: new Date().toISOString(),
});
// Store in SQL for history
this
.sql`INSERT INTO events (type, payload, timestamp) VALUES (${type}, ${JSON.stringify(payload)}, ${Date.now()})`;
}
}export class WebhookAgent extends Agent {
async onRequest(request: Request): Promise<Response> {
// 1. Validate method
if (request.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
// 2. Get the GitHub event type
const eventType = request.headers.get("X-GitHub-Event") ?? "unknown";
// 3. Verify the GitHub signature
const signature = request.headers.get("X-Hub-Signature-256");
const body = await request.text();
if (
!(await verifyGitHubWebhook(body, signature, this.env.WEBHOOK_SECRET))
) {
return new Response("Invalid signature", { status: 401 });
}
// 4. Parse and process
const payload = JSON.parse(body);
await this.handleEvent(eventType, payload);
// 5. Respond quickly
return new Response("OK", { status: 200 });
}
private async handleEvent(type: string, payload: unknown) {
// Update state (broadcasts to connected clients)
this.setState({
...this.state,
lastEventType: type,
lastEventTime: new Date().toISOString(),
});
// Store in SQL for history
this
.sql`INSERT INTO events (type, payload, timestamp) VALUES (${type}, ${JSON.stringify(payload)}, ${Date.now()})`;
}
}Сохранение событий вебхуков
Используйте SQLite, чтобы сохранять события webhook для истории и повторного воспроизведения.
Схема таблицы событий
class WebhookAgent extends Agent {
async onStart() {
this.sql`
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
action TEXT,
title TEXT NOT NULL,
description TEXT,
url TEXT,
actor TEXT,
payload TEXT,
timestamp TEXT NOT NULL
)
`;
this.sql`
CREATE INDEX IF NOT EXISTS idx_events_timestamp
ON events(timestamp DESC)
`;
}
}class WebhookAgent extends Agent {
async onStart(): Promise<void> {
this.sql`
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
action TEXT,
title TEXT NOT NULL,
description TEXT,
url TEXT,
actor TEXT,
payload TEXT,
timestamp TEXT NOT NULL
)
`;
this.sql`
CREATE INDEX IF NOT EXISTS idx_events_timestamp
ON events(timestamp DESC)
`;
}
}Очистите старые события
Предотвращение неограниченного роста путём сохранения только последних событий:
// Keep last 100 events
this.sql`
DELETE FROM events WHERE id NOT IN (
SELECT id FROM events ORDER BY timestamp DESC LIMIT 100
)
`;
// Or delete events older than 30 days
this.sql`
DELETE FROM events
WHERE timestamp < datetime('now', '-30 days')
`;// Keep last 100 events
this.sql`
DELETE FROM events WHERE id NOT IN (
SELECT id FROM events ORDER BY timestamp DESC LIMIT 100
)
`;
// Or delete events older than 30 days
this.sql`
DELETE FROM events
WHERE timestamp < datetime('now', '-30 days')
`;Запросы событий
import { Agent, callable } from "agents";
class WebhookAgent extends Agent {
@callable()
getEvents(limit = 20) {
return [
...this.sql`
SELECT * FROM events
ORDER BY timestamp DESC
LIMIT ${limit}
`,
];
}
@callable()
getEventsByType(type, limit = 20) {
return [
...this.sql`
SELECT * FROM events
WHERE type = ${type}
ORDER BY timestamp DESC
LIMIT ${limit}
`,
];
}
}import { Agent, callable } from "agents";
class WebhookAgent extends Agent {
@callable()
getEvents(limit = 20) {
return [
...this.sql`
SELECT * FROM events
ORDER BY timestamp DESC
LIMIT ${limit}
`,
];
}
@callable()
getEventsByType(type: string, limit = 20) {
return [
...this.sql`
SELECT * FROM events
WHERE type = ${type}
ORDER BY timestamp DESC
LIMIT ${limit}
`,
];
}
}Вещание в реальном времени
Когда приходит вебхук, обновите состояние агента, чтобы оно автоматически разошлось всем подключённым клиентам WebSocket.
class WebhookAgent extends Agent {
async processWebhook(eventType, payload) {
// Update state - this automatically broadcasts to all connected clients
this.setState({
...this.state,
stats: payload.stats,
lastEvent: {
type: eventType,
timestamp: new Date().toISOString(),
},
});
}
}class WebhookAgent extends Agent {
private async processWebhook(eventType: string, payload: WebhookPayload) {
// Update state - this automatically broadcasts to all connected clients
this.setState({
...this.state,
stats: payload.stats,
lastEvent: {
type: eventType,
timestamp: new Date().toISOString(),
},
});
}
}На стороне клиента:
import { useAgent } from "agents/react";
function Dashboard() {
const [state, setState] = useState(null);
const agent = useAgent({
agent: "webhook-agent",
name: "my-entity-id",
onStateUpdate: (newState) => {
setState(newState); // Automatically updates when webhooks arrive
},
});
return <div>Last event: {state?.lastEvent?.type}</div>;
}Паттерны
Дедупликация событий
Предотвращение обработки повторяющихся событий с помощью идентификаторов событий:
class WebhookAgent extends Agent {
async handleEvent(eventId, payload) {
// Check if already processed
const existing = [
...this.sql`
SELECT id FROM events WHERE id = ${eventId}
`,
];
if (existing.length > 0) {
console.log(`Event ${eventId} already processed, skipping`);
return;
}
// Process and store
await this.processPayload(payload);
this.sql`INSERT INTO events (id, ...) VALUES (${eventId}, ...)`;
}
}class WebhookAgent extends Agent {
async handleEvent(eventId: string, payload: unknown) {
// Check if already processed
const existing = [
...this.sql`
SELECT id FROM events WHERE id = ${eventId}
`,
];
if (existing.length > 0) {
console.log(`Event ${eventId} already processed, skipping`);
return;
}
// Process and store
await this.processPayload(payload);
this.sql`INSERT INTO events (id, ...) VALUES (${eventId}, ...)`;
}
}Отвечать быстро, обрабатывать асинхронно
Поставщики вебхуков ожидают быстрых ответов. Для ресурсоёмкой обработки используйте очередь:
class WebhookAgent extends Agent {
async onRequest(request) {
const payload = await request.json();
// Quick validation
if (!this.isValid(payload)) {
return new Response("Invalid", { status: 400 });
}
// Queue heavy processing
await this.queue("processWebhook", payload);
// Respond immediately
return new Response("Accepted", { status: 202 });
}
async processWebhook(payload) {
// Heavy processing happens here, after response sent
await this.enrichData(payload);
await this.notifyDownstream(payload);
await this.updateAnalytics(payload);
}
}class WebhookAgent extends Agent {
async onRequest(request: Request): Promise<Response> {
const payload = await request.json();
// Quick validation
if (!this.isValid(payload)) {
return new Response("Invalid", { status: 400 });
}
// Queue heavy processing
await this.queue("processWebhook", payload);
// Respond immediately
return new Response("Accepted", { status: 202 });
}
async processWebhook(payload: WebhookPayload) {
// Heavy processing happens here, after response sent
await this.enrichData(payload);
await this.notifyDownstream(payload);
await this.updateAnalytics(payload);
}
}Если асинхронная задача представляет собой один ход чата Think, используйте submitMessages() вместо этого. Он сразу возвращает устойчивый идентификатор отправки и позволяет повторным попыткам использовать ключ идемпотентности вместо дублирования хода сообщения:
const submission = await this.submitMessages(messages, {
idempotencyKey: payload.id,
});
return Response.json(
{ submissionId: submission.submissionId },
{ status: 202 },
);const submission = await this.submitMessages(messages, {
idempotencyKey: payload.id,
});
return Response.json(
{ submissionId: submission.submissionId },
{ status: 202 },
);Если вебхук отвечает за побочные эффекты приложения вокруг хода, например за восстановление треда провайдера и публикацию видимого ответа, используйте startFiber() вокруг этой задачи. Управляемые fiber сохраняют статус, устраняют дублирование повторных попыток провайдера и позволяют onFiberRecovered() или resolveFiber() записывает результат восстановления на уровне приложения.
Маршрутизация между несколькими провайдерами
Используйте одну типизированную вспомогательную функцию для проверки и разбора, специфичных для провайдера. Она должна проверять исходный запрос согласно документации провайдера, указанной в Типичные поставщики вебхуков, затем выведите agentName только из проверенного тела запроса.
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname.startsWith("/webhooks/")) {
const verified = await verifyAndParseWebhook(request.clone(), env);
if (!verified) {
return new Response("Invalid signature", { status: 401 });
}
switch (verified.provider) {
case "github":
return (
await getAgentByName(env.GitHubAgent, verified.agentName)
).fetch(request);
case "stripe":
return (
await getAgentByName(env.StripeAgent, verified.agentName)
).fetch(request);
case "slack":
return (
await getAgentByName(env.SlackAgent, verified.agentName)
).fetch(request);
}
}
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
};type VerifiedWebhook =
| { provider: "github"; agentName: string }
| { provider: "stripe"; agentName: string }
| { provider: "slack"; agentName: string };
declare function verifyAndParseWebhook(
request: Request,
env: Env,
): Promise<VerifiedWebhook | null>;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname.startsWith("/webhooks/")) {
const verified = await verifyAndParseWebhook(request.clone(), env);
if (!verified) {
return new Response("Invalid signature", { status: 401 });
}
switch (verified.provider) {
case "github":
return (
await getAgentByName(env.GitHubAgent, verified.agentName)
).fetch(request);
case "stripe":
return (
await getAgentByName(env.StripeAgent, verified.agentName)
).fetch(request);
case "slack":
return (
await getAgentByName(env.SlackAgent, verified.agentName)
).fetch(request);
}
}
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;Отправка исходящих вебхуков
Agents также могут отправлять вебхуки во внешние сервисы:
export class NotificationAgent extends Agent {
async notifySlack(message) {
const response = await fetch(this.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: message }),
});
if (!response.ok) {
throw new Error(`Slack notification failed: ${response.status}`);
}
}
async sendSignedWebhook(url, payload) {
const body = JSON.stringify(payload);
const signature = await this.sign(body, this.env.WEBHOOK_SECRET);
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Signature": signature,
},
body,
});
}
}export class NotificationAgent extends Agent {
async notifySlack(message: string) {
const response = await fetch(this.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: message }),
});
if (!response.ok) {
throw new Error(`Slack notification failed: ${response.status}`);
}
}
async sendSignedWebhook(url: string, payload: unknown) {
const body = JSON.stringify(payload);
const signature = await this.sign(body, this.env.WEBHOOK_SECRET);
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Signature": signature,
},
body,
});
}
}Рекомендации по безопасности
- Всегда проверяйте подписи - Никогда не доверяйте непроверенным вебхукам.
- Используйте секреты окружения - Храните секреты с помощью
wrangler secret put, а не в коде. - Отвечать быстро - Возвращайте 200/202 в течение нескольких секунд, чтобы избежать повторных попыток.
- Валидируйте полезные нагрузки - проверяйте обязательные поля перед обработкой.
- Регистрируйте отклонения - Отслеживайте недействительные подписи для мониторинга безопасности.
- Используйте HTTPS - URL вебхуков всегда должны использовать TLS.
// Store secrets securely
// wrangler secret put GITHUB_WEBHOOK_SECRET
// Access in agent
const secret = this.env.GITHUB_WEBHOOK_SECRET;// Store secrets securely
// wrangler secret put GITHUB_WEBHOOK_SECRET
// Access in agent
const secret = this.env.GITHUB_WEBHOOK_SECRET;Типичные поставщики вебхуков
| Провайдер | Документация |
|---|---|
| GitHub | События и полезные нагрузки вебхуков ↗ |
| Stripe | Подписи вебхуков ↗ |
| Twilio | Валидируйте запросы webhook ↗ |
| Slack | Проверка запросов ↗ |
| Shopify | Проверка вебхуков ↗ |
| SendGrid | Вебхук событий ↗ |
| Linear | Webhooks ↗ |