Pular para conteúdo

Guia de migração — SmartNFCe

Se o seu PDV já fala com o Motor Emissor local, a migração é curta: muda o endereço, e praticamente nada mais. Este guia cobre o que muda e, principalmente, as convenções de fio deste dialeto. Elas são estranhas, e são de propósito.

1. Endereço base

Ambiente Endereço base
Produção https://smartnfce.api.veraciti.com.br
Homologação https://smartnfce-sandbox.api.veraciti.com.br

No lugar do http://localhost:<porta> do motor local, aponte para o endereço acima. Os caminhos continuam iguais, com o mesmo prefixo /api/v1.

O ambiente é escolhido pelo endereço, não por campo no corpo. O motor local não tem conceito de ambiente além do tpAmb configurado na caixa, e nós mantivemos isso. O tpAmb do XML que você envia precisa combinar com o ambiente do endereço. Se não combinar, o documento é recusado.

2. A autenticação já está resolvida

Esta é a maior diferença entre este dialeto e os outros dois, e é uma diferença a seu favor.

Não há credencial de adaptador para provisionar. O ACBrAPI pede um client_id/secret e o FocusNFE pede um token por empresa. Aqui não há passo de provisionamento nenhum. O adaptador aceita o mesmo token de dispositivo OAuth2 que o seu equipamento já carrega hoje para falar com a plataforma.

curl -X POST https://smartnfce-sandbox.api.veraciti.com.br/api/v1/nfce/xml/8f3a1c02-5d47-4a2e-9b10-6e2f7c4d8a31 \
  -H "Authorization: Bearer $TOKEN_DO_DISPOSITIVO" \
  -H "Content-Type: application/xml" \
  --data-binary @nfce.xml

Três consequências práticas:

  • Nenhum escopo novo. Tokens que já estão em campo continuam valendo como estão. Não há reemissão de credencial, nem janela de migração.
  • O dispositivo já sabe de qual empresa ele é. O vínculo vem do próprio token, então você não informa CNPJ na emissão.
  • A autenticação é a única coisa que responde fora do padrão 200. O motor local não tem autenticação alguma. Ele roda em localhost. Ela é um acréscimo nosso, e por isso uma falha de credencial responde um 401 HTTP de verdade, não um erro em banda. É a exceção declarada à regra da próxima seção.
{ "detail": "Could not validate credentials" }

Essa resposta é sempre a mesma, e de propósito: ela nunca diz qual verificação falhou. Token ausente, expirado, malformado, de outra empresa ou de um dispositivo que não pode emitir: todos respondem exatamente isso. Não tente distinguir os casos pelo corpo.

Nem todo dispositivo pode emitir

Só equipamentos de emissão alcançam os verbos fiscais: fiscal_box e smart_nfce. Um token de dispositivo que existe apenas para custódia de documentos emitidos por outro programa não emite, não cancela e não inutiliza. Ele recebe o 401 acima.

Se a sua frota é mista, verifique isso antes de migrar: é o tipo de coisa que só aparece em produção.

Tokens muito antigos precisam de renovação. Um token emitido antes de o tipo de dispositivo passar a constar nele não tem como ser classificado. Ele é recusado por segurança em vez de liberado. Nenhum escopo mudou e nada precisa ser reprovisionado. O dispositivo só precisa renovar o token uma vez.

3. A entrada continua em XML

Você envia o documento já montado e no layout SEFAZ, exatamente como faz com o motor local. Não é um JSON de venda como nos outros dois dialetos. Nós assumimos a numeração, assinamos e transmitimos.

O uuid do caminho é seu: é ele que identifica o pedido, e é por ele que você consulta depois. Reenviar o mesmo uuid não emite um segundo documento. Ver Referência e reenvio.

Nunca reemita sob um uuid novo por causa de uma consulta

Se a consulta responder -5 com em processamento, consulte novamente, o pedido existe e ainda está sendo resolvido. O caminho certo é consultar de novo, com o mesmo uuid, até o desfecho aparecer.

