convertajá

Guia 3 de 8

Webhooks: receber o aviso de conclusão e validar a assinatura

Em vez de ficar consultando a tarefa, deixe que a API avise o seu sistema quando terminar. Cada aviso vem assinado para você ter certeza de que foi o Convertajá que mandou.

15 min · Intermediário · atualizado em 16/10/2026

O que você vai construir

Um endpoint HTTPS no seu servidor que recebe os avisos, confere a assinatura X-Convertaja-Signature, ignora reenvios repetidos e responde em menos de 10 segundos. Em Node.js (Express), Python (Flask) e PHP.

Antes de começar

  • Uma chave de API em CONVERTAJA_KEY (veja Primeiros passos).
  • Um endereço HTTPS público para receber os avisos (em desenvolvimento, um túnel como ngrok ou Cloudflare Tunnel).
  • O segredo dos webhooks da sua conta (passo 1).

Passo 1: Pegue o segredo dos webhooks

Em Minha conta → API, no quadro Segredo dos webhooks, clique em Mostrar. O segredo começa com whsec_ e é o mesmo para todas as chaves da conta. Guarde em CONVERTAJA_WEBHOOK_SECRET, como a chave.

Passo 2: Informe o webhook_url ao criar a tarefa

Envie webhook_url junto com o arquivo: como campo do formulário, no corpo JSON ou na URL (?webhook_url=…). Troque https://seusite.com.br/… pelo seu endereço:

curl -X POST "https://convertaja.com.br/v1/tools/xml-nfe-para-pdf" \
  -H "Authorization: Bearer $CONVERTAJA_KEY" \
  -F "files=@nota.xml" \
  -F "webhook_url=https://seusite.com.br/webhooks/convertaja"

A URL precisa ser HTTPS e apontar para um endereço público; caso contrário a tarefa nem é criada:

400: URL sem HTTPS
{
  "error": {
    "code": "invalid_webhook_url",
    "message": "webhook_url inválida: Use uma URL https://.",
    "request_id": "req_UkbrW3QRi59d"
  }
}

Passo 3: Entenda o que chega

Quando a tarefa termina, fazemos um POST com JSON para o seu endereço. Cabeçalhos de uma entrega real:

Content-Type: application/json
User-Agent: Convertaja-API/1.0
X-Convertaja-Event: job.completed
X-Convertaja-Delivery: evt_36120396b5775c92ae92
X-Convertaja-Attempt: 1
X-Convertaja-Signature: t=1792182366,v1=db12c0994763d9afcc170a89eb85654d0011f420c5f2ace59a344ed34347763c
Corpo
{
  "id": "evt_36120396b5775c92ae92",
  "type": "job.completed",
  "created_at": "2026-10-16T20:26:06.404Z",
  "data": {
    "id": "cfY2um9L6235",
    "tool": "xml-nfe-para-pdf",
    "operation": "nfe-danfe",
    "status": "done",
    "progress": 100,
    "message": "Pronto!",
    "created_at": "2026-10-16T20:26:05.972Z",
    "finished_at": "2026-10-16T20:26:06.404Z",
    "expires_at": "2026-10-16T21:26:05.972Z",
    "credits_charged": 1,
    "credits_refunded": 0,
    "test": false,
    "outputs": [
      {
        "index": 0,
        "name": "NF-e 4521 - DISTRIBUIDORA PAULISTA DE FERRAGENS E MA.pdf",
        "size": 6291,
        "url": "https://convertaja.com.br/v1/jobs/cfY2um9L6235/files/0"
      }
    ],
    "download_url": "https://convertaja.com.br/v1/jobs/cfY2um9L6235/download",
    "info": "1 nota convertida"
  }
}

data é a tarefa, igual à resposta de GET /v1/jobs/{id}. Os tipos de evento são:

  • job.completed e job.failed: tarefas de arquivo (no job.failed, data.error.message diz o motivo e data.credits_refunded mostra o estorno);
  • esign.envelope.completed, esign.envelope.refused, esign.envelope.expired e esign.envelope.canceled: assinatura eletrônica, com o envelope em data (veja o guia).

Passo 4: Valide a assinatura

O cabeçalho é X-Convertaja-Signature: t=<unix>,v1=<hex>, em que v1 é o HMAC-SHA256 (em hexadecimal) do texto "{t}.{corpo cru}" com o seu segredo. Para conferir:

  1. pegue o corpo exatamente como chegou, antes de qualquer parse de JSON (reformatar muda os bytes e a assinatura não bate);
  2. separe t e v1 do cabeçalho;
  3. recuse se t estiver a mais de 5 minutos do relógio do seu servidor (protege contra reenvio de um aviso antigo capturado);
  4. calcule o HMAC e compare em tempo constante.

