Integração
Avisos e assinatura
O Sellume avisa o seu servidor quando algo acontece. Cada aviso vem assinado; o seu endpoint confere a assinatura antes de confiar no conteúdo.
Cadastrar um endereço#
curl -s -X PUT $B/avisos -H "Authorization: Bearer $K" -H 'content-type: application/json' \
-d '{"url":"https://erp.exemplo.com.br/webhooks/sellume","eventos":["geracao.concluida","geracao.falhou"],"descricao":"Produção"}'- PUT
/avisoscadastra ou atualiza pelourl, sem duplicar:201com osegredona primeira vez;200sem o segredo quando o endereço já existia. POST/avisossó cadastra. - O
segredo(whsec_…) aparece uma única vez. Guarde no cofre do seu servidor. - Só HTTPS público, porta 443 ou 8443. Endereço interno,
localhost, IP privado ou com usuário e senha é recusado. Redirecionamentos não são seguidos. - Até 5 endereços por integrador e ambiente (teste e produção têm endereços próprios).
- Os avisos de contas vinculadas por OAuth também chegam aqui, com o
contaIddelas.
Envelope#
Todo aviso é um POST com corpo JSON neste formato:
{
"eventId": "evt_01J9Z8H7J6K5L4M3N2P1Q0R9S8",
"date": "2026-10-10T14:08:32Z",
"version": "1",
"event": "geracao.concluida",
"integradorId": "itg_01J9Z5W4V3U2T1S0R9Q8P7N6M5",
"contaId": "cta_01J9Z6K8X2M4R7T1Q3W5E8Y0U2",
"data": { "id": "ger_01J9Z8C4D5E6F7G8H9J0K1L2M3", "tipo": "galeria", "situacao": "concluida" }
}| Cabeçalho | O que é |
|---|---|
X-Sellume-Signature | t=<segundos unix>,v1=<hex>; durante a troca do segredo vêm dois v1 |
X-Sellume-Event-Id | igual ao eventId; use para descartar duplicados |
X-Sellume-Event | nome do evento |
X-Sellume-Delivery-Id | id da entrega (ent_…), diferente a cada entrega |
A entrega é "pelo menos uma vez": o mesmo evento pode chegar mais de uma vez. Guarde os eventId já processados e ignore os repetidos. contaId é nulo em eventos da carteira.
Assinatura#
v1 = HMAC-SHA256(segredo, "<t>.<corpo bruto>") em hexadecimal minúsculoO segredo é o texto inteiro whsec_…, usado como chave. Para conferir:
- Leia o corpo bruto, exatamente os bytes recebidos, antes de qualquer
JSON.parse. Corpo reformatado não confere. - Separe
te osv1do cabeçalhoX-Sellume-Signature. - Recuse se
testiver a mais de 5 minutos do seu relógio (proteção contra reenvio de má-fé). - Calcule o HMAC de
t+.+ corpo bruto e compare em tempo constante com cadav1. Aceite se algum bater. - Assinatura que não confere ou carimbo velho: responda 401 (ou 400 / 403) e não processe.
- Confere: responda 2XX rápido (em até 5 segundos) e processe numa fila do seu lado.
Node.js#
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const SEGREDO = process.env.SELLUME_WHSEC; // whsec_…
const TOLERANCIA_S = 300; // 5 minutos
export function assinaturaValida(corpoBruto, cabecalho, segredo, agora = Math.floor(Date.now() / 1000)) {
if (typeof cabecalho !== 'string' || !segredo) return false;
let t = null;
const assinaturas = [];
for (const parte of cabecalho.split(',')) {
const i = parte.indexOf('=');
if (i < 0) continue;
const chave = parte.slice(0, i).trim();
const valor = parte.slice(i + 1).trim();
if (chave === 't') t = valor;
else if (chave === 'v1') assinaturas.push(valor);
}
if (!t || !/^\d+$/.test(t) || assinaturas.length === 0) return false;
if (Math.abs(agora - Number(t)) > TOLERANCIA_S) return false;
const esperado = createHmac('sha256', segredo).update(`${t}.`).update(corpoBruto).digest();
return assinaturas.some((v1) => /^[0-9a-f]{64}$/.test(v1) && timingSafeEqual(Buffer.from(v1, 'hex'), esperado));
}
createServer((pedido, resposta) => {
const pedacos = [];
pedido.on('data', (pedaco) => pedacos.push(pedaco));
pedido.on('end', () => {
const corpo = Buffer.concat(pedacos); // corpo BRUTO
if (!assinaturaValida(corpo, pedido.headers['x-sellume-signature'], SEGREDO)) {
resposta.writeHead(401).end();
return;
}
resposta.writeHead(200).end(); // responda logo
const aviso = JSON.parse(corpo.toString('utf8'));
// descarte se aviso.eventId já foi processado; senão, coloque numa fila
});
}).listen(3000);Com Express, use express.raw({ type: 'application/json' }) na rota do aviso para receber o corpo bruto (req.body vira um Buffer).
PHP#
<?php
function assinaturaValida(string $corpo, string $cabecalho, string $segredo, ?int $agora = null): bool
{
$agora = $agora ?? time();
$t = null;
$assinaturas = [];
foreach (explode(',', $cabecalho) as $parte) {
$par = explode('=', trim($parte), 2);
if (count($par) !== 2) continue;
if ($par[0] === 't') $t = $par[1];
elseif ($par[0] === 'v1') $assinaturas[] = $par[1];
}
if ($t === null || !ctype_digit($t) || !$assinaturas) return false;
if (abs($agora - (int) $t) > 300) return false; // 5 minutos
$esperado = hash_hmac('sha256', $t . '.' . $corpo, $segredo);
foreach ($assinaturas as $v1) {
if (hash_equals($esperado, $v1)) return true;
}
return false;
}
$corpo = file_get_contents('php://input'); // corpo BRUTO, antes de json_decode
$cabecalho = $_SERVER['HTTP_X_SELLUME_SIGNATURE'] ?? '';
$segredo = getenv('SELLUME_WHSEC'); // whsec_…
if (!assinaturaValida($corpo, $cabecalho, (string) $segredo)) {
http_response_code(401);
exit;
}
http_response_code(200);
$aviso = json_decode($corpo, true);
// descarte se $aviso['eventId'] já foi processado; senão, coloque numa filaPython#
import hashlib
import hmac
import os
import time
SEGREDO = os.environ["SELLUME_WHSEC"] # whsec_…
TOLERANCIA_S = 300 # 5 minutos
def assinatura_valida(corpo: bytes, cabecalho: str, segredo: str, agora=None) -> bool:
agora = int(time.time()) if agora is None else agora
t = None
assinaturas = []
for parte in (cabecalho or "").split(","):
chave, _, valor = parte.strip().partition("=")
if chave == "t":
t = valor
elif chave == "v1":
assinaturas.append(valor)
if not t or not (t.isascii() and t.isdigit()) or not assinaturas:
return False
if abs(agora - int(t)) > TOLERANCIA_S:
return False
esperado = hmac.new(segredo.encode(), t.encode() + b"." + corpo, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(esperado.encode(), v1.encode()) for v1 in assinaturas)
# Exemplo com Flask: request.get_data() é o corpo bruto (não use request.json para conferir).
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/sellume")
def receber_aviso():
corpo = request.get_data()
if not assinatura_valida(corpo, request.headers.get("X-Sellume-Signature", ""), SEGREDO):
return "", 401
# descarte se o eventId já foi processado; senão, coloque numa fila
return "", 200Teste de assinatura#
POST/avisos/teste envia o evento aviso.teste em três entregas ao endereço indicado (avisoId) ou a todos os ativos do ambiente:
| Entrega | O que chega | O seu endpoint precisa responder |
|---|---|---|
| válida | assinatura certa, carimbo atual | 2XX |
| assinatura errada | X-Sellume-Teste: assinatura_invalida | 400, 401 ou 403 |
| carimbo antigo | carimbo de 10 minutos atrás, assinatura certa (X-Sellume-Teste: carimbo_antigo) | 400, 401 ou 403 |
curl -s -X POST $B/avisos/teste -H "Authorization: Bearer $K" -H 'content-type: application/json' -d '{"avisoId":"avs_…"}'A resposta é 202 com uma lista — um teste por endereço (sem avisoId, todos os ativos do ambiente). Guarde o id (tst_…) de cada um:
{ "data": [ { "id": "tst_01M4NCF47MR0BHT90ENTXB52JK", "avisoId": "avs_01M4NCE2Q8VJ4R6T9W1Y3B5D7F", "situacao": "em_andamento" } ] }Só as três certas deixam verde a etapa AVISOS_ASSINATURA. Assim o Sellume sabe que você confere a assinatura, e não só responde 2XX. Aceitar uma das duas erradas aparece como assinatura_nao_conferida. O resultado sai em segundos em GET/testes-de-avisos/{testeId} e no GET/integracao/status.
{
"data": {
"id": "tst_01M4NCF47MR0BHT90ENTXB52JK",
"situacao": "aprovado",
"entregas": [
{ "caso": "valido", "esperado": "2XX", "statusHttp": 200, "aprovado": true },
{ "caso": "assinatura_invalida", "esperado": "400|401|403", "statusHttp": 401, "aprovado": true },
{ "caso": "carimbo_antigo", "esperado": "400|401|403", "statusHttp": 401, "aprovado": true }
]
}
}Retentativas e desligamento#
- Tempo-limite de 5 segundos por entrega. Responda 2XX logo e processe depois.
- Sem 2XX, novas tentativas em 1 min, 5 min, 30 min, 2 h, 6 h, 12 h e depois a cada 12 h, por até 3 dias.
- Endereço sem nenhuma entrega com sucesso por 3 dias é desligado automaticamente (
situacao: desligado) e você recebe um e-mail. Para religar: PATCH/avisos/{avisoId}comsituacao: ativo. - Cada entrega tem o motivo da falha (
motivoFalha) e o como corrigir: GET/avisos/{avisoId}/entregas. Motivos: tempo_esgotado, status_http, conexao_recusada, dns, tls, assinatura_nao_conferida, endereco_bloqueado. - Reenviar agora: POST
/avisos/{avisoId}/entregas/{entregaId}/reenvio. O reenvio leva o mesmoeventId, com nova assinatura e novo carimbo.
Eventos#
| Evento | Quando | data |
|---|---|---|
produto.conferido | a conferência de uma versão do produto terminou | a conferência |
geracao.iniciada | a geração saiu da fila e começou (gerando) | a geração |
geracao.concluida | a geração terminou com entrega (concluida ou parcial) | a geração, com os links |
geracao.falhou | a geração falhou; créditos devolvidos | a geração, com erro |
creditos.baixos | o saldo ficou abaixo do limite de alerta | o saldo |
cota.alerta | o uso do ciclo chegou a 70% ou 90% da cota do plano, ou bateu o teto | a cota do mês, com gatilho: cota_70, cota_90 ou teto |
aviso.teste | você pediu o teste de assinatura | { "mensagem": "…" } |
Os links de geracao.concluida vencem: para links novos, consulte GET/geracoes/{geracaoId}. Formato completo de cada evento na Referência.
Troca do segredo#
POST/avisos/{avisoId}/segredo gera um segredo novo, mostrado uma única vez. O anterior continua aceito por 24 horas (segredoAnteriorValidoAte): nesse período cada aviso traz dois v1, um de cada segredo. Os exemplos acima já aceitam qualquer um dos v1.
- Gere o segredo novo e guarde-o ao lado do antigo.
- Confira com qualquer um dos dois enquanto durar a troca.
- Depois das 24 horas, apague o antigo.