@toobstudio/notas
v0.2.1
Published
SDK oficial da TOOB Notas — emita NFS-e (nota fiscal de serviço) reais no Brasil com uma chamada. TypeScript, zero dependências, idempotente por padrão.
Downloads
281
Maintainers
Readme
@toobstudio/notas
SDK oficial da TOOB Notas: emita NFS-e (nota fiscal de serviço) reais no Brasil com uma chamada. TypeScript, zero dependências, idempotente por padrão.
npm i @toobstudio/notasComece pelo sandbox. Uma chave
toob_test_simula tudo — nada é enviado à Prefeitura, nenhuma numeração fiscal é consumida e nenhum cliente recebe e-mail. Para produção, usetoob_live_e prepare também no catálogo live o serviço fiscal correspondente: idssrv_...de sandbox não funcionam live.
Em 30 segundos
import { Toob } from "@toobstudio/notas";
const toob = new Toob(); // lê TOOB_API_KEY do ambiente
const nota = await toob.nfse.createAndWait({
reference: stripeInvoice.id, // id estável da cobrança/pedido no seu sistema
customer: {
document: "39053344705",
name: "Cliente Exemplo",
address: { zipCode: "01310-100", street: "Avenida Paulista", number: "100", district: "Bela Vista" },
},
service: { serviceId: "srv_...", amount: 150_000 }, // centavos, inteiro
});
console.log(nota.status); // "issued"
console.log(nota.number); // "1207"
console.log(nota.verification_code); // "ABCD-1234"createAndWait envia a emissão e consulta até a autoridade fiscal responder.
Recusa vira ToobNfseRejectedError com o motivo traduzido.
Crie a chave no painel: https://notas.toob.com.br → Integrações. Ela aparece uma única vez — guarde em variável de ambiente, nunca no código.
Mas escolha o caminho certo para o seu caso
Emitir é assíncrono: a nota entra numa fila e a autoridade fiscal responde depois. Existem dois jeitos de saber o desfecho, e o certo depende de onde o seu código roda:
| Onde o código roda | Use |
| --- | --- |
| Tem endpoint público (app web, API, ERP) | create() + webhook |
| Request HTTP com timeout curto (serverless, Edge) | create() + webhook — nunca createAndWait |
| Script, cron, worker, fila sua, teste manual | createAndWait() |
O webhook é o caminho de produção. createAndWait existe para quem não
tem endpoint público: ele segura a sua conexão consultando com espera progressiva,
o que é desperdício num servidor que recebe requisições — e estouro de timeout
numa função serverless. Com webhook cadastrado, você não consulta nada:
é avisado.
As quatro regras que evitam 90% dos problemas
1. Dinheiro é centavo, inteiro. R$ 1.500,00 → 150000. Nunca 1500.00.
2. Emitir é assíncrono. create() responde 202 queued; a nota ainda não
existe na Prefeitura. Use createAndWait(), ou cadastre um webhook e pare
de perguntar.
3. Nunca duplique. Toda emissão exige uma reference: o identificador
estável da cobrança, pedido ou fatura no seu sistema. Repetir a mesma referência
com o mesmo payload devolve a mesma nota; mudar o payload gera conflito.
const dados = {
reference: stripeInvoice.id,
customer: {
document: "39053344705",
name: "Cliente Exemplo",
address: { zipCode: "01310-100", street: "Avenida Paulista", number: "100", district: "Bela Vista" },
},
service: { serviceId: "srv_...", amount: 150_000 },
};
await toob.nfse.create(dados); // pode repetir estes mesmos dados com segurançaSe createAndWait falhar com nfseId, a nota já existe — foi aceita e a
falha veio apenas durante a espera. Consulte com retrieve(id), não reemita.
Cheque primeiro se o erro é ToobNfseRejectedError: recusa fiscal não deve ser
tratada como sucesso.
4. Guarde livemode. Toda nota traz o campo: true = documento fiscal
real; false = simulação do sandbox. Guarde junto no seu banco — é o que
impede confundir um teste com uma nota real seis meses depois.
if (process.env.NODE_ENV === "production" && !toob.livemode) {
throw new Error("Chave de sandbox em produção — nenhuma nota teria valor fiscal.");
}Sandbox
const toob = new Toob({ apiKey: process.env.TOOB_TEST_KEY }); // toob_test_...
// Só com chave de teste: escolha o desfecho da simulação.
await toob.nfse.createAndWait(
{ /* ... */, sandboxScenario: "rejected" }, // testa o seu tratamento de erro
{ throwOnRejected: false },
);| sandboxScenario | O que acontece |
| --- | --- |
| authorized (padrão) | autoriza em ~2 s |
| rejected | termina em rejected, com motivo real e traduzido |
| slow | autoriza ~20 s depois — para exercitar espera e timeout |
As regras do seu provedor fiscal valem no teste: um payload aprovado no sandbox continua aprovado em produção. Um sandbox mais permissivo seria uma armadilha, não uma facilidade.
Webhooks — o caminho de produção
Cadastre o endereço no painel (Integrações → aba do ambiente → Webhooks) e
receba um POST assinado a cada mudança: nfse.issued, nfse.rejected,
nfse.cancelled. O data.object é exatamente o objeto de
toob.nfse.retrieve() — um formato só para aprender.
// Next.js — app/api/webhooks/toob/route.ts
import { Toob } from "@toobstudio/notas";
const toob = new Toob();
export async function POST(req: Request) {
const raw = await req.text(); // o corpo CRU, sem reserializar
let event;
try {
event = await toob.webhooks.constructEvent(
raw,
req.headers.get("toob-signature"),
process.env.TOOB_WEBHOOK_SECRET!, // whsec_... do painel
);
} catch {
return new Response("assinatura inválida", { status: 400 });
}
// Responda 2xx rápido: acima de 10s a entrega conta como falha.
await fila.enfileirar(event); // deduplique pelo event.id
return new Response("ok");
}O corpo tem que ser o cru. Reserializar um objeto já parseado muda espaços e ordem de chaves, e a assinatura nunca fecha:
| Framework | Como obter o corpo cru |
| --- | --- |
| Next.js (Route Handler) | await req.text() |
| Express | express.raw({ type: "application/json" }), use req.body |
| Hono / Workers / Deno | await c.req.text() / await request.text() |
| Fastify | registre um parser que preserve o corpo cru |
Os outros três headers da entrega saem de readWebhookHeaders(req.headers):
deliveryId (identifica a tentativa no histórico do painel — guarde no seu
log), event e livemode (guarda barata contra processar evento de teste em
produção).
constructEvent confere HMAC-SHA256 em tempo constante e rejeita entregas
fora da janela de 5 minutos — sem isso, uma entrega capturada valeria para
sempre. Entrega repetida acontece (é o preço de entregar ao menos uma vez):
deduplique pelo event.id, que é estável por ocorrência.
Referência
Notas
// Emitir (assíncrono: responde 202, a nota ainda não existe na autoridade)
const criada = await toob.nfse.create(params, { timeoutMs?, maxRetries?, signal? });
// Emitir e esperar o desfecho
const nota = await toob.nfse.createAndWait(params, {
intervalMs?, waitTimeoutMs?, timeoutMs?, maxRetries?, throwOnRejected?, onPoll?, signal?,
});
// Consultar
const nota = await toob.nfse.retrieve("nfse_...");
// Esperar uma nota já criada chegar a um estado final
const nota = await toob.nfse.waitUntilSettled("nfse_...", { intervalMs?, waitTimeoutMs? });
// Listar (mais recentes primeiro)
const { data, next_cursor } = await toob.nfse.list({ limit: 100, starting_after });
// Percorrer TODAS as páginas, sem mexer em cursor
for await (const item of toob.nfse.listAll()) console.log(item.id);
// Cancelar (só nota "issued"; o prazo depende do município)
await toob.nfse.cancel("nfse_...", {
reason_code: "2", // "1" erro na emissão · "2" serviço não prestado · "9" outros
reason_text: "Serviço não foi prestado ao cliente.", // 15 a 255 caracteres
});
// Baixar o XML fiscal
const doc = await toob.nfse.xml("nfse_...");
if (doc.kind !== "nfse") throw new Error("ainda não é a nota autorizada");
await writeFile(doc.filename ?? "nota.xml", doc.xml); // doc.sha256 confere integridadeSem kind, a API escolhe o documento principal — a NFS-e autorizada, ou o
pedido enviado (rps/dps) se ela ainda não existir. doc.kind diz qual
veio de fato: é a diferença entre ter guardado a nota e ter guardado só o
pedido.
Não existe URL nem PDF da nota: o XML sai por download autenticado (um link assinado seria um endereço de documento fiscal válido sem autenticação), e a DANFSE em PDF ainda não existe na API — hoje só no painel.
Estados: queued → processing → issued | rejected.
issued pode virar cancelled. Terminais: issued, rejected, cancelled,
validated.
Montar a emissão
O cadastro de clientes e serviços é o mesmo do painel. A emissão sempre usa um serviço fiscal cadastrado e um valor explícito em centavos:
// A) Cliente no payload — criado se ainda não existir (origem "api").
await toob.nfse.createAndWait({
reference: stripeInvoice.id,
customer: {
document: "39053344705",
name: "Cliente Exemplo",
address: { zipCode: "01310-100", street: "Avenida Paulista", number: "100", district: "Bela Vista" },
},
service: { serviceId: "srv_...", amount: 150_000 },
});
// B) Cliente já cadastrado.
await toob.nfse.createAndWait({
reference: pedido.id,
customer: { id: "cus_..." },
service: { serviceId: "srv_...", amount: 150_000 },
});
// C) Descrição específica desta nota, sem alterar o catálogo.
await toob.nfse.createAndWait({
reference: pedido.id,
customer: { id: "cus_..." },
service: {
serviceId: "srv_...",
amount: 220_000,
description: "Consultoria — agosto/2026",
},
});Campos da emissão (contrato completo em https://api.notas.toob.com.br/llms-full.txt):
| Campo | Obrigatório | Notas |
| --- | --- | --- |
| reference | sim | id estável da cobrança/pedido; resolve a idempotência |
| customer | sim | { id } de cliente cadastrado, ou { document, name, ... } para sincronizar o cadastro |
| service.serviceId | sim | serviço fiscal cadastrado (srv_...); códigos e tributação vêm dele |
| service.amount | sim | inteiro positivo em centavos |
| service.description | não | substitui apenas a descrição desta nota |
| issWithheld | não | ISS retido pelo tomador |
| sandboxScenario | não | só com chave de teste |
Na emissão direta, customer.address exige zipCode, street, number e
district. A TOOB usa o CEP somente para resolver o IBGE do município e não
sobrescreve os dados que seu sistema enviou. municipalityCode é opcional,
avançado e conferido contra o CEP quando a consulta estiver disponível.
Clientes e serviços
Cadastro compartilhado com o painel: o que a API cria aparece lá, e o que você edita lá vale para as próximas emissões.
customers.create é upsert por documento: o id do cadastro deriva do
CPF/CNPJ, então o mesmo documento nunca vira dois cadastros e repetir a chamada
é seguro. Campos que você não mandar permanecem como estavam.
Clientes e serviços são paginados. Use listAll() para percorrer o catálogo
inteiro sem manipular cursor.
await toob.customers.list({ limit: 100, starting_after: "cursor_da_pagina_anterior" });
for await (const customer of toob.customers.listAll()) console.log(customer.id);
await toob.customers.create({ document: "39053344705", name: "Cliente" }); // cria ou atualiza
await toob.customers.retrieve("cus_...");
await toob.customers.update("cus_...", { email: "[email protected]" }); // parcial
await toob.services.list();
await toob.services.create({
name: "Consultoria",
description: "Consultoria de software",
municipality_code: "3550308",
default_amount_cents: 150_000,
});
await toob.services.retrieve("srv_...");
await toob.services.update("srv_...", { default_amount_cents: 200_000 });Erros
Todo erro da API é uma subclasse de ToobApiError e carrega code, status,
field, retryable e requestId — informe o requestId ao suporte.
import {
ToobApiError, ToobValidationError, ToobFiscalError,
ToobRateLimitError, ToobNfseRejectedError, ToobConnectionError,
} from "@toobstudio/notas";
try {
await toob.nfse.createAndWait(dados);
} catch (error) {
if (error instanceof ToobNfseRejectedError) {
// A autoridade fiscal recusou. error.terminal = reenviar não resolve.
console.error(error.message, error.action, error.nfse.rejection?.raw_message);
} else if (error instanceof ToobValidationError) {
console.error("Corrija o payload:", error.field, error.message);
} else if (error instanceof ToobFiscalError) {
console.error("Resolva no painel:", error.message); // perfil fiscal / A1
} else if (error instanceof ToobRateLimitError) {
agendar(error.retryAfterSeconds); // vem do header Retry-After da API
} else if (error instanceof ToobConnectionError) {
// Sem resposta: repita com a mesma reference, nunca com outra.
} else if (error instanceof ToobApiError) {
console.error(error.code, error.status, error.requestId);
}
}| Classe | HTTP | Quando |
| --- | --- | --- |
| ToobValidationError | 400 / 405 | campo faltando ou inválido (field aponta) |
| ToobAuthenticationError | 401 | chave ausente, inválida ou revogada |
| ToobPermissionError | 403 | trial encerrado, plano suspenso, empresa em exclusão |
| ToobNotFoundError | 404 | id ou rota inexistente |
| ToobConflictError | 409 | mesma reference com payload diferente; nota não cancelável |
| ToobFiscalError | 422 | perfil fiscal/A1 impede emitir, ou a autoridade recusou |
| ToobRateLimitError | 429 | limite atingido (retryAfterSeconds) |
| ToobServerError | 5xx | falha nossa ou autoridade fora do ar — nada foi duplicado |
| ToobConnectionError | — | rede, DNS, TLS ou timeout: não houve resposta |
| ToobNfseRejectedError | — | a nota terminou em rejected (não é erro de HTTP) |
| ToobTimeoutError | — | a espera acabou; a nota continua viva no servidor |
| ToobWebhookSignatureError | — | assinatura do webhook não confere — descarte |
| ToobConfigError | — | uso incorreto do SDK, detectado antes da rede |
Limites: leituras 300/min · emissões 120/h · cancelamentos 10/h, por empresa.
O que o SDK faz pela sua segurança
- Bloqueia o navegador. A chave é segredo de servidor e a API não tem CORS.
Construir o cliente num bundle de frontend lança erro na hora, em vez de
vazar a chave para todo visitante. (
dangerouslyAllowBrowserexiste, e o nome é o aviso.) - Exige TLS.
baseUrlsemhttpsé recusada — exceto em loopback, onde não há rede para escutar. - Nunca imprime a chave — por nenhum caminho. A chave fica num campo
privado de classe (
#), que não existe em runtime paraconsole.log,JSON.stringify, spread ouutil.inspect— no cliente e em todos os resources. Um teste varre o grafo inteiro de propriedades a partir do cliente e falha apontando o caminho se algum dia ela reaparecer. - Valida a chave antes da primeira chamada, com mensagem que diz o que
está errado — inclusive o engano comum de usar o
whsec_do webhook. - Só repete o que é seguro repetir. Leituras, emissão (com
reference), cancelamento, criação de cliente por documento ePATCHsão retentados;services.createnão é, porque repetir criaria um segundo serviço.429é retentado em qualquer rota, porque nunca chegou a executar.400e409nunca são retentados. - Não segue redirecionamento. A API nunca redireciona; um 3xx significa que outra coisa respondeu no lugar dela. Seguir mandaria o CPF/CNPJ do tomador para um destino que ninguém escolheu, e a resposta de lá viraria "nota emitida" no seu banco. Mesma política que a TOOB aplica aos webhooks.
- Verificação de webhook em tempo constante, com janela de 5 minutos e recusa explícita de corpo já parseado.
- Opção numérica inválida falha alto.
Number(process.env.NAO_DEFINIDA)éNaN, eNaNem comparação é sempre falso: seria uma janela anti-replay que aceita para sempre e um timeout que nunca termina. Toda opção numérica é validada na entrada. - Teto de resposta (10 MB) e ids validados na forma conhecida, para um valor torto virar erro em vez de chamada silenciosa a outra rota.
- Zero dependências de runtime. Nada de árvore transitiva para auditar.
Para agentes de IA (Claude Code, Cursor, Codex)
Este pacote traz AGENTS.md e llms.txt com as regras e os
erros mais comuns de integração.
A referência completa da API — todos os campos, códigos de erro e o contrato de webhook — está num arquivo único feito para ser lido inteiro:
https://api.notas.toob.com.br/llms-full.txt
Prompt sugerido: "Leia https://api.notas.toob.com.br/llms-full.txt e o AGENTS.md de @toobstudio/notas antes de escrever qualquer código de emissão de nota fiscal."
Requisitos
- Node.js 20+, ou qualquer runtime com
fetche Web Crypto: Deno, Bun, Cloudflare Workers, Vercel Edge. - ESM e CommonJS. Tipos incluídos.
new Toob({
apiKey, // default: process.env.TOOB_API_KEY
baseUrl, // default: process.env.TOOB_BASE_URL ou a API oficial
timeoutMs, // default: 60000 (uma requisição)
maxRetries, // default: 2
fetch, // default: fetch global
onResponse, // (meta) => void — status, duração e request_id
dangerouslyAllowBrowser, // default: false
});onResponse existe porque o request_id só viaja no header: em chamadas bem
sucedidas ele não aparece no retorno, e é o valor que o suporte pede. Registre
junto do seu log.
Para checar a API sem ter uma chave (health check de infraestrutura):
import { health } from "@toobstudio/notas";
const { status, request_id } = await health();Licença
MIT © TOOB Creative Studio · https://notas.toob.com.br
