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 emlocalhost. Ela é um acréscimo nosso, e por isso uma falha de credencial responde um401HTTP de verdade, não um erro em banda. É a exceção declarada à regra da próxima seção.
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:
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"
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:
É 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 |
| 2ª | 30 minutos |
| 3ª | 1 hora |
| 4ª | 3 horas |
| 5ª | 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.