A webhook is not a source of truth: what I learned integrating electronic tax invoices
The problem: the answer does not come right away
In Oficina Simples, the repair shop issues invoices for parts (NF-e) and for services (NFS-e) straight from the work order. Issuing goes through an intermediary that talks to the state tax authority and to city governments.
Almost everything there is asynchronous. You send the invoice and get back a "processing" status. The authorization, or the rejection, arrives minutes later through a webhook.
The temptation is obvious: receive the webhook, read the status from the body and write it to the database. It works on the first test. And it is exactly the kind of code I did not want in production.
Rule 1: the webhook is a nudge, not the answer
The endpoint that receives the notification does four things, in this order:
- Checks a per-company secret in the header, using a constant-time comparison.
- Looks up the invoice by its reference, within the company in the URL.
- If the reference does not exist, logs it and returns 200. It might be an invoice issued outside the system or from another environment, and there is no point making the sender retry.
- If it exists, queries the invoice at the source and only then updates the status.
The webhook body is not used for anything except the reference. The consequence is that a forged POST cannot authorize any invoice: at most, it triggers a lookup. The header secret is not the main defense, it only prevents pointless lookups.
This pattern applies to any integration: payments, shipping, e-signatures. Use the webhook to learn that something changed. Ask the source what changed.
Rule 2: notifications get lost, so someone has to check
Webhooks fail. The server was mid-deploy, the network flickered, the sender gave up after a few retries. If the system depends only on the notification, the invoice stays in "Processing" forever.
So there is a sweep that runs every minute and checks pending invoices with a growing interval:
- In the first 30 minutes: every 1 minute.
- Up to 6 hours: every 15 minutes.
- After that: hourly, for up to 3 days.
Each invoice stores when it was last checked, the sweep takes at most 100 per run, and a failure on one invoice does not take the others down. On screen, the list refreshes itself every 10 seconds while any invoice is processing.
The webhook makes the experience fast. The sweep makes the system correct.
Rule 3: validate before calling whoever is going to reject you
A rejection from the tax authority comes with a code and a message that no shop owner should have to learn to read.
Before any issuance, the system checks everything that would be rejected: the shop's tax ID and tax regime, an expired certificate, the parts' product classification codes, the customer's document and address. The answer comes back as a plain-language list that points to where to fix it, such as "Enter the shop's tax ID under Company Details".
It is more code than just passing the error along. But it is the difference between one support ticket a day and none.
Rule 4: store as little as possible
The A1 digital certificate is the most sensitive credential in the whole operation. In reseller mode, the file is not stored: it goes straight to the intermediary and the temporary copy is deleted right after. The system keeps only the tokens, encrypted, and the expiry date, which feeds reminders 30, 15, 7 and 1 day ahead.
You cannot leak what you never stored.
Rule 5: what is not covered should be blocked, not guessed
Taxes have too many edge cases. One of them, selling to an end consumer in another state under certain tax regimes, I decided not to cover in this version.
The easy way out would be to issue with an approximate rule. The way I chose: block it with a clear warning to issue through the accountant. A wrong invoice costs far more than an invoice the system did not issue.
Rule 6: be honest about what was tested
The integration was written from the official documentation and tested against a simulated API. That is not the same as issuing for real.
So the documentation has an explicit certification checklist with what can only be confirmed with a real tax ID and certificate: the city's service tax rate format, the correction letter response, the fields from the upcoming tax reform. And issuing starts switched off for everyone, enabled by a toggle in the admin panel, with no deploy.
Tax integration is not the place for "it worked on my machine". It is the place to distrust every response until the source confirms it.
Related posts
Hiding the menu is not access control: how I built modules each customer can switch on and off
A small shop does not want to see commissions, invoicing and a customer portal on day one....
My second SaaS started from the code of the first: what I could reuse and what I had to rewrite
Oficina Simples was born as a fork of Estoque Simples. Login, plans, billing, privacy comp...
You did not get 4x faster. You got 10x less secure — and now there is data
Nearly half of AI-generated code is born with an OWASP Top 10 flaw. Commits ship 4x faster...