WordPress REST API Tutorial

WordPress REST API Idempotency Keys: A Practical Setup for Safe Retries

Learn how to make WordPress REST API retries safer by using idempotency keys, request fingerprints, database constraints, and REST dispatch hooks

Author: Irfan Ahmed

How to Add Idempotency Keys to the WordPress REST API

A WordPress REST request can succeed on the server while the client receives a timeout. That creates an awkward problem: the client does not know whether it is safe to retry.

Consider a publishing integration that sends POST /wp-json/wp/v2/posts. PHP creates post #4812, WordPress runs its REST hooks, and the connection fails before the client receives the 201 Created response. The client retries the same operation. Without an idempotency mechanism, WordPress could create post #4813.

HTTP defines PUT and DELETE as idempotent methods, but POST is not inherently idempotent. An API can still give POST idempotent behavior by assigning an operation identity to the request and remembering the result. RFC 9110 also distinguishes HTTP method idempotency from the broader problem of safely retrying an operation after a communication failure.

For WordPress integrations, the useful question is not “is POST idempotent?” It is “can this exact POST be safely retried after the client loses the response?”

Quick Answer

The basic idea is simple: give each logical operation a unique Idempotency-Key, store that key with a fingerprint of the request, and save the original response after the operation completes. If the client retries with the same key, WordPress can return the stored result instead of running the endpoint again.

In WordPress, this logic can be placed around the REST dispatch process using rest_pre_dispatch and rest_post_dispatch. A database unique constraint should provide the atomic ownership check so two PHP workers cannot process the same operation at the same time.

The WordPress Failure Scenario You Actually Need to Solve

Assume an external publishing service sends a request every time an article reaches an approved state:

POST /wp-json/wp/v2/posts
Authorization: Basic ...
Idempotency-Key: 8d7c7f3a-4c4b-4f15-b9bb-4d4e9c2a5d21
Content-Type: application/json

{
  "title": "API Reliability Test",
  "status": "draft",
  "content": "Example content"
}

The server performs the operation in 450 ms. At 500 ms, the load balancer or network connection fails. The client sees a timeout and retries with the same operation.

The second request must not create another post. Instead, WordPress should recognize the key and return the result generated by the first request.

This is different from checking whether a post with the same title already exists. Titles are business data, not request identity. Two legitimate posts can have the same title, while one operation can legitimately be retried with exactly the same payload.

What the Idempotency Key Actually Identifies

Treat the idempotency key as an identifier for one logical operation, not as an identifier for a resource.

A useful lookup identity is:

authenticated_principal
+ HTTP_method
+ REST_route
+ idempotency_key

The request should also carry a fingerprint derived from the request data that matters to the operation.

For example:

fingerprint = SHA-256(
    canonical_method
    + canonical_route
    + authenticated_principal
    + canonical_request_body
)

This gives you two separate protections:

  • The key prevents the same logical operation from being executed twice.
  • The fingerprint prevents a caller from reusing an existing key for different data.

Stripe uses the same general principle: the server associates a key with the original request parameters and rejects reuse when the parameters differ. Its documented keys can be retained for at least 24 hours, after which a reused key may represent a new request.

How to Add Idempotency to WordPress

1

Intercept REST Requests

Do not copy idempotency code into every REST controller. WordPress already exposes REST dispatch hooks that let a plugin inspect a request before the endpoint executes and inspect the response afterward.

The two hooks that matter most are:

  • rest_pre_dispatch — return an existing response before the normal endpoint callback executes.
  • rest_post_dispatch — inspect the response after endpoint execution and persist the result.

WordPress documents rest_pre_dispatch as a way to replace the calculated dispatch result before the request is served. rest_post_dispatch allows the REST response to be modified before it is returned to the client.

2

Build the Idempotency Table

The database must solve the race condition. A PHP-level check is not sufficient when multiple PHP workers can execute simultaneously.

A minimal table can look like this:

CREATE TABLE wp_api_idempotency (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    principal_id BIGINT UNSIGNED NOT NULL,
    method VARCHAR(10) NOT NULL,
    route_hash CHAR(64) NOT NULL,
    idempotency_key VARCHAR(255) NOT NULL,
    request_hash CHAR(64) NOT NULL,
    state VARCHAR(20) NOT NULL,
    status_code SMALLINT NULL,
    response_body LONGTEXT NULL,
    response_headers LONGTEXT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    expires_at DATETIME NOT NULL,

    PRIMARY KEY (id),

    UNIQUE KEY uq_operation (
        principal_id,
        method,
        route_hash,
        idempotency_key
    ),

    KEY idx_expiry (expires_at),
    KEY idx_state (state)
);

