DEV Community

Cover image for WordPress to ERP over REST: build the field mapper before the auth layer
AumCreate
AumCreate

Posted on

WordPress to ERP over REST: build the field mapper before the auth layer

Two systems, one record, and a question nobody asked at kickoff: for each field, which side is allowed to write it. Answer that in a table and a surprising amount of the integration design falls out of it.

The first version of an ERP integration usually works. Three fields go out, a status comes back, everyone moves on. Two months later the ERP adds a status code the mapper has never seen, the code falls through to a default, and orders stop moving without a single error in the log. That failure is decided before any HTTP call exists.

Write the field contract before the endpoint

External (ERP) Local (WordPress) Type Transform Writes
order_no _erp_order_id (meta) string, unique trim, uppercase ERP
status post_status enum explicit code map ERP
grand_total _order_total (meta) decimal string stored verbatim ERP
placed_at post_date_gmt ISO 8601 with offset to UTC ERP
customer_note post_excerpt string, max 500 trim, strip control chars both — rule below
fulfilment_state _fulfilment (meta) enum none WordPress

Two rules come out of the table. A field with a single writer is copied, never merged. A field with two writers gets a conflict rule you actually implement — timestamp comparison, or the ERP wins, or the local edit wins until the record is closed — written down where the next developer will find it. If you cannot state the rule in one sentence, make it single-writer and save yourself the argument.

The identity column is the one to get right early. Every record needs one key that exists in both systems and never changes. If the ERP recycles numbers or a local user can edit the key, deduplication later is guesswork, and guesswork on a live order table is how you get double shipments.

Build the mapper as a pure function and test it on fixtures

The function that turns an ERP payload into something WordPress can store should not know about HTTP, auth or the database. Give it an array, get an array back, or get a list of field errors. That shape lets you run it against fixtures: real payloads saved from the ERP, plus the ugly ones — a null status, a total with a thousands separator, a date in local time.

<?php
/**
 * Map a raw ERP order payload.
 * Returns a normalised array, or WP_Error carrying per-field messages.
 */
function acme_map_order( array $in ) {
    $errors = [];

    $ext_id = trim( (string) ( $in['order_no'] ?? '' ) );
    if ( '' === $ext_id ) {
        $errors['order_no'] = 'Required.';
    }

    // The ERP sends money as a decimal string. Keep it a string — float math on money is how cents vanish.
    $total = $in['grand_total'] ?? null;
    if ( ! is_string( $total ) || ! preg_match( '/^-?\d{1,9}(\.\d{1,4})?$/', $total ) ) {
        $errors['grand_total'] = 'Expected a decimal string.';
    }

    $status_map = [
        'N' => 'pending',
        'P' => 'processing',
        'C' => 'completed',
        'X' => 'cancelled',
    ];
    $code   = strtoupper( trim( (string) ( $in['status'] ?? '' ) ) );
    $status = $status_map[ $code ] ?? null;
    if ( null === $status ) {
        $errors['status'] = 'Unmapped status code: ' . $code;
    }

    $placed_at = acme_parse_iso8601( (string) ( $in['placed_at'] ?? '' ) );
    if ( null === $placed_at ) {
        $errors['placed_at'] = 'Expected ISO 8601 with an offset.';
    }

    if ( $errors ) {
        return new WP_Error( 'acme_invalid_payload', 'Validation failed', [
            'status' => 422,
            'fields' => $errors,
        ] );
    }

    return [
        'external_id' => $ext_id,
        'status'      => $status,
        'total'       => $total,
        'placed_at'   => $placed_at, // 'Y-m-d H:i:s' in UTC
        'note'        => mb_substr( trim( (string) ( $in['customer_note'] ?? '' ) ), 0, 500 ),
    ];
}

function acme_parse_iso8601( string $value ) {
    $dt = DateTimeImmutable::createFromFormat( DateTimeInterface::ATOM, $value );
    if ( ! $dt instanceof DateTimeImmutable ) {
        return null;
    }
    return $dt->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
}
Enter fullscreen mode Exit fullscreen mode

