How to implement idempotency in Webhooks

Asaas Webhooks guarantee that events will be delivered at least once, following the "at least once" principle. This means that your endpoint may occasionally receive the same webhook event multiple times in certain situations. For example, when Asaas does not receive a response from your endpoint.

Therefore, the ideal approach is for your application to handle duplicate events using idempotency, and this article aims to explain how idempotency works and how you can protect your application.

What is idempotency?

Idempotency refers to the ability of an operation (function) to consistently return the same result, regardless of how many times it is executed, as long as the parameters remain unchanged.

Bringing this concept into the context of Webhooks, if Asaas occasionally sends the same webhook twice, the ideal behavior is for your application to respond to both requests with HTTP Status 200, always maintaining the same outcome as the first request received.

Why use idempotency?

Before explaining why idempotency is important, let's look at the main HTTP verbs: GET, PUT, DELETE, and POST.

When REST patterns are properly applied, the GET, PUT, and DELETE methods are inherently idempotent:

  • GET is a retrieval operation that does not modify the resource state.
  • PUT, when executed multiple times with the same parameters, always produces the same result.
  • DELETE changes the resource state to deleted on the first request, and additional DELETE requests keep the resource in the same state.

However, POST is the only HTTP verb that is not idempotent by default:

  • POST can create a new unique resource every time the operation is executed.

Since Asaas Webhooks use the POST method, it is important that your application applies the concept of idempotency so that duplicate Webhook deliveries do not interfere with your system's business logic.

Idempotency strategies

1. Using a unique index in the database

Events sent by Asaas Webhooks have unique IDs, and even if they are delivered more than once, you will always receive the same ID.

One strategy is to create an event queue in your database and use this ID as a unique key.

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
    [...]
);

The recommended approach is that, when receiving the event from Asaas, you persist the information in a table like the one above and respond with HTTP 200 to Asaas to acknowledge successful receipt.

Remember to return HTTP 200 only after the event has been successfully persisted in your database table, since we do not guarantee that this event will be resent automatically.

After that, create a processing routine, such as Cron Jobs or Workers, to process persisted events with status = PENDING. Once processing is complete, mark them as DONE or simply remove them from the table.

If event order is important to your system, make sure to fetch and process them in ascending order.

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; // Persist the entire payload to inspect "event" during processing
    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;
    }

    // Return a response indicating that the webhook was received
    return response.json({ received: true });
  }
);

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

If your system receives hundreds of thousands of events per day, the recommendation is to use a more robust queue solution such as Amazon SQS, RabbitMQ, or Kafka.

In addition to solving the idempotency issue, this approach also promotes asynchronous event processing, providing faster responses to Asaas and greater throughput.

2. Storing processed events

Another common strategy is to process Webhooks and store each event ID in a table.

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

This way, whenever a new event is received, you can check this table and verify whether the ID has already been processed.

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;

      // ... handle other events
      default:
        console.log(`This event is not supported ${body.event}`);
    }

    // Return a response indicating that the webhook was received
    return response.json({ received: true });
  }
);

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

In this approach, the table is used as a control mechanism after processing, which still happens within the request timeout limit.

Best practices

In addition to the strategies presented above, we recommend:

  • responding quickly with HTTP 200 after persisting the event;
  • processing business rules asynchronously;
  • using the event identifier (id) as a unique key;
  • monitoring pending events and processing failures;
  • processing events in chronological order when sequence matters;
  • using queue solutions such as RabbitMQ, Amazon SQS, or Kafka for high-volume integrations.

Next steps

After implementing idempotency, we recommend reviewing: