Cotação MutualDocumentação da operação · cotações por grupo com fees da Mutual · fase 1 (somente cotações)

🎛 Painel de controle
1. Arquitetura 2. Fluxo da cotação 3. Fluxo da compra 4. cURLs (todas as chamadas) 5. Variáveis da cotação 6. Fórmulas e exemplo 7. Requisitos técnicos 8. Resolução de merchants 9. Regras da fase atual 10. Operação (runbook)

1. Arquitetura

👥 Grupo WhatsAppcliente envia /COTAR, /COMPRAR
MESSAGES_UPSERT
webhook
📱 Evolution API v2.3.7whatsapp.talkhub.me
instância talkbia
POST /webhook
?token=…
🪙 cotacaomutualNode 22 · Express
Swarm + Traefik (talkhub)
Bearer ak_ +
x-service-token
🏦 Mutual API v2merchants + fees (prod)
ticker (hml*)
POST /message/sendText/talkbia (apikey) — resposta ao grupo
🎛 Painel /paineltoggles por canal/grupo · fila ao vivo
merchants/fees · registro · diagnóstico
PANEL_TOKEN
💾 Volume cotacaomutual_datasettings.json (toggles)
quote-log.jsonl (auditoria)
TTL 6h
🔗 Resolver de gruposconvite chat.whatsapp.com/…
→ JID via /group/inviteInfo

* A fonte do preço é o ticker (preço de mercado sem fee). Hoje ele é servido pela URL apis-hml (MUTUAL_CRYPTO_ENV=hml); a URL apis (prod) responde no formato "quote" com spread de provider embutido, usado apenas como último recurso. Com QUOTE_TICKER_FALLBACK=true, o sistema prefere o ticker onde quer que ele esteja — quando a Mutual publicar o ticker em prod, basta trocar MUTUAL_CRYPTO_ENV=prod.

2. Fluxo da cotação (/COTAR)

Mensagem chega pelo webhookEvolution envia messages.upsert. Filtros: só grupos (…@g.us), ignora fromMe (anti-loop) e outros eventos.
Comando interpretado/COTAR 25K USDT (qtd do ativo) · /COTAR 5000 BRL USDT (orçamento) · /COTAR 1 BTC BRL (venda) · /VENDER 1 BTC · aliases USD/dólar→USDC, K/M, vírgula decimal.
Merchant identificado pelo grupoBusca nos linkGroups dos merchants (cache 60s): JID direto, link de convite ou código — convites são resolvidos para JID na Evolution (/group/inviteInfo, cache 6h). Exige merchant.status=active e linkGroup.active=true.
Toggles do painelCotações desligadas no canal/grupo → responde que a cotação será conduzida manualmente (nunca cita "bot desligado").
Operação classificada + fee exataBRL→cripto=buy · BRL→USDC e USDC→BRL e cripto→cripto=conversion · cripto→BRL=sell. Fee localizada por operation+source+destination (cache 60s) — sem fee exata a cotação é interrompida (nunca usa fee de outro par).
FILA: 10 mensagens, requisições separadasCada mensagem traz os DOIS lados do par (compra e venda), cada um com a fee do seu lado — lado sem fee sai como sob consulta. Para cada mensagem: ticker NOVO na Mutual (nunca cacheado, preços mudam a cada segundo) → fee aplicada na direção correta → sendText no grupo (📊 Cotação X/10) → registro completo no painel. Intervalo padrão 3s. Nova /COTAR substitui a fila; /COMPRAR interrompe.

Seleção do preço no ticker (lado do cliente)

DireçãoLado do bookCampo do ticker
Cliente COMPRA o ativo (BRL → cripto)asksell (fallback last, buy)
Cliente VENDE o ativo (cripto → BRL)bidbuy (fallback last, sell)
Cripto → cripto (taxa cruzada via BRL)vende origem no bid ÷ compra destino no ask2 requisições por mensagem

3. Fluxo da compra (/COMPRAR)

