Webhooks
Webhooks (exclusivo dos planos pagos) enviam os eventos do seu servidor — ticket aberto, whitelist aprovada, punição aplicada, venda aprovada na sua loja — para uma URL sua, em tempo real. Cada entrega é assinada; se o seu sistema estiver fora do ar, reenviamos automaticamente com intervalos crescentes.
1Crie o destino
Em Integrações → Webhooks (visível só para o dono do servidor), clique em "Novo destino", informe a URL https pública do seu endpoint e marque os eventos que quer receber.
💡 A URL precisa ser pública e https. Endereços internos (localhost, IP privado) são recusados por segurança.
2Entenda o formato do evento
Cada entrega é um POST com JSON no corpo e headers de contexto: X-Outpost-Topic (qual evento), X-Outpost-Event-Id (identificador único — use para ignorar duplicatas), X-Outpost-Timestamp (quando foi gerado) e X-Outpost-Signature (assinatura).
{
"topic": "ticket.closed",
"guildId": "123456789012345678",
"occurredAt": "2026-07-10T12:00:00.000Z",
"resource": "ticket",
"resourceId": "uuid-do-ticket",
"actorId": "id-do-usuario-ou-null",
"metadata": {}
}3Valide a assinatura (obrigatório)
Pegue o segredo do destino em "Ver segredo" e valide TODA entrega antes de confiar nela: calcule HMAC-SHA256 do corpo cru da requisição com o segredo e compare (em tempo constante) com o header X-Outpost-Signature, que vem no formato v0=<hex>. Durante uma rotação, o header traz mais de uma assinatura separada por vírgula — aceite se qualquer uma bater.
import crypto from 'node:crypto';
// rawBody: corpo CRU da requisição (Buffer/string, antes do JSON.parse)
function verifySignature(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Rotação: "v0=abc...,def..." — qualquer assinatura válida serve.
return signatureHeader
.split(',')
.map((s) => s.trim().replace(/^v0=/, ''))
.some((sig) => {
const a = Buffer.from(sig, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}💡 Rejeite também entregas com X-Outpost-Timestamp muito antigo (ex.: mais de 5 minutos) — proteção extra contra replay.
4Responda rápido e trate duplicatas
Responda 2xx em até poucos segundos — processe o evento depois (fila/job). Qualquer resposta fora de 2xx conta como falha e entra na fila de reenvio automático. Como reenvio pode duplicar, guarde o X-Outpost-Event-Id processado e ignore repetidos.
5Código pronto na sua linguagem
Os exemplos abaixo são completos e fazem as três coisas certas: validam a assinatura, ignoram duplicatas pelo X-Outpost-Event-Id e respondem 200 rápido (processando o evento depois). Copie, configure o segredo e aponte o destino para a URL do seu endpoint.
▸Node.js (Express)
// npm install express
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SECRET = process.env.LZNN_WEBHOOK_SECRET; // painel -> destino -> "Ver segredo"
const processed = new Set(); // producao: persista (banco/Redis)
// A assinatura cobre o corpo CRU: use express.raw NESTA rota (nao express.json).
app.post('/webhooks/lznnstore', express.raw({ type: '*/*' }), (req, res) => {
// 1. Assinatura (rotacao pode enviar mais de uma, separadas por virgula)
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const header = String(req.get('x-outpost-signature') ?? '');
const valid = header.split(',').some((part) => {
const sig = Buffer.from(part.trim().replace(/^v0=/, ''), 'hex');
const want = Buffer.from(expected, 'hex');
return sig.length === want.length && crypto.timingSafeEqual(sig, want);
});
if (!valid) return res.status(401).end();
// 2. Anti-replay: rejeita entregas com mais de 5 minutos
const ts = Date.parse(req.get('x-outpost-timestamp') ?? '');
if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 5 * 60 * 1000) {
return res.status(401).end();
}
// 3. Idempotencia: reenvio pode duplicar — processe cada event id UMA vez
const eventId = req.get('x-outpost-event-id') ?? '';
if (processed.has(eventId)) return res.status(200).end();
processed.add(eventId);
// 4. Responda rapido; processe depois
const event = JSON.parse(req.body.toString('utf8'));
res.status(200).end();
setImmediate(() => {
// sua logica aqui — ex.:
if (event.topic === 'ticket.closed') {
console.log('ticket fechado na guild', event.guildId, event.resourceId);
}
});
});
app.listen(3000);▸PHP
<?php
// webhook.php — cadastre https://seusite.com/webhook.php como destino
$secret = getenv('LZNN_WEBHOOK_SECRET'); // painel -> destino -> "Ver segredo"
$raw = file_get_contents('php://input'); // corpo CRU (antes do json_decode)
// 1. Assinatura (rotacao pode enviar mais de uma, separadas por virgula)
$expected = hash_hmac('sha256', $raw, $secret);
$valid = false;
foreach (explode(',', $_SERVER['HTTP_X_OUTPOST_SIGNATURE'] ?? '') as $part) {
$sig = preg_replace('/^v0=/', '', trim($part));
if (hash_equals($expected, $sig)) { $valid = true; }
}
if (!$valid) { http_response_code(401); exit; }
// 2. Anti-replay: rejeita entregas com mais de 5 minutos
$ts = strtotime($_SERVER['HTTP_X_OUTPOST_TIMESTAMP'] ?? '');
if ($ts === false || abs(time() - $ts) > 300) { http_response_code(401); exit; }
// 3. Idempotencia: guarde o event id processado (banco/Redis) e ignore repetidos
$eventId = $_SERVER['HTTP_X_OUTPOST_EVENT_ID'] ?? '';
// if (jaProcessado($eventId)) { http_response_code(200); exit; }
// marcarProcessado($eventId);
// 4. Responda rapido; processe depois (fila/cron)
$event = json_decode($raw, true);
http_response_code(200);
// ex.: if ($event['topic'] === 'whitelist.submission.approved') { ... }▸Lua (FiveM)
-- Resource FiveM completo para receber webhooks da lznnstore.
--
-- fxmanifest.lua:
-- fx_version 'cerulean'
-- game 'gta5'
-- server_script 'server.lua'
--
-- server.cfg:
-- set lznn_webhook_secret "whsec_..." -- painel -> destino -> "Ver segredo"
--
-- O endpoint fica em http://SEU_IP:30120/<nome-do-resource>/webhook.
-- O painel so aceita URL https publica: coloque um proxy HTTPS na frente
-- (ex.: Cloudflare apontando para a porta 30120) e cadastre
-- https://fivem.seudominio.com/<nome-do-resource>/webhook como destino.
-- ===== HMAC-SHA256 em Lua puro (o FiveM nao expoe cripto nativa) =====
local K = {
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
}
local function rrot(x, n)
return ((x >> n) | (x << (32 - n))) & 0xffffffff
end
local function sha256(msg)
local h = { 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 }
local len = #msg
msg = msg .. "\128" .. string.rep("\0", (55 - len) % 64) .. string.pack(">I8", len * 8)
for i = 1, #msg, 64 do
local w = {}
for j = 0, 15 do
w[j + 1] = string.unpack(">I4", msg, i + j * 4)
end
for j = 17, 64 do
local s0 = rrot(w[j - 15], 7) ~ rrot(w[j - 15], 18) ~ (w[j - 15] >> 3)
local s1 = rrot(w[j - 2], 17) ~ rrot(w[j - 2], 19) ~ (w[j - 2] >> 10)
w[j] = (w[j - 16] + s0 + w[j - 7] + s1) & 0xffffffff
end
local a, b, c, d, e, f, g, hh = h[1], h[2], h[3], h[4], h[5], h[6], h[7], h[8]
for j = 1, 64 do
local S1 = rrot(e, 6) ~ rrot(e, 11) ~ rrot(e, 25)
local ch = (e & f) ~ ((~e) & g)
local t1 = (hh + S1 + ch + K[j] + w[j]) & 0xffffffff
local S0 = rrot(a, 2) ~ rrot(a, 13) ~ rrot(a, 22)
local maj = (a & b) ~ (a & c) ~ (b & c)
local t2 = (S0 + maj) & 0xffffffff
hh, g, f, e, d, c, b, a = g, f, e, (d + t1) & 0xffffffff, c, b, a, (t1 + t2) & 0xffffffff
end
h[1] = (h[1] + a) & 0xffffffff; h[2] = (h[2] + b) & 0xffffffff
h[3] = (h[3] + c) & 0xffffffff; h[4] = (h[4] + d) & 0xffffffff
h[5] = (h[5] + e) & 0xffffffff; h[6] = (h[6] + f) & 0xffffffff
h[7] = (h[7] + g) & 0xffffffff; h[8] = (h[8] + hh) & 0xffffffff
end
return string.format("%08x%08x%08x%08x%08x%08x%08x%08x", table.unpack(h))
end
local function fromHex(hex)
return (hex:gsub("%x%x", function(b) return string.char(tonumber(b, 16)) end))
end
local function hmacSha256(key, msg)
if #key > 64 then key = fromHex(sha256(key)) end
key = key .. string.rep("\0", 64 - #key)
local outer = (key:gsub(".", function(c) return string.char(string.byte(c) ~ 0x5c) end))
local inner = (key:gsub(".", function(c) return string.char(string.byte(c) ~ 0x36) end))
return sha256(outer .. fromHex(sha256(inner .. msg)))
end
-- Comparacao em tempo constante (nao vaza a posicao da divergencia).
local function safeEquals(a, b)
if #a ~= #b then return false end
local diff = 0
for i = 1, #a do
diff = diff | (a:byte(i) ~ b:byte(i))
end
return diff == 0
end
-- ===== Handler HTTP =====
local SECRET = GetConvar('lznn_webhook_secret', '')
local processed = {} -- producao: persista (oxmysql etc.)
SetHttpHandler(function(req, res)
if req.method ~= 'POST' or req.path ~= '/webhook' then
res.writeHead(404); res.send(''); return
end
req.setDataHandler(function(body)
-- 1. Assinatura (rotacao pode enviar mais de uma, separadas por virgula)
if SECRET == '' then res.writeHead(500); res.send(''); return end
local header = req.headers['x-outpost-signature'] or req.headers['X-Outpost-Signature'] or ''
local expected = hmacSha256(SECRET, body)
local valid = false
for part in header:gmatch('[^,]+') do
local sig = part:gsub('^%s*v0=', ''):gsub('%s+', '')
if safeEquals(sig, expected) then valid = true end
end
if not valid then res.writeHead(401); res.send(''); return end
-- 2. Idempotencia: reenvio pode duplicar — processe cada event id UMA vez
local eventId = req.headers['x-outpost-event-id'] or req.headers['X-Outpost-Event-Id'] or ''
if processed[eventId] then res.writeHead(200); res.send('ok'); return end
processed[eventId] = true
-- 3. Responda rapido; processe depois
res.writeHead(200); res.send('ok')
local event = json.decode(body)
CreateThread(function()
-- sua logica aqui — ex.:
if event.topic == 'whitelist.submission.approved' then
print(('[lznnstore] whitelist aprovada na guild %s (user %s)')
:format(event.guildId, event.actorId or '?'))
end
end)
end)
end)6Teste e acompanhe as entregas
Use "Enviar evento de teste" (topic test.ping) para validar seu endpoint de ponta a ponta. A tabela "Entregas recentes" mostra cada tentativa com status e permite reenviar manualmente uma entrega que falhou.
7Rotacione o segredo quando precisar
Em "Ver segredo" → "Rotacionar", um novo segredo é gerado e o anterior continua válido por um período de graça — troque no seu sistema sem perder entregas. Se suspeitar de vazamento, rotacione imediatamente.
Dúvidas comuns
Por que só o dono do servidor configura?
Webhooks exportam dados do servidor para um sistema externo. Por segurança, seguimos a mesma regra das conexões de banco: apenas o dono gerencia.
Meu endpoint ficou fora do ar — perdi eventos?
Não de imediato: cada falha entra na fila de reenvio automático com intervalos crescentes, e você ainda pode reenviar manualmente pela tabela de entregas. Após muitas falhas seguidas o destino pode ser desativado automaticamente — reative-o na página.
O plano expirou. Meus destinos somem?
Não. As entregas param enquanto o servidor estiver sem plano pago, mas os destinos e configurações ficam guardados — renovando o plano, tudo volta a funcionar sozinho.
Posso apontar para localhost ou IP interno para testar?
Não — por segurança só aceitamos URLs https públicas. Para testar localmente, use um túnel (ex.: cloudflared, ngrok) que exponha seu endpoint em uma URL pública temporária.