OpenNota — API de emissão fiscal (v1)#
Referência de máquina:
openapi.json(OpenAPI 3.1, gerado do código; os exemplos deste guia passam na validação real da API).
Estado desta versão — leia primeiro#
| Recurso | Situação |
|---|---|
| Autenticação, sandbox, idempotência, validação completa do pedido | pronto (POST /v1/emissoes/validar) |
| Consulta e segunda via do XML | pronto (GET /v1/emissoes…) |
| Transmissão ao órgão | habilitada por modelo, nesta ordem: NFS-e, NFCom, NF-e. Até lá POST /v1/emissoes valida tudo e responde 503 MODELO_NAO_HABILITADO sem consumir número |
| PDF (DANFSe, DANFE-COM, DANFE) e link de segunda via para o consumidor | junto com cada modelo |
| Cancelamento | junto com cada modelo |
O contrato abaixo é o definitivo: dá para escrever o cliente inteiro agora e testá-lo contra o
/validar; quando cada modelo for habilitado, nada muda do seu lado.
1. Em uma frase#
Um endpoint só. POST https://api.opennota.com.br/v1/emissoes com modelo no corpo
(NFCOM, NFSE ou NFE), o CNPJ do emitente e os dados do destinatário. O OpenNota
monta o XML, assina com o A1 do emitente (guardado no cofre do OpenNota), transmite, guarda a
nota e devolve número, chave, protocolo e XML.
O OpenNota não lê nem grava o banco do seu sistema. Você grava a nota no seu banco a partir da resposta. E o OpenNota também guarda tudo: a segunda via sai dele a qualquer momento (§8).
2. Emitente: por que só o CNPJ#
O emitente é cadastrado uma vez no OpenNota (plano administrativo): razão social, IE, IM,
regime (CRT/Simples), endereço, padrões fiscais por modelo (CFOP, cClass, ICMS, FUST/FUNTTEL,
cTribNac…), séries e o certificado A1 com a senha, cifrados. Por isso o pedido manda só
emitente.cnpj:
- o certificado nunca trafega em pedido nem fica no seu sistema;
- os dados fiscais do emitente não podem divergir de um pedido para outro;
- trocar o A1 (renovação anual) não exige mudar nada no seu sistema.
A sua credencial recebe permissão para os CNPJs que ele pode usar. CNPJ fora da
permissão responde 404 (igual a um CNPJ que não existe).
3. Ambientes e sandbox#
Mesmo endereço, credenciais diferentes. O ambiente é da credencial, nunca do corpo:
| Sandbox | Produção | |
|---|---|---|
| Credencial | key_id + segredo de homologação |
key_id + segredo de produção |
| Ambiente fiscal | sempre HOMOLOGACAO (SVRS homologação / Sefin produção restrita) — sem valor fiscal |
PRODUCAO |
| Série | própria de homologação (ex.: 99) — nunca queima número de produção | a do emitente |
| O que enxerga | só emissões de homologação | só emissões de produção |
A credencial de sandbox não abre produção: um .env de teste copiado por engano não emite
nota de verdade. Não existe campo ambiente no corpo.
4. Autenticação (todas as rotas)#
| Header | Valor |
|---|---|
X-OpenNota-Key |
key_id da credencial (público) |
X-OpenNota-Timestamp |
epoch em segundos (janela ±300 s — mantenha o NTP em dia) |
X-OpenNota-Nonce |
UUID v4 novo a cada requisição (reuso → 401 NONCE_REPETIDO) |
X-OpenNota-Signature |
sha256= + hex(HMAC-SHA256(segredo, mensagem)) |
Content-Type |
application/json (só com corpo) |
Idempotency-Key |
obrigatório em POST /v1/emissoes (§5) |
Mensagem assinada — cinco partes separadas por ponto:
{timestamp}.{nonce}.{MÉTODO}.{path}.{corpo}
MÉTODOem maiúsculas (GET,POST).path= caminho com a query string, exatamente como vai na URL:/v1/emissoes?referenciaExterna=fin-1.corpo= os bytes exatos enviados (vazio emGET). Serialize o JSON uma vez, assine essa string e envie essa mesma string — reserializar muda um espaço e quebra a assinatura.
Nonce, método e path dentro da assinatura impedem reenviar uma requisição capturada e reaproveitar uma assinatura em outra rota.
Cliente Laravel (pronto para usar)#
// config/services.php
'opennota' => [
'base' => env('OPENNOTA_BASE', 'https://api.opennota.com.br'),
'key_id' => env('OPENNOTA_KEY_ID'), // sandbox ou produção
'secret' => env('OPENNOTA_SECRET'), // NUNCA no repositório nem em log
],
// app/Services/OpenNotaClient.php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class OpenNotaClient
{
private function headers(string $metodo, string $pathComQuery, string $corpo): array
{
$ts = (string) time();
$nonce = (string) Str::uuid(); // UUID v4
$msg = "{$ts}.{$nonce}." . strtoupper($metodo) . ".{$pathComQuery}.{$corpo}";
$sig = hash_hmac('sha256', $msg, config('services.opennota.secret'));
return [
'X-OpenNota-Key' => config('services.opennota.key_id'),
'X-OpenNota-Timestamp' => $ts,
'X-OpenNota-Nonce' => $nonce,
'X-OpenNota-Signature' => "sha256={$sig}",
];
}
private function get(string $pathComQuery): Response
{
return Http::withHeaders($this->headers('GET', $pathComQuery, ''))
->timeout(20)
->get(config('services.opennota.base') . $pathComQuery);
}
/** Emite. $idempotencyKey: estável por nota, ex.: "fin-{$financeiro->id}-nfcom". */
public function emitir(array $pedido, string $idempotencyKey): Response
{
$corpo = json_encode($pedido, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return Http::withHeaders($this->headers('POST', '/v1/emissoes', $corpo) + ['Idempotency-Key' => $idempotencyKey])
->withBody($corpo, 'application/json') // envia EXATAMENTE a string assinada
->timeout(90) // órgãos são síncronos e podem demorar
->post(config('services.opennota.base') . '/v1/emissoes');
}
/** Valida sem emitir (não consome número). */
public function validar(array $pedido): Response
{
$corpo = json_encode($pedido, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return Http::withHeaders($this->headers('POST', '/v1/emissoes/validar', $corpo))
->withBody($corpo, 'application/json')->timeout(20)
->post(config('services.opennota.base') . '/v1/emissoes/validar');
}
public function ping(): Response { return $this->get('/v1/ping'); }
public function consultar(string $id): Response { return $this->get("/v1/emissoes/{$id}"); }
public function xml(string $id): Response { return $this->get("/v1/emissoes/{$id}/xml"); }
public function porReferencia(string $ref): Response
{
return $this->get('/v1/emissoes?referenciaExterna=' . rawurlencode($ref));
}
}
Na query, assine o path já codificado (o mesmo texto que vai na URL).
rawurlencodedos dois lados resolve.
Prova do signer (antes de chamar a API)#
Com o segredo de teste 0123456789abcdef0123456789abcdef (só para este teste), timestamp
1788200000, nonce 11111111-1111-4111-8111-111111111111, GET /v1/ping, corpo vazio:
mensagem: 1788200000.11111111-1111-4111-8111-111111111111.GET./v1/ping.
assinatura: sha256=b8eac8d2bd190ea28ef83881ede730d94a29eb5605d072a9c183de6a9aa649ed
printf '%s' '1788200000.11111111-1111-4111-8111-111111111111.GET./v1/ping.' \
| openssl dgst -sha256 -hmac '0123456789abcdef0123456789abcdef' | sed 's/^.* //'
curl#
BASE=https://api.opennota.com.br; KEY='<key_id>'; SECRET='<segredo>' # segredo vem do cofre
TS=$(date +%s); NONCE=$(uuidgen | tr 'A-Z' 'a-z'); P=/v1/ping
SIG=$(printf '%s' "$TS.$NONCE.GET.$P." | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -sS "$BASE$P" -H "X-OpenNota-Key: $KEY" -H "X-OpenNota-Timestamp: $TS" \
-H "X-OpenNota-Nonce: $NONCE" -H "X-OpenNota-Signature: sha256=$SIG"
GET /v1/ping responde o ambiente da credencial, os emitentes permitidos (com documentos e
operações) e as séries daquele ambiente. Use no início do command: se o ping falhar, não percorra a fila.
5. Idempotência — a garantia contra nota duplicada#
Idempotency-Key é obrigatório no POST /v1/emissoes (até 128 caracteres visíveis). Use um
valor estável por nota, ex.: fin-3816978-nfcom.
| Situação | Resposta |
|---|---|
| chave nova | processa normalmente |
| mesma chave + mesmo corpo | devolve a resposta original (200 no lugar de 201), header Idempotent-Replay: true — não emite de novo |
| mesma chave + corpo diferente | 409 IDEMPOTENCY_CONFLICT |
| mesma chave, primeira ainda processando | 409 IDEMPOTENCY_IN_PROGRESS — espere e consulte |
primeira terminou em 5xx |
a chave fica livre: pode repetir com o mesmo corpo |
A ordem dos campos do JSON não importa para "mesmo corpo". Com isso, timeout + retentativa não gera nota duplicada (o problema que existia sem idempotência).
6. POST /v1/emissoes — o pedido#
Campos comuns (os três modelos)#
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
modelo |
"NFCOM" | "NFSE" | "NFE" |
sim | NFCom 62 · NFS-e Nacional (DPS) · NF-e 55 |
emitente.cnpj |
14 dígitos | sim | emitente cadastrado no OpenNota |
destinatario |
objeto | sim | dados completos ou { "id": "…" } ou { "codigoExterno": "…" } |
referenciaExterna |
texto ≤ 100 | não | livre; volta em toda resposta e permite buscar a nota (ex.: fin-3816978) |
observacoes |
até 12 linhas ≤ 500 | não | informação complementar |
Destinatário completo:
| Campo | Obrigatório | Regra |
|---|---|---|
documento |
sim | CPF (11) ou CNPJ (14), só dígitos, DV conferido |
nome |
sim | ≤ 60 (o OpenNota trunca no XML conforme o leiaute de cada modelo) |
nomeFantasia |
não | |
ie |
não | só dígitos ou ISENTO |
im |
não | só para o DANFSe |
email |
não | |
telefone |
sim na NFCOM | DDD + número, só dígitos (10 ou 11) |
endereco.logradouro, numero, bairro, municipio |
sim | numero = "S/N" se não houver |
endereco.complemento |
não | |
endereco.ibge |
sim | 7 dígitos, da mesma UF |
endereco.uf |
sim | sigla |
endereco.cep |
sim | 8 dígitos |
codigoExterno |
não | id do cliente no seu sistema. Na NFCom vira o iCodAssinante. Com ele o OpenNota mantém o cadastro do destinatário, e os pedidos seguintes podem mandar só { "codigoExterno": "…" } |
Um destinatário pertence a um emitente: o mesmo codigoExterno em dois emitentes são dois
cadastros independentes. A nota guarda uma cópia do destinatário no momento da emissão — mudar
o cadastro depois não altera nota emitida.
NFCOM (mensalidade de acesso — modelo 62)#
| Campo | Obrigatório | Descrição |
|---|---|---|
competencia |
sim | mês de referência AAAA-MM |
vencimento |
sim | AAAA-MM-DD |
contrato.numero / inicio / fim |
não | padrão: documento do destinatário e 1º/último dia da competência |
tipoServicoUtilizado |
não | tpServUtil (4 = internet). Padrão do cadastro do emitente |
itens[] |
sim (1–990) | |
itens[].codigo, descricao |
sim | |
itens[].cClass |
sim | 7 dígitos (mensalidade de internet: 0400401) |
itens[].unidade |
não | 1 minuto · 2 MB · 3 GB · 4 UN (padrão) |
itens[].quantidade, valorUnitario |
sim | valor em reais, 2 casas |
itens[].icms {cst, aliquota, fcp} |
não | padrão do emitente (hoje CST 90, 20 %, FCP 4 %) |
itens[].fust, funttel |
não | padrão do emitente (1 % e 0,5 %; base líquida de ICMS, calculada pelo OpenNota) |
fatura.codigoBarras |
não | linha digitável do boleto (vai no gFat) |
fatura.pix |
não | copia-e-cola |
Exemplo (o mesmo de docs/openapi.json):
{
"modelo": "NFCOM",
"emitente": { "cnpj": "11222333000181" },
"destinatario": {
"documento": "52998224725", "nome": "Fulano de Tal",
"email": "fulano@example.com", "telefone": "21999998888",
"endereco": { "logradouro": "Rua do Teste", "numero": "100", "complemento": "Casa 2",
"bairro": "Centro", "ibge": "3304557", "municipio": "Rio de Janeiro",
"uf": "RJ", "cep": "20040002" },
"codigoExterno": "169162"
},
"referenciaExterna": "fin-3816978",
"competencia": "2026-10",
"vencimento": "2026-10-10",
"contrato": { "numero": "169162", "inicio": "2026-10-01", "fim": "2026-10-31" },
"tipoServicoUtilizado": 4,
"itens": [{ "codigo": "3816978", "descricao": "Plano de acesso a Internet", "cClass": "0400401",
"unidade": 4, "quantidade": 1, "valorUnitario": 99.9,
"icms": { "cst": "90", "aliquota": 20, "fcp": 4 }, "fust": 1, "funttel": 0.5 }],
"fatura": { "codigoBarras": "34191790010104351004791020150008291070026000" }
}
NFSE (serviço com ISS — NFS-e Nacional)#
| Campo | Obrigatório | Descrição |
|---|---|---|
competencia |
sim | data AAAA-MM-DD |
servico.cTribNac |
sim | 6 dígitos. Rio, acesso e acessórios (instalação, visita, cabeamento, mudança de ponto): 010301 |
servico.cTribMun |
não | Rio, acesso: 002 |
servico.cNBS |
não | 9 dígitos |
servico.descricao |
sim | ≤ 2000 |
servico.municipioPrestacao |
sim | IBGE do local da prestação |
valores.servico |
sim | > 0 |
valores.desconto |
não | ≤ valor do serviço |
valores.iss.retido |
sim | |
valores.iss.tributacao |
não | tribISSQN, padrão 1 |
valores.iss.aliquota |
não | só emitente não optante do Simples (o cadastro do emitente decide; optante usa pTotTribSN) |
valores.federais |
não | retenções PIS/COFINS/IRRF/CSLL/CP (tomador PJ) |
Para outros serviços, informe o cTribNac e o cTribMun do seu serviço (confirme com a sua
contabilidade).
NFE (mercadoria — modelo 55)#
naturezaOperacao, tipo (0/1), finalidade (1–4), consumidorFinal, presenca (indPres),
destinoOperacao (1 interna · 2 interestadual · 3 exterior), itens[] (codigo, descricao,
ncm 8 dígitos, cest, cfop, unidade, quantidade, valorUnitario, icms com cst OU
csosn, ipi, pis.cst, cofins.cst), transporte.modalidade (9 = sem frete),
pagamento[] (tPag 90 = sem pagamento, para remessa) e referencias[] (chaves de NF-e).
Regras conferidas: CFOP coerente com tipo e destino (saída interna começa com 5, interestadual
com 6…), soma dos pagamentos = total dos itens (exceto tPag 90), DV das chaves referenciadas.
7. Respostas#
Sucesso#
201 — autorizada:
{
"emissao": {
"id": "5b2c…", "modelo": "NFCOM", "ambiente": "PRODUCAO", "status": "AUTORIZADA",
"emitenteCnpj": "…", "destinatarioId": "…", "serie": "2", "numero": "123",
"chave": "33…", "protocolo": "3…", "valor": 99.9,
"referenciaExterna": "fin-3816978", "idempotencyKey": "fin-3816978-nfcom",
"origemFalha": null, "motivo": null,
"criadaEm": "…", "autorizadaEm": "…",
"links": { "self": "/v1/emissoes/5b2c…", "xml": "/v1/emissoes/5b2c…/xml" }
},
"xml": "<nfcomProc …>…</nfcomProc>"
}
202 — INDETERMINADA: a rede caiu depois de transmitir. O OpenNota consulta o órgão
sozinho até ter certeza. Consulte GET /v1/emissoes/{id}; não reenvie com outra chave.
Erros#
Formato único:
{ "erro": { "codigo": "VALIDACAO_LOCAL", "mensagem": "…", "correlationId": "…",
"origem": "VALIDACAO_LOCAL",
"campos": [ { "campo": "/destinatario/telefone",
"mensagem": "telefone obrigatório na NFCom (NroTermPrinc)",
"correcao": "informe DDD + número, só dígitos" } ] } }
| HTTP | codigo |
Significado | O que fazer |
|---|---|---|---|
| 400 | JSON_INVALIDO, PAYLOAD_INVALIDO, IDEMPOTENCY_KEY_OBRIGATORIA, FILTRO_OBRIGATORIO |
pedido malformado | corrigir o código; não repetir |
| 401 | ASSINATURA_INVALIDA, TIMESTAMP_FORA_DA_JANELA, NONCE_REPETIDO, NONCE_INVALIDO, CREDENCIAL_AUSENTE |
autenticação | conferir segredo, relógio, nonce novo |
| 403 | SEM_PERMISSAO, IP_NAO_PERMITIDO |
credencial sem a operação/modelo, ou IP fora da lista | falar com o OpenNota |
| 404 | NAO_ENCONTRADO |
inexistente ou fora do escopo da credencial (emitente, emissão) | conferir CNPJ/id |
| 409 | IDEMPOTENCY_CONFLICT, IDEMPOTENCY_IN_PROGRESS, XML_INDISPONIVEL |
ver §5 / nota sem XML | |
| 413 / 415 | CORPO_GRANDE_DEMAIS / TIPO_DE_CONTEUDO_NAO_SUPORTADO |
> 256 KB / sem application/json |
|
| 422 | VALIDACAO_LOCAL |
cadastro a corrigir — campos[] diz qual e como. Nenhum número consumido |
corrigir o cadastro e reenviar |
| 422 | código do órgão (ex.: E0312, cStat) com origem: "ORGAO" |
rejeição da Sefin/SVRS | corrigir; um número foi consumido (o OpenNota trata a lacuna) |
| 429 | LIMITE_DE_TAXA, CREDENCIAL_BLOQUEADA |
muitas requisições / muitas falhas de assinatura | esperar Retry-After |
| 503 | MODELO_NAO_HABILITADO e indisponibilidades antes de transmitir |
nada foi transmitido | repetir depois com a mesma chave |
| 500 | ERRO_INTERNO |
falha nossa | informe o correlationId |
Regra de retentativa: só 429, 503 e 202 (consultando) são transitórios. 4xx nunca se
repete sem mudar algo. Com a mesma Idempotency-Key, repetir é sempre seguro.
8. Consulta e segunda via#
As notas ficam gravadas no OpenNota (XML autorizado, protocolo, eventos), com guarda legal de 5 anos. A segunda via sai daqui a qualquer momento, mesmo que o seu sistema perca o arquivo.
| Rota | Retorno |
|---|---|
GET /v1/emissoes/{id} |
situação (status, número, chave, motivo da rejeição…) |
GET /v1/emissoes/{id}/xml |
XML autorizado como anexo (application/xml) — nota AUTORIZADA ou CANCELADA; senão 409 |
GET /v1/emissoes?referenciaExterna=… (ou idempotencyKey=…, chave=…) |
lista, mais recente primeiro (limite até 50) |
GET /v1/emissoes/{id}/pdf |
com cada modelo: DANFSe / DANFE-COM / DANFE gerado na hora |
link público https://api.opennota.com.br/v1/d/{token} |
com cada modelo: link assinado e com validade para enviar ao consumidor |
Cada credencial só enxerga as emissões do próprio cliente, dos emitentes que pode ler e do próprio ambiente (sandbox não vê produção e vice-versa).
9. Limites e regras de uso#
- Rate limit: 10 req/s por credencial e por emitente (rajada de 30).
- 20 falhas de assinatura em 5 min bloqueiam a credencial por 15 min.
- Corpo até 256 KB. Timeout recomendado no cliente: 90 s na emissão, 20 s no resto.
- Segredo só no
.envdo servidor que chama; nunca em repositório, log, ticket ou e-mail junto com esta documentação.