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.