Pular para o conteúdo principal

Visão Geral

O webhook de cancelamento é enviado automaticamente quando um boleto é cancelado através da API ou por ação administrativa. Este evento informa que o boleto não pode mais ser pago e permite que você tome as ações necessárias em seu sistema.
Este webhook é enviado imediatamente após o cancelamento ser processado, normalmente em alguns segundos.

Evento: boleto.cancelled

Quando é Enviado

  • Boleto cancelado via API /cancel
  • Cancelamento por ação administrativa
  • Cancelamento automático por regras de negócio
  • Estorno de boleto por problemas técnicos

Payload do Webhook

Boleto Cancelado (Status: CANCELED)
{
  "product": "BOLETO",
  "paymentId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "status": "CANCELED",
  "externalId": "pedido-12345",
  "processedAt": "2025-04-07T15:23:45.678Z",
  "additionalInfo": {
    "reason": "Boleto cancelado pela loja"
  }
}

Implementação do Endpoint

Node.js/Express

app.post('/webhooks/boleto-cancelled', express.raw({type: 'application/json'}), async (req, res) => {
  try {
    const payload = JSON.parse(req.body);

    // Verificar assinatura do webhook (recomendado)
    if (!verifyWebhookSignature(req.headers, req.body)) {
      return res.status(401).send('Assinatura inválida');
    }

    // Verificar se já processamos este cancelamento (idempotência)
    const alreadyProcessed = await checkIfCancellationProcessed(payload.boleto_id);
    if (alreadyProcessed) {
      console.log(`Cancelamento ${payload.boleto_id} já foi processado`);
      return res.status(200).send('Já processado');
    }

    // Processar cancelamento do boleto
    await processBoletoCancelled(payload);

    res.status(200).send('OK');
  } catch (error) {
    console.error('Erro no webhook boleto cancelado:', error);
    res.status(500).send('Erro interno');
  }
});

async function processBoletoCancelled(payload) {
  const { boleto_id, external_id, data } = payload;

  try {
    // Iniciar transação no banco
    const transaction = await startDatabaseTransaction();

    try {
      // 1. Atualizar status do boleto
      await updateBoletoStatus(boleto_id, 'cancelled', {
        cancelled_at: data.cancellation.cancelled_at,
        cancellation_reason: data.cancellation.reason,
        cancelled_by: data.cancellation.cancelled_by,
        cancellation_method: data.cancellation.method
      }, transaction);

      // 2. Processar cancelamento do pedido
      await processCancelledOrder(external_id, {
        status: 'cancelled',
        cancelled_reason: data.cancellation.reason,
        cancelled_at: data.cancellation.cancelled_at,
        payment_method: 'boleto'
      }, transaction);

      // 3. Reverter reservas/estoques se necessário
      await revertReservations(external_id, transaction);

      // Commit da transação
      await commitTransaction(transaction);

      // 4. Ações pós-cancelamento (fora da transação)
      await postCancellationActions(data);

    } catch (error) {
      await rollbackTransaction(transaction);
      throw error;
    }

    console.log(`Boleto ${boleto_id} cancelado e processado com sucesso`);
  } catch (error) {
    console.error(`Erro ao processar cancelamento do boleto ${boleto_id}:`, error);
    throw error;
  }
}

async function postCancellationActions(boletoData) {
  // Notificar cliente sobre cancelamento
  await sendCancellationNotification(boletoData);

  // Processar reembolsos se aplicável
  await processRefundsIfApplicable(boletoData);

  // Atualizar métricas e analytics
  await logCancellationEvent(boletoData);

  // Notificar equipes internas
  await notifyInternalTeams(boletoData);
}

Python/Flask

from flask import Flask, request, jsonify
import json
import logging
from datetime import datetime

app = Flask(__name__)

@app.route('/webhooks/boleto-cancelled', methods=['POST'])
def handle_boleto_cancelled():
    try:
        payload = request.get_json()

        # Verificar assinatura
        if not verify_webhook_signature(request.headers, request.data):
            return 'Assinatura inválida', 401

        # Verificar idempotência
        if check_if_cancellation_processed(payload['boleto_id']):
            logging.info(f"Cancelamento {payload['boleto_id']} já processado")
            return 'Já processado', 200

        # Processar cancelamento
        process_boleto_cancelled(payload)

        return 'OK', 200

    except Exception as e:
        logging.error(f'Erro no webhook boleto cancelado: {e}')
        return 'Erro interno', 500

