LZNN STORELZNN STORE
Todos os guias

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.