WordPress REST API idempotency is essential for building reliable plugins. Learn how to implement deterministic request sequencing to prevent duplicate side effects.
Last month, I spent about three days debugging a payment gateway integration that kept double-charging customers whenever a client's server timed out mid-request. It wasn't a bug in the gateway itself, but a classic failure in my WordPress REST API architecture: I hadn't accounted for the fact that network retries are an inevitable part of distributed systems.
If you’re building plugins that perform state-changing mutations—like processing payments, updating inventory, or triggering webhooks—you cannot assume the client will only send a request once. You need to design for the reality of the wire.
In a perfect world, every request succeeds. In production, requests fail due to packet loss, load balancer timeouts, or transient database locks. When a client doesn't get a 200 OK response, they often retry automatically. If your endpoint is a simple POST that creates a new database record, you’ve just created a duplicate.
We initially tried to solve this by checking for "recent" requests based on a timestamp. That failed because two requests sent in the same second by an aggressive retry loop were still causing race conditions. We needed a system that treated the request itself as a unique, traceable entity.
To ensure WordPress REST API mutations are safe, we need to implement a strategy similar to what I discussed in Idempotency keys: Making Retries Safe in Distributed Systems. The core idea is simple: the client provides a unique X-Idempotency-Key in the request header.
Our plugin then checks if that key has been processed before. Here is a simplified version of the pattern we use in our custom controller:
PHPpublic function create_order(WP_REST_Request $request) { $idempotency_key = $request->get_header('X-Idempotency-Key'); if (empty($idempotency_key)) { return new WP_Error('missing_key', 'Idempotency key required', ['status' => 400]); } #6A9955">// Use a custom table to track processed keys global $wpdb; $processed = $wpdb->get_var($wpdb->prepare( "SELECT response_body FROM {$wpdb->prefix}idempotency_keys WHERE request_key = %s", $idempotency_key )); if ($processed) { return rest_ensure_response(json_decode($processed, true)); } #6A9955">// Proceed with the actual mutation... $result = $this->execute_mutation($request); #6A9955">// Store the result for future retries $wpdb->insert("{$wpdb->prefix}idempotency_keys", [ 'request_key' => $idempotency_key, 'response_body' => json_encode($result) ]); return $result; }
When you’re deep into plugin architecture, you have to remember that WordPress isn't inherently built for high-concurrency state management. If two requests with the same key hit your server at the exact same time, you risk a race condition where both pass the if ($processed) check.
To handle this, we use GET_LOCK() in MySQL. By locking the specific key string during the execution, we force the second request to wait or fail gracefully. It adds roughly 20-50ms of overhead, but it prevents the "double-charge" scenario entirely.
If you’re managing complex state transitions, you might find that combining this with API Idempotency: Implementing Deterministic Correlation IDs for Safety is the right move. Correlation IDs allow you to track a single user action across multiple microservices or internal API calls, not just the initial entry point.
In distributed systems, the order of operations is rarely guaranteed. If your plugin performs multiple mutations, you need to ensure that retrying a request doesn't result in an out-of-order state.
We’ve found that using a combination of a unique key and a versioning header helps. As I covered in API Design: Implementing Versioning via Custom Request Headers, being explicit about the API version allows you to change your mutation logic without breaking existing clients that might be mid-retry.
Does this increase database bloat?
Yes, it does. You’ll need a cleanup routine. We run a WP-Cron event daily that deletes records from the idempotency_keys table older than 24 hours.
What happens if the server crashes after the mutation but before the response? This is the danger zone. By using a database transaction to wrap both the mutation and the insertion of the idempotency key, you ensure atomicity. If the transaction fails, the key isn't stored, and the client will safely retry.
Is it worth the complexity for small plugins? If you’re just saving a post meta field, probably not. If you’re dealing with financial data, inventory, or any system where a duplicate action causes a support ticket, it’s mandatory.
I’m still experimenting with moving this logic out of the controller and into a middleware layer. WordPress doesn't have a formal middleware concept like Laravel, so we've been using rest_pre_dispatch filters. It’s cleaner, but it can be harder to debug if you aren't careful with your hook priorities.
Next time, I’d probably look into using an external store like Redis for the idempotency keys instead of the MySQL table. It would be faster and simplify the cleanup process significantly. For now, keep your mutations deterministic and your keys unique—your users will thank you when the network inevitably drops.
Hooks and filters done right are the backbone of professional WordPress development. Learn scalable patterns to keep your plugins maintainable and conflict-free.