Emitir de novo sob um uuid diferente é a única forma de este dialeto lhe custar um documento duplicado. O primeiro pedido pode autorizar enquanto o segundo também autoriza, e você fica com dois documentos autorizados para uma venda só. É exatamente isso que a idempotência por uuid existe para evitar. Mas ela só protege enquanto o uuid for o mesmo.

As duas mensagens do -5 são diferentes de propósito, e a diferença é a que importa. A mensagem uuid not found significa que nada foi criado e você pode reenviar à vontade. Já em processamento, consulte novamente significa que existe um pedido em voo e reenviar é perigoso. Não trate as duas como o mesmo caso.

O tpEmis continua sendo seu. Nós não decidimos contingência por você. Se o XML vem com tpEmis 9, ele é tratado como contingência offline. Se vem online, é transmitido online. É uma diferença deliberada em relação aos outros dialetos, onde quem decide somos nós.

4. Convenções de fio que você precisa conhecer

Tudo nesta seção é contrato, não defeito. Nós fixamos estas formas a partir de respostas reais do motor, e reproduzi-las é o que faz o seu código continuar funcionando sem alteração.

Tudo responde 200

O motor não tem semântica de erro em HTTP: toda resposta documentada é 200, e o desfecho vem no corpo. Um corpo malformado também responde 200, com o erro em banda e não um 422.

Sentinelas negativos em cStat fazem parte disso:

Sentinela Onde aparece Significa
-100 Emissão Emitido em contingência
-5 Consulta por uuid Ou não existe pedido nenhum (uuid not found), ou existe um em voo (em processamento, consulte novamente). Ver por que a diferença importa
-404 Contingência O documento não é relevante para contingência

A única exceção é a autenticação, pelo motivo explicado acima.

cStat muda de tipo conforme o contexto

Não é inconsistência nossa. É a forma do motor, e mudá-la quebraria clientes:

Contexto Tipo de cStat
Corpo de emissão string ("100", "-100")
Consulta de contingência e relatório de processamento int (100, -404)
Resposta de inutilização string
Corpo de webhook (dados) int. Ver os tipos por origem

Se o seu código compara cStat sem normalizar o tipo, normalize antes de migrar.

Três campos são JSON dentro de uma string

diagnostico, infEvento e retConsSitNFe são strings que contêm JSON serializado, nunca objetos aninhados. Você precisa desserializar duas vezes:

corpo = response.json()
diagnostico = json.loads(corpo["diagnostico"])   # string -> objeto

Os erros de digitação são contrato

O motor tem erros de digitação em nomes de campo e de caminho. Nós os reproduzimos verbatim, de propósito. Corrigi-los quebraria todo cliente existente.

O que você vê O que "deveria" ser Onde
nProto nProt Só no corpo de emissão
dhRecibo dhRecbto Só no corpo de emissão
certificareExpiryCountdown certificateExpiryCountdown Corpo de emissão
processamentocontigencia processamentocontingencia No caminho da rota

Nas outras rotas, nProt e dhRecbto aparecem escritos corretamente. A grafia errada existe apenas no corpo de emissão, e no mesmo corpo convive com certificateExpirationDate, que está certo. Não tente adivinhar pela consistência: vá pela tabela.

O corpo de emissão muda de tamanho conforme o desfecho

Uma autorização responde o corpo "gordo", com cerca de 29 campos de identificação e telemetria. Uma rejeição da SEFAZ responde o corpo mínimo, com apenas cStat, xMotivo e diagnostico. Se o seu código lê chNFe ou nNF direto da resposta sem checar o desfecho antes, ele quebra na primeira rejeição.

5. Os dois vocabulários de status

Esta é a parte mais confusa da superfície, e ela é assim no motor original: existem dois vocabulários de status que nunca se encontram. Nenhum é convertido no outro, e o mesmo documento pode ser descrito pelos dois ao mesmo tempo, em rotas diferentes.

