npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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/notas

Comece 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, use toob_live_ e prepare também no catálogo live o serviço fiscal correspondente: ids srv_... 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.brIntegraçõ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ça

Se 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 integridade

Sem 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: queuedprocessingissued | 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 requestIdinforme 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. (dangerouslyAllowBrowser existe, e o nome é o aviso.)
  • Exige TLS. baseUrl sem https é 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 para console.log, JSON.stringify, spread ou util.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 e PATCH são retentados; services.create não é, porque repetir criaria um segundo serviço. 429 é retentado em qualquer rota, porque nunca chegou a executar. 400 e 409 nunca 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, e NaN em 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 fetch e 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