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.
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"import { openAsBlob } from 'node:fs';
const form = new FormData();
form.append('files', await openAsBlob('nota.xml'), 'nota.xml');
form.append('webhook_url', 'https://seusite.com.br/webhooks/convertaja');
const res = await fetch('https://convertaja.com.br/v1/tools/xml-nfe-para-pdf', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.CONVERTAJA_KEY}` },
body: form,
});
const job = await res.json();
console.log(res.status, job.id); // guarde o id: ele chega de volta em evento.data.idimport os
import requests
with open("nota.xml", "rb") as f:
res = requests.post(
"https://convertaja.com.br/v1/tools/xml-nfe-para-pdf",
headers={"Authorization": f"Bearer {os.environ['CONVERTAJA_KEY']}"},
files=[("files", ("nota.xml", f, "application/xml"))],
data={"webhook_url": "https://seusite.com.br/webhooks/convertaja"},
timeout=120,
)
job = res.json()
print(res.status_code, job["id"]) # guarde o id: ele chega de volta em evento["data"]["id"]<?php
$ch = curl_init('https://convertaja.com.br/v1/tools/xml-nfe-para-pdf');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('CONVERTAJA_KEY')],
CURLOPT_POSTFIELDS => [
'files' => new CURLFile('nota.xml', 'application/xml'),
'webhook_url' => 'https://seusite.com.br/webhooks/convertaja',
],
]);
$job = json_decode(curl_exec($ch), true);
echo "{$job['id']}\n"; // guarde o id: ele chega de volta em $evento['data']['id']
A URL precisa ser HTTPS e apontar para um endereço público; caso contrário a tarefa nem é criada:
{
"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
{
"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.completedejob.failed: tarefas de arquivo (nojob.failed,data.error.messagediz o motivo edata.credits_refundedmostra o estorno);esign.envelope.completed,esign.envelope.refused,esign.envelope.expiredeesign.envelope.canceled: assinatura eletrônica, com o envelope emdata(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:
- pegue o corpo exatamente como chegou, antes de qualquer parse de JSON (reformatar muda os bytes e a assinatura não bate);
- separe
tev1do cabeçalho; - recuse se
testiver a mais de 5 minutos do relógio do seu servidor (protege contra reenvio de um aviso antigo capturado); - 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'));# webhook.py: recebe os avisos do Convertajá (pip install flask; python webhook.py)
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
SEGREDO = os.environ["CONVERTAJA_WEBHOOK_SECRET"] # whsec_…, em Minha conta → API
vistos = set() # em produção, guarde o id do evento no banco (chave única)
def assinatura_valida(corpo_cru: bytes, cabecalho: str, segredo: str, tolerancia: int = 300) -> bool:
try:
partes = dict(p.split("=", 1) for p in (cabecalho or "").split(","))
t = int(partes.get("t", "0"))
except ValueError:
return False
if not t or not partes.get("v1") or abs(time.time() - t) > tolerancia:
return False
esperado = hmac.new(segredo.encode(), f"{t}.".encode() + corpo_cru, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperado, partes["v1"])
app = Flask(__name__)
@app.post("/webhooks/convertaja")
def webhook():
corpo = request.get_data() # bytes, exatamente como chegaram
if not assinatura_valida(corpo, request.headers.get("X-Convertaja-Signature", ""), SEGREDO):
abort(400)
evento = request.get_json()
if evento["id"] in vistos:
return "", 200 # reenvio de um evento que já tratamos
vistos.add(evento["id"])
if evento["type"] == "job.completed":
for out in evento["data"]["outputs"]:
print("Pronto:", out["name"], out["url"])
elif evento["type"] == "job.failed":
print("Falhou:", evento["data"]["id"], evento["data"]["error"]["message"])
elif evento["type"].startswith("esign.envelope."):
print("Envelope", evento["data"]["id"], "->", evento["data"]["status"])
return "", 200 # responda em até 10 s
if __name__ == "__main__":
app.run(port=8080)<?php
// webhook.php: recebe os avisos do Convertajá (publique em https://seusite.com.br/webhooks/convertaja)
function assinatura_valida(string $corpoCru, string $cabecalho, string $segredo, int $tolerancia = 300): bool {
$partes = [];
foreach (explode(',', $cabecalho) as $par) {
[$chave, $valor] = array_pad(explode('=', $par, 2), 2, '');
$partes[$chave] = $valor;
}
$t = (int) ($partes['t'] ?? 0);
if (!$t || empty($partes['v1']) || abs(time() - $t) > $tolerancia) return false;
$esperado = hash_hmac('sha256', $t . '.' . $corpoCru, $segredo);
return hash_equals($esperado, $partes['v1']);
}
$segredo = getenv('CONVERTAJA_WEBHOOK_SECRET'); // whsec_…, em Minha conta → API
$corpo = file_get_contents('php://input'); // corpo cru, antes do json_decode
if (!assinatura_valida($corpo, $_SERVER['HTTP_X_CONVERTAJA_SIGNATURE'] ?? '', $segredo)) {
http_response_code(400);
exit;
}
$evento = json_decode($corpo, true);
// Reenvios trazem o mesmo $evento['id']: grave-o numa coluna única e ignore se já existir.
switch ($evento['type']) {
case 'job.completed':
foreach ($evento['data']['outputs'] as $out) error_log("Pronto: {$out['name']} {$out['url']}");
break;
case 'job.failed':
error_log("Falhou: {$evento['data']['id']} {$evento['data']['error']['message']}");
break;
default: // esign.envelope.completed | refused | expired | canceled
error_log("Envelope {$evento['data']['id']}: {$evento['data']['status']}");
}
http_response_code(200); // responda em até 10 s
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-Attemptdiz qual é a tentativa. - Os reenvios trazem o mesmo
idde evento (e o mesmoX-Convertaja-Delivery) e o mesmo corpo, com uma assinatura nova (outrot). Grave oidnuma 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"// simular-webhook.mjs: assina um evento de teste como o Convertajá faria e envia para o seu servidor local
import crypto from 'node:crypto';
const corpo = JSON.stringify({
id: 'evt_teste_local', type: 'job.completed', created_at: new Date().toISOString(),
data: { id: 'teste', status: 'done', outputs: [] },
});
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac('sha256', process.env.CONVERTAJA_WEBHOOK_SECRET).update(`${t}.${corpo}`).digest('hex');
const res = await fetch('http://localhost:8080/webhooks/convertaja', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Convertaja-Signature': `t=${t},v1=${v1}`, 'X-Convertaja-Event': 'job.completed' },
body: corpo,
});
console.log(res.status); // 200: assinatura conferida · 400: segredo ou corpo diferentes# simular_webhook.py: assina um evento de teste como o Convertajá faria e envia para o seu servidor local
import hashlib
import hmac
import json
import os
import time
import requests
corpo = json.dumps({
"id": "evt_teste_local", "type": "job.completed", "created_at": "2026-10-16T20:26:06.404Z",
"data": {"id": "teste", "status": "done", "outputs": []},
}).encode()
t = int(time.time())
v1 = hmac.new(os.environ["CONVERTAJA_WEBHOOK_SECRET"].encode(), f"{t}.".encode() + corpo, hashlib.sha256).hexdigest()
res = requests.post(
"http://localhost:8080/webhooks/convertaja",
data=corpo,
headers={"Content-Type": "application/json", "X-Convertaja-Signature": f"t={t},v1={v1}", "X-Convertaja-Event": "job.completed"},
timeout=10,
)
print(res.status_code) # 200: assinatura conferida · 400: segredo ou corpo diferentes
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.