Real-time ERP stock, inside your chatbot. The customer asks "do you have this set in size S?", and the answer comes from the real Bling v3 balance — not from a spreadsheet, not from a guess.
The problem · How it works · Decisions · Run it · License
A sales chatbot knows how to hold a conversation, but it doesn't know what's in stock. The usual ways out are all bad: a spreadsheet updated by hand, an ERP export that goes stale the same day, or — worst of all — the bot guessing at availability while the store sells something that already ran out.
This API is the bridge that was missing. It takes the question in natural language, searches the real Bling catalog, decides which product it is, looks up the balance and returns JSON the bot can use without inventing anything.
"do you have the black set in P?"
│
▼
GET /estoque?q=conjunto preto P
│
├─ extracts the product terms (color and size drop out of the search)
├─ searches Bling and ranks by match score
├─ resolves the right variant — or asks back
▼
{ "found": true, "stock": 5, "available": true }
The full path of a question, from WhatsApp to the balance:
flowchart TD
A["Customer asks on WhatsApp"] --> B["Chatbot (HTTP Action)"]
B --> C["GET /estoque?q=..."]
C --> D{"X-API-Key valid?"}
D -- "no" --> E["401"]
D -- "yes" --> F{"query has enough terms?"}
F -- "no" --> G["found=false, asks for more detail"]
F -- "yes" --> H["extracts terms and searches Bling"]
H --> I{"access_token expired?"}
I -- "yes" --> J["renews via refresh_token"]
J --> H
I -- "no" --> K["ranks the products by score"]
K --> L{"found anything?"}
L -- "no" --> M["found=false"]
L -- "yes" --> N{"parent product with no clear variant?"}
N -- "yes" --> O["ambiguous=true with the options"]
N -- "no" --> P["looks up the variant balance"]
P --> Q{"stock greater than zero?"}
Q -- "yes" --> R["available=true with the quantity"]
Q -- "no" --> S["available=false"]
O --> T["Bot answers the customer"]
R --> T
S --> T
M --> T
G --> T
Color and size drop out of the search, but come back in the ranking. Bling
searches by name, and sending conjunto preto P as the query finds nothing:
the product is registered as CONJUNTO ... COR:PRETO;TAMANHO:P. The API
separates the two roles — it searches by the product terms, and uses color and
size only to choose among the variants that came back.
When the question is ambiguous, the API asks back. If the customer didn't
say the size, five existing variants are no reason to guess at the first one.
The response comes with ambiguous: true and the list of options, and the bot
is what drives the conversation.
The balance has two paths. Bling's stock endpoint doesn't always return the
product's row. When it doesn't, the API falls back to the product's
saldoVirtualTotal — rather than reporting zero and killing a sale.
Token renewal is protected against a race. Two requests hitting a 401 at the same time would renew the token twice, and the second would invalidate the first. Renewal sits behind a single promise, and before calling the API it re-reads the token file: if another process already renewed, it doesn't spend the call.
Color aliases become a canonical form. Nobody types AZUL MARINHO. The API
expands marinho, terracota, oliva and company before comparing.
| Method | Route | What for |
|---|---|---|
GET |
/estoque?q= |
The main query: a natural-language question, returns the balance |
GET |
/estoque/variantes?nome= |
Every variant of a product, with the balance of each |
GET |
/health |
Availability probe |
GET |
/oauth/url |
Generates the Bling authorization URL |
GET |
/oauth/callback |
Exchanges the Bling code for tokens |
In stock, with a balance:
{
"found": true,
"id": 900000001,
"name": "CONJUNTO EXEMPLO COR:PRETO;TAMANHO:P",
"price": 160.0,
"stock": 5,
"available": true,
"message": "Sim, temos 5 unidade(s) disponivel(is)."
}The variant is missing — the bot needs to ask:
{
"found": false,
"ambiguous": true,
"message": "Encontrei algumas opcoes para este produto. Qual delas voce quer verificar?",
"options": [
{ "id": 900000001, "name": "CONJUNTO EXEMPLO COR:PRETO;TAMANHO:P" },
{ "id": 900000002, "name": "CONJUNTO EXEMPLO COR:PRETO;TAMANHO:M" }
]
}Not found:
{
"found": false,
"message": "Produto nao encontrado para \"xyz\". Verifique nome, cor e tamanho."
}The data in these examples is fictitious. The real catalog belongs to the integration's client and isn't in this repository. The
messagefield ships in Portuguese because it's the text the bot relays to the end customer.
src/
app.js builds the Express app, without opening a port
main.js runtime entry point
bin/oauth.js CLI for the initial authorization
config/env.js every process.env value passes through here
controllers/ HTTP handlers: estoque, health, oauth
http/middleware/ X-API-Key and the global error handler
routes/ the routes of the stock feature
services/
bling.service.js OAuth2 client and calls to Bling
estoque.service.js the rule: search, rank, resolve the variant
utils/matcher.js normalization, aliases and match score
docs/ integration guide and architecture map
The business rules live in services and utils/matcher.js, with no knowledge
that HTTP exists. The controllers only translate a request into a call and a
response into JSON — that's what makes the matcher testable without starting a
server.
| Decision | Why |
|---|---|
| No database | Bling is the source of truth. Caching the balance is how a store gets a sale wrong. |
| Tokens in a file and in env | On a host with ephemeral disk the file disappears; the refresh_token from env guarantees the boot. |
app.js separate from main.js |
The app builds without listening on a port, so it can be tested without starting a server. |
| Rate limit in front of everything | The Bling quota belongs to the client. 120 req/min per IP protects it. |
npm install
cp .env.example .env # fill in your Bling app credentials
npm run dev # http://localhost:3000Authorize the app in Bling (one time only):
npm run oauth:url # open the URL, authorize, copy the code
npm run oauth:exchange -- YOUR_CODE # exchanges the code for tokensQuery it:
curl http://localhost:3000/health
curl "http://localhost:3000/estoque?q=conjunto+preto+P" \
-H "X-API-Key: your_key"Any Node host works. The Procfile is ready for Railway or Render: push the
repository, configure the environment variables and point the Bling app's
redirect URL at https://your-domain/oauth/callback.
No .env goes into the repository — .env.example is the
template.
| Variable | What for |
|---|---|
BLING_CLIENT_ID / BLING_CLIENT_SECRET |
Your Bling app credentials. They are secrets |
BLING_REFRESH_TOKEN |
Renews access without intervention. It is a secret |
API_SECRET_KEY |
The key the bot sends in the X-API-Key header. It is a secret |
BLING_SYNC_ENV |
false stops the API from rewriting the local .env on renewal |
PORT |
Railway and Render inject it automatically |
The Bling access_token expires in 1 hour and the API renews it on its own. On
a host with ephemeral disk, .tokens.json doesn't survive a restart: update
BLING_REFRESH_TOKEN in the environment variables whenever you reauthorize.
- No test suite.
matcher.jsis a pure function and the obvious candidate for the first one — score, aliases and term extraction. - No cache. Every query hits Bling. At high volume, a short TTL per search term would save quota, at the cost of a slightly stale balance.
- Search limited to 100 products per term. A large catalog with a very generic name may not surface the right variant on the first page.
© 2026 NerdResolve. All rights reserved. See LICENSE.md.
The repository is public for technical evaluation and portfolio demonstration. The code may be read and studied; there is no license to use, copy or redistribute it. Bling and BotConversa are trademarks of their respective owners; this project only consumes the public APIs of both.
Inventory API · built by NerdResolve
Want your bot answering with real stock levels? contact@nerdresolve.com
Made by Matheus Mariath · NerdResolve