Pular para o conteúdo
tulha
Documentação

Integre a emissão de NFS-e em uma tarde.

Uma API REST em JSON. Você envia a venda, o Tulha emite a NFS-e no padrão nacional (ou no sistema da prefeitura), guarda o XML e o PDF e avisa seu sistema por webhook.

  • Assíncrona: o POST responde 202 na hora; a autorização chega por webhook (ou por consulta).
  • Sem duplicidade: toda emissão leva uma Idempotency-Key; repetir a chamada devolve a mesma nota.
  • Códigos fiscais no cadastro: código de tributação, NBS, alíquota e regime ficam configurados no seu emitente. Na chamada vão só valor, serviço e cliente.
  • Valores em reais com até 2 casas (150.00); datas em ISO 8601.

Autenticação e ambientes

Todas as chamadas vão para https://api.tulha.dev/v1 com o token da sua conta no cabeçalho:

Authorization: Bearer SEU_TOKEN
Accept: application/json
Content-Type: application/json

O token decide o ambiente. Você recebe dois tokens ao entrar:

TokenO que aconteceLimite
sandboxEmite no ambiente de testes do emissor. Não tem valor fiscal e não debita saldo.60 req/min
productionNota real. Debita R$ 0,49 do saldo quando é autorizada.600 req/min

Notas de um ambiente não aparecem no outro, e a numeração é separada. Passou do limite, a resposta é 429.

Guarde o token como senha. Ele fica no seu servidor, nunca no navegador nem no app do seu cliente.

Sua primeira nota

Com o token de sandbox, emita uma nota de teste:

curl -X POST https://api.tulha.dev/v1/nfse \
  -H "Authorization: Bearer $TULHA_TOKEN" \
  -H "Idempotency-Key: pedido-1001" \
  -H "Content-Type: application/json" \
  -d '{
    "issuer_cnpj": "12345678000195",
    "external_ref": "pedido-1001",
    "service": { "amount": 150.00, "description": "Consultoria - outubro/2026" },
    "customer": { "document": "12345678909", "name": "Maria da Silva", "email": "maria@exemplo.com" },
    "send_email": true
  }'

Resposta imediata:

HTTP/1.1 202 Accepted
{ "id": "9d3f6a2e-0b7c-4a51-9e7a-2f4c1d8b6e10", "status": "queued" }

Em seguida chega o webhook nfse.authorized com a chave de acesso, o número e os links do XML e do PDF. Pronto: é o mesmo fluxo em produção, trocando o token.

POST /nfse — Emitir

Valida tudo de forma síncrona (dados, emitente, certificado, saldo) e só então enfileira. O que entra na fila tem chance real de ser autorizado.

Cabeçalhos

  • Idempotency-Key obrigatório, até 191 caracteres. Use o ID do pedido ou da cobrança no seu sistema.

Corpo

CampoTipoDescrição
issuer_cnpjstringObrigatório. CNPJ do emitente (prestador), 14 dígitos sem máscara.
external_refstringSua referência (pedido, cobrança). Volta nos webhooks e serve de filtro na listagem. Até 191.
service.amountnúmeroObrigatório. Valor do serviço em reais, maior que zero, até 2 casas.
service.descriptionstringDescrição na nota. Obrigatória se o serviço cadastrado não tiver descrição padrão. Até 2.000.
service.codestringCódigo de um serviço cadastrado no seu emitente. Sem ele, vale o serviço padrão.
service.competencestringAAAA-MM ou AAAA-MM-DD. Padrão: data da emissão. Nunca no futuro.
service.national_tax_codestringCódigo de tributação nacional (6 dígitos), se quiser sobrescrever o do cadastro.
service.nbs_codestringCódigo NBS, se quiser sobrescrever o do cadastro.
customer.documentstringObrigatório. CPF ou CNPJ do tomador (com ou sem máscara, validado).
customer.namestringObrigatório. Nome ou razão social. Até 300.
customer.emailstringE-mail do tomador. Necessário para send_email.
customer.addressobjetoOpcional. Com ele: zip, street, number e city_ibge (7 dígitos) obrigatórios; district, complement e state opcionais. Sem district, o endereço não vai para a nota.
send_emailbooleanEnvia o PDF e o XML ao tomador quando a nota é autorizada. Padrão false.

Respostas

  • 202 {"id", "status": "queued"}: aceita, saldo reservado.
  • 400 dados inválidos · 402 saldo insuficiente · 409 idempotência em conflito · 422 emitente ou certificado sem condição de emitir. Detalhes em Erros.

Idempotência

A rede falha. Por isso a mesma chamada pode ser repetida sem medo:

  • Mesma chave, mesmo corpo: devolve a nota já criada, com o mesmo id. Nada é emitido de novo.
  • Mesma chave, corpo diferente: 409 idempotency_conflict, com o document_id da nota original.