def process_boleto_cancelled(payload):
    boleto_id = payload['boleto_id']
    external_id = payload['external_id']
    data = payload['data']

    try:
        conn = get_db_connection()
        cursor = conn.cursor()

        try:
            # Atualizar boleto
            cursor.execute("""
                UPDATE boletos SET
                    status = 'cancelled',
                    cancelled_at = %s,
                    cancellation_reason = %s,
                    cancelled_by = %s
                WHERE id = %s
            """, (
                data['cancellation']['cancelled_at'],
                data['cancellation']['reason'],
                data['cancellation']['cancelled_by'],
                boleto_id
            ))

            # Atualizar pedido
            cursor.execute("""
                UPDATE orders SET
                    status = 'cancelled',
                    cancelled_reason = %s,
                    cancelled_at = %s
                WHERE external_id = %s
            """, (
                data['cancellation']['reason'],
                data['cancellation']['cancelled_at'],
                external_id
            ))

            # Reverter estoque se necessário
            revert_inventory_reservation(external_id, cursor)

            # Commit
            conn.commit()

            # Ações pós-cancelamento
            send_cancellation_notification(data)
            update_analytics(data)

        except Exception as e:
            conn.rollback()
            raise e
        finally:
            conn.close()

    except Exception as e:
        logging.error(f'Erro ao processar cancelamento {boleto_id}: {e}')
        raise

def revert_inventory_reservation(external_id, cursor):
    """Reverter reservas de estoque quando pedido é cancelado"""
    cursor.execute("""
        UPDATE inventory SET
            reserved_quantity = reserved_quantity - oi.quantity,
            available_quantity = available_quantity + oi.quantity
        FROM order_items oi
        WHERE inventory.product_id = oi.product_id
        AND oi.order_external_id = %s
    """, (external_id,))

PHP

<?php
// webhook-boleto-cancelled.php

$payload = json_decode(file_get_contents('php://input'), true);

if (!$payload) {
    http_response_code(400);
    die('Payload inválido');
}

// Verificar assinatura
if (!verifyWebhookSignature(getallheaders(), file_get_contents('php://input'))) {
    http_response_code(401);
    die('Assinatura inválida');
}

try {
    // Verificar idempotência
    if (checkIfCancellationProcessed($payload['boleto_id'])) {
        error_log("Cancelamento {$payload['boleto_id']} já processado");
        http_response_code(200);
        die('Já processado');
    }

    processBoletoCancelled($payload);

    http_response_code(200);
    echo 'OK';
} catch (Exception $e) {
    error_log('Erro no webhook boleto cancelado: ' . $e->getMessage());
    http_response_code(500);
    echo 'Erro interno';
}

