Pular para o conteúdo
sellumeDOCS · INTEGRADORES Portal do integrador

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#

bash
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/avisos cadastra ou atualiza pelo url, sem duplicar: 201 com o segredo na primeira vez; 200 sem o segredo quando o endereço já existia. POST/avisos só 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 contaId delas.

Envelope#

Todo aviso é um POST com corpo JSON neste formato:

json
{
  "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çalhoO que é
X-Sellume-Signaturet=<segundos unix>,v1=<hex>; durante a troca do segredo vêm dois v1
X-Sellume-Event-Idigual ao eventId; use para descartar duplicados
X-Sellume-Eventnome do evento
X-Sellume-Delivery-Idid 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#

texto
v1 = HMAC-SHA256(segredo, "<t>.<corpo bruto>")   em hexadecimal minúsculo

O segredo é o texto inteiro whsec_…, usado como chave. Para conferir:

  1. Leia o corpo bruto, exatamente os bytes recebidos, antes de qualquer JSON.parse. Corpo reformatado não confere.
  2. Separe t e os v1 do cabeçalho X-Sellume-Signature.
  3. Recuse se t estiver a mais de 5 minutos do seu relógio (proteção contra reenvio de má-fé).
  4. Calcule o HMAC de t + . + corpo bruto e compare em tempo constante com cada v1. Aceite se algum bater.
  5. Assinatura que não confere ou carimbo velho: responda 401 (ou 400 / 403) e não processe.
  6. Confere: responda 2XX rápido (em até 5 segundos) e processe numa fila do seu lado.

Node.js#

Node.js (sem dependências)
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
<?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 fila

Python#

Python
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 "", 200

Teste 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:

EntregaO que chegaO seu endpoint precisa responder
válidaassinatura certa, carimbo atual2XX
assinatura erradaX-Sellume-Teste: assinatura_invalida400, 401 ou 403
carimbo antigocarimbo de 10 minutos atrás, assinatura certa (X-Sellume-Teste: carimbo_antigo)400, 401 ou 403
bash
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:

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

resposta de GET /testes-de-avisos/{testeId} (resumida)
{
  "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#

Eventos#

EventoQuandodata
produto.conferidoa conferência de uma versão do produto terminoua conferência
geracao.iniciadaa geração saiu da fila e começou (gerando)a geração
geracao.concluidaa geração terminou com entrega (concluida ou parcial)a geração, com os links
geracao.falhoua geração falhou; créditos devolvidosa geração, com erro
creditos.baixoso saldo ficou abaixo do limite de alertao saldo
cota.alertao uso do ciclo chegou a 70% ou 90% da cota do plano, ou bateu o tetoa cota do mês, com gatilho: cota_70, cota_90 ou teto
aviso.testevocê 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.

  1. Gere o segredo novo e guarde-o ao lado do antigo.
  2. Confira com qualquer um dos dois enquanto durar a troca.
  3. Depois das 24 horas, apague o antigo.