Note what happens to an unknown status: it errors, it does not default. A default is how a new ERP code becomes silently wrong data six months later. If you genuinely cannot reject the record, store the raw value next to the mapped one and flag it, but do not invent a mapping to keep the pipeline green.

Dates deserve their own row in the contract. An ERP that sends local time without an offset cannot be converted reliably. Ask for an offset and reject the payload until you get one — a wrong post_date_gmt is invisible until someone sorts by date.

Then the auth boundary, in both directions

Inbound, ERP calling WordPress:

add_action( 'rest_api_init', function () {
    register_rest_route( 'acme/v1', '/orders', [
        'methods'             => 'POST',
        'permission_callback' => 'acme_verify_signature',
        'callback'            => 'acme_receive_order',
    ] );
} );

function acme_verify_signature( WP_REST_Request $request ) {
    $keys = acme_signing_keys(); // [ key_id => secret ], defined in wp-config.php
    $kid  = (string) $request->get_header( 'x-acme-key-id' );
    if ( ! isset( $keys[ $kid ] ) ) {
        return new WP_Error( 'acme_unknown_key', 'Unknown key', [ 'status' => 401 ] );
    }

    $ts = (int) $request->get_header( 'x-acme-timestamp' );
    if ( abs( time() - $ts ) > 300 ) {
        return new WP_Error( 'acme_stale', 'Timestamp outside the window', [ 'status' => 401 ] );
    }

    $expected = hash_hmac( 'sha256', $ts . '.' . $request->get_body(), $keys[ $kid ] );
    if ( ! hash_equals( $expected, (string) $request->get_header( 'x-acme-signature' ) ) ) {
        return new WP_Error( 'acme_bad_signature', 'Signature mismatch', [ 'status' => 401 ] );
    }

    return true;
}
Enter fullscreen mode Exit fullscreen mode

Things about that which bite later if you skip them:

  • permission_callback is the gate. Returning true makes the request anonymous — get_current_user_id() is 0, so any current_user_can() inside the callback is checking a user who is not there.
  • Sign the timestamp together with the body, and reject stale timestamps. A signature over the body alone is replayable forever.
  • Keep two keys live and have the caller send x-acme-key-id. Rotating without downtime costs one header.
  • Secrets go in wp-config.php or the environment. An option row ends up in a database dump and in the staging copy someone restores six months later.

Outbound, WordPress calling the ERP: a dedicated user whose role has only the capabilities the integration needs, plus an application password over HTTPS, is workable at low volume. Be clear about what that credential is — it skips the login form and the second factor, has no expiry of its own, and carries every capability of that user. Revoke per integration and log the user ID on every call so a leak has a blast radius you can measure.

If your auth story is "the request had a valid nonce", that is CSRF protection for a logged-in browser, not authentication for a server. Server-to-server needs its own credential.

Idempotency before retries

A retry without a dedupe key is a duplicate generator. Decide what makes two requests the same request: a key generated once at the source — a UUID, or external_id plus a revision counter — sent as a header and stored with the result.

function acme_claim_request( string $key ) : bool {
    global $wpdb;
    $table = $wpdb->prefix . 'acme_sync_log'; // unique index on idem_key

    $inserted = $wpdb->query( $wpdb->prepare(
        "INSERT IGNORE INTO {$table} (idem_key, state, created_at) VALUES (%s, 'in_flight', %s)",
        $key,
        gmdate( 'Y-m-d H:i:s' )
    ) );

    return (bool) $inserted; // false means someone already claimed it
}
Enter fullscreen mode Exit fullscreen mode

INSERT IGNORE against a unique index does the work atomically, so two workers cannot both win the read-then-write race. When the claim fails, look up the stored result and return it: same key, same answer, no second order.

Retries need an error taxonomy or they make things worse

Sort the responses before writing the loop.

Response Retry What to do
Network timeout, DNS failure (WP_Error) yes back off
429 yes honour Retry-After when present
500–599 yes back off, cap the attempts
409 conflict no fetch the current record, resolve by the ownership rules
400 / 422 no validation; store the field errors, fix the data, replay
401 / 403 no credential problem; alert a human, more attempts buy nothing

Then the loop: exponential backoff with jitter, a cap, and somewhere durable to wait.

add_action( 'acme_push_order', 'acme_push_order', 10, 2 );

function acme_push_order( int $order_id, int $attempt ) {
    $key = get_post_meta( $order_id, '_acme_idem_key', true );
    if ( ! $key ) {
        return;
    }

    // One worker per record. add_option() is atomic on option_name.
    $lock = 'acme_lock_' . $order_id;
    if ( ! add_option( $lock, time(), '', 'no' ) ) {
        return;
    }

    $response = wp_remote_post( ACME_ERP_ENDPOINT, [
        'timeout' => 15,
        'headers' => [
            'content-type'      => 'application/json',
            'x-idempotency-key' => $key,
            'authorization'     => 'Bearer ' . ACME_ERP_TOKEN,
        ],
        'body'    => wp_json_encode( acme_build_payload( $order_id ) ),
    ] );

    $code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );

    if ( 0 === $code || 429 === $code || $code >= 500 ) {
        delete_option( $lock );
        acme_schedule_retry(
            $order_id,
            $attempt,
            is_wp_error( $response ) ? $response->get_error_message() : 'HTTP ' . $code
        );
        return;
    }

    if ( $code >= 400 ) {
        update_post_meta( $order_id, '_acme_sync_state', 'dead' );
        update_post_meta(
            $order_id,
            '_acme_last_error',
            mb_substr( (string) wp_remote_retrieve_body( $response ), 0, 1000 )
        );
        delete_option( $lock );
        return;
    }

    update_post_meta( $order_id, '_acme_sync_state', 'synced' );
    delete_option( $lock );
}

function acme_schedule_retry( int $order_id, int $attempt, string $reason ) {
    update_post_meta( $order_id, '_acme_last_error', $reason );

    if ( $attempt >= 5 ) {
        update_post_meta( $order_id, '_acme_sync_state', 'dead' );
        return;
    }

    // Jitter, so a batch failure does not come back as a stampede.
    $delay = (int) ( 60 * pow( 2, $attempt ) ) + wp_rand( 0, 30 );
    wp_schedule_single_event( time() + $delay, 'acme_push_order', [ $order_id, $attempt + 1 ] );
}
Enter fullscreen mode Exit fullscreen mode

Four details that matter more than the code:

  • wp_schedule_single_event runs on WP-Cron, which fires on incoming traffic. On a quiet site a "60 second" retry can wait hours. Run a real system cron against wp-cron.php, or move the queue to a table your own worker drains.
  • Dead is a state, not a failure. Store the payload and the last error and give yourself a replay path — wp acme replay <order_id> is enough — so the fix is a command rather than a database edit.
  • Cap what you store from the error body. An ERP that replies with a 200 KB HTML error page will bloat post meta fast.
  • Give the lock a TTL or sweep stale ones. A worker that dies mid-request otherwise blocks that record forever.

Something has to compare both sides

Every integration drifts: a webhook that never fired, a record edited on both sides, a dead record nobody replayed. A nightly job that pulls a count per day plus a checksum over a sample of IDs catches most of it, and a report of records sitting in dead for more than a day catches the rest.

Keep the sync log in its own table with an index on the external ID. Building that report out of wp_postmeta over thousands of rows is slow enough that people quietly stop running it.

Build the pieces in that order and each one is testable on its own: the mapper against fixtures, the signature against a known payload and a clock you control, the retry loop against a fake endpoint that returns 500 twice and then 200. Start with auth and you get a green request that tells you nothing about whether the data landed correctly.

If you are wiring WordPress to an ERP and would rather hand the mapping layer and the retry queue to someone else, that is the kind of custom work we quote for: AumCreate services.

Top comments (0)