/COMPRA ou /VENDA chega (com ou sem argumentos)/COMPRA · /VENDA USDT · /COMPRA 50K USDT. Aliases: /ORDER, /ORDEM, /FECHAR.
Argumentos conferidos contra a cotação ativaAtivo e quantidade precisam bater com a cotação do grupo (fila ativa ou concluída há < 15 min). Divergência (ex: cotação de USDT e pedido de BTC) → NÃO confirma e orienta: "envie /COTAR <valor> <ativo>".
Cotação é TRAVADA (consumida)Cada cotação confirma no máximo UMA operação. O próximo /COMPRAR exige uma /COTAR nova — preços mudam a cada segundo.
Registro da operação emitido no grupoID do grupo · ID da Transação (UUID) · Data (America/Sao_Paulo) · Tipo (COMPRA/VENDA/CONVERSÃO + ativo) · Cotação final · Montantes Total/Pendente no ativo e em BRL.
Conclusão manual"A operação será concluída manualmente por um operador da Mutual." — nenhuma ordem é criada nesta fase. Tudo fica no Registro de operações do painel (transactionId, fees, ticker cru) para o operador dar sequência.

Exemplo de registro emitido

✅ Pedido recebido!

ID do grupo:
120363406233321709@g.us

ID da Transação:
b571d632-add7-43f8-9750-1e0b75d2f2f1

📅 Data da Operação:
11/07/2026 18:07

Tipo de Operação:
COMPRA USDT

Cotação:
1 USDT = R$ 5,16362

💵 Montante em USDT:
Total: 100.000 USDT
Pendente: 100.000 USDT

💼 Montante em BRL:
Total: R$ 516.361,63
Pendente: R$ 516.361,63

A operação será concluída manualmente por um
operador da Mutual. Aguarde a confirmação
aqui no grupo. 🤝

Casos de recusa

SituaçãoResposta
Sem cotação válida (nenhuma, expirada >15min ou já consumida)"É preciso uma cotação atualizada… envie /COTAR e confirme com /COMPRAR"
Argumentos divergentes da cotação ativa"A cotação ativa é: … Para operar X, envie /COTAR X"
Argumentos incompreensíveis"Não entendi os detalhes… /COMPRAR confirma a cotação ativa"
Grupo sem merchant ativo vinculado"Este grupo ainda não está vinculado a um cliente habilitado para cotações."
Compra LIGADA no painel (+ ORDERS_ENABLED=false)Mesmo registro + "execução automática ainda não habilitada… conclusão manual"

4. cURLs — todas as chamadas externas

4.1 Mutual · Merchants (produção, cache 60s)

curl --location -G 'https://apis.mutual.app.br/api/v2/resource/merchants' \
  --header 'Authorization: Bearer ak_SUA_CHAVE' \
  --header 'Content-Type: application/json' \
  --header 'x-service-token: SEU_SERVICE_TOKEN' \
  --data-urlencode 'page=1' --data-urlencode 'limit=100'
# usa: id, legalName, status, linkGroups[{channel, groupId, name, active}]
# groupId aceita: JID (…@g.us), link https://chat.whatsapp.com/CODIGO ou código puro

4.2 Mutual · Fees do merchant (produção, cache 60s)

curl --location -G 'https://apis.mutual.app.br/api/v2/resource/fees/merchant/org_3DXaWmcAaguU8rn2ryheTS2kRdw' \
  --header 'Authorization: Bearer ak_SUA_CHAVE' \
  --header 'Content-Type: application/json' \
  --header 'x-service-token: SEU_SERVICE_TOKEN'
# usa: operation + sourceAsset + destinationAsset + feeFixed + feePercentage (match EXATO)

4.3 Mutual · Cotação-base / ticker fonte do preço (NUNCA cacheada — 1 requisição por mensagem da fila)

curl --location -G 'https://apis-hml.mutual.app.br/api/v2/crypto/quote' \
  --header 'Authorization: Bearer ak_SUA_CHAVE' \
  --header 'Content-Type: application/json' \
  --header 'x-service-token: SEU_SERVICE_TOKEN' \
  --data-urlencode 'symbol=USDT-BRL' \
  --data-urlencode 'amount=1000' \
  --data-urlencode 'sourceAsset=BRL' \
  --data-urlencode 'targetAsset=USDT' \
  --data-urlencode 'targetNetwork=TRON'

