Como implementar idempotência em Webhooks

Os webhooks do Asaas garantem que os eventos serão enviados ao menos uma vez, ou seja, seguem a premissa "at least once". Isso significa que seu endpoint pode, ocasionalmente, receber o mesmo evento de webhook repetidamente em algumas situações esporádicas. Como, por exemplo, numa situação em que o Asaas não recebe uma resposta do seu endpoint.

Dito isso, o ideal é que sua aplicação saiba tratar os eventos recebidos com duplicidade utilizando idempotência e este artigo tem o objetivo de explicar como a idempotência funciona e como você pode proteger a sua aplicação.

O que é idempotência?

Idempotência se refere a capacidade que uma operação (função) tem de retornar constantemente o mesmo resultado independente da quantidade de vezes que possa ser executada, desde que os parâmetros se mantenham sempre os mesmos.

Trazendo para o contexto de webhook, se o Asaas ocasionalmente enviar o mesmo webhook duas vezes, o ideal é que a sua aplicação responda às duas requisições com HTTP Status 200, mantendo sempre o mesmo retorno da primeira requisição recebida.

Por que usar idempotência?

Antes de explicarmos o porquê de utilizar idempotência, vamos analisar os principais verbos HTTP: GET, PUT, DELETE e POST.

Aplicando os padrões REST corretamente na sua aplicação, os verbos GET, PUT e DELETE serão sempre idempotentes:

  • O GET é um verbo de consulta que não altera o estado do recurso.
  • O PUT, se executado diversas vezes com os mesmos parâmetros, sempre retornará o mesmo resultado.
  • O DELETE na primeira requisição torna o estado do recurso como “excluído”, mesmo que sejam enviadas outras requisições de DELETE, o estado do recurso se manterá o mesmo.

No entanto, o verbo POST é o único dos verbos HTTPs que não possui o comportamento de idempotência por padrão:

  • O POST pode criar um novo recurso único a cada vez que a operação for executada.

Os webhooks que são disparados pelo Asaas, por padrão, utilizam o verbo POST e é por isso que é importante que a sua aplicação aplique o conceito de idempotência para que o recebimento de webhooks repetidos não interfira na lógica aplicada pelo seu sistema.

Estratégias de idempotência

  1. Usando um index único no banco de dados

Os eventos enviados pelos Webhooks do Asaas possuem IDs únicos e, mesmo que eles sejam enviados mais de uma vez, você sempre receberá o mesmo ID. Uma das estratégias é criar uma fila de eventos no seu banco de dados e utilizar esse ID como uma chave única, desta maneira você não conseguirá salvar dois IDs iguais

CREATE TABLE asaas_events (
    id bigint PRIMARY KEY,
    asaas_event_id text UNIQUE NOT NULL,
    payload JSON NOT NULL,
    status ENUM('PENDING','DONE') NOT NULL
    [...]
);

O indicado é que ao receber o evento do Asaas na sua aplicação, você salve essa informação em uma tabela como mostrada acima e responda 200 para o Asaas para indicar o recebimento com sucesso. Lembre-se de retornar 200 somente após a confirmação da persistência do evento na sua tabela no banco de dados, pois não garantimos que este evento será reenviado automaticamente.

Após isso, crie uma rotina de processamento, como Cron Jobs ou Workers, para processar os eventos persistidos e não processados (status = PENDING), assim que finalizar o seu processamento, marque-os com o status DONE ou simplesmente remova o registro da tabela. Caso a ordem dos eventos seja importante para o seu sistema, lembre-se de buscar e processá-los de forma ascendente.

const express = require('express');
const app = express();

app.post('/asaas/webhooks/payments', express.json({type: 'application/json'}), (request, response) => {
  const body = request.body;
  const eventId = body.id;
  const eventType = body.event;
  const payload = body; // Salvar o payload inteiro para verificar o "event" no processamento
  const status = "PENDING";
  
  await client
    .query("INSERT INTO asaas_events (asaas_event_id, payload, status) VALUES ($1, $2, $3)", [eventId, payload, status])
    .catch((e) => {
      // PostgreSQL code for unique violation
      if (e.code == "23505") {
        response.json({received: true});
        return;
      }
      throw e;
    });

  // Retorne uma resposta para dizer que o webhook foi recebido
  response.json({received: true});
});

app.listen(8000, () => console.log('Running on port 8000'));

Se o seu sistema recebe mais de centenas de milhares de eventos por dia, a indicação é utilizar uma solução de fila mais robusta, como Amazon SQS, RabbitMQ ou Kafka.

Nesta solução, além de resolver o ponto da idempotência, a sugestão também é que o processamento dos eventos seja assíncrono, logo tendo uma resposta mais rápida para o Asaas e uma vazão maior da fila de eventos enviados.

  1. Salvar eventos já processados

Outra estratégia comum é realizar o processamento dos Webhooks e salvar o ID de cada evento em uma tabela.

CREATE TABLE asaas_processed_webhooks (
    id bigint PRIMARY KEY,
    asaas_evt_id text UNIQUE NOT NULL,
    [...]
);

Dessa forma você pode sempre verificar essa tabela quando receber um novo evento e verificar se o ID já foi processado anteriormente.

const express = require('express');
const app = express();

app.post('/asaas/webhooks/payments', express.json({type: 'application/json'}), (request, response) => {
  const body = request.body;

  const eventId = body.id;

  
  await client
    .query("INSERT INTO asaas_processed_webhooks (asaas_evt_id) VALUES $1", [eventId])
    .catch((e) => {
      // PostgreSQL code for unique violation
      if (e.code == "23505") {
        response.json({received: true});
        return;
      }
      throw e;
    });

  switch (body.event) {
    case 'PAYMENT_CREATED':
      const payment = body.payment;
      createPayment(payment);
      break;
    // ... trate outos eventos
    default:
      console.log(`Este evento não é aceito ${body.event}`);
  }

  // Retorne uma resposta para dizer que o webhook foi recebido
  response.json({received: true});
});

app.listen(8000, () => console.log('Running on port 8000'));

Nesta solução, a tabela é usada como um check após o processamento, esse que é feito ainda nos 10s de limite de timeout que o Asaas tem da requisição.