Do nosso lado, o número da DPS é reservado antes de falar com a prefeitura, e toda nova tentativa começa consultando se a nota já existe. Uma venda, uma nota.

Estados da nota

StatusSignificadoFinal?
queuedAceita, esperando o worker.Não
sendingSendo enviada ao emissor.Não
retryingEmissor fora do ar ou resposta incerta. Vamos conferir e tentar de novo, com o mesmo número.Não
authorizedAutorizada. Tem chave de acesso, número, XML e PDF. Debita o saldo.Sim*
rejectedRecusada pelo emissor; o motivo vem em errors. A reserva volta para o saldo.Sim
failedNão pôde ser enviada (ex.: certificado vencido). A reserva volta para o saldo.Sim
cancelledCancelada depois de autorizada.Sim
replacedSubstituída por outra nota.Sim

* Uma nota authorized ainda pode virar cancelled ou replaced.

GET /nfse/{id} — Consultar

{
  "id": "9d3f6a2e-0b7c-4a51-9e7a-2f4c1d8b6e10",
  "status": "authorized",
  "environment": "production",
  "external_ref": "pedido-1001",
  "issuer_cnpj": "12345678000195",
  "series": "2",
  "dps_number": 4,
  "access_key": "3168002212345678000195260000000000123426100000000017",
  "number": "1234",
  "amount": 150.0,
  "errors": [],
  "attempts": 1,
  "replaces": null,
  "events": [],
  "links": { "self": "…/v1/nfse/9d3f…", "xml": "…/xml", "pdf": "…/pdf" },
  "authorized_at": "2026-10-15T14:32:17-03:00",
  "cancelled_at": null,
  "created_at": "2026-10-15T14:32:14-03:00"
}

GET /nfse — Listar

Filtros opcionais: issuer_cnpj, status, external_ref, from, to (datas) e limit (1 a 100, padrão 50). Mais recentes primeiro, com paginação por cursor:

GET /v1/nfse?status=authorized&limit=50
{ "data": [ … ], "next_cursor": "eyJjcmVhdGVkX2F0Ijo…", "has_more": true }

GET /v1/nfse?status=authorized&cursor=eyJjcmVhdGVkX2F0Ijo…

GET /nfse/{id}/xml · /pdf

Responde 302 para um link temporário (15 minutos) do arquivo. Siga o redirecionamento ou guarde o arquivo do seu lado. Antes de a nota sair, a resposta é 404 xml_not_ready / pdf_not_ready.

POST /nfse/{id}/cancel — Cancelar

Só para notas authorized. Assíncrono: responde 202 e o resultado chega pelo webhook nfse.cancelled.

{ "reason_code": "1", "reason": "Nota emitida com valor incorreto" }

HTTP/1.1 202 Accepted
{ "id": "9d3f…", "event": { "type": "cancelamento", "status": "queued" } }
  • reason_code: 1 erro na emissão, 2 serviço não prestado, 9 outros.
  • reason: de 15 a 255 caracteres.
  • Repetir enquanto o cancelamento está em andamento devolve o mesmo evento. Cancelar não gera cobrança nem estorno.

POST /nfse/{id}/replace — Substituir

Emite uma nota nova que substitui a autorizada. O corpo é o mesmo do POST /nfse (com Idempotency-Key), mais o motivo. issuer_cnpj é opcional e, se vier, tem que ser o da nota original.

{
  "reason_code": "01",
  "service": { "amount": 140.00, "description": "Consultoria - outubro/2026" },
  "customer": { "document": "12345678909", "name": "Maria da Silva" }
}

HTTP/1.1 202 Accepted
{ "id": "(nota nova)", "status": "queued", "replaces": "(nota original)" }

reason_code segue a tabela nacional (01 a 05, ou 99 com reason de 15 a 255 caracteres). Quando a nova é autorizada, a original vira replaced e chega o webhook nfse.replaced. A nota nova é cobrada como qualquer emissão.

Saldo e emitentes

GET /account/balance

{
  "balance": 120.54, "reserved": 0.98, "available": 119.56,
  "price_per_document": 0.49, "status": "active",
  "last_recharge": { "amount": 100.0, "status": "paid", … }
}

Cada nota aceita reserva R$ 0,49 (reserved). A reserva vira débito na autorização ou volta para o saldo em rejected/failed. A recarga é automática no cartão quando o saldo fica baixo.

GET /issuers

Lista os emitentes da conta, com certificate_required e a validade do certificado A1 (valid_until, days_to_expire). Emitente que emite pelo sistema da prefeitura não precisa de certificado: certificate_required: false.

Webhooks

Informe uma URL HTTPS na sua configuração e o Tulha faz um POST em JSON a cada evento:

