Home / Articles / Web Development
Web Development

API Requests Can Be Sent Twice: How to Prevent Duplicate Orders and Payments

Unstable networks, repeated button presses, or automatic retries can cause a single API request to be processed more than once. With an idempotency key, database transactions, and the right unique rules, applications...

Request API Bisa Terkirim Dua Kali: Cara Mencegah Order dan Pembayaran Ganda

A single click on the "Pay" button does not necessarily result in just one request. Users may press the button twice, the browser may resend the request after a connection drop, or the client may automatically retry due to not receiving a timely response.

The problem is that the server may have already successfully created the order. The response may just be lost along the way. When the client sends the same request again, an application not designed to handle this condition could create two orders, two invoices, or even two payment transactions.

This is where the concept of idempotency becomes important. Simply put, an idempotent operation can be executed multiple times, but the end result remains as if it were executed just once.

Understanding the Issue of Duplicate Requests

Imagine the following endpoint:

POST /api/orders

The client sends cart data, and the server creates a new order. If the request is received twice, the server might execute the following processes twice:

  1. Create a new order number.
  2. Store item details.
  3. Reduce stock.
  4. Send a payment request.
  5. Send a confirmation email.

Not all of these steps are safe to repeat. Sending an email twice may be disruptive. Reducing stock twice could lead to incorrect inventory counts. However, the most serious impact usually occurs when payments are processed twice.

It is important to distinguish between failed requests and the client being unaware of the request result. In the latter case, the client does not know whether the server has processed the request. Assuming it has failed and then resending it is a source of much duplication.

Use Idempotency Key from the Client Side

Idempotency key is a unique value representing a single operation. The client generates the key before sending the request and includes it in the header. If the request needs to be retried, the client must use the same key, not create a new one.

POST /api/orders
Idempotency-Key: 7f8c2a9e-5b31-4b20-9d7e-12a9f1b4c300
Content-Type: application/json

The server then stores the key along with the processing result. When it receives the same key, the server does not create a new order. The server simply returns the result that has already been generated.

Here’s an example flow:

  1. The client generates a UUID as the idempotency key.
  2. The client sends the request to the server.
  3. The server checks if the key has been used before.
  4. If not, the server processes the transaction and stores the result.
  5. If it has, the server returns the old result without repeating side effects.

The key should be created for a single business action, not for the entire user session. One checkout has one key. The next checkout should use a different key.

Store Keys and Results in the Database

Storing the idempotency key only in application memory is not sufficient. If the application has multiple servers, the first request may go to server A, while the retry goes to server B. Additionally, data in memory can be lost during a restart process.

Create a dedicated table, for example, idempotency_requests:

CREATE TABLE idempotency_requests (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    idempotency_key VARCHAR(100) NOT NULL,
    request_hash CHAR(64) NOT NULL,
    response_status INT NOT NULL,
    response_body JSON NOT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uniq_user_key (user_id, idempotency_key)
);

The request_hash column is useful to ensure that the same key is not used for different request contents. For example, the first key is used to purchase product A, but then it is sent again with the contents of product B. Such a condition should be rejected because one key must represent one consistent operation.

Don’t forget to set a retention period. Keys for payments may need to be stored longer than keys for lightweight operations. Deleting old data can be done through scheduled jobs, but do not delete it too quickly if the client may still retry.

Combine with Database Transactions

The idempotency key is not a substitute for database transactions. Both handle different issues.

  • Idempotency prevents the same request from producing duplicate effects.
  • Transaction ensures that multiple database changes succeed together or fail together.
  • Unique constraint serves as the last line of defense when application code experiences a request race condition.

For the order creation process, important operations should be within a transaction:

BEGIN;

-- Save order
-- Save order details
-- Reduce stock with quantity validation
-- Save idempotency record

COMMIT;

If stock is insufficient or an error occurs, use ROLLBACK. This way, the application does not end up in a half-finished state, for example, the order is recorded but the item details are not saved.

However, database transactions should not always include calls to external services. Holding a transaction while waiting for a payment gateway or email service can keep the database connection open for too long. For such processes, use a status like pending, and then continue the work through a queue or worker.

Be Cautious with External Side Effects

The most challenging part is the side effects that occur outside the database. Examples include sending emails, calling payment gateways, or sending webhooks to other systems.

One pattern that can be used is the outbox pattern. The application stores events that need to be sent to an outbox table within the same transaction as the order changes. A worker then reads from that table and sends the events to external services.

The worker must also be designed to safely handle the same event being executed more than once. The receiving system can use the event ID as an idempotency key. This way, protection is not only on one side.

If an operation has real effects, assume that requests can come in twice. Do not make network luck a part of application design.

Validations to Perform

Before implementing idempotency, conduct some practical checks:

  • Is the client using the same key when retrying?
  • Does the key have a clear length limit and format?
  • Is the key bound to the correct user or account?
  • Is the request content verified to remain unchanged for the same key?
  • Are two parallel requests with the same key safe against race conditions?
  • Are error responses also stored or only successful responses?
  • Do external operations have deduplication mechanisms?

For cases of parallel requests, do not just check "key does not exist" and then perform an insert. Two processes can read the empty condition at nearly the same time. Use unique constraints and handle conflict errors properly.

What Does This Mean for Us?

Idempotency is most important for endpoints that create or modify something: checkouts, payments, registrations, invoice creation, form submissions, and ticket bookings. For data reading operations like GET, the issues are usually different, although caching and consistency still need to be considered.

Start with the most at-risk endpoint. Add an idempotency key, a result storage table, database transactions, and testing for duplicate requests. Simulate a connection drop after the server has stored data but before the client receives a response.

A mature application is not one that assumes the network is always smooth. A mature application is one that continues to produce correct results when users double-click, connections drop, or systems retry.

– Rio Yotto @rioyotto