The most important part is the unique index:

UNIQUE KEY uq_operation (
    principal_id,
    method,
    route_hash,
    idempotency_key
)

That constraint is the concurrency boundary. Two requests arriving at almost exactly the same time cannot both successfully claim the same operation.

WordPress’s $wpdb class is appropriate for accessing a custom table, but the uniqueness guarantee comes from the database schema. Do not depend on a preceding SELECT to provide mutual exclusion.

3

Define the Request States

Use at least three operational states:

  • processing — one request currently owns execution.
  • completed — the original HTTP response has been persisted.
  • failed — the chosen policy requires the result to remain replayable.

An optional fourth state is useful for operations that need manual recovery:

  • expired — the operation exceeded its retry window.

A typical retry policy is 24 hours for integrations where retries can occur after delayed queue processing. Shorter windows, such as 15 or 60 minutes, may be appropriate for low-value content synchronization. Payment-like operations often need a longer business policy.

The TTL is not a technical constant. It should be based on the maximum realistic period during which an upstream system can retry the same operation.

4

Claim the Key Atomically

The dangerous implementation is:

$existing = find_key( $key );

if ( ! $existing ) {
    create_key( $key );
    process_request();
}

Two workers can both observe that the row does not exist.

Instead, make the insert itself the claim operation:

INSERT INTO wp_api_idempotency
(
    principal_id,
    method,
    route_hash,
    idempotency_key,
    request_hash,
    state,
    created_at,
    updated_at,
    expires_at
)
VALUES
(
    %d,
    %s,
    %s,
    %s,
    %s,
    'processing',
    UTC_TIMESTAMP(),
    UTC_TIMESTAMP(),
    DATE_ADD(UTC_TIMESTAMP(), INTERVAL 24 HOUR)
);

If the unique constraint accepts the insert, this request owns the operation. If it rejects the insert because the key already exists, the middleware reads the existing record and decides whether to replay, reject, or wait.

5

Connect the WordPress REST Hooks

The following is a focused implementation pattern. It intentionally keeps storage and endpoint execution separate so the datastore can later be replaced with Redis or another shared store.

<?php

final class WP_Idempotency {

    private const HEADER = 'Idempotency-Key';
    private const TTL_HOURS = 24;

    public static function boot(): void {
        add_filter(
            'rest_pre_dispatch',
            [ self::class, 'pre_dispatch' ],
            10,
            3
        );

        add_filter(
            'rest_post_dispatch',
            [ self::class, 'post_dispatch' ],
            10,
            3
        );
    }

    public static function pre_dispatch(
        $result,
        WP_REST_Server $server,
        WP_REST_Request $request
    ) {
        if ( $result !== null ) {
            return $result;
        }

        if ( ! self::supports_method( $request->get_method() ) ) {
            return $result;
        }

        $key = self::get_key( $request );

        if ( ! $key ) {
            return $result;
        }

        if ( ! self::valid_key( $key ) ) {
            return new WP_Error(
                'invalid_idempotency_key',
                'The Idempotency-Key header is invalid.',
                [ 'status' => 400 ]
            );
        }

        $record = self::find_record( $request, $key );

        if ( ! $record ) {
            if ( self::claim_key( $request, $key ) ) {
                return $result;
            }

            // Another worker claimed the key between lookup and insert.
            $record = self::find_record( $request, $key );
        }

        if ( ! $record ) {
            return new WP_Error(
                'idempotency_claim_failed',
                'The idempotency request could not be claimed.',
                [ 'status' => 409 ]
            );
        }

        $request_hash = self::request_hash( $request );

        if ( ! hash_equals( $record->request_hash, $request_hash ) ) {
            return new WP_Error(
                'idempotency_key_reused',
                'The Idempotency-Key was already used with different request data.',
                [ 'status' => 409 ]
            );
        }

        if ( 'completed' === $record->state ) {
            return self::replay_response( $record );
        }

        if ( 'processing' === $record->state ) {
            $record = self::wait_for_completion(
                $request,
                $key,
                $record
            );

            if ( $record && 'completed' === $record->state ) {
                return self::replay_response( $record );
            }

            return new WP_Error(
                'idempotency_request_in_progress',
                'The original request is still being processed.',
                [ 'status' => 409 ]
            );
        }

        return new WP_Error(
            'idempotency_request_unavailable',
            'The idempotency record cannot be reused.',
            [ 'status' => 409 ]
        );
    }