# Resposta formato TICKER (preço de mercado SEM fee — é a base da cotação):
{"error":false,"message":"Quote fetched successfully","data":{
  "buy":"5.1635","sell":"5.1636","last":"5.1636","high":"5.1659","low":"5.1375",
  "open":"5.1453","vol":"7768643.22971","pair":"USDT-BRL","date":1783801754080}}

4.4 Mutual · Mesmo endpoint em produção formato "quote" — só último recurso

# Mesma chamada em https://apis.mutual.app.br → responde com spread de provider embutido:
{"error":false,"data":{"quote_id":"…","type":"BUY","source_asset":"BRL","target_asset":"USDT",
  "source_amount":"1000000","target_amount":"184501.845018","price":"5.42","fee_bps":50,
  "provider":"MERCADO_BITCOIN","locked_at":"…","expires_at":"…"}}
# 5,42 ≠ mercado (5,1636) → NÃO serve de base. Com QUOTE_TICKER_FALLBACK=true o sistema
# detecta o formato e busca o ticker na URL alternativa automaticamente.

4.5 Evolution · Envio da mensagem ao grupo

curl -X POST 'https://whatsapp.talkhub.me/message/sendText/talkbia' \
  --header 'apikey: SUA_EVOLUTION_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{"number":"120363429012757266@g.us","text":"📊 Cotação BRL → USDT (1/10)…"}'

4.6 Evolution · Resolver convite → JID (cache 6h)

curl -G 'https://whatsapp.talkhub.me/group/inviteInfo/talkbia' \
  --header 'apikey: SUA_EVOLUTION_API_KEY' \
  --data-urlencode 'inviteCode=C34dh5vXFPJ8wgOGlE9LYG'
# resposta: { "id": "120363429012757266@g.us", "subject": "teste", … }

4.7 Evolution · Registro do webhook (feito pelo setup.sh)

curl -X POST 'https://whatsapp.talkhub.me/webhook/set/talkbia' \
  --header 'apikey: SUA_EVOLUTION_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{"webhook":{"enabled":true,
    "url":"https://cotacaomutual.talkhub.me/webhook?token=SEU_WEBHOOK_TOKEN",
    "webhookByEvents":false,"webhookBase64":false,"events":["MESSAGES_UPSERT"]}}'

4.8 Nosso serviço · Webhook de entrada (contrato genérico p/ testes)

curl -X POST 'https://cotacaomutual.talkhub.me/webhook?token=SEU_WEBHOOK_TOKEN' \
  --header 'Content-Type: application/json' \
  --data '{"channel":"whatsapp","groupId":"120363429012757266@g.us","text":"/COTAR 25K USDT"}'

4.9 Nosso serviço · API do painel

# visão geral (canais, grupos, toggles, filas)
curl 'https://cotacaomutual.talkhub.me/api/panel/overview' -H 'Authorization: Bearer SEU_PANEL_TOKEN'

# liga/desliga bots (compra nasce SEMPRE desligada; ativação manual)
curl -X POST 'https://cotacaomutual.talkhub.me/api/panel/toggle' \
  -H 'Authorization: Bearer SEU_PANEL_TOKEN' -H 'Content-Type: application/json' \
  -d '{"scope":"group","channel":"whatsapp","groupId":"1203…@g.us","feature":"buy","enabled":true}'

# registro detalhado + diagnóstico da cotação-base ao vivo
curl 'https://cotacaomutual.talkhub.me/api/panel/logs?limit=100' -H 'Authorization: Bearer SEU_PANEL_TOKEN'
curl 'https://cotacaomutual.talkhub.me/api/panel/diag/quote?asset=USDT&side=buy&env=hml' -H 'Authorization: Bearer SEU_PANEL_TOKEN'

5. Variáveis utilizadas na cotação

5.1 Parâmetros da requisição de ticker

ParâmetroValorOrigem
symbol{ATIVO}-BRL (ex: USDT-BRL, BTC-BRL)ativo da perna cotada
amountorçamento em BRL (comando … BRL …) ou QUOTE_REFERENCE_BRL_AMOUNT (1000) quando o usuário informa quantidade do ativocomando do grupo
sourceAssetBRLfixo (referência)
targetAssetBTC · ETH · USDT · USDCcomando (após normalização de aliases)
targetNetworkBTC→BITCOIN · ETH→ERC20 · USDT→TRON · USDC→ERC20src/constants.ts

