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 à capacidade que uma operação (função) tem de retornar constantemente o mesmo resultado, independentemente da quantidade de vezes que possa ser executada, desde que os parâmetros se mantenham sempre os mesmos.

Trazendo para o contexto de Webhooks, 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 HTTP 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 disparados pelo Asaas 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 índice único no banco de dados

Os eventos enviados pelos Webhooks do Asaas possuem IDs únicos e, mesmo que sejam enviados mais de uma vez, você sempre receberá o mesmo ID.

Uma das estratégias é criar uma fila de eventos no banco de dados e utilizar esse ID como chave única.

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 HTTP 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 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 buscá-los e processá-los em ordem ascendente.

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

app.post(
  '/asaas/webhooks/payments',
  express.json({ type: 'application/json' }),
  async (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";

    try {

      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") {
        return response.json({ received: true });
      }

      throw e;
    }

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

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

Se o seu sistema recebe 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, proporcionando uma resposta mais rápida para o Asaas e uma vazão maior da fila de eventos.

2. 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 validar se o ID já foi processado anteriormente.

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

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

    const body = request.body;
    const eventId = body.id;

    try {

      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") {
        return response.json({ received: true });
      }

      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
    return response.json({ received: true });
  }
);

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

Nesta solução, a tabela é usada como um controle após o processamento, que ainda ocorre dentro do limite de timeout da requisição.

Boas práticas

Além das estratégias apresentadas, recomendamos:

  • responder rapidamente com HTTP 200 após persistir o evento;
  • processar regras de negócio de forma assíncrona;
  • utilizar o identificador do evento (id) como chave única;
  • monitorar eventos pendentes e falhas de processamento;
  • processar eventos em ordem cronológica quando a sequência for importante;
  • utilizar soluções de fila como RabbitMQ, Amazon SQS ou Kafka em integrações com grande volume de eventos.

Próximos passos

Após implementar a idempotência, recomendamos consultar: