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.
/**
* 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' );
}
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;
}
Things about that which bite later if you skip them:
permission_callbackis the gate. Returning true makes the request anonymous -get_current_user_id()is0, so anycurrent_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.phpor 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
}
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 ] );
}
Four details that matter more than the code:
wp_schedule_single_eventruns on WP-Cron, which fires on incoming traffic. On a quiet site a "60 second" retry can wait hours. Run a real system cron againstwp-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.
Comments
No comments yet. Start the discussion.