Voltar para o blog
WebhooksBackendConfiabilidade

Webhooks com entrega garantida: retry, idempotência e o outbox pattern

9 de junho de 202610 min de leiturapor Sávio Araújo

O problema do webhook fire-and-forget

A implementação mais ingênua de webhook é simples:

async onOrderCreated(order: Order): Promise<void> {
  await axios.post(endpoint.url, { event: "order.created", data: order });
}

Problemas com essa abordagem:

  • Se o endpoint do cliente estiver offline, o evento é perdido
  • Se a sua aplicação travar depois de persistir o pedido mas antes de disparar o webhook, o cliente nunca fica sabendo
  • Não há como saber se o webhook foi recebido

Para integrações críticas (pagamentos, pedidos, notificações), isso não serve.

Entrega garantida com retry

O primeiro passo é persistir a intenção de envio antes de tentar:

// Tabela de webhooks pendentes
// CREATE TABLE webhook_deliveries (
//   id UUID PRIMARY KEY,
//   endpoint_id UUID NOT NULL,
//   event_type VARCHAR NOT NULL,
//   payload JSONB NOT NULL,
//   status VARCHAR DEFAULT 'pending',
//   attempts INT DEFAULT 0,
//   next_attempt_at TIMESTAMPTZ DEFAULT NOW(),
//   last_error TEXT,
//   created_at TIMESTAMPTZ DEFAULT NOW()
// );
 
@Injectable()
export class WebhookService {
  async schedule(endpointId: string, eventType: string, payload: unknown): Promise<void> {
    await this.db("webhook_deliveries").insert({
      id: crypto.randomUUID(),
      endpointId,
      eventType,
      payload: JSON.stringify(payload),
      status: "pending",
      nextAttemptAt: new Date(),
    });
  }
}

Um job separado tenta entregar as pendentes:

@Cron("*/30 * * * * *") // a cada 30 segundos
async deliverPendingWebhooks(): Promise<void> {
  const pending = await this.db("webhook_deliveries")
    .where({ status: "pending" })
    .where("nextAttemptAt", "<=", new Date())
    .where("attempts", "<", 10) // máximo de 10 tentativas
    .limit(50)                  // processa em lotes
    .forUpdate()                // lock para evitar processamento duplo
    .skipLocked();              // pula os que outro worker já pegou
 
  for (const delivery of pending) {
    await this.deliver(delivery);
  }
}
 
private async deliver(delivery: WebhookDelivery): Promise<void> {
  const endpoint = await this.endpointsRepo.findById(delivery.endpointId);
 
  try {
    const signature = this.sign(delivery.payload, endpoint.secret);
 
    await axios.post(endpoint.url, JSON.parse(delivery.payload), {
      headers: {
        "X-Webhook-Signature": signature,
        "X-Webhook-ID": delivery.id,
        "X-Webhook-Timestamp": Date.now().toString(),
      },
      timeout: 10_000,
    });
 
    await this.db("webhook_deliveries")
      .where({ id: delivery.id })
      .update({ status: "delivered", attempts: delivery.attempts + 1 });
 
  } catch (error) {
    const attempts = delivery.attempts + 1;
    const nextAttempt = this.calculateNextAttempt(attempts);
 
    await this.db("webhook_deliveries")
      .where({ id: delivery.id })
      .update({
        status: attempts >= 10 ? "failed" : "pending",
        attempts,
        nextAttemptAt: nextAttempt,
        lastError: error.message,
      });
  }
}

Backoff exponencial com jitter

O backoff cresce exponencialmente para não sobrecarregar endpoints com problemas:

private calculateNextAttempt(attempt: number): Date {
  // Backoff: 30s, 1min, 2min, 4min, 8min, 16min, 32min, 64min, 128min, 256min
  const baseDelay = 30_000; // 30 segundos
  const exponential = Math.pow(2, attempt - 1) * baseDelay;
  const maxDelay = 256 * 60 * 1000; // máximo de 256 minutos
  const delay = Math.min(exponential, maxDelay);
 
  // Jitter: ±20% para evitar thundering herd
  const jitter = delay * 0.2 * (Math.random() - 0.5);
 
  return new Date(Date.now() + delay + jitter);
}

Com esse backoff, 10 tentativas cobrem um período de aproximadamente 8 horas. O cliente tem bastante tempo para resolver um problema sem perder o evento.