    public static function post_dispatch(
        WP_HTTP_Response $response,
        WP_REST_Server $server,
        WP_REST_Request $request
    ) {
        $key = self::get_key( $request );

        if ( ! $key ) {
            return $response;
        }

        if ( ! self::supports_method( $request->get_method() ) ) {
            return $response;
        }

        self::store_response(
            $request,
            $key,
            $response
        );

        return $response;
    }

    private static function supports_method( string $method ): bool {
        return in_array(
            strtoupper( $method ),
            [ 'POST', 'PUT', 'PATCH' ],
            true
        );
    }

    private static function get_key(
        WP_REST_Request $request
    ): ?string {
        $headers = $request->get_headers();

        if ( empty( $headers['idempotency-key'][0] ) ) {
            return null;
        }

        return trim( $headers['idempotency-key'][0] );
    }

    private static function valid_key( string $key ): bool {
        return strlen( $key ) >= 16 && strlen( $key ) <= 255;
    }

    private static function request_hash(
        WP_REST_Request $request
    ): string {
        $payload = [
            'method' => strtoupper( $request->get_method() ),
            'route'  => $request->get_route(),
            'body'   => $request->get_json_params(),
        ];

        return hash(
            'sha256',
            wp_json_encode( $payload )
        );
    }

    // Storage methods intentionally omitted from this abbreviated example.
}

The key architectural point is not the exact class above. It is where the logic sits: rest_pre_dispatch decides whether the endpoint should execute, while rest_post_dispatch captures the resulting response.

WordPress officially documents these dispatch hooks for intercepting and modifying REST request processing.

What to Store and How to Handle Retries

1

Store the Full HTTP Result

Do not store only the WordPress post ID. Save the status code, response body, request fingerprint, operation state, timestamps, and any headers required by the API contract.

2

Handle Concurrent Requests

When two requests arrive together, only one should execute the endpoint. The other request should wait for the stored result or receive a controlled conflict response.

3

Reject Key Reuse With Different Data

A key represents one logical operation. If the same key arrives with a different request body, return a conflict instead of treating it as a retry.

4

Keep the Retry Window Explicit

Expire old idempotency records according to the maximum realistic period during which the upstream client can retry the operation.

Do Not Store Only the WordPress Post ID

A common mistake is to persist something like:

{
  "post_id": 4812
}

That does not reproduce the original HTTP contract. The client may depend on the original status code, response body, or response headers.

Store at least:

  • HTTP status code
  • Serialized response body
  • Request fingerprint
  • Operation state
  • Creation and expiration timestamps

For responses that contain headers relevant to the API contract, serialize the necessary headers as well.

Stripe follows this stricter model by saving the original status code and body and returning the same result for later requests using the same key. That is a better reference model than simply returning the previously created object’s ID.

Handle Two Requests That Arrive Together

This is where many otherwise-correct examples fail.

Imagine request A and request B arrive 3 ms apart with the same key:

Request A
   |
   +-- claims key
   |
   +-- creates WordPress post
   |
   +-- execution takes 1.8 seconds
   |
   +-- stores response

Request B
   |
   +-- sees "processing"
   |
   +-- waits
   |
   +-- reads completed response
   |
   +-- returns response from A

The second request must not execute the WordPress endpoint independently.

A short polling policy is often sufficient. For example:

poll interval: 250 ms
maximum polls: 12
maximum wait: 3 seconds

A 3-second wait is a reasonable starting point for ordinary WordPress content mutations that normally complete in well under a second. If the endpoint routinely takes 10 seconds, waiting synchronously for 3 seconds may be the wrong architecture; return a conflict or pending state and let the client retry.

Jacobs’ WordPress implementation uses this same conceptual model with a request poller, configurable polling intervals, and a bounded number of attempts.

Important WordPress Use Cases

↻

Publishing Integrations

Protect post-creation requests when an external publishing system retries after a timeout or lost response.

↗

Orders and Resource Creation

Use operation identity when an external application creates WordPress resources and cannot safely determine whether the first request completed.

⚙

Custom REST Endpoints

Add idempotency as a separate request-processing concern around custom WordPress REST routes rather than duplicating the logic inside every controller.

🔗

Distributed Integrations

Use shared idempotency state when multiple application servers or workers can process the same operation.

WordPress-Specific Edge Case: Custom Endpoints

