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 не адказаў — паўтарыце запыт пазней.

Ключ бачны цалкам толькі адзін раз пры стварэнні — захавай яго. Адміністрацыя праекта можа адключыць ключ пры злоўжыванні.

Не забудзьце захаваць змены.
Ёсць незахаваныя змены — ужыць або скасаваць?