API Echo

API REST pública y webhooks: conecta la economía, los niveles y el monitoreo de tu servidor con tus propios servicios: un servidor de juego, una web, un bot o automatizaciones.

https://api.echo-assistants.tech

Autorización

Cada solicitud necesita una clave en la cabecera Authorization. La clave se emite para un un servidor, no para una cuenta. Solo el propietario del servidor puede crearla en el panel → «Desarrolladores»: hasta 3 (10 con Premium) claves. Los endpoints /catalog/* son públicos: no se necesita clave (límite de 60/min por IP, las respuestas se almacenan en caché).

curl https://api.echo-assistants.tech/me \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Crear una clave

Seguridad de las claves

La clave empieza por echoapi_, seguida de 30 letras latinas y cifras aleatorias y una suma de control de 6 caracteres. Una cadena con la suma incorrecta la rechaza la API de inmediato (401). Las claves del formato anterior (echo_…) siguen funcionando; al reemitirla, la clave recibe el formato nuevo.

Expresión para buscar claves en su propio código
\bechoapi_[0-9A-Za-z]{36}\b

Una clave expuesta públicamente se desactiva automáticamente. Mediante el programa Secret Scanning, GitHub nos avisa de las claves en repositorios públicos, y el bot de Echo detecta claves en los mensajes de los servidores y borra esos mensajes. El dueño del servidor y el creador de la clave reciben un aviso en el sitio, en Telegram y por correo, con un enlace al lugar donde se encontró.

Qué hacer: abra las claves del servidor en el portal para desarrolladores y pulse Reemitir en la tarjeta de la clave. La clave recibirá un secreto nuevo y volverá a funcionar; el anterior dejará de servir de inmediato. Sustituya la clave en todos los sitios donde esté guardada. Una clave encontrada no se puede volver a activar sin reemitirla. Si ya no la necesita, revóquela.

Guarde la clave en su propio servidor: en variables de entorno o en un almacén de secretos. No la ponga en el código de páginas o aplicaciones, en repositorios, mensajes ni capturas de pantalla.

Servicio

GET/mesin scope

Comprobación de clave: devuelve el servidor y sus scopes.

Ejemplo
curl "https://api.echo-assistants.tech/me" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "guildId": "1369...", "scopes": ["levels:read"] }

Economía

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

Saldo del miembro: cartera, banco, fichas.

Ejemplo
curl "https://api.echo-assistants.tech/economy/balance?user=123456789012345678" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "user": "123...", "balance": 1500, "bank": 200, "chips": 0 }
POST/economy/balanceeconomy:write

Añadir o restar moneda. delta puede ser negativo; el saldo se mantiene en el rango 0…2 000 000 000.

Ejemplo
curl -X POST "https://api.echo-assistants.tech/economy/balance" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{ "user": "123...", "delta": 250 }'
Cuerpo de la solicitud
{ "user": "123...", "delta": 250 }
Respuesta
{ "ok": true, "user": "123...", "balance": 1750 }
GET/economy/leaderboard?limit=<n>economy:read

Top de miembros por saldo (limit 1–100, por defecto 20).

Ejemplo
curl "https://api.echo-assistants.tech/economy/leaderboard?limit=20" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "guildId": "1369...", "leaderboard": [ { "rank": 1, "userId": "627...", "balance": 15000, "bank": 3000 } ] }

Niveles

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

Top de miembros por experiencia (limit 1–100, por defecto 20).

Ejemplo
curl "https://api.echo-assistants.tech/levels/leaderboard?limit=20" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "guildId": "1369...", "leaderboard": [ { "rank": 1, "userId": "627...", "xp": 3965, "messages": 374, "voiceSeconds": 1273 } ] }
GET/levels/xp?user=<id>levels:read

La experiencia y el nivel de un miembro; nextLevelXp es la experiencia total necesaria para el siguiente nivel.

Ejemplo
curl "https://api.echo-assistants.tech/levels/xp?user=123456789012345678" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "user": "123...", "xp": 1320, "level": 4, "nextLevelXp": 1500 }
POST/levels/xplevels:write

Sumar o restar experiencia: delta es un número entero, hasta ±1 000 000 por solicitud; la experiencia nunca baja de cero. El nivel se recalcula al instante. Si sube, el bot entrega las recompensas de nivel (roles, monedas, anuncio) como en la subida normal y envía el evento member.level_up con la marca "via": "api". Bajar de nivel no retira recompensas. Solo para miembros del servidor (si no, 404 not_member); si los niveles están desactivados en el servidor — 409 levels_off.

Ejemplo
curl -X POST "https://api.echo-assistants.tech/levels/xp" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{ "user": "123...", "delta": 250 }'
Cuerpo de la solicitud
{ "user": "123...", "delta": 250 }
Respuesta
{ "ok": true, "user": "123...", "xp": 1570, "level": 5, "previousXp": 1320, "previousLevel": 4, "levelUp": true }

Monitoreo

GET/monitoring/servermonitoring:read

Ficha del servidor en el catálogo: Llamas del período, total, puesto y estados.

Ejemplo
curl "https://api.echo-assistants.tech/monitoring/server" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "guildId": "1369...", "slug": "my-server", "flames": 420, "flamesTotal": 9100, "published": true, "rank": 3, "partner": true, "verified": true }
GET/monitoring/top?limit=<n>monitoring:read

Top de servidores del catálogo por Llamas (limit 1–100, por defecto 20).

Ejemplo
curl "https://api.echo-assistants.tech/monitoring/top?limit=20" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "top": [ { "rank": 1, "guildId": "1369...", "slug": "my-server", "flames": 980, "flamesTotal": 21000 } ] }

Integraciones

Conecta tu juego, web o CRM: los atributos de los miembros se convierten en roles según las reglas del panel → «Integraciones», los jugadores vinculan cuentas con un código y los roles permitidos se pueden asignar directamente. Los cuerpos de escritura son JSON de hasta 8 KB.

PUT/members/<discordId>/attributesmembers:write

Enviar los atributos de un miembro (reemplazo completo): hasta 25 campos, nombres con letras latinas/dígitos/_, valores de texto de hasta 100 caracteres, número o true/false. El bot recalcula los roles de las reglas al instante.

Ejemplo
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 } }'
Cuerpo de la solicitud
{ "attributes": { "faction": "police", "rank": 3, "vip": true } }
Respuesta
{ "ok": true, "discordId": "123...", "attributes": { "faction": "police", "rank": 3, "vip": true } }
GET/members/<discordId>/attributesmembers:read

Lo enviado para el miembro y qué cuenta externa vinculó.

timedRoles — roles permitidos para la API que se dieron por un tiempo, y cuándo el bot los quitará.

Ejemplo
curl "https://api.echo-assistants.tech/members/123456789012345678/attributes" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "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

Borrar todos los atributos de un miembro. El bot quita los roles dados por reglas y deja los dados a mano.

Ejemplo
curl -X DELETE "https://api.echo-assistants.tech/members/123456789012345678/attributes" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "ok": true, "removed": true }
PUT/external/<externalId>/attributesmembers:write

Lo mismo por el ID de tu sistema (apodo, UUID, license:…). Se aplica a quien vinculó la cuenta con un código; hasta entonces se guarda y se aplica al vincular. Los atributos por Discord ID ganan si coinciden los nombres.

Ejemplo
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 } }'
Cuerpo de la solicitud
{ "attributes": { "faction": "army", "rank": 5 } }
Respuesta
{ "ok": true, "externalId": "Ivan_Petrov", "discordId": "123...", "attributes": { "faction": "army", "rank": 5 } }
GET/external/<externalId>/attributesmembers:read

Lo enviado para la cuenta externa y quién la vinculó (discordId: null — nadie aún).

Ejemplo
curl "https://api.echo-assistants.tech/external/Ivan_Petrov/attributes" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "externalId": "Ivan_Petrov", "discordId": "123...", "attributes": { "faction": "army", "rank": 5 } }
DELETE/external/<externalId>/attributesmembers:write

Borrar todos los atributos de una cuenta externa.

Ejemplo
curl -X DELETE "https://api.echo-assistants.tech/external/Ivan_Petrov/attributes" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "ok": true, "removed": true }
POST/link-codeslinks:write

Un código de vinculación para un jugador: 8 caracteres, de un solo uso, válido ttlMinutes (1–60, por defecto 15). El jugador pulsa «Vincular cuenta» en el panel del servidor (o usa /link) y lo introduce. Un código nuevo para la misma cuenta anula el anterior.

Ejemplo
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 }'
Cuerpo de la solicitud
{ "externalId": "Ivan_Petrov", "ttlMinutes": 15 }
Respuesta
{ "code": "K7PQ3MZA", "display": "K7PQ-3MZA", "externalId": "Ivan_Petrov", "expiresAt": "2026-09-29T12:15:00.000Z" }
GET/links?externalId=<externalId>links:read

De quién es la cuenta: por externalId o por discordId (?discordId=…). Sin vínculo: 404 not_linked.

Ejemplo
curl "https://api.echo-assistants.tech/links?externalId=Ivan_Petrov" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "externalId": "Ivan_Petrov", "discordId": "123...", "linkedAt": "2026-09-29T12:03:11.000Z" }
DELETE/links?externalId=<externalId>links:write

Desvincular una cuenta (o ?discordId=…). Los atributos de la cuenta externa dejan de aplicarse a la persona.

Ejemplo
curl -X DELETE "https://api.echo-assistants.tech/links?externalId=Ivan_Petrov" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "ok": true, "externalId": "Ivan_Petrov", "discordId": "123..." }
POST/members/<discordId>/rolesroles:write

Dar y quitar roles directamente, solo los que el dueño marcó en el portal de desarrolladores → «Claves de API» → «Permitido a las claves» (hasta 10 por lista). remove quita cualquier rol permitido, aunque se haya dado a mano. Lo hace el bot: respuesta 202 con número de tarea.

durationMinutes (1–525 600) — dar los roles de add por un tiempo: al terminar, el bot los quita solo. El plazo nunca acorta uno ya fijado, y un rol que el miembro ya tenía sin plazo sigue siendo permanente. add sin durationMinutes hace el rol permanente, remove cancela la retirada programada.

Ejemplo
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 }'
Cuerpo de la solicitud
{ "add": ["1369..."], "remove": ["1370..."], "durationMinutes": 1440 }
Respuesta
{ "ok": true, "opId": 42, "status": "pending" }
GET/role-ops/<opId>roles:write

El resultado de una tarea: pending, done o failed, y qué pasó con cada rol.

En una asignación con plazo, result.expiresAt indica cuándo el bot quitará cada rol asignado (null — el miembro ya tenía el rol sin plazo y sigue siendo permanente).

Ejemplo
curl "https://api.echo-assistants.tech/role-ops/42" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "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

Un mensaje en nombre del bot (el bot propio del servidor, si está conectado) — solo en los canales que el dueño marcó en el portal de desarrolladores → «Claves de API» → «Permitido a las claves» (hasta 10). content — hasta 2000 caracteres, embeds — hasta 10 embeds simples: title, description, url, color, timestamp, author.name, footer.text, image.url, thumbnail.url, fields; los enlaces solo con https. Por defecto las menciones no avisan a nadie: solo se avisa a los miembros del servidor de mentionUsers (hasta 10), y nunca a @everyone, @here ni a roles. Cuerpo de la solicitud — hasta 32 KB. Lo hace el bot: respuesta 202 con número de tarea.

Ejemplo
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..."] }'
Cuerpo de la solicitud
{ "content": "Сервер перезапустится через 5 минут", "embeds": [ { "title": "Технические работы", "description": "Начало в 03:00 МСК", "color": 9133302 } ], "mentionUsers": ["123..."] }
Respuesta
{ "ok": true, "opId": 77, "status": "pending" }
GET/message-ops/<opId>messages:write

El resultado del envío: pending, done (id del mensaje y enlace) o failed con el motivo: channel_not_allowed, channel_missing, missing_permissions, rejected.

Ejemplo
curl "https://api.echo-assistants.tech/message-ops/42" \
  -H "Authorization: Bearer echoapi_ВАШ_КЛЮЧ"
Respuesta
{ "opId": 77, "channelId": "1371...", "status": "done", "result": { "messageId": "1372...", "channelId": "1371...", "url": "https://discord.com/channels/1369.../1371.../1372..." } }

Catálogo

GET/catalog/servers?search=&tag=&lang=&limit=público · sin clave

Buscar y listar servidores en el catálogo de monitoreo. Totalmente público: no se necesita clave.

Ejemplo
curl "https://api.echo-assistants.tech/catalog/servers"
Respuesta
{ "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>público · sin clave

Top del catálogo por Llamas.

Ejemplo
curl "https://api.echo-assistants.tech/catalog/top?limit=20"
Respuesta
{ "top": [ { "rank": 1, "slug": "my-server", "name": "My Server", "flames": 980 } ] }
GET/catalog/server/<slug>público · sin clave

Ficha pública del servidor: descripción, Llamas, puesto, etiquetas, insignias, invitación, valoración.

Ejemplo
curl "https://api.echo-assistants.tech/catalog/server/my-server"
Respuesta
{ "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>público · sin clave

Reseñas del servidor con valoración media.

Ejemplo
curl "https://api.echo-assistants.tech/catalog/reviews/my-server?limit=20"
Respuesta
{ "slug": "my-server", "summary": { "avg": 8.7, "count": 42 }, "reviews": [ { "user": "Ник", "rating": 9, "comment": "Топ", "at": "..." } ] }
GET/catalog/tagspúblico · sin clave

Etiquetas/categorías del catálogo con el número de servidores.

Ejemplo
curl "https://api.echo-assistants.tech/catalog/tags"
Respuesta
{ "tags": [ { "tag": "Игры", "count": 128 } ] }

Webhooks

Echo envía eventos a tu URL en tiempo real. Configura un webhook en el panel → «Desarrolladores». Cada solicitud se firma con la cabecera X-Echo-Signature (HMAC-SHA256 del cuerpo con tu secreto); el nombre del evento está en X-Echo-Event.

flame.bump

El servidor recibió Llama, en el catálogo o con /flame.

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

Se envía con el botón «Enviar prueba» para comprobar que el receptor funciona.

{ "event": "test", "guildId": "1369...", "message": "Проверка вебхука Echo", "at": "..." }
Verificación de la firma
import crypto from "node:crypto";

// body es el texto sin procesar de la petición; secret viene del panel
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);

Avisos de integraciones (Premium)

Se configuran en el panel → «Integraciones». Echo envía un POST con JSON {"id", "event", "guildId", "at", "data"}: la firma va en X-Echo-Signature (sha256= más HMAC-SHA256 del cuerpo sin procesar con tu secreto), el nombre del evento en X-Echo-Event y el número de entrega en X-Echo-Delivery.

Responde con 2xx en 8 segundos. Si no, reintentamos a los 30 s, 2 min, 10 min y 1 h con el mismo id; no se siguen redirecciones. La URL debe ser https en 443 con IP pública. Tras 20 fallos seguidos el envío se desactiva: vuelve a activarlo en el panel cuando tu receptor funcione.

member.join

Un miembro entró al servidor.

{ "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

Un miembro salió o fue expulsado.

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

Un miembro subió de nivel.

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

Una compra en la tienda del servidor: rol asignado, paquete de monedas acreditado (kind "coins", campo coins) o una donación. Las donaciones anónimas no tienen comprador.

{ "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

Se abrió un ticket.

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

Un jugador vinculó una cuenta con un código.

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

El botón «Enviar prueba» en el panel → «Integraciones».

{ "id": "evt_…", "event": "test", "guildId": "1369...", "at": "…", "data": { "message": "…" } }
Verificación de firma — 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 часа. Несколько процессов используют одну таблицу.

// la firma se calcula sobre el cuerpo sin procesar, antes de parsear el 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();
  }
});
Verificación de firma — PHP
<?php
$secret = getenv('ECHO_WEBHOOK_SECRET'); // whsec_… из панели
$guildId = getenv('ECHO_GUILD_ID');
if (!$secret || !$guildId) { http_response_code(500); exit; }
// la firma se calcula sobre el cuerpo sin procesar, antes de parsear el 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);
}

Scopes

Al crear una clave eliges permisos: cada endpoint requiere su propio scope.

guild:read
Datos del servidor
Información básica del servidor.
economy:read
Leer economía
Saldos: cartera, banco, fichas.
economy:write
Escribir economía
Añadir y restar moneda.
levels:read
Leer niveles
Experiencia y clasificación de miembros.
monitoring:read
Leer monitoreo
Llamas, puesto, estados del catálogo.
members:read
Leer atributos
Leer atributos de miembros por Discord ID y por ID del sistema externo, sin poder cambiarlos.
members:write
Atributos de miembros
Enviar atributos por Discord ID y por ID del sistema externo.
roles:write
Asignación de roles
Dar y quitar los roles permitidos en el portal de desarrolladores → «Claves de API» → «Permitido a las claves».
links:read
Leer vínculos
De quién es la cuenta: vínculo ID externo ↔ Discord.
links:write
Códigos de vinculación
Emitir códigos de vinculación y desvincular cuentas.
levels:write
Escritura de experiencia
Sumar y restar experiencia; las recompensas de nivel las entrega el bot.
messages:write
Mensajes del bot
Mensajes en nombre del bot en los canales permitidos en el portal de desarrolladores → «Claves de API» → «Permitido a las claves».

Límites y errores

Hasta 3 (10 con Premium) claves por servidor, con un límite de 40 (120 con Premium) solicitudes por minuto por clave. Los errores se devuelven como JSON { "error": "..." } con el código HTTP correspondiente.

CódigoerrorCuándo
400bad_fieldsParámetros de la solicitud inválidos o ausentes.
401unauthorizedClave no proporcionada, inválida o revocada.
403insufficient_scopeLa clave carece del scope necesario para esta solicitud.
403key_paused_premiumLa clave está en pausa: el servidor no tiene Premium y sin él solo funcionan las claves dentro del límite gratuito que se emitieron primero.
404not_linkedLa cuenta no está vinculada (métodos de vínculos) o no se encontró la tarea.
409levels_offLa acción no encaja con la configuración del servidor: por ejemplo, los niveles están desactivados.
413body_too_largeEl cuerpo de la petición supera los 8 KB.
429rate_limitedSe superó el límite de 40 (120 con Premium) solicitudes por minuto.
502discord_unavailableDiscord no respondió; repita la solicitud más tarde.

La clave se muestra completa solo una vez al crearla: guárdala. La administración del proyecto puede desactivar una clave en caso de abuso.

No olvide guardar los cambios.
Hay cambios sin guardar: ¿aplicar o descartar?