If you control the REST endpoint, expose idempotency explicitly in its API contract. WordPress custom routes should be registered through rest_api_init, use a unique namespace, define a permission callback, and validate incoming arguments.

add_action(
    'rest_api_init',
    function () {

        register_rest_route(
            'acme/v1',
            '/orders',
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => 'acme_create_order',
                'permission_callback' => function () {
                    return current_user_can( 'edit_posts' );
                },
            ]
        );

    }
);

WordPress recommends namespaced REST routes and provides validation and sanitization callbacks for endpoint arguments. The route itself should remain responsible for the business operation; idempotency should remain a separate request-processing concern.

Be Careful With WordPress Side Effects

A REST request can trigger more than the obvious database write.

The WordPress posts REST controller calls wp_insert_post() and then fires rest_insert_{$post_type}. Plugins can attach additional work to these hooks. An integration might also trigger:

An idempotency layer around the HTTP request prevents the endpoint from being executed twice, but it does not automatically make every downstream system idempotent.

If a REST request creates a WordPress post and then publishes a message to a queue, the queue consumer may need its own idempotency key or deduplication strategy.

Tips for a Safer Implementation

  • Generate the key before the first request.
  • Reuse the key for every retry of the same logical operation.
  • Limit the key length and reject malformed values.
  • Scope records by authenticated principal, method, and route.
  • Hash the request data that matters to the operation.
  • Reject reuse when the request fingerprint changes.
  • Use a database unique constraint for atomic ownership.
  • Store the original HTTP result instead of only a resource ID.
  • Handle concurrent requests explicitly.
  • Expire old records and recover stale processing records.
  • Test concurrent requests instead of only sequential retries.
  • Monitor replay and conflict rates.
  • Apply separate idempotency controls to downstream queues or external APIs when necessary.

Decide What Happens to Errors Before You Deploy

There is no universal rule that every 4xx or 5xx response must be cached. You need a deliberate policy.

ResponseTypical PolicyReason
400 validation errorUsually do not cacheClient may correct the request.
401/403 authentication failureUsually do not cacheCredentials or permissions may change.
409 key reuse conflictDo not treat as original operationThe client supplied a conflicting request.
500 after endpoint execution beganDepends on operationA retry might repeat an external side effect.
Successful mutationCache and replayThis is the primary idempotency case.

Stripe takes a deliberately strict approach and stores the result once endpoint execution begins, including 500 responses. Its documentation also distinguishes validation failures and concurrent execution conflicts because those cases occur before an endpoint has actually begun execution.

For WordPress, choose the policy per operation. A post creation endpoint and an endpoint that calls an external payment provider do not necessarily have the same retry semantics.

Use a Client Retry Policy That Preserves the Key

The client must generate the idempotency key before the first attempt and reuse that exact value for every retry of the same logical operation.

operation starts
      |
      v
generate UUID
      |
      +---- request #1 ----> timeout
      |
      +---- request #2 ----> same Idempotency-Key
      |
      +---- request #3 ----> same Idempotency-Key

Do not generate a new UUID inside the retry loop.

Incorrect:

for each retry:
    key = uuid()
    POST request

Correct:

key = uuid()

for each retry:
    POST request with key

A new key tells WordPress that the client is asking for a new operation. Reusing the original key tells WordPress that the client is attempting the same operation again.

When to Use a Database Table or Redis

A custom WordPress database table is often the simplest starting point because the idempotency record is durable and easy to inspect.

Redis becomes useful when:

  • Multiple application servers need shared low-latency state.
  • The request volume makes database lookups disproportionately expensive.
  • The idempotency record is primarily short-lived.
  • You already operate Redis for shared application state.

Do not introduce Redis merely because the request is distributed. The important property is atomic ownership of the key and durable enough storage for the retry window.

If Redis is used, the claim operation must still be atomic. A simple GET followed by SET has the same race problem as SELECT followed by INSERT.

Authentication, Privacy, and Key Scope

A globally unique key is useful, but your database lookup should still include the authenticated principal.

For example:

principal_id = authenticated WordPress user
method       = POST
route_hash   = SHA-256("/wp/v2/posts")
key          = client supplied Idempotency-Key

This prevents one caller’s key from becoming meaningful to another caller.

WordPress supports cookie authentication with REST nonces for logged-in browser contexts and Application Passwords for remote API access. Application Passwords should be used over HTTPS for remote integrations.

Do not confuse the WordPress REST nonce with an idempotency key. They solve different problems:

