Pitch - Como garantir que seu cliente nunca perca dinheiro quando sua API falha
Construo uma API de WhatsApp (AraraHQ) e um dos problemas mais difíceis
que precisei resolver foi: o que acontece com o dinheiro do cliente quando
algo dá errado no meio do envio?
Parece simples. Não é.
O cenário
O fluxo de enviar uma mensagem WhatsApp via API tem 4 etapas:
- Cliente chama
POST /v1/messages - Sistema cobra o crédito da wallet do cliente
- Mensagem vai pra fila (SQS) → worker pega e envia pro provedor (Twilio → Meta)
- Meta entrega (ou não) pro destinatário final
O problema é que qualquer etapa pode falhar depois da cobrança.
A fila pode rejeitar. O Twilio pode retornar 400. A Meta pode recusar o
template. O número pode não existir. O webhook de status pode voltar com
failed 30 minutos depois.
Se você cobrou e não entregou, o cliente perdeu dinheiro. E em uma API
de mensageria, isso acontece em escala — milhares de mensagens por dia.
A solução: estorno automático em 3 camadas
Eu precisei colocar "checkpoints de estorno" em cada ponto de falha do
pipeline. Na prática ficou assim:
Camada 1 — Falha ao publicar na fila
Se o SQS rejeita a mensagem (fila cheia, payload inválido, timeout),
o worker que recebeu o request HTTP detecta e estorna imediatamente:
try {
sqsTemplate.send(queueName, payload)
} catch (e: Exception) {
// Fila rejeitou — estorna o crédito
walletService.refund(userId, message.cost, message.id,
reason = "Falha ao enfileirar: ${e.message}")
message.status = MessageStatus.FAILED
}
Camada 2 — Falha ao enviar pro provedor
O worker SQS pega a mensagem da fila e tenta enviar pro Twilio. Aqui
tem um detalhe importante: nem todo erro deve ser retentado.
Um 400 Bad Request (template inválido, variáveis faltando) é fatal —
não adianta retentar. Já um timeout de rede é transiente — o SQS
pode retentar.
try {
val providerSid = provider.sendTemplateMessage(message)
message.status = MessageStatus.SENT_TO_PROVIDER
} catch (e: TwilioFatalException) {
// Erro fatal: estorna e NÃO retenta
refundMessageCost(message, e)
message.status = MessageStatus.FAILED
} catch (e: Exception) {
// Erro transiente: estorna e RETENTA (throw faz o SQS reprocessar)
refundMessageCost(message, e)
throw e
}
A distinção entre TwilioFatalException e Exception genérica é o
que evita um loop infinito de retry em erros que nunca vão funcionar.
Camada 3 — Falha reportada pelo webhook de status
Essa é a mais traiçoeira. A mensagem foi aceita pelo Twilio (status
SENT_TO_PROVIDER), mas 5, 10, 30 minutos depois o webhook de status
volta com failed. O número não existe, a Meta recusou, o destinatário
bloqueou.
Nesse ponto a mensagem já saiu do pipeline. O estorno precisa acontecer
de forma assíncrona, quando o webhook chega:
fun updateFailedStatus(message: Message, errorCode: String) {
// Só estorna se: modo LIVE + tem custo + tem usuário
if (message.mode == ApiKeyMode.LIVE &&
message.cost != null && message.cost > BigDecimal.ZERO &&
message.user?.id != null) {
walletService.refund(
userId = message.user.id,
amount = message.cost,
messageId = message.id,
reason = "Estorno Automático (Webhook): $errorCode"
)
}
}
O detalhe que quase me pegou: estorno duplo
Se a Camada 2 estorna porque deu timeout, mas o Twilio na verdade
recebeu e processou, e depois o webhook de status chega com delivered...
o cliente foi estornado mas a mensagem foi entregue. Dinheiro de graça.
O inverso também: se a Camada 2 estorna por timeout E depois o webhook
volta com failed, o cliente recebe estorno duplo.
A solução foi um guard simples mas essencial: o walletService.refund()
verifica se já existe um estorno para aquele messageId antes de
processar. Idempotência.
fun refund(userId: UUID, amount: BigDecimal, messageId: UUID, reason: String) {
// Previne estorno duplo
if (walletTransactionRepository.existsByMessageIdAndType(messageId, "REFUND")) {
logger.warn("Estorno já existe para mensagem $messageId, ignorando")
return
}
// ... processa estorno
}
O que aprendi
-
Cobre depois, não antes — se possível. Mas se o modelo de negócio
exige pré-pagamento (wallet), cada ponto de falha precisa de um
checkpoint de estorno. -
Classifique seus erros — fatal vs. transiente muda completamente
o comportamento. Um400não vai funcionar na 5ª tentativa. -
Idempotência em tudo que envolve dinheiro — webhooks podem chegar
duplicados, workers podem reprocessar, timeouts mentem. OmessageId
como chave de deduplicação salvou meu billing. -
Audit trail separado — além da wallet do cliente, mantenho um
PartnerLedgercom o custo real pago ao provedor. Isso permite
reconciliar: "cobrei R X do cliente, paguei R Y pro Twilio,
estornei R$ Z". Se os números não batem, algo está errado.
Esse sistema roda em produção na AraraHQ (ararahq.com) — uma API de
WhatsApp Business que estou construindo. Se alguém tiver dúvida
sobre messaging pipelines, billing em APIs pre-paid, ou qualquer
coisa relacionada, pergunta aí.
Fonte: https://ararahq.com/