Assinatura e verificação

O endpoint do cliente precisa verificar que o webhook veio de você, não de um atacante:

// Assinar (no sender):
private sign(payload: string, secret: string): string {
  const timestamp = Date.now();
  const data = `${timestamp}.${payload}`;
  const signature = crypto
    .createHmac("sha256", secret)
    .update(data)
    .digest("hex");
  return `t=${timestamp},v1=${signature}`;
}
 
// Verificar (no receiver):
function verifyWebhook(
  payload: string,
  signatureHeader: string,
  secret: string,
  toleranceMs = 300_000 // 5 minutos
): boolean {
  const parts = signatureHeader.split(",");
  const timestamp = parseInt(parts.find((p) => p.startsWith("t="))?.slice(2) ?? "0");
  const signature = parts.find((p) => p.startsWith("v1="))?.slice(3);
 
  if (!timestamp || !signature) return false;
 
  // Verifica se o webhook não é muito antigo (replay attack)
  if (Date.now() - timestamp > toleranceMs) return false;
 
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${payload}`)
    .digest("hex");
 
  // Comparação em tempo constante para evitar timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

O outbox pattern: consistência transacional

O problema mais sutil: e se o pedido for salvo mas a aplicação cair antes de registrar o webhook na tabela webhook_deliveries?

O outbox pattern resolve isso usando a mesma transação do banco:

@Injectable()
export class OrdersService {
  async createOrder(dto: CreateOrderDto): Promise<Order> {
    // Tudo na mesma transação
    return this.db.transaction(async (trx) => {
      const order = await trx("orders").insert(dto).returning("*").first();
 
      // Webhook registrado na mesma transação
      await trx("webhook_deliveries").insert({
        id: crypto.randomUUID(),
        endpointId: dto.webhookEndpointId,
        eventType: "order.created",
        payload: JSON.stringify({ event: "order.created", data: order }),
        status: "pending",
        nextAttemptAt: new Date(),
      });
 
      return order;
    });
  }
}

Se a transação for commitada, o webhook está registrado. Se o commit falhar, o webhook não existe. Nunca ficam fora de sincronia.

Idempotência no receiver

O cliente que recebe seus webhooks precisa tratar duplicatas, pois com retry você pode entregar duas vezes:

// No endpoint do cliente
app.post("/webhooks/order-created", async (req, res) => {
  const webhookId = req.headers["x-webhook-id"] as string;
 
  // Verifica se já processamos esse webhook
  const alreadyProcessed = await redis.exists(`webhook:processed:${webhookId}`);
  if (alreadyProcessed) {
    return res.status(200).json({ status: "already processed" });
  }
 
  try {
    await processOrderCreated(req.body.data);
 
    // Marca como processado por 7 dias
    await redis.setex(`webhook:processed:${webhookId}`, 7 * 24 * 3600, "1");
 
    res.status(200).json({ status: "ok" });
  } catch (error) {
    // Retorna 5xx para o sender tentar novamente
    res.status(500).json({ error: error.message });
  }
});

Retornar 2xx apenas quando o processamento for concluído com sucesso. Retornar 5xx para indicar que o sender deve tentar novamente.

Dashboard de entregas

Visibilidade é fundamental para debugging:

@Get("deliveries")
async listDeliveries(
  @Query("status") status?: string,
  @Query("endpointId") endpointId?: string
) {
  return this.db("webhook_deliveries")
    .where(status ? { status } : {})
    .where(endpointId ? { endpointId } : {})
    .orderBy("createdAt", "desc")
    .limit(100);
}
 
@Post("deliveries/:id/retry")
async retryDelivery(@Param("id") id: string) {
  await this.db("webhook_deliveries")
    .where({ id, status: "failed" })
    .update({ status: "pending", nextAttemptAt: new Date(), attempts: 0 });
 
  return { message: "Agendado para retry imediato" };
}

Conclusão

Webhooks confiáveis precisam de quatro partes: persistência antes do envio, retry com backoff exponencial, assinatura para segurança e idempotência no receiver.

O outbox pattern é o passo mais importante: ele garante que nenhum webhook seja perdido mesmo com falhas na aplicação. Com ele, o "enviei mas não tenho certeza se chegou" deixa de ser um problema.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech