Executando verificação de segurança...
1

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:

  1. Cliente chama POST /v1/messages
  2. Sistema cobra o crédito da wallet do cliente
  3. Mensagem vai pra fila (SQS) → worker pega e envia pro provedor (Twilio → Meta)
  4. 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

  1. 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.

  2. Classifique seus erros — fatal vs. transiente muda completamente
    o comportamento. Um 400 não vai funcionar na 5ª tentativa.

  3. Idempotência em tudo que envolve dinheiro — webhooks podem chegar
    duplicados, workers podem reprocessar, timeouts mentem. O messageId
    como chave de deduplicação salvou meu billing.

  4. Audit trail separado — além da wallet do cliente, mantenho um
    PartnerLedger com 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í.

Carregando publicação patrocinada...