Servidores completos, prontos para rodar (é a mesma lógica que usamos nos nossos testes):

// webhook.mjs: recebe os avisos do Convertajá (npm install express; node webhook.mjs)
import crypto from 'node:crypto';
import express from 'express';

const SEGREDO = process.env.CONVERTAJA_WEBHOOK_SECRET; // whsec_…, em Minha conta → API
const vistos = new Set(); // em produção, guarde o id do evento no banco (chave única)

function assinaturaValida(corpoCru, cabecalho, segredo, toleranciaSeg = 300) {
  const partes = Object.fromEntries(String(cabecalho || '').split(',').map((p) => p.split('=')));
  const t = Number(partes.t);
  if (!t || !partes.v1 || Math.abs(Date.now() / 1000 - t) > toleranciaSeg) return false;
  const esperado = crypto.createHmac('sha256', segredo).update(`${t}.${corpoCru}`).digest('hex');
  const a = Buffer.from(esperado);
  const b = Buffer.from(String(partes.v1));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// express.raw: a assinatura é calculada sobre o corpo exatamente como chegou
app.post('/webhooks/convertaja', express.raw({ type: 'application/json' }), (req, res) => {
  const corpo = req.body.toString('utf8');
  if (!assinaturaValida(corpo, req.get('X-Convertaja-Signature'), SEGREDO)) return res.sendStatus(400);

  const evento = JSON.parse(corpo);
  res.sendStatus(200); // responda em até 10 s; o trabalho pesado vem depois
  if (vistos.has(evento.id)) return; // reenvio de um evento que já tratamos
  vistos.add(evento.id);
  processar(evento).catch(console.error);
});

async function processar(evento) {
  if (evento.type === 'job.completed') {
    for (const out of evento.data.outputs) console.log('Pronto:', out.name, out.url);
  } else if (evento.type === 'job.failed') {
    console.log('Falhou:', evento.data.id, evento.data.error.message);
  } else if (evento.type.startsWith('esign.envelope.')) {
    console.log('Envelope', evento.data.id, '→', evento.data.status);
  }
}

app.listen(8080, () => console.log('Esperando avisos em http://localhost:8080/webhooks/convertaja'));

Dica Em Express, use express.raw() só na rota do webhook. Se um express.json() global ler o corpo antes, você perde os bytes originais. Em PHP, use file_get_contents('php://input'), não $_POST.

Passo 5: Responda rápido e trate reenvios

  • Responda com qualquer status 2xx em até 10 segundos. Baixar arquivos e atualizar o banco pode ficar para depois (fila, job em segundo plano).
  • Se não receber 2xx (erro, demora ou conexão recusada), tentamos de novo: até 3 tentativas no total, com esperas de 5 s e 30 s. O cabeçalho X-Convertaja-Attempt diz qual é a tentativa.
  • Os reenvios trazem o mesmo id de evento (e o mesmo X-Convertaja-Delivery) e o mesmo corpo, com uma assinatura nova (outro t). Grave o id numa coluna única e ignore se já existir.
  • Depois da 3ª falha, não há mais tentativas. Como os arquivos ficam 1 hora no ar, tenha um plano B: se o aviso não chegou em alguns minutos, consulte GET /v1/jobs/{id}.

Passo 6: Teste no seu computador

Não enviamos avisos para localhost nem para redes internas. Para testar a integração inteira, exponha a porta local com um túnel HTTPS (ex.: ngrok http 8080) e use o endereço público como webhook_url.

Para testar só a conferência da assinatura, simule um aviso assinado com o seu segredo:

# Assina um evento de teste como o Convertajá faria e envia para o seu servidor local
CORPO='{"id":"evt_teste_local","type":"job.completed","created_at":"2026-10-16T20:26:06.404Z","data":{"id":"teste","status":"done","outputs":[]}}'
T=$(date +%s)
V1=$(printf '%s' "$T.$CORPO" | openssl dgst -sha256 -hmac "$CONVERTAJA_WEBHOOK_SECRET" | sed 's/^.* //')

curl -i -X POST "http://localhost:8080/webhooks/convertaja" \
  -H "Content-Type: application/json" \
  -H "X-Convertaja-Signature: t=$T,v1=$V1" \
  -H "X-Convertaja-Event: job.completed" \
  --data-raw "$CORPO"

Se responder 400, o segredo do seu servidor é diferente do usado na assinatura, ou o corpo foi alterado antes da conferência.

Trocar o segredo

Em /conta/api, Trocar gera um segredo novo na hora; avisos enviados depois disso já usam o novo. Para não perder nada durante a troca, aceite por alguns minutos as duas assinaturas (antiga e nova) e depois remova a antiga.

Próximos passos

Todos os endpoints e opções: referência da API. Dúvidas? Fale com a gente e informe o X-Request-Id da chamada.