Aparece em GET /api/v1/nfce/contingencia/resultado/{nNF}, descrevendo onde o documento está no ciclo de transmissão:

status Significa
pendente Esperando transmissão
a consultar Transmitido, desfecho ainda não provado por consulta
emitida Autorizado
cancelada Cancelado

Aparece em GET /api/v1/nfce/processamentocontigencia, descrevendo o desfecho fiscal do documento:

status Significa
Autorizada Autorizado
Cancelada Cancelado
Rejeitada Rejeitado ou denegado
Inutilizada O número foi inutilizado

A regra prática: as minúsculas respondem "onde está", as capitalizadas respondem "no que deu". Um documento autorizado é emitida num vocabulário e Autorizada no outro. Se o seu código tem um switch de status, ele precisa saber de qual rota veio o valor. Não existe um mapeamento único entre os dois.

6. Contingência manual é um interruptor de verdade

A contingência manual não é um sinalizador informativo: ligá-la muda de fato como as próximas emissões acontecem.

curl https://smartnfce-sandbox.api.veraciti.com.br/api/v1/nfce/contingencia/enable \
  -H "Authorization: Bearer $TOKEN_DO_DISPOSITIVO"
{ "Agent version": "...", "command": "...", "ok": true, "status": "Contingencia ativada" }

O campo é status, não xMotivo, e o texto vem sem acento, como no motor.

Com a contingência ligada, cada emissão passa a responder:

Campo Valor
cStat "-100"
xMotivo Emitido em contingencia
tpEmis 9
nProto e dhRecibo vazios (ainda não há protocolo)
danfe_base64 presente, e imprimível

O DANFE vem preenchido de propósito: o PDV precisa entregá-lo ao consumidor mesmo sem autorização ainda.

Esta tabela vem de captura do motor. Nem toda forma publicada nesta página vem. Algumas foram inferidas, entre elas o corpo de GET /api/v1/nfce/contingencia/resultado/{nNF}, e a página de compatibilidade diz quais. Onde a forma é inferida, quem vale é o contrato, não o exemplo. Ver Formas sem captura.

GET /api/v1/nfce/contingencia/disable desliga e as emissões voltam ao normal. Desligar não processa a fila: os documentos emitidos em contingência continuam esperando, e quem os resolve é o relatório de processamento. Ver os dois vocabulários de status para ler o resultado.

7. Contagens são por ambiente

pendingContingencyInvoices, no corpo de emissão, e o relatório de processamento contam apenas o ambiente do endereço que você chamou. Um integrador em homologação não vê a fila de produção, e vice-versa.

Se você opera nos dois ambientes, some você mesmo. Não existe uma visão consolidada, e não deve existir: misturar as duas filas produziria um número que não corresponde a nenhum dos dois ambientes.

8. Webhooks

A configuração é uma só por empresa e ambiente, não uma lista. POST grava, GET lê:

curl -X POST https://smartnfce-sandbox.api.veraciti.com.br/api/v1/webhook/config \
  -H "Authorization: Bearer $TOKEN_DO_DISPOSITIVO" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://seu-sistema.exemplo.com/webhook", "secret": "um-segredo-bem-aleatorio"}'

A gravação é parcial: só o que você envia muda. A primeira chamada precisa trazer a url. A unicidade da configuração é garantida no banco, então não há como registrar duas vezes por engano e passar a receber tudo em duplicidade.

O segredo lê de volta como ***

Este é o ponto que mais gera erro.

O secret é somente escrita: o GET devolve ***, nunca o valor real. Por isso enviar *** de volta significa "não mude o segredo".

O laço mais comum fica seguro: ler a configuração, mudar um campo e gravar o objeto inteiro de volta.

config = get("/api/v1/webhook/config").json()   # secret == "***"
config["url"] = nova_url
post("/api/v1/webhook/config", json=config)     # o segredo continua o mesmo

Uma configuração que ainda não existe devolve secret vazio, e não ***. É a forma de saber se já há um segredo definido.

