How to Build Idempotent Rails APIs: 7 Battle-Tested Patterns That Prevent Duplicate Charges
Learn 7 proven idempotency patterns for Ruby on Rails APIs to prevent duplicate charges and data errors. With real code examples, build safer, more reliable endpoints.
I remember the first time a payment API I wrote double-charged a user because of a network glitch. The client sent the request, didn’t get a response, and sent it again. My endpoint processed both. That user’s bank statement showed two charges. I learned about idempotency the hard way.
An operation is idempotent when doing it once has the same effect as doing it five times. For APIs, especially those handling money, orders, or any state change, idempotency keeps things safe. If a client retries a request, the server should not create duplicates or have different side effects. Ruby on Rails gives you several ways to enforce this. Let me walk you through seven patterns I have used in production. Each pattern has code, and I will explain it as simply as I can.
Pattern 1: The Idempotency Key with Database Storage
This is the most common pattern. The client generates a unique key, sends it as a header (like Idempotency-Key), and the server checks if that key has been used before. If it has, the server returns the original response without executing the action again.
I start by creating a table to store idempotency records. A migration like this:
class CreateIdempotencyRecords < ActiveRecord::Migration[7.0]
def change
create_table :idempotency_records do |t|
t.string :key, null: false
t.string :resource_type
t.integer :resource_id
t.integer :response_status
t.text :response_body
t.datetime :expired_at
t.timestamps
end
add_index :idempotency_records, :key, unique: true
end
end
The key column has a unique index. When a request arrives, I try to find a record with that key. If it exists, I return the stored response. If not, I create a new record, run the business logic, and update the record with the response.
Here is a simplified controller concern:
module Idempotency
extend ActiveSupport::Concern
included do
before_action :validate_idempotency_key
end
private
def validate_idempotency_key
key = request.headers['Idempotency-Key']
return unless key.present?
record = IdempotencyRecord.find_by(key: key)
if record
# Return the cached response
render json: JSON.parse(record.response_body), status: record.response_status
else
# Create a placeholder record so concurrent requests are blocked
@idempotency_record = IdempotencyRecord.create!(
key: key,
expired_at: 24.hours.from_now
)
end
rescue ActiveRecord::RecordNotUnique
# Another thread inserted the same key, treat as duplicate
retry
end
def finalize_idempotency(status, body)
return unless @idempotency_record
@idempotency_record.update!(
resource_type: controller_name,
resource_id: body[:id],
response_status: status,
response_body: body.to_json
)
end
end
Then in your controller action:
class OrdersController < ApplicationController
include Idempotency
def create
order = Order.create!(order_params)
finalize_idempotency(201, order.as_json)
render json: order, status: :created
rescue ActiveRecord::RecordInvalid => e
finalize_idempotency(422, { errors: e.record.errors })
render json: { errors: e.record.errors }, status: :unprocessable_entity
end
end
The trick: creating the idempotency record before the real work prevents double processing even if two identical requests arrive at the same time. The unique index handles race conditions.
I always set an expiration on these records. No need to keep them forever. Twenty‑four hours is usually enough for retries.
Pattern 2: Database Unique Constraints
Sometimes you don’t need a separate key. The request itself carries a natural unique identifier. For example, an external payment ID or an order number. You can enforce uniqueness at the database level.
I once worked on a system where each payment had a unique transaction_id from a third party. The controller looked like this:
class PaymentsController < ApplicationController
def create
payment = Payment.new(payment_params)
payment.save!
render json: payment, status: :created
rescue ActiveRecord::RecordNotUnique
# The transaction_id already exists, return the existing payment
existing = Payment.find_by!(transaction_id: payment_params[:transaction_id])
render json: existing, status: :ok
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors }, status: :unprocessable_entity
end
end
The transaction_id column has a unique index:
add_index :payments, :transaction_id, unique: true
If a retry comes in with the same transaction_id, the insert fails, and we retrieve the existing record. The client gets back the same response as the first success. No double charge.
This pattern is simpler than a separate idempotency key table. It works best when the client can guarantee the uniqueness of that identifier. I like it for webhooks where the provider sends an event_id.
Pattern 3: Optimistic Locking
Optimistic locking helps when you are updating a resource and want to avoid two concurrent updates overwriting each other. It uses a lock_version column. Each time you update the record, the version increments. If you try to update a stale version, Rails raises ActiveRecord::StaleObjectError.
This is not true idempotency for retries, but it prevents accidental double processing. Let me explain with an example.
Imagine you have a Job model with a status field. A worker picks up a job, changes status to “running”, and then to “done”. If a duplicate worker picks up the same job, you want only one to succeed. Optimistic locking gives you that.
Add a column:
add_column :jobs, :lock_version, :integer, default: 0
The controller or worker:
def process_job(job)
job.with_lock do
# This automatically checks lock_version
job.update!(status: 'running')
# do work
job.update!(status: 'done')
end
rescue ActiveRecord::StaleObjectError
# Another process already updated the job
logger.warn "Job #{job.id} was already processed"
end
with_lock uses a database row lock combined with optimistic locking. The first process gets the lock, the second one fails with StaleObjectError. This pattern is great for idempotent processing of queue jobs. The action either runs exactly once or fails cleanly.
Pattern 4: Using Safe HTTP Methods
Sometimes idempotency is built into the protocol. GET, PUT, DELETE are idempotent by definition. POST is not. But many people forget that PUT and DELETE have extra rules.
For example, a PUT endpoint should replace the resource. If you call it twice with the same body, the resource should be the same after both calls. Rails handles this naturally. I always ensure my PUT endpoints do not have side effects beyond the replacement.
DELETE should delete the resource once. If you call it twice, the second call should return 404 or 204, but the system state is unchanged. I write my destroy actions like this:
def destroy
resource = find_resource
resource.destroy!
head :no_content
rescue ActiveRecord::RecordNotFound
head :no_content # idempotent: already deleted
end
Returning 204 on a second delete makes the endpoint idempotent. The client does not get an error; it gets the same success code. Simple and effective.
Pattern 5: State Machine Guards
When a resource moves through states (e.g., pending → confirmed → shipped), a retry can cause problems if you try to transition from an invalid state. Using a state machine (like the aasm gem) helps you define allowed transitions.
I once had an order system where a webhook could mark orders as “paid”. If the webhook fired twice, the second call would try to transition from “paid” to “paid” again. That would fail. The fix is to handle the duplicate gracefully.
class Order < ApplicationRecord
include AASM
aasm column: :status do
state :pending, initial: true
state :paid
state :shipped
event :pay do
transitions from: :pending, to: :paid
end
end
end
In the controller:
def webhook_payment
order = Order.find_by!(external_id: params[:external_id])
order.pay! # Raises AASM::InvalidTransition if already paid
rescue AASM::InvalidTransition
# If already paid, return success anyway
render json: order, status: :ok
end
The guard ensures that the pay event can only happen once. For a retry, we catch the error and return the same successful response. The state machine acts as an idempotency guard.
I prefer this pattern when the business logic already has states. It avoids adding extra tables.
Pattern 6: Idempotency Key with Redis
Using the database for idempotency keys works, but Redis is faster and lets you set TTL automatically. I use Redis when I need low latency and high throughput.
The idea is similar to pattern one, but instead of a database table, I store the key in Redis with a TTL. If the key exists, I return the cached response. If not, I set a placeholder, run the logic, and store the final response.
I use the redis gem and a concern:
module RedisIdempotency
extend ActiveSupport::Concern
included do
before_action :check_idempotency
end
private
def check_idempotency
key = request.headers['Idempotency-Key']
return unless key
cached = Redis.current.get("idempotency:#{key}")
if cached
data = JSON.parse(cached)
render json: data['body'], status: data['status']
else
Redis.current.setex("idempotency:#{key}", 86400, '{"status":204,"body":{}}')
@idempotency_key = key
end
end
def finalize_redis(status, body)
return unless @idempotency_key
Redis.current.setex(
"idempotency:#{@idempotency_key}",
86400,
{ status: status, body: body }.to_json
)
end
end
The placeholder set with setex locks the key for 24 hours. If a duplicate request comes before the first finishes, it finds the placeholder and returns the dummy response. But the placeholder is not the final answer. There is a race: the first request may still be executing. A better approach is to use a mutex or a check for “processing” state. I sometimes use setnx (set if not exists) to claim the key, then update later.
Simpler approach: use a two-phase store. First set a “processing” flag, then after logic, overwrite with actual result. If a request finds “processing”, you can return 409 Conflict and ask the client to retry after a short delay. That adds idempotency without caching incomplete results.
I lean on this pattern for high‑traffic endpoints like user signups.
Pattern 7: Conditional Requests with ETags and If-Match
HTTP provides built‑in idempotency through conditional headers. When you update a resource, you can require the client to send an ETag (a hash of the resource) or a version number. If the resource has changed since the client fetched it, the server rejects the update. This prevents duplicate writes from overwriting each other.
Rails simplifies this with the stale? method and fresh_when. But for idempotent updates, I use the request.headers['If-Match'].
I add a version column to my model:
add_column :articles, :version, :integer, default: 1
In the controller:
def update
article = Article.find(params[:id])
expected_version = request.headers['If-Match']&.gsub('"', '')
if expected_version && expected_version != article.version.to_s
render json: { error: 'precondition failed' }, status: :precondition_failed
return
end
article.update!(article_params)
article.increment!(:version) # or let optimistic locking handle it
render json: article, status: :ok
end
If the client sends the same update twice, the second request will have the same If-Match value (the old version). After the first update, the version increments, so the second call will fail with 412 Precondition Failed. The client then should fetch the new version and decide if it needs to retry.
This pattern works great when you want to prevent lost updates. It doesn’t directly make the endpoint idempotent in the sense of returning the same response, but it prevents double application of the same change. Combined with a GET endpoint that returns an ETag, you have a clean loop.
Putting It Together
Each pattern solves a different flavor of the idempotency problem. I choose based on the context. For payments, I use an idempotency key with a database table or Redis. For job processing, I use optimistic locking. For state‑driven resources, I use guards. For safe methods, I lean on HTTP semantics.
I always handle errors gracefully. When a retry comes and the action already happened, I return the same successful response the client would have gotten the first time. That is the heart of idempotency.
The simplest advice: think about what can go wrong if a client sends the same request twice. Then pick the pattern that prevents that scenario. If you are unsure, start with an idempotency key stored in the database. It covers most cases.
Over the years, I have seen teams ignore idempotency while building the first version of an API. Then, when users start complaining about duplicates, they scramble to add retry logic. Adding idempotency later is painful, especially if the database has duplicates you need to clean up. I always build idempotency from day one. It takes a few extra lines of code and saves you a world of pain.
If you are building a new Rails API today, add an idempotency module to your base controller. Even if you don’t need it everywhere, it is easier to remove than to add later. Your clients will thank you. Your future self will thank you.