MechanismPurpose
X-WP-NonceCSRF protection and authentication context for cookie-authenticated REST requests.
Application PasswordAuthentication for remote WordPress API clients.
Idempotency-KeyIdentity of one logical operation across retries.

Frequently Asked Questions

Is POST automatically idempotent?

No. POST is not inherently idempotent. You can give a POST operation idempotent behavior by assigning it an operation identity, storing the result, and replaying that result when the same operation is retried.

Should the idempotency key change when a request is retried?

No. Every retry of the same logical operation should use the same key. A new key tells the server that the client is starting a different operation.

What if the same key is sent with a different request body?

Treat it as a conflict. The request fingerprint should differ, so the server should reject the request instead of executing it as a retry.

Is checking for an existing post enough?

No. A resource lookup such as “find a post with this title” is not the same as request idempotency. Business data can legitimately be duplicated, while an idempotency key identifies one logical operation.

Does an idempotency layer make every downstream operation safe?

No. If the WordPress endpoint sends messages to a queue, calls another API, or triggers other external side effects, those systems may need their own idempotency or deduplication mechanisms.

Should I use MySQL or Redis?

A custom WordPress table is often the simplest starting point. Redis can make sense when you need shared low-latency state, especially for short-lived records or higher-volume workloads. Either option still needs an atomic claim operation.

Test the Implementation With Concurrent Requests

A sequential test does not prove idempotency.

Test at least these cases:

  1. First request succeeds.
  2. Same key is sent again after success.
  3. Same key is sent with a different body.
  4. Two identical requests arrive concurrently.
  5. The original request takes longer than the polling window.
  6. The original PHP process terminates while state is processing.
  7. The key expires and is subsequently reused.
  8. Two different users use the same key.

A basic duplicate test:

KEY="$(uuidgen)"

curl -i \
  -X POST \
  "https://example.com/wp-json/wp/v2/posts" \
  -H "Authorization: Basic ..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: ${KEY}" \
  --data '{
    "title": "Idempotency Test",
    "status": "draft"
  }'

Repeat the command with the same value of KEY. The second response should represent the same logical operation rather than create another post.

Then alter the title while keeping the key. The expected result is a 409 Conflict, not another post.

Concurrent Test

To expose the race condition, send several requests at once:

KEY="$(uuidgen)"

for i in 1 2 3 4 5; do
  curl -s \
    -X POST \
    "https://example.com/wp-json/wp/v2/posts" \
    -H "Authorization: Basic ..." \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: ${KEY}" \
    --data '{
      "title": "Concurrent Idempotency Test",
      "status": "draft"
    }' &
done

wait

Inspect the WordPress database afterward. There should be one created resource, not five.

Monitor the Failure Modes

Once deployed, normal HTTP metrics do not tell you whether idempotency is working.

Track at least:

  • idempotency.claimed
  • idempotency.replayed
  • idempotency.conflict
  • idempotency.processing_timeout
  • idempotency.expired
  • Average processing time for first attempts
  • Number of stale processing records

A sudden rise in replayed requests is useful operational information. It can indicate a slow upstream service, an aggressive client retry policy, a load balancer timeout that is shorter than the WordPress request time, or intermittent network failures.

A sudden rise in idempotency_key_reused conflicts usually points toward a client bug or incorrect job correlation.

Final Recommendation

For most WordPress REST integrations, start with a custom idempotency table and a small middleware layer around rest_pre_dispatch and rest_post_dispatch. Use a unique database constraint to claim operations, SHA-256 request fingerprints to detect key misuse, and a 24-hour initial retention period unless your integration requires something different.

The critical design decision is not whether to use MySQL or Redis. It is whether the system can guarantee that exactly one request owns execution for a given operation while every legitimate retry receives a deterministic result.

That distinction is what separates a key stored in a header from an actual idempotency mechanism.

Need Help Implementing This in a WordPress Integration?

If your WordPress REST endpoint creates posts, orders, users, media, or other resources from an external application, the next step is to map the endpoint’s actual side effects and retry behavior before choosing the datastore. An implementation review should include the REST lifecycle, authentication scope, database uniqueness, concurrent-request tests, and recovery behavior for interrupted PHP workers.

Review the Implementation Steps

Irfan Ahmed
Written by

Irfan Ahmed

Senior Systems Architect specializing in web architecture, CRM platforms, AI automation, API integrations, performance optimization, and scalable digital systems. At DesignerPK, I share technical research, platform evaluations, and practical insights for building reliable and maintainable technology solutions.

Leave a Reply

Your email address will not be published. Required fields are marked *