API Echo

Публічний REST API та вебхуки: підключайте економіку, рівні та моніторинг сервера до своїх сервісів — ігрового сервера, сайту, бота чи автоматизацій.

https://api.echo-assistants.tech

Авторизація

Кожен запит потребує ключ у заголовку Authorization. Ключ видається на сервер, а не на акаунт. Створити його може лише власник сервера у дашборді → «Розробникам»: до 3 (з Premium — 10) ключів. Ендпоінти /catalog/* — публічні, ключ не потрібен (ліміт 60/хв за IP, відповіді кешуються).

curl https://api.echo-assistants.tech/me \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Створити ключ

Безпека ключів

Ключ починається з echoapi_, далі 30 випадкових латинських літер і цифр та 6 символів контрольної суми. Рядок із неправильною сумою API відхиляє одразу (401). Ключі попереднього вигляду (echo_…) продовжують працювати; під час перевипуску ключ отримує новий вигляд.

Вираз для пошуку ключів у власному коді
\bechoapi_[0-9A-Za-z]{36}\b

Ключ у відкритому доступі вимикається автоматично. GitHub за програмою Secret Scanning повідомляє нам про ключі в публічних репозиторіях, а бот Echo помічає ключі в повідомленнях на серверах і видаляє такі повідомлення. Власник сервера й творець ключа отримують сповіщення на сайті, у Telegram і на пошту — з посиланням на місце знахідки.

Що робити: відкрийте ключі сервера в кабінеті розробника й натисніть Перевипустити в картці ключа. Ключ отримає новий секрет і знову запрацює, попередній перестане діяти одразу. Замініть ключ усюди, де він записаний. Увімкнути знайдений ключ без перевипуску не можна. Якщо ключ більше не потрібен, відкличте його.

Зберігайте ключ на власному сервері — у змінних оточення або сховищі секретів. Не вставляйте його в код сторінок і застосунків, у репозиторії, повідомлення та на знімки екрана.

Сервіс

GET/meбез скоупа

Перевірка ключа: повертає сервер та його скоупи.

Приклад
curl "https://api.echo-assistants.tech/me" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "guildId": "1369...", "scopes": ["levels:read"] }

Економіка

GET/economy/balance?user=<id>economy:read

Баланс учасника: гаманець, банк, фішки.

Приклад
curl "https://api.echo-assistants.tech/economy/balance?user=123456789012345678" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "user": "123...", "balance": 1500, "bank": 200, "chips": 0 }
POST/economy/balanceeconomy:write

Нарахувати або списати валюту. delta може бути від’ємним; баланс залишається в діапазоні 0…2 000 000 000.

Приклад
curl -X POST "https://api.echo-assistants.tech/economy/balance" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{ "user": "123...", "delta": 250 }'
Тіло запиту
{ "user": "123...", "delta": 250 }
Відповідь
{ "ok": true, "user": "123...", "balance": 1750 }
GET/economy/leaderboard?limit=<n>economy:read

Топ учасників за балансом (limit 1–100, за замовчуванням 20).

Приклад
curl "https://api.echo-assistants.tech/economy/leaderboard?limit=20" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "guildId": "1369...", "leaderboard": [ { "rank": 1, "userId": "627...", "balance": 15000, "bank": 3000 } ] }

Рівні

GET/levels/leaderboard?limit=<n>levels:read

Топ учасників за досвідом (limit 1–100, за замовчуванням 20).

Приклад
curl "https://api.echo-assistants.tech/levels/leaderboard?limit=20" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "guildId": "1369...", "leaderboard": [ { "rank": 1, "userId": "627...", "xp": 3965, "messages": 374, "voiceSeconds": 1273 } ] }
GET/levels/xp?user=<id>levels:read

Досвід і рівень учасника; nextLevelXp — скільки всього досвіду потрібно для наступного рівня.

Приклад
curl "https://api.echo-assistants.tech/levels/xp?user=123456789012345678" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "user": "123...", "xp": 1320, "level": 4, "nextLevelXp": 1500 }
POST/levels/xplevels:write

Нарахувати або списати досвід: delta — ціле число, до ±1 000 000 за запит; досвід не опускається нижче нуля. Рівень перераховується одразу. Якщо він виріс, бот видає нагороди за рівень (ролі, монети, оголошення), як під час звичайної прокачки, і надсилає подію member.level_up з позначкою "via": "api". Зниження рівня нагород не забирає. Лише для учасників сервера (інакше 404 not_member); якщо рівні на сервері вимкнено — 409 levels_off.

Приклад
curl -X POST "https://api.echo-assistants.tech/levels/xp" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{ "user": "123...", "delta": 250 }'
Тіло запиту
{ "user": "123...", "delta": 250 }
Відповідь
{ "ok": true, "user": "123...", "xp": 1570, "level": 5, "previousXp": 1320, "previousLevel": 4, "levelUp": true }

Моніторинг

GET/monitoring/servermonitoring:read

Картка сервера в каталозі: Полум’я за період, усього, місце в топі та статуси.

Приклад
curl "https://api.echo-assistants.tech/monitoring/server" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "guildId": "1369...", "slug": "my-server", "flames": 420, "flamesTotal": 9100, "published": true, "rank": 3, "partner": true, "verified": true }
GET/monitoring/top?limit=<n>monitoring:read

Топ серверів каталогу за Полум’ям (limit 1–100, за замовчуванням 20).

Приклад
curl "https://api.echo-assistants.tech/monitoring/top?limit=20" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "top": [ { "rank": 1, "guildId": "1369...", "slug": "my-server", "flames": 980, "flamesTotal": 21000 } ] }

Інтеграції

Підключення своєї гри, сайту чи CRM: атрибути учасників стають ролями за правилами з дашборду → «Інтеграції», гравці прив'язують акаунти кодом, дозволені ролі можна видавати напряму. Тіло запису — JSON до 8 КБ.

PUT/members/<discordId>/attributesmembers:write

Передати атрибути учасника (заміна повністю): до 25 полів, назва — латиниця/цифри/_, значення — рядок до 100 символів, число або true/false. Ролі за правилами бот перерахує одразу.

Приклад
curl -X PUT "https://api.echo-assistants.tech/members/123456789012345678/attributes" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{ "attributes": { "faction": "police", "rank": 3, "vip": true } }'
Тіло запиту
{ "attributes": { "faction": "police", "rank": 3, "vip": true } }
Відповідь
{ "ok": true, "discordId": "123...", "attributes": { "faction": "police", "rank": 3, "vip": true } }
GET/members/<discordId>/attributesmembers:read

Що надіслано про учасника і який зовнішній акаунт він прив'язав.

timedRoles — ролі з дозволених для API, видані на строк, і час, коли бот їх зніме.

Приклад
curl "https://api.echo-assistants.tech/members/123456789012345678/attributes" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "discordId": "123...", "externalId": "Ivan_Petrov", "attributes": { "faction": "police", "rank": 3 }, "timedRoles": [ { "roleId": "1369...", "expiresAt": "2026-10-10T12:00:00.000Z" } ] }
DELETE/members/<discordId>/attributesmembers:write

Зняти всі атрибути учасника. Ролі, видані правилом, бот зніме; видані вручну — не чіпає.

Приклад
curl -X DELETE "https://api.echo-assistants.tech/members/123456789012345678/attributes" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "ok": true, "removed": true }
PUT/external/<externalId>/attributesmembers:write

Те саме за ID у вашій системі (нік, UUID, license:…). Діє на того, хто прив'язав акаунт кодом; поки не прив'язано — зберігається й застосується під час прив'язки. Атрибути за Discord ID важливіші за збігу назви.

Приклад
curl -X PUT "https://api.echo-assistants.tech/external/Ivan_Petrov/attributes" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{ "attributes": { "faction": "army", "rank": 5 } }'
Тіло запиту
{ "attributes": { "faction": "army", "rank": 5 } }
Відповідь
{ "ok": true, "externalId": "Ivan_Petrov", "discordId": "123...", "attributes": { "faction": "army", "rank": 5 } }
GET/external/<externalId>/attributesmembers:read

Що надіслано про зовнішній акаунт і хто його прив'язав (discordId: null — поки ніхто).

Приклад
curl "https://api.echo-assistants.tech/external/Ivan_Petrov/attributes" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "externalId": "Ivan_Petrov", "discordId": "123...", "attributes": { "faction": "army", "rank": 5 } }
DELETE/external/<externalId>/attributesmembers:write

Зняти всі атрибути зовнішнього акаунта.

Приклад
curl -X DELETE "https://api.echo-assistants.tech/external/Ivan_Petrov/attributes" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "ok": true, "removed": true }
POST/link-codeslinks:write

Код прив'язки для гравця: 8 знаків, одноразовий, діє ttlMinutes (1–60, типово 15). Гравець натискає «Прив'язати акаунт» на панелі сервера (або /link) і вводить код. Новий код того самого акаунта гасить попередній.

Приклад
curl -X POST "https://api.echo-assistants.tech/link-codes" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{ "externalId": "Ivan_Petrov", "ttlMinutes": 15 }'
Тіло запиту
{ "externalId": "Ivan_Petrov", "ttlMinutes": 15 }
Відповідь
{ "code": "K7PQ3MZA", "display": "K7PQ-3MZA", "externalId": "Ivan_Petrov", "expiresAt": "2026-09-29T12:15:00.000Z" }
GET/links?externalId=<externalId>links:read

Чий акаунт: за externalId або за discordId (?discordId=…). Немає зв'язку — 404 not_linked.

Приклад
curl "https://api.echo-assistants.tech/links?externalId=Ivan_Petrov" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "externalId": "Ivan_Petrov", "discordId": "123...", "linkedAt": "2026-09-29T12:03:11.000Z" }
DELETE/links?externalId=<externalId>links:write

Відв'язати акаунт (або ?discordId=…). Атрибути зовнішнього акаунта перестають діяти на людину.

Приклад
curl -X DELETE "https://api.echo-assistants.tech/links?externalId=Ivan_Petrov" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "ok": true, "externalId": "Ivan_Petrov", "discordId": "123..." }
POST/members/<discordId>/rolesroles:write

Видати й зняти ролі напряму — лише ті, що власник позначив у кабінеті розробника → «Ключі API» → «Дозволено ключам» (до 10 у кожному списку). remove знімає будь-яку дозволену роль, навіть видану вручну. Виконує бот: відповідь 202 з номером завдання.

durationMinutes (1–525 600) — видати ролі з add на строк: після його завершення бот зніме їх сам. Строк не скорочує вже призначений, а роль, яка була в учасника без строку, лишається безстроковою. add без durationMinutes робить роль безстроковою, remove скасовує призначене зняття.

Приклад
curl -X POST "https://api.echo-assistants.tech/members/123456789012345678/roles" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{ "add": ["1369..."], "remove": ["1370..."], "durationMinutes": 1440 }'
Тіло запиту
{ "add": ["1369..."], "remove": ["1370..."], "durationMinutes": 1440 }
Відповідь
{ "ok": true, "opId": 42, "status": "pending" }
GET/role-ops/<opId>roles:write

Підсумок завдання видачі: pending, done або failed і що вийшло з кожною роллю.

У видачі на строк у result.expiresAt вказано, коли бот зніме кожну видану роль (null — роль була в учасника без строку й лишилася безстроковою).

Приклад
curl "https://api.echo-assistants.tech/role-ops/42" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "opId": 42, "discordId": "123...", "status": "done", "durationMinutes": 1440, "result": { "added": ["1369..."], "removed": [], "failed": [], "expiresAt": { "1369...": "2026-10-10T12:00:00.000Z" } } }
POST/channels/<channelId>/messagesmessages:write

Повідомлення від імені бота (власного бота сервера, якщо його підключено) — лише в канали, які власник позначив у кабінеті розробника → «Ключі API» → «Дозволено ключам» (до 10). content — до 2000 символів, embeds — до 10 простих ембедів: title, description, url, color, timestamp, author.name, footer.text, image.url, thumbnail.url, fields; посилання — лише https. Згадки за замовчуванням нікого не кличуть: покликати можна лише учасників сервера з mentionUsers (до 10), а @everyone, @here і ролі — ніколи. Тіло запиту — до 32 КБ. Виконує бот: відповідь 202 з номером завдання.

Приклад
curl -X POST "https://api.echo-assistants.tech/channels/137100000000000000/messages" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{ "content": "Сервер перезапустится через 5 минут", "embeds": [ { "title": "Технические работы", "description": "Начало в 03:00 МСК", "color": 9133302 } ], "mentionUsers": ["123..."] }'
Тіло запиту
{ "content": "Сервер перезапустится через 5 минут", "embeds": [ { "title": "Технические работы", "description": "Начало в 03:00 МСК", "color": 9133302 } ], "mentionUsers": ["123..."] }
Відповідь
{ "ok": true, "opId": 77, "status": "pending" }
GET/message-ops/<opId>messages:write

Підсумок надсилання: pending, done (id повідомлення та посилання) або failed з причиною: channel_not_allowed, channel_missing, missing_permissions, rejected.

Приклад
curl "https://api.echo-assistants.tech/message-ops/42" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Відповідь
{ "opId": 77, "channelId": "1371...", "status": "done", "result": { "messageId": "1372...", "channelId": "1371...", "url": "https://discord.com/channels/1369.../1371.../1372..." } }

Каталог

GET/catalog/servers?search=&tag=&lang=&limit=публічний · без ключа

Пошук і список серверів у каталозі моніторингу. Усе публічно — ключ не потрібен.

Приклад
curl "https://api.echo-assistants.tech/catalog/servers"
Відповідь
{ "count": 1, "servers": [ { "slug": "my-server", "name": "My Server", "memberCount": 5200, "flames": 980, "tags": ["Игры"], "language": "ru", "invite": "https://discord.gg/...", "badges": { "verified": true } } ] }
GET/catalog/top?limit=<n>публічний · без ключа

Топ каталогу за Полум’ям.

Приклад
curl "https://api.echo-assistants.tech/catalog/top?limit=20"
Відповідь
{ "top": [ { "rank": 1, "slug": "my-server", "name": "My Server", "flames": 980 } ] }
GET/catalog/server/<slug>публічний · без ключа

Публічна картка сервера: опис, Полум’я, місце, теги, бейджі, інвайт, рейтинг.

Приклад
curl "https://api.echo-assistants.tech/catalog/server/my-server"
Відповідь
{ "slug": "my-server", "name": "My Server", "flames": 980, "rank": 3, "rating": { "avg": 8.7, "count": 42 }, "invite": "https://discord.gg/..." }
GET/catalog/reviews/<slug>?limit=<n>публічний · без ключа

Відгуки про сервер із середньою оцінкою.

Приклад
curl "https://api.echo-assistants.tech/catalog/reviews/my-server?limit=20"
Відповідь
{ "slug": "my-server", "summary": { "avg": 8.7, "count": 42 }, "reviews": [ { "user": "Ник", "rating": 9, "comment": "Топ", "at": "..." } ] }
GET/catalog/tagsпублічний · без ключа

Теги/категорії каталогу з кількістю серверів.

Приклад
curl "https://api.echo-assistants.tech/catalog/tags"
Відповідь
{ "tags": [ { "tag": "Игры", "count": 128 } ] }

Вебхуки

Echo сам надсилає події на ваш URL у реальному часі. Налаштуйте вебхук у дашборді → «Розробникам». Кожен запит підписаний заголовком X-Echo-Signature (HMAC-SHA256 тіла за вашим секретом), ім’я події — у X-Echo-Event.

flame.bump

Серверу додано Полум'я — у каталозі або командою /flame.

{
  "event": "flame.bump",
  "guildId": "1369...",
  "flames": 421,
  "flamesTotal": 9101,
  "amount": 1,
  "source": "web",
  "at": "2026-07-27T20:30:00.000Z"
}
test

Надсилається кнопкою «Надіслати тест» — перевірити, що приймач працює.

{ "event": "test", "guildId": "1369...", "message": "Проверка вебхука Echo", "at": "..." }
Перевірка підпису
import crypto from "node:crypto";

// body — сирий текст запиту, secret — з дашборда
const expected = "sha256=" +
  crypto.createHmac("sha256", secret).update(body).digest("hex");
const header = req.headers["x-echo-signature"];
const got = Buffer.from(typeof header === "string" ? header : "");
const want = Buffer.from(expected);
const ok = got.length === want.length && crypto.timingSafeEqual(got, want);

Сповіщення інтеграцій (Premium)

Налаштовуються в дашборді → «Інтеграції». Echo надсилає POST із JSON {"id", "event", "guildId", "at", "data"}: підпис — X-Echo-Signature (sha256= і HMAC-SHA256 сирого тіла за вашим секретом), назва події — X-Echo-Event, номер доставки — X-Echo-Delivery.

Відповідайте 2xx за 8 секунд. Інакше — повтор через 30 с, 2 хв, 10 хв і 1 год із тим самим id; редиректи не виконуються. Адреса — лише https на 443 і публічна IP. Після 20 невдач поспіль надсилання вимикається — увімкніть його в дашборді, коли приймач запрацює.

member.join

Учасник зайшов на сервер.

{ "id": "evt_…", "event": "member.join", "guildId": "1369...", "at": "2026-09-29T12:00:00.000Z",
  "data": { "discordId": "123...", "username": "ivan", "joinedAt": "2026-09-29T12:00:00.000Z" } }
member.leave

Учасник вийшов або його вигнали.

{ "id": "evt_…", "event": "member.leave", "guildId": "1369...", "at": "…", "data": { "discordId": "123...", "username": "ivan" } }
member.level_up

Учасник підняв рівень.

{ "id": "evt_…", "event": "member.level_up", "guildId": "1369...", "at": "…", "data": { "discordId": "123...", "oldLevel": 4, "newLevel": 5, "xp": 1320 } }
store.purchase

Покупка на вітрині сервера: роль видано, пакет монет нараховано (kind "coins", поле coins) або підтримка сервера. В анонімної підтримки покупця немає.

{ "id": "evt_…", "event": "store.purchase", "guildId": "1369...", "at": "…",
  "data": { "orderId": 815, "kind": "role", "itemName": "VIP", "roleId": "1369...", "buyerId": "123...", "discordId": "123...", "priceKopecks": 14900, "durationDays": 30 } }
ticket.opened

Відкрито тікет.

{ "id": "evt_…", "event": "ticket.opened", "guildId": "1369...", "at": "…", "data": { "ticketId": 311, "number": 57, "discordId": "123...", "channelId": "1371...", "panelId": 4 } }
account.linked

Гравець прив'язав акаунт кодом.

{ "id": "evt_…", "event": "account.linked", "guildId": "1369...", "at": "…",
  "data": { "externalId": "Ivan_Petrov", "discordId": "123...", "username": "ivan", "previousDiscordId": null, "previousExternalId": null } }
test

Кнопка «Надіслати тест» у дашборді → «Інтеграції».

{ "id": "evt_…", "event": "test", "guildId": "1369...", "at": "…", "data": { "message": "…" } }
Перевірка підпису — Node.js
import crypto from "node:crypto";
import express from "express";
import pg from "pg";

const app = express();
const SECRET = process.env.ECHO_WEBHOOK_SECRET; // whsec_… из панели
const GUILD_ID = process.env.ECHO_GUILD_ID;
if (!SECRET || !GUILD_ID) throw new Error("ECHO_WEBHOOK_SECRET and ECHO_GUILD_ID required");
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

// Выполните миграцию в своей PostgreSQL заранее:
// CREATE TABLE echo_webhook_events (
//   guild_id text NOT NULL, event_id text NOT NULL,
//   PRIMARY KEY (guild_id, event_id)
// );
// Храните ID минимум 2 часа. Несколько процессов используют одну таблицу.

// підпис рахується від сирого тіла, до розбору JSON
app.post("/echo-webhook", express.raw({ type: "application/json", limit: "64kb" }), async (req, res) => {
  if (!Buffer.isBuffer(req.body)) return res.status(400).end();
  const expected = "sha256=" +
    crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  const got = Buffer.from(req.get("X-Echo-Signature") ?? "");
  const want = Buffer.from(expected);
  const ok = got.length === want.length && crypto.timingSafeEqual(got, want);
  if (!ok) return res.status(401).end();

  let event;
  try { event = JSON.parse(req.body.toString("utf8")); }
  catch { return res.status(400).end(); }
  if (!event || typeof event !== "object" || Array.isArray(event) ||
      typeof event.id !== "string" || !/^evt_[0-9a-f-]{36}$/.test(event.id) ||
      event.guildId !== GUILD_ID || typeof event.event !== "string" ||
      typeof event.at !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(event.at)) return res.status(400).end();
  const at = Date.parse(event.at);
  const age = Date.now() - at;
  // Повторы сохраняют at; неверная дата и будущее дальше 5 минут — отказ.
  if (!Number.isFinite(at) || new Date(at).toISOString() !== event.at || age > 2 * 60 * 60_000 || age < -5 * 60_000) return res.status(400).end();

  let db;
  try {
    db = await pool.connect();
    await db.query("BEGIN");
    const claimed = await db.query(
      "INSERT INTO echo_webhook_events (guild_id, event_id) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING event_id",
      [event.guildId, event.id],
    );
    if (!claimed.rowCount) {
      await db.query("ROLLBACK");
      return res.status(204).end();
    }
    // Изменение награды/баланса делайте через db В ЭТОЙ транзакции.
    // Для внешнего действия записывайте outbox здесь и обрабатывайте отдельно.
    await db.query("COMMIT");
    return res.status(204).end();
  } catch {
    if (db) await db.query("ROLLBACK").catch(() => {});
    return res.status(500).end();
  } finally {
    db?.release();
  }
});
Перевірка підпису — PHP
<?php
$secret = getenv('ECHO_WEBHOOK_SECRET'); // whsec_… из панели
$guildId = getenv('ECHO_GUILD_ID');
if (!$secret || !$guildId) { http_response_code(500); exit; }
// підпис рахується від сирого тіла, до розбору JSON
$body = file_get_contents('php://input');
if ($body === false || strlen($body) > 65536) { http_response_code(413); exit; }
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
$got = $_SERVER['HTTP_X_ECHO_SIGNATURE'] ?? '';
if (!hash_equals($expected, $got)) {
    http_response_code(401);
    exit;
}
$event = json_decode($body, true);
if (!is_array($event) || !is_string($event['id'] ?? null) ||
    !preg_match('/^evt_[0-9a-f-]{36}$/D', $event['id']) ||
    ($event['guildId'] ?? null) !== $guildId ||
    !is_string($event['event'] ?? null) || !is_string($event['at'] ?? null) ||
    !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/D', $event['at'])) {
    http_response_code(400);
    exit;
}
$date = DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s.v\Z', $event['at'], new DateTimeZone('UTC'));
$at = $date && $date->format('Y-m-d\TH:i:s.v\Z') === $event['at'] ? $date->getTimestamp() : false;
$age = $at === false ? null : time() - $at;
if ($age === null || $age > 7200 || $age < -300) {
    http_response_code(400); exit;
}
// Та же миграция PostgreSQL, что в Node.js-примере.
// ECHO_DATABASE_DSN: pgsql:host=…;dbname=… (PDO_PGSQL).
$db = new PDO(getenv('ECHO_DATABASE_DSN'), getenv('ECHO_DATABASE_USER'), getenv('ECHO_DATABASE_PASSWORD'),
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
try {
    $db->beginTransaction();
    $claim = $db->prepare('INSERT INTO echo_webhook_events (guild_id, event_id) VALUES (?, ?) ON CONFLICT DO NOTHING RETURNING event_id');
    $claim->execute([$event['guildId'], $event['id']]);
    if ($claim->fetchColumn() === false) {
        $db->rollBack(); http_response_code(204); exit;
    }
    // Изменение награды/баланса делайте через $db В ЭТОЙ транзакции.
    // Внешние действия — через outbox, записанный здесь.
    $db->commit();
    http_response_code(204);
} catch (Throwable $e) {
    if ($db->inTransaction()) $db->rollBack();
    http_response_code(500);
}

Скоупи

Під час створення ключа обираються права — кожен ендпоінт потребує свій скоуп.

guild:read
Дані сервера
Базова інформація про сервер.
economy:read
Читання економіки
Баланси: гаманець, банк, фішки.
economy:write
Запис економіки
Нарахування та списання валюти.
levels:read
Читання рівнів
Досвід і лідерборд учасників.
monitoring:read
Читання моніторингу
Полум’я, місце в топі, статуси каталогу.
members:read
Читання атрибутів
Читання атрибутів учасників за Discord ID і за ID у зовнішній системі — без права їх змінювати.
members:write
Атрибути учасників
Передача атрибутів за Discord ID і за ID у зовнішній системі.
roles:write
Видача ролей
Видача й зняття ролей, дозволених у кабінеті розробника → «Ключі API» → «Дозволено ключам».
links:read
Читання прив'язок
Чий акаунт: зв'язок зовнішнього ID і Discord.
links:write
Коди прив'язки
Випуск кодів прив'язки й відв'язка акаунтів.
levels:write
Запис досвіду
Нарахування та списання досвіду; нагороди за рівень видає бот.
messages:write
Повідомлення від бота
Повідомлення від імені бота в канали, дозволені в кабінеті розробника → «Ключі API» → «Дозволено ключам».

Ліміти та помилки

До 3 (з Premium — 10) ключів на сервер, ліміт — 40 (з Premium — 120) запитів на хвилину на кожен ключ. Помилки приходять як JSON { "error": "..." } з відповідним HTTP-кодом.

КодerrorКоли
400bad_fieldsНевірні або відсутні параметри запиту.
401unauthorizedКлюч не передано, невірний або відкликаний.
403insufficient_scopeУ ключа немає потрібного скоупа для цього запиту.
403key_paused_premiumКлюч на паузі: у сервера немає Premium, а без нього працюють лише ключі в межах безкоштовного ліміту, випущені раніше за інші.
404not_linkedАкаунт не прив'язано (методи прив'язок) або завдання не знайдено.
409levels_offДія не відповідає налаштуванням сервера: наприклад, рівні вимкнено.
413body_too_largeТіло запиту більше за 8 КБ.
429rate_limitedПеревищено ліміт 40 (з Premium — 120) запитів на хвилину.
502discord_unavailableDiscord не відповів — повторіть запит пізніше.

Ключ видно повністю лише один раз під час створення — збережіть його. Адміністрація проєкту може вимкнути ключ у разі зловживання.

Не забудьте зберегти зміни.
Є незбережені зміни — застосувати чи скасувати?