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
POSTresponde202na 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:
| Token | O que acontece | Limite |
|---|---|---|
sandbox | Emite no ambiente de testes do emissor. Não tem valor fiscal e não debita saldo. | 60 req/min |
production | Nota 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.
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-Keyobrigatório, até 191 caracteres. Use o ID do pedido ou da cobrança no seu sistema.
Corpo
| Campo | Tipo | Descrição |
|---|---|---|
issuer_cnpj | string | Obrigatório. CNPJ do emitente (prestador), 14 dígitos sem máscara. |
external_ref | string | Sua referência (pedido, cobrança). Volta nos webhooks e serve de filtro na listagem. Até 191. |
service.amount | número | Obrigatório. Valor do serviço em reais, maior que zero, até 2 casas. |
service.description | string | Descrição na nota. Obrigatória se o serviço cadastrado não tiver descrição padrão. Até 2.000. |
service.code | string | Código de um serviço cadastrado no seu emitente. Sem ele, vale o serviço padrão. |
service.competence | string | AAAA-MM ou AAAA-MM-DD. Padrão: data da emissão. Nunca no futuro. |
service.national_tax_code | string | Código de tributação nacional (6 dígitos), se quiser sobrescrever o do cadastro. |
service.nbs_code | string | Código NBS, se quiser sobrescrever o do cadastro. |
customer.document | string | Obrigatório. CPF ou CNPJ do tomador (com ou sem máscara, validado). |
customer.name | string | Obrigatório. Nome ou razão social. Até 300. |
customer.email | string | E-mail do tomador. Necessário para send_email. |
customer.address | objeto | Opcional. 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_email | boolean | Envia o PDF e o XML ao tomador quando a nota é autorizada. Padrão false. |
Respostas
202{"id", "status": "queued"}: aceita, saldo reservado.400dados inválidos ·402saldo insuficiente ·409idempotência em conflito ·422emitente 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 odocument_idda 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
| Status | Significado | Final? |
|---|---|---|
queued | Aceita, esperando o worker. | Não |
sending | Sendo enviada ao emissor. | Não |
retrying | Emissor fora do ar ou resposta incerta. Vamos conferir e tentar de novo, com o mesmo número. | Não |
authorized | Autorizada. Tem chave de acesso, número, XML e PDF. Debita o saldo. | Sim* |
rejected | Recusada pelo emissor; o motivo vem em errors. A reserva volta para o saldo. | Sim |
failed | Não pôde ser enviada (ex.: certificado vencido). A reserva volta para o saldo. | Sim |
cancelled | Cancelada depois de autorizada. | Sim |
replaced | Substituí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:1erro na emissão,2serviço não prestado,9outros.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:
| Evento | Quando |
|---|---|
nfse.authorized | Nota autorizada: chave, número, links de XML e PDF. |
nfse.rejected | Recusada pelo emissor, com errors. |
nfse.failed | Não pôde ser enviada. |
nfse.cancelled | Cancelamento registrado. |
nfse.replaced | Substituição concluída. |
certificate.expiring | Certificado A1 perto de vencer. |
wallet.low_balance · wallet.credited · wallet.recharge_failed | Movimentos 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
2xxem 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 oidda 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 } ] } }
| HTTP | code | O que fazer |
|---|---|---|
| 400 | validation_error | Corrija os campos listados em details (field, message). |
| 400 | idempotency_key_required | Envie o cabeçalho Idempotency-Key. |
| 401 | invalid_token | Token ausente, inválido ou revogado. |
| 402 | insufficient_balance | Saldo acabou. Aguarde a recarga confirmar. |
| 402 | account_suspended | Fale com o suporte. |
| 404 | not_found | Nota não existe nesta conta e neste ambiente. |
| 409 | idempotency_conflict | Mesma chave com outro corpo. Use outra chave ou repita o corpo original. |
| 409 | invalid_state | Só notas authorized podem ser canceladas ou substituídas. |
| 422 | issuer_not_found · issuer_inactive | CNPJ não cadastrado nesta conta, ou emitente inativo. |
| 422 | certificate_missing · certificate_expired · certificate_not_yet_valid · certificate_invalid | Envie um certificado A1 válido do emitente. |
| 422 | municipality_not_supported | O 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.
- 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.
- Você recebe uma URL de webhook exclusiva e um token.
- No Asaas, em Integrações › Webhooks, crie um webhook com essa URL, o token de autenticação e os eventos
PAYMENT_CONFIRMEDePAYMENT_RECEIVED. - 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.