Verificação da assinatura sobre os bytes recebidos

Se há segredo, cada entrega vai assinada:

X-Webhook-Signature: sha256=<hex>

É um HMAC-SHA256 sobre os bytes exatos do corpo que enviamos. Assinar é opcional: sem segredo configurado, as entregas vão sem assinatura.

Não reserialize o corpo antes de conferir

Calcule o HMAC sobre o corpo cru, exatamente como ele chegou. Se você desserializar o JSON e serializar de novo para conferir, a ordem das chaves e os separadores mudam. O digest dá diferente e toda entrega legítima parece falsificada.

retryMax e timeoutMs são aceitos, mas não obedecidos

Os dois campos existem, são gravados e voltam no GET, mas não governam a entrega. Quem governa é a nossa escada fixa de tentativas:

Tentativa Quando
1ª reentrega 1 minuto
30 minutos
1 hora
3 horas
24 horas

Depois disso a entrega é considerada perdida. Um retryMax de 10 não produz dez tentativas. Mantivemos os campos por compatibilidade de fio em vez de recusá-los, mas não queremos que você acredite neles: é uma divergência declarada, não um detalhe.

O envelope é sempre o mesmo

{
  "evento": "nota_autorizada",
  "timestamp": "2026-05-23T14:22:10",
  "versao": "1.0",
  "agente": "Veraciti",
  "dados": { }
}

O timestamp não é UTC

É a hora local da UF da empresa, com precisão de segundos e sem indicação de fuso. Interpretá-lo como UTC desloca o horário em até três horas. Se você guarda em UTC, converta a partir do fuso da empresa. Não assuma Z.

Os sete eventos

Sete eventos não querem dizer sete entregas por documento

Esta é a lista de eventos, não a contagem de entregas que um documento produz. Um documento que termina com avisos de pré-validação gera duas entregas separadas: nota_autorizada (ou nota_rejeitada) e prevalidacao, em dois POSTs para a mesma URL. O evento prevalidacao nunca chega sozinho. Ele sempre acompanha um desfecho.

Escreva o seu handler idempotente por evento, não por documento. Quem assume "um documento, uma entrega" ignora a segunda.

Evento dados
nota_autorizada nNF, chNFe, nProt, dhRecbto (formato brasileiro dd/mm/aaaa hh:mm:ss), modelo, cStat
nota_rejeitada nNF, cStat, xMotivo
nota_cancelada chNFe
contingencia_ativada motivo, pendentes
contingencia_desativada motivo
contingencia_sincronizada sincronizadas, pendentes
prevalidacao chNFe, nNF, serie, versao_base, violacoes

contingencia_desativada não traz pendentes, ao contrário de contingencia_ativada. Os dois não são simétricos, e a assimetria é o contrato. Não escreva um handler comum aos dois que leia pendentes.

Em contingencia_sincronizada, sincronizadas conta os documentos que chegaram a um desfecho: autorizados e rejeitados, não só os autorizados. O evento dispara uma vez por ambiente, então a contagem nunca mistura homologação com produção.

Os mesmos campos mudam de tipo conforme a origem

nNF e cStat são inteiros no webhook e strings na emissão

Em dados, nNF e cStat chegam como inteiros. No corpo de resposta da emissão, os mesmos campos são strings. As duas formas vêm de respostas reais do motor, e as duas são contrato. Como os erros de digitação, isto é o que o fio diz, não um descuido.

É a origem mais provável de bug num handler deste dialeto, porque o nome do campo é idêntico e só o tipo muda. Se você reaproveita a mesma função de leitura nas duas superfícies, normalize o tipo na entrada.

nota_autorizada e nota_rejeitada não são da contingência

Eles disparam tanto na emissão normal quanto na resolução da fila de contingência, e não dependem de tpEmis 9. Não os use para inferir que um documento passou por contingência. Para isso, o sinal é o tpEmis da própria chave.

O comportamento do fluxo de emissão já está documentado e não muda por causa deste dialeto.