O desafio
Hoje eu quero falar da Temperô, um SaaS multi-tenant de gestão de restaurantes: pedidos, cozinha, caixa, comandas, várias unidades por restaurante. Stack em Node/TypeScript/Express, Postgres com pg puro (sem ORM, SQL cru, migrations numeradas).
O objetivo era simples de enunciar,mas em termos de produção, bem difícil de sustentar: um pedido feito no marketplace iFood precisa entrar automaticamente no fluxo operacional do Temperô, cair na cozinha, seguir para produção e entrega, fechar o pagamento - e tudo isso com o status sincronizado nos dois sentidos. O restaurante não pode ter que operar dois sistemas em paralelo, um para o delivery do marketplace e outro para o resto.
No artigo de hoje, eu conto como implementamos isso, os bugs que apareceram no caminho, e principalmente a investigação mais longa do projeto: o por que, às vezes, o evento de confirmação de um cancelamento simplesmente não chegava nunca.
As decisões de arquitetura
Polling, não webhook. A Order API do iFood expõe GET /order/v1.0/events:polling, que devolve os eventos pendentes de um merchant, e POST /order/v1.0/events/acknowledgment, que confirma o recebimento e tira o evento da fila. A cada 15 segundos, o Temperô sempre inicia a chamada, dentro de um setInterval no próprio processo Node, sem serviço separado. Uma flag de controle simples evita sobreposição de execuções caso um tick demore mais que o intervalo:
let polling = false;
setInterval(async () => {
if (polling) return;
polling = true;
try {
await pollIfoodEvents();
} finally {
polling = false;
}
}, 15_000);
Nesse sentido, é importante dizer que não escolhemos polling por preferência técnica; ela era a única opção viável no momento: o iFood nunca chama o Temperô, o Temperô sempre chama o iFood.
App "Centralizado" do iFood Developer. O iFood oferece dois modelos de credencial: "Distribuído", uma credencial OAuth2 por restaurante/unidade, e "Centralizado", uma única credencial client_credentials para toda a plataforma, filtrando o merchant específico via header x-polling-merchants em cada request. Por aqui, escolhemos Centralizado, o que exigiu refatorar no meio do projeto um cache de token que originalmente era por unidade para um cache único em memória.
Token OAuth2 com renovação proativa e reativa. O token fica em memória, com expiração baseada no expiresIn que o próprio iFood devolve. Em termos de renovação, fizemos tanto de forma proativa, com um buffer de 30 segundos antes de expirar, como de forma reativa, invalidando e tentando de novo uma vez em qualquer resposta 401.
Pedido do iFood sempre nasce com origin='ifood' e dine_type='entrega' fixo. O v1 da integração cobre só delivery. Pedidos de takeout ou dine-in que por algum motivo chegam via marketplace são recusados automaticamente, chamando requestCancellation com o primeiro motivo disponível na lista de códigos.
Sem mapeamento de cardápio. O item do pedido iFood entra com menu_item_id=null, usando nome e preço direto do payload recebido. Mapear cada item do cardápio do iFood para o cardápio interno do Temperô ficou fora do escopo do v1.
Pagamento sem operador humano. Cada unidade tem um usuário "de sistema" sintético e um caixa virtual que abre e fecha sozinho, sem depender de alguém abrindo caixa manualmente pra receber pedido de marketplace.
O bug do code vs fullCode
Ao longo da trajetória, é claro, surgiram alguns problemas.
Entre eles, estava o fato de que cada evento do polling do iFood vem com dois campos parecidos: code, um código abreviado tipo "PLC", e fullCode, o nome por extenso, tipo "PLACED". Durante semanas, o switch que processava os eventos comparava contra code:
switch (event.code) {
case "PLACED":
await handleOrderPlaced(event);
break;
case "CANCELLED":
await handleOrderCancelled(event);
break;
// ...
default:
break;
}
O problema é que event.code nunca era "PLACED", o valor real ali era "PLC". Todo evento verdadeiro caía silenciosamente no default, um no-op sem log de erro, sem exceção, sem nada que chamasse atenção. O sistema simplesmente parecia "não fazer nada" com eventos reais, enquanto testes manuais via curl pareciam funcionar, porque testavam outras partes do fluxo, não o processamento do evento em si.
Só descobrimos isso analisando o JSON bruto de um evento capturado no log estruturado, comparando campo por campo com a documentação:
{
"id": "b3f1...",
"code": "PLC",
"fullCode": "PLACED",
"orderId": "a921...",
"merchantId": "9f02...",
"createdAt": "2026-09-03T14:22:10.000Z"
}
A correção foi fácil, com a troca de event.code por event.fullCode no switch, mas o aprendizado não foi, nem de perto, trivial: sempre compare contra o campo que a documentação define como canônico, não o que parece, pelo nome da propriedade, ser o identificador do evento. code parecia óbvio demais pra estar errado, e foi exatamente por isso que passou despercebido por tanto tempo.
Foi na mesma leva de correções que outro bug sutil apareceu, de filtro de data em UTC comparado contra horário local sem conversão explícita, fazendo pedidos de fim de noite sumirem da tela de "Pedidos do dia": o tipo de erro que só aparece quando alguém confere manualmente um caso de borda, não em teste automatizado com dados sintéticos.
O problema do cancelamento assíncrono
Na esteira de problemas, tivemos que o POST /requestCancellation do iFood devolve 202 Accepted. Isso não significa que o pedido foi cancelado, mas que "recebi seu pedido de cancelamento". A confirmação real chega depois, de forma assíncrona, pelo mesmo mecanismo de polling: um evento CANCELLED se o cancelamento foi aceito, ou CANCELLATION_REQUEST_FAILED se foi recusado.
A primeira versão do sistema não respeitava essa assincronia. Ela marcava o pedido como cancelado imediatamente, de forma otimista, no mesmo instante da chamada HTTP. Isso divergia do estado real da plataforma toda vez que o iFood demorava ou nunca confirmava.
A correção foi introduzir um estado intermediário, só para pedidos origin='ifood':
ALTER TABLE ifood_orders ADD COLUMN cancellation_pending boolean DEFAULT false;
ALTER TABLE ifood_orders ADD COLUMN cancellation_requested_at timestamptz;
Enquanto cancellation_pending é verdadeiro, o pedido não muda de status de verdade. Na interface, a linha de ações do pedido é substituída por um único indicador, "Cancelamento em processamento", em vez de simplesmente desabilitar o botão de cancelar ao lado dos outros (o que deixava a tela ambígua sobre o que realmente estava acontecendo). O status só muda quando o evento de confirmação chega pelo polling.
A investigação mais longa: por que o evento de cancelamento às vezes não chegava
Trocando em miúdos, essa foi a parte que mais consumiu tempo do projeto, e a que mais ensinou. Reuni algumas hipóteses que guiaram essa trajetória.
Hipótese 1: instabilidade genérica do sandbox
A primeira suspeita foi que o ambiente de testes do iFood processava eventos em lote, às vezes com atraso de horas. Tínhamos evidência real disso em outro contexto: um pedido que foi pra DISPATCHED recebeu o evento CONCLUDED só várias horas depois, com um payload de metadata assim:
{
"fullCode": "CONCLUDED",
"metadata": {
"triggerEvent": {
"previousStatus": "DISPATCHED",
"value": "CONCLUDED"
}
}
}
Isso reforçou a hipótese, mas não explicava por que, em alguns testes, o evento de cancelamento nunca chegava, nem depois de horas.
Hipótese 2: o motivo do cancelamento importava
Reparamos que testes usando o código de cancelamento "503" (item indisponível) travavam sem confirmação, enquanto "501" e "512" (problema de sistema, loja vai abrir mais tarde) resolviam na hora. Isso virou a teoria de que códigos "contestáveis" pelo consumidor final exigiriam algum fluxo de negociação separado antes da confirmação.
Hipótese 3: o Handshake Platform
Pesquisando a documentação a fundo, encontramos endpoints reais de disputa: POST /disputes/{disputeId}/accept, /reject, /alternatives/{alternativeId}. Existiam para cancelamentos contestáveis iniciados pelo consumidor, e nunca eram chamados pelo nosso sistema. Ufa, finalmente! Ela pareceu ser a explicação perfeita: estávamos deixando uma disputa aberta sem responder.
Mas como nem tudo são flores, o teste que derrubou a hipótese 3. Minutos depois, repetimos o mesmo teste, com o mesmo cancellationCode "503". Dessa vez, o evento CANCELLED chegou em cerca de 200 milissegundos, sem chamar nenhum endpoint de disputa. O payload trazia um metadado informativo, não um estado pendente de fato:
{
"fullCode": "CANCELLED",
"metadata": {
"CANCELLATION_DISPUTE": {
"IS_CONTESTABLE": "CANCELLATION_IS_CONTESTABLE"
}
}
}
Ou seja, o campo só indicava que aquele cancelamento poderia, em tese, ser contestado pelo consumidor. Não significava que estava, de fato, esperando handshake.
A causa raiz foi encontrada por acaso. Tínhamos duas instâncias da aplicação rodando o mesmo loop de polling contra o mesmo merchantId: o ambiente local de desenvolvimento e a produção no Railway, ambos usando a mesma credencial de teste.
A fila de eventos do iFood é por merchant, não por consumidor da API. Quem faz o poll primeiro recebe o evento e o reconhece via acknowledgment, tirando-o da fila pra sempre. Se a instância que fez a chamada de cancelamento não fosse a mesma que vencesse a corrida pelo próximo poll, ela nunca mais veria a confirmação, enquanto a outra instância recebia e descartava um evento que não tinha efeito nenhum no banco dela.
Naquele momento, tivemos um grande aprendizado, que entenderíamos só tempos depois: nunca rode dois consumidores de uma fila de webhook ou polling apontando pro mesmo recurso externo ao mesmo tempo, mesmo em ambientes nominalmente "separados", como dev local e produção, se eles compartilharem a mesma credencial e o mesmo merchant de teste.
Para corrigir o problema, isolamos de vez os ambientes: o polling contra o merchant passou a rodar exclusivamente em produção, e o ambiente local parou de consumir a fila. Um único consumidor ativo por merchant deixou de ser uma regra implícita e virou uma invariante garantida pela própria topologia do sistema, não por disciplina de quem estava testando naquele dia.
Homologação: quando "funcionar certo" não é suficiente
Fomos reprovados duas ou três vezes no processo formal de homologação do iFood, com uma mensagem genérica de "Erro ao recuperar logs da aplicação" no cenário de cancelamento. Em uma das tentativas, os logs estruturados provavam, com timestamp de cada etapa, que o fluxo inteiro (confirmação, requisição de cancelamento, CANCELLATION_REQUESTED, CANCELLED, acknowledgment) rodou em menos de dois minutos, sem erro nenhum do nosso lado.
Às vezes o "seu sistema está errado" de um avaliador automático de terceiros é, na verdade, um sintoma de um problema no próprio avaliador. A única defesa nessa situação é ter logs estruturados e cronometrados o suficiente para provar isso com uma solicitação de revisão manual, em vez de ficar tentando adivinhar o que "consertar" num sistema que já estava funcionando.
O que sustentou toda essa investigação
Nada disso teria sido reconstruível sem uma decisão tomada cedo: logar toda chamada HTTP ao iFood (path, método, unitId, merchantId, orderId, status, código de erro) e todo evento recebido, com o payload bruto completo, como uma linha JSON por evento, não só em caso de erro:
logger.info({
event: "ifood.polling.event_received",
merchantId,
orderId: event.orderId,
code: event.code,
fullCode: event.fullCode,
raw: event,
});
Foi isso que permitiu reconstruir cada uma das hipóteses da investigação, analisando só os logs do Railway, sem acesso direto ao painel do iFood. Numa integração assíncrona com um sistema externo que você não controla, o log estruturado deixa de ser um detalhe de observabilidade e vira a única fonte de verdade disponível quando algo não bate.
O que você precisa saber quando for integrar com marketplaces por polling
Compare sempre contra o campo canônico documentado, não o que parece ser o identificador pelo nome. Trate qualquer resposta 202 ou similar como confirmação de recebimento, nunca como confirmação de efeito. Modele o estado intermediário explicitamente no banco em vez de assumir otimisticamente que uma ação já aconteceu. E, acima de tudo, garanta que só existe um consumidor ativo por fila externa compartilhada, porque duas instâncias "separadas" brigando pela mesma fila produzem um bug que parece instabilidade de terceiro, mas é “apenas” um problema inteiramente seu.
Top comments (0)