EventoQuando
nfse.authorizedNota autorizada: chave, número, links de XML e PDF.
nfse.rejectedRecusada pelo emissor, com errors.
nfse.failedNão pôde ser enviada.
nfse.cancelledCancelamento registrado.
nfse.replacedSubstituição concluída.
certificate.expiringCertificado A1 perto de vencer.
wallet.low_balance · wallet.credited · wallet.recharge_failedMovimentos da carteira.
POST https://seu-sistema.com/webhooks/tulha
X-Tulha-Event: nfse.authorized
X-Tulha-Delivery: 5b1d0c8e-…
X-Tulha-Signature: 3f7a…

{
  "event": "nfse.authorized",
  "occurred_at": "2026-10-15T14:32:17-03:00",
  "data": {
    "id": "9d3f…", "external_ref": "pedido-1001", "status": "authorized",
    "environment": "production", "access_key": "3168…", "number": "1234",
    "xml_url": "https://api.tulha.dev/v1/nfse/9d3f…/xml", "pdf_url": "https://api.tulha.dev/v1/nfse/9d3f…/pdf",
    "pdf_public_url": "https://api.tulha.dev/nfse/9d3f…/danfse?signature=…"
  }
}

Confira a assinatura

X-Tulha-Signature é o HMAC-SHA256 (hex) do corpo exato da requisição com o segredo do seu endpoint. Compare antes de confiar no conteúdo:

// PHP
$corpo = file_get_contents('php://input');
$esperada = hash_hmac('sha256', $corpo, getenv('TULHA_WEBHOOK_SECRET'));
if (! hash_equals($esperada, $_SERVER['HTTP_X_TULHA_SIGNATURE'] ?? '')) { http_response_code(401); exit; }

// Node.js (Express com express.raw)
const esperada = crypto.createHmac('sha256', process.env.TULHA_WEBHOOK_SECRET).update(req.body).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(req.get('X-Tulha-Signature') || ''));

Entrega e repetição

  • Responda 2xx em até 10 segundos. Qualquer outra coisa é tentada de novo até 10 vezes: depois de 1 min, 5 min, 30 min, 2 h, 6 h, 12 h e então a cada 24 h.
  • A mesma entrega pode chegar mais de uma vez: use X-Tulha-Delivery (ou o id da nota + o evento) para ignorar repetidas.
  • Perdeu algo? GET /nfse/{id} sempre tem o estado atual.

Erros

Todo erro vem no mesmo envelope:

HTTP/1.1 402 Payment Required
{ "error": { "code": "insufficient_balance", "message": "Saldo insuficiente para emitir…",
  "details": [ { "available": 0.2, "required": 0.49 } ] } }
HTTPcodeO que fazer
400validation_errorCorrija os campos listados em details (field, message).
400idempotency_key_requiredEnvie o cabeçalho Idempotency-Key.
401invalid_tokenToken ausente, inválido ou revogado.
402insufficient_balanceSaldo acabou. Aguarde a recarga confirmar.
402account_suspendedFale com o suporte.
404not_foundNota não existe nesta conta e neste ambiente.
409idempotency_conflictMesma chave com outro corpo. Use outra chave ou repita o corpo original.
409invalid_stateSó notas authorized podem ser canceladas ou substituídas.
422issuer_not_found · issuer_inactiveCNPJ não cadastrado nesta conta, ou emitente inativo.
422certificate_missing · certificate_expired · certificate_not_yet_valid · certificate_invalidEnvie um certificado A1 válido do emitente.
422municipality_not_supportedO município do emitente ainda não é atendido. Fale com a gente.
429—Limite de requisições. Espere e tente de novo.

Rejeições do emissor (ex.: código de serviço inválido para o município) não são erros HTTP: a nota vira rejected e o motivo vem em errors, com o código oficial.

Integração com o Asaas (sem código)

Se você cobra pelo Asaas, nem precisa chamar a API: cada cobrança paga vira uma NFS-e automaticamente.

  1. Gere uma chave de API no Asaas sem permissão de saque e envie para nós. Usamos só para ler os dados do cliente da cobrança.
  2. Você recebe uma URL de webhook exclusiva e um token.
  3. No Asaas, em Integrações › Webhooks, crie um webhook com essa URL, o token de autenticação e os eventos PAYMENT_CONFIRMED e PAYMENT_RECEIVED.
  4. Se o Asaas também emite notas para você, desligue a emissão de lá, para não sair nota em dobro.

Uma cobrança gera uma nota só, mesmo que o Asaas mande os dois eventos. A nota leva external_ref = id da cobrança e segue o mesmo fluxo de webhooks.

Pronto para integrar?

Entre na lista de espera e receba seu token de sandbox.

Entrar na lista de espera