5.2 Variáveis de ambiente (.env)

VariávelValor atualPapel na cotação
MUTUAL_API_KEY / MUTUAL_SERVICE_TOKENsegredoautenticação em TODAS as chamadas à Mutual
MUTUAL_PROD_BASE_URLhttps://apis.mutual.app.brmerchants + fees (sempre) · quote se env=prod
MUTUAL_HML_BASE_URLhttps://apis-hml.mutual.app.brticker (fonte atual do preço)
MUTUAL_CRYPTO_ENVhml (interino)ambiente consultado primeiro no /crypto/quote
QUOTE_TICKER_FALLBACKtruese vier formato "quote" (spread), busca o ticker na URL alternativa
QUOTE_QUEUE_MESSAGES10mensagens por fila de cotação
QUOTE_QUEUE_INTERVAL_MS3000intervalo entre mensagens (preço novo a cada uma)
QUOTE_REFERENCE_BRL_AMOUNT1000amount de referência p/ descobrir preço unitário
MERCHANT_CACHE_TTL_MS / FEE_CACHE_TTL_MS60000cache de merchants/fees (ticker NUNCA cacheia)
ORDERS_ENABLEDfalsetrava absoluta: nenhuma ordem nesta fase
OUTBOUND_MODE + EVOLUTION_BASE_URL/INSTANCE/API_KEYevolution · talkbiaentrega das mensagens ao grupo
WEBHOOK_TOKEN / PANEL_TOKENgerados no setup.shproteção do webhook e do painel
DATA_DIR/app/data (volume)toggles do painel + quote-log.jsonl

5.3 Variáveis do cálculo (por mensagem da fila)

VariávelDe onde vem
baseUnitPriceticker: sell(ask) na compra · buy(bid) na venda · fallback last → razão source_amount/target_amountprice
feePercentage · feeFixedfee EXATA do merchant (operation+source+destination) — AMBAS em pontos percentuais, somadas: taxa = (fixa + percentual)/100
quantity / amountBRLcomando do grupo (25K → 25.000; aceita K/M e vírgula)
finalUnitPrice · finalTotal / netAmountresultado das fórmulas abaixo — único dado exibido no grupo

6. Fórmulas e exemplo numérico

taxa = (feeFixed + feePercentage) / 100
       ← as duas fees da Mutual são PONTOS PERCENTUAIS que se SOMAM
       (ex: fixa 0.1 + percentual 0.65 → taxa total de 0,75%)

① Quantidade do ativo (/COTAR 25K USDT — cliente paga mais):
     finalTotal = quantidade × baseUnitPrice × (1 + taxa)

② Orçamento em BRL (/COTAR 5000 BRL USDT — fee sai do orçamento):
     baseAvailable = amountBRL / (1 + taxa)
     quantity      = baseAvailable / baseUnitPrice

③ Venda/conversão da origem (/COTAR 1 BTC BRL — cliente recebe menos):
     net = quantidade × baseUnitPrice × (1 − taxa)

Exemplo real (fee do merchant: fixa 0% + percentual 0,65% = 0,65%)

PassoValor
Comando no grupo/COTAR 1K USDT
Ticker (ask, sem fee)1 USDT = R$ 5,0781
baseTotal = 1.000 × 5,0781R$ 5.078,10
taxa total = (0 + 0,65) / 1000,65%
fee = 5.078,10 × 0,0065R$ 33,01
finalTotal (mostrado no grupo)R$ 5.111,11 → 1 USDT = R$ 5,11111

O grupo vê apenas a cotação final. feePercentage, feeFixed, preço-base sem fee, merchantId e ticker cru ficam SÓ no painel/registro (data/quote-log.jsonl).

7. Requisitos técnicos

Infraestrutura

VPSDocker Swarm ativo (nó manager)
Redeoverlay externa talkhub
ProxyTraefik v3 · entrypoint websecure · resolver letsencryptresolver
DNScotacaomutual.talkhub.me → IP da VPS
Imagemcotacaomutual:latest (build local, Node 22-alpine, multi-stage)
Volumecotacaomutual_data (externo) → /app/data
Recursos0.5 CPU · 512 MB · 1 réplica
HealthcheckGET /health a cada 30s
Portasnenhuma publicada no host (Traefik → :3000 interno)

Integrações e credenciais

Mutual API v2chave ak_… + x-service-token válidos p/ prod e hml
Evolution APIv2.3.7 · instância talkbia conectada · apikey
WebhookMESSAGES_UPSERT → /webhook?token=… (registrado pelo setup.sh)
Cadastro do grupomerchant ativo + linkGroup ativo (JID, link ou código de convite)
Fees do merchantmatriz por par cadastrada na Mutual (sem fee = sem cotação)
FusoTZ=America/Sao_Paulo (datas do registro)
Node/devNode ≥ 20 · npm run build · npm test (66 testes)

8. Resolução de merchants (resiliência)

A listagem GET /resource/merchants pode recusar o service token (Service token not accepted on this endpoint) e a API não expõe merchant por ID/resource/merchants/{id} responde 404 em HTML (Cannot GET …). O sistema tem quatro níveis; o primeiro que funcionar vence, e o painel mostra qual está em uso.

ListagemGET /api/v2/resource/merchants?page&limit — caminho normal, traz linkGroups completos. Alimenta o snapshot em disco.
Consulta individual por organização (fila)Para cada ID conhecido tenta o endpoint direto e, como ele não existe, cai na sonda via fees: GET /resource/fees/merchant/{orgId}200 confirma que o merchant existe e está acessível; 404 significa fora desta conta. O nome vem do catálogo semente/snapshot. IDs que não respondem ficam 10 min em quarentena (sem remartelar a API) e o 404 esperado não vai para o log.
Snapshot em discodata/merchants-snapshot.json — último estado válido. Os linkGroups do snapshot são preservados quando a resposta individual não os traz: o vínculo de grupo nunca se perde.
Vínculos manuais do painelGrupo → merchant informado pelo link de convite, aplicado por cima de todos os níveis. Se nenhuma fonte da Mutual responder, os grupos vinculados continuam cotando normalmente.

8.1 Origem dos IDs de organização

FonteOnde
Catálogo sementesrc/mutual/known-merchants.ts — IDs + razão social já observados em produção
AmbienteMUTUAL_KNOWN_MERCHANT_IDS (separados por vírgula)
Snapshottodo merchant já visto fica registrado automaticamente
Vínculostodo merchant vinculado no painel entra na lista de IDs conhecidos

8.2 Vincular grupo → merchant pelo link de convite

O usuário final não sabe o JID interno do grupo — ele tem o link de convite. É esse link que se cola no painel; a resolução para JID acontece no clique.

Operador cola o linkhttps://chat.whatsapp.com/C34dh5vXFPJ8wgOGlE9LYG — o JID interno (…@g.us) também é aceito, para quem já o tem. Qualquer outro texto é recusado com instrução clara.
Resolução na horaGET /group/inviteInfo na Evolution, com consulta forçada (o cache guarda falhas por 60s; sem forçar, um novo clique falharia sozinho depois de a instância entrar no grupo).
Grava pelo JIDO JID resolvido é a forma canônica do vínculo; o link fica guardado como referência e aparece na linha do grupo (convite: …), com a etiqueta manual.

Cada linha de grupo tem um botão vincular que já preenche o formulário. Para remover, deixe o merchant vazio e clique em Desvincular — aceita o link ou o JID, independentemente de qual foi usado no cadastro. O vínculo é gravado em DATA_DIR (volume externo) e sobrevive a reinícios e redeploys.

curl -X POST 'https://cotacaomutual.talkhub.me/api/panel/bind-group' \
  -H 'Authorization: Bearer SEU_PANEL_TOKEN' -H 'Content-Type: application/json' \
  -d '{"channel":"whatsapp","groupId":"https://chat.whatsapp.com/C34dh5vXFPJ8wgOGlE9LYG",
       "merchantId":"org_3G8y…","label":"Grupo VIZZO"}'