function processBoletoCancelled($payload) {
    $pdo = new PDO($dsn, $username, $password);

    try {
        $pdo->beginTransaction();

        $boletoId = $payload['boleto_id'];
        $externalId = $payload['external_id'];
        $data = $payload['data'];
        $cancellation = $data['cancellation'];

        // Atualizar boleto
        $stmt = $pdo->prepare("
            UPDATE boletos SET
                status = 'cancelled',
                cancelled_at = :cancelled_at,
                cancellation_reason = :reason,
                cancelled_by = :cancelled_by
            WHERE id = :boleto_id
        ");

        $stmt->execute([
            'boleto_id' => $boletoId,
            'cancelled_at' => $cancellation['cancelled_at'],
            'reason' => $cancellation['reason'],
            'cancelled_by' => $cancellation['cancelled_by']
        ]);

        // Atualizar pedido
        $stmt = $pdo->prepare("
            UPDATE orders SET
                status = 'cancelled',
                cancelled_reason = :reason,
                cancelled_at = :cancelled_at
            WHERE external_id = :external_id
        ");

        $stmt->execute([
            'external_id' => $externalId,
            'reason' => $cancellation['reason'],
            'cancelled_at' => $cancellation['cancelled_at']
        ]);

        $pdo->commit();

        // Ações pós-cancelamento
        sendCancellationNotification($data);
        updateOrderAnalytics($data);

    } catch (Exception $e) {
        $pdo->rollback();
        throw $e;
    }
}
?>

Email de Notificação de Cancelamento

Template HTML

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Boleto Cancelado</title>
    <style>
        .container { max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; }
        .header { background: #6c757d; color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }
        .content { padding: 30px; background: #f8f9fa; }
        .cancellation-details { background: white; padding: 20px; border-radius: 8px; margin: 20px 0; }
        .cancel-icon { font-size: 48px; margin-bottom: 20px; }
        .reason-box { background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 4px; margin: 15px 0; }
        .next-steps { background: #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0; }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <div class="cancel-icon"></div>
            <h1>Boleto Cancelado</h1>
            <p>Seu boleto foi cancelado</p>
        </div>

        <div class="content">
            <p>Olá <strong>{{buyer_name}}</strong>,</p>

            <p>Informamos que seu boleto foi cancelado e não pode mais ser pago.</p>

            <div class="cancellation-details">
                <h3>📋 Detalhes do Cancelamento</h3>
                <p><strong>Valor:</strong> R$ {{amount}}</p>
                <p><strong>Descrição:</strong> {{description}}</p>
                <p><strong>Data do Cancelamento:</strong> {{cancelled_date}}</p>
                <p><strong>Referência:</strong> {{external_id}}</p>

                <div class="reason-box">
                    <strong>Motivo:</strong> {{cancellation_reason}}
                </div>
            </div>

            <div class="next-steps">
                <h4>🔄 Próximos Passos</h4>
                {{#if_can_regenerate}}
                <p>Se você ainda deseja efetuar o pagamento, entre em contato conosco para gerar um novo boleto.</p>
                <p style="text-align: center;">
                    <a href="{{regenerate_url}}" style="display: inline-block; background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px;">
                        Gerar Novo Boleto
                    </a>
                </p>
                {{else}}
                <p>{{next_steps_message}}</p>
                {{/if_can_regenerate}}
            </div>

            <p>Se você tiver alguma dúvida sobre este cancelamento, entre em contato conosco.</p>

            <p>Att,<br>Equipe de Atendimento</p>
        </div>
    </div>
</body>
</html>

Função de Envio

async function sendCancellationNotification(boletoData) {
  const template = await loadEmailTemplate('boleto-cancelled');

  const emailHtml = template
    .replace('{{buyer_name}}', boletoData.buyer.name)
    .replace('{{amount}}', formatCurrency(boletoData.amount))
    .replace('{{description}}', boletoData.description)
    .replace('{{cancelled_date}}', formatDate(boletoData.cancellation.cancelled_at))
    .replace('{{external_id}}', boletoData.external_id)
    .replace('{{cancellation_reason}}', boletoData.cancellation.reason)
    .replace('{{next_steps_message}}', getNextStepsMessage(boletoData))
    .replace('{{regenerate_url}}', getRegenerateUrl(boletoData));

  const emailData = {
    to: boletoData.buyer.email,
    subject: `❌ Boleto Cancelado - ${boletoData.description}`,
    html: emailHtml
  };

  try {
    await sendEmail(emailData);
    console.log(`Email de cancelamento enviado para ${boletoData.buyer.email}`);
  } catch (error) {
    console.error('Erro ao enviar email de cancelamento:', error);
  }
}

function getNextStepsMessage(boletoData) {
  const reason = boletoData.cancellation.reason.toLowerCase();

  if (reason.includes('cliente')) {
    return 'Como este cancelamento foi por sua solicitação, não são necessárias outras ações.';
  } else if (reason.includes('estoque')) {
    return 'Devido à falta de estoque, vamos entrar em contato assim que o produto estiver disponível.';
  } else if (reason.includes('erro')) {
    return 'Por conta de um erro técnico, um novo boleto será gerado automaticamente.';
  } else {
    return 'Entre em contato conosco para esclarecimentos sobre este cancelamento.';
  }
}

function getRegenerateUrl(boletoData) {
  // Determinar se pode regenerar baseado no motivo
  const reason = boletoData.cancellation.reason.toLowerCase();
  const canRegenerate = !reason.includes('cliente cancelou') && !reason.includes('fraude');

  if (canRegenerate) {
    return `${process.env.FRONTEND_URL}/orders/${boletoData.external_id}/regenerate-boleto`;
  }

  return null;
}

Processamento de Estornos

Estorno Automático

async function processRefundsIfApplicable(boletoData) {
  const cancellation = boletoData.cancellation;

  // Verificar se deve processar estorno automático
  const shouldRefund = shouldProcessAutomaticRefund(cancellation);

  if (shouldRefund) {
    await processAutomaticRefund(boletoData);
  }
}

function shouldProcessAutomaticRefund(cancellation) {
  const autoRefundReasons = [
    'produto_descontinuado',
    'erro_sistema',
    'problema_tecnico',
    'estoque_insuficiente'
  ];

  return autoRefundReasons.some(reason =>
    cancellation.reason.toLowerCase().includes(reason)
  );
}

async function processAutomaticRefund(boletoData) {
  // Verificar se boleto foi pago
  const paymentHistory = await getPaymentHistory(boletoData.id);

  if (paymentHistory.status === 'paid') {
    // Processar estorno
    const refundData = {
      original_boleto_id: boletoData.id,
      amount: paymentHistory.paid_amount,
      reason: `Estorno automático: ${boletoData.cancellation.reason}`,
      refund_method: 'pix', // ou 'ted' dependendo da configuração
      beneficiary: {
        name: boletoData.buyer.name,
        document: boletoData.buyer.document,
        bank_account: paymentHistory.refund_account
      }
    };

    await createRefundTransaction(refundData);
  }
}

Notificações Internas

Slack/Teams

async function notifyInternalTeams(boletoData) {
  const cancellation = boletoData.cancellation;
  const reason = cancellation.reason.toLowerCase();

  // Notificar equipes específicas baseado no motivo
  if (reason.includes('estoque')) {
    await notifyInventoryTeam(boletoData);
  } else if (reason.includes('fraude')) {
    await notifySecurityTeam(boletoData);
  } else if (reason.includes('erro') || reason.includes('técnico')) {
    await notifyTechTeam(boletoData);
  }

  // Notificação geral para financeiro
  await notifyFinanceTeam(boletoData);
}

async function notifyInventoryTeam(boletoData) {
  const message = {
    text: `🚨 Boleto cancelado por falta de estoque`,
    attachments: [
      {
        color: 'warning',
        fields: [
          { title: 'Pedido', value: boletoData.external_id, short: true },
          { title: 'Valor', value: formatCurrency(boletoData.amount), short: true },
          { title: 'Cliente', value: boletoData.buyer.name, short: true },
          { title: 'Motivo', value: boletoData.cancellation.reason, short: false }
        ],
        actions: [
          {
            type: 'button',
            text: 'Ver Pedido',
            url: `${process.env.ADMIN_URL}/orders/${boletoData.external_id}`
          }
        ]
      }
    ]
  };

  await sendSlackMessage('#inventory', message);
}

Métricas e Analytics

Registro de Eventos

async function logCancellationEvent(boletoData) {
  const event = {
    event_type: 'boleto_cancelled',
    timestamp: new Date(),
    boleto_id: boletoData.id,
    external_id: boletoData.external_id,
    amount: boletoData.amount,
    cancellation_reason: boletoData.cancellation.reason,
    cancelled_by: boletoData.cancellation.cancelled_by,
    cancellation_method: boletoData.cancellation.method,
    time_until_cancellation: calculateTimeUntilCancellation(
      boletoData.registered_at,
      boletoData.cancellation.cancelled_at
    ),
    metadata: boletoData.metadata
  };

  // Enviar para sistema de analytics
  await sendToAnalytics(event);

  // Atualizar métricas em tempo real
  await updateCancellationMetrics(event);
}

function calculateTimeUntilCancellation(registeredAt, cancelledAt) {
  const registered = new Date(registeredAt);
  const cancelled = new Date(cancelledAt);
  return Math.round((cancelled - registered) / (1000 * 60 * 60)); // horas
}

async function updateCancellationMetrics(event) {
  // Incrementar contadores
  await incrementMetric('boletos_cancelled_total');
  await incrementMetric(`boletos_cancelled_by_reason.${event.cancellation_reason}`);
  await incrementMetric(`boletos_cancelled_by_method.${event.cancellation_method}`);

  // Atualizar médias
  await updateAverageMetric('time_until_cancellation', event.time_until_cancellation);
}

Dashboard em Tempo Real

WebSocket Updates

async function notifyDashboard(boletoData) {
  const dashboardUpdate = {
    type: 'boleto_cancelled',
    data: {
      id: boletoData.id,
      external_id: boletoData.external_id,
      amount: boletoData.amount,
      buyer: boletoData.buyer.name,
      reason: boletoData.cancellation.reason,
      cancelled_at: boletoData.cancellation.cancelled_at
    }
  };

  // Enviar via WebSocket para dashboards conectados
  websocketServer.broadcast('boleto-updates', dashboardUpdate);

  // Atualizar contadores em tempo real
  websocketServer.broadcast('metrics-update', {
    type: 'cancellation',
    total_cancelled: await getCancelledBoletosCount(),
    cancellation_rate: await getCancellationRate()
  });
}

Relatórios de Cancelamento

Análise de Motivos

async function generateCancellationReport(startDate, endDate) {
  const cancellations = await getCancellationsByPeriod(startDate, endDate);

  const report = {
    period: { start: startDate, end: endDate },
    total_cancellations: cancellations.length,
    cancellation_rate: await getCancellationRate(startDate, endDate),
    by_reason: groupBy(cancellations, 'cancellation_reason'),
    by_method: groupBy(cancellations, 'cancellation_method'),
    by_time_period: groupByTimeToCancel(cancellations),
    financial_impact: {
      total_amount: cancellations.reduce((sum, c) => sum + c.amount, 0),
      refunds_processed: cancellations.filter(c => c.refund_processed).length
    }
  };

  return report;
}

function groupByTimeToCancel(cancellations) {
  const timeRanges = {
    '0-1h': 0,
    '1-6h': 0,
    '6-24h': 0,
    '1-7d': 0,
    '7d+': 0
  };

  cancellations.forEach(cancellation => {
    const hours = cancellation.time_until_cancellation;

    if (hours <= 1) timeRanges['0-1h']++;
    else if (hours <= 6) timeRanges['1-6h']++;
    else if (hours <= 24) timeRanges['6-24h']++;
    else if (hours <= 168) timeRanges['1-7d']++;
    else timeRanges['7d+']++;
  });

  return timeRanges;
}

Próximos Passos