# resposta quando o convite resolve:
{"ok":true,"binding":{"channel":"whatsapp","groupId":"120363429012757266@g.us",
  "merchantId":"org_3G8y…","invite":"https://chat.whatsapp.com/C34dh…"},
 "resolvedJid":"120363429012757266@g.us","warning":null}
# warning != null → convite não resolveu agora; o vínculo ficou salvo pelo link

# desvincular: merchantId vazio (link ou JID)
curl -X POST 'https://cotacaomutual.talkhub.me/api/panel/bind-group' \
  -H 'Authorization: Bearer SEU_PANEL_TOKEN' -H 'Content-Type: application/json' \
  -d '{"channel":"whatsapp","groupId":"https://chat.whatsapp.com/C34dh5vXFPJ8wgOGlE9LYG","merchantId":""}'

# catálogo para o seletor + vínculos ativos
curl 'https://cotacaomutual.talkhub.me/api/panel/known-merchants' -H 'Authorization: Bearer SEU_PANEL_TOKEN'

# diagnóstico: testa listagem E consulta individual ao vivo, com status e corpo do erro
curl 'https://cotacaomutual.talkhub.me/api/panel/diag/merchants' -H 'Authorization: Bearer SEU_PANEL_TOKEN'

9. Regras da fase atual

  1. Só cotações. ORDERS_ENABLED=false; a chamada de criação de ordem não existe no código.
  2. Compra (execução) sempre nasce desligada — ativação exclusivamente manual no painel, por canal ou grupo.
  3. Fila de 10 cotações por comando, cada mensagem com requisição própria (sem cache de ticker), interrompível pelo /COMPRAR.
  4. 1 cotação = 1 confirmação. O /COMPRAR trava (consome) a cotação; nova confirmação exige /COTAR novo. Validade da cotação para confirmação: 15 min.
  5. Argumentos do /COMPRAR são conferidos (ativo e quantidade) contra a cotação ativa — divergência não confirma.
  6. Fee exata obrigatória por operation+source+destination — sem fallback silencioso.
  7. Nunca citar estado do bot — recursos inativos respondem apenas que a operação será concluída manualmente por um operador da Mutual.
  8. Grupo vê só a cotação final; todos os internos vão para o painel e o quote-log (auditoria completa por transactionId).

10. Operação (runbook)

# Primeiro deploy (pergunta só a MUTUAL_API_KEY; gera tokens; registra webhook)
cd /root/cotacaomutual.talkhub.me && bash setup.sh

# Atualização rápida
git pull && docker build -t cotacaomutual:latest . && \
docker service update --image cotacaomutual:latest --force cotacaomutual_cotacaomutual

# Redeploy limpo (remove stack, limpa imagens do serviço, rebuild --no-cache, preserva .env e volume)
bash redeploy.sh            # flags: --no-pull · --wipe-data · --yes

# Observabilidade
docker service logs -f cotacaomutual_cotacaomutual
https://cotacaomutual.talkhub.me/painel     # toggles, fila ao vivo, merchants/fees, registro, diagnóstico
https://cotacaomutual.talkhub.me/health
SintomaCausa provável / ação
"grupo não vinculado"linkGroup ausente/inativo na Mutual, merchant inativo, ou convite não resolvível (registre o JID direto). Ver logs: Falha ao resolver convite. Solução imediata: colar o link de convite do grupo no painel e vincular ao merchant (§8.2).
Painel sem merchants / lista vaziaPainel → Testar merchants (/api/panel/diag/merchants) mostra o erro exato de cada caminho. Com a listagem recusando o token, o rótulo de fonte deve indicar consulta individual; se nenhum caminho responder, use os vínculos manuais (§8.2) — os grupos vinculados continuam cotando.
"não há taxa configurada"Falta a fee EXATA do par no merchant — painel → Merchants & Fees mostra os pendentes.
"não foi possível gerar a cotação"Painel → Diagnóstico (mostra o erro HTTP exato da Mutual) e Registro de operações.
Preço divergente do mercadoConferir priceSource no diagnóstico: deve ser ticker/ticker-fallback, nunca quote.
Bot não responde no grupoWebhook na Evolution (URL/token), instância conectada, docker service logs.