A private WordPress knowledge base with live Shopify data
By Rolando Escobar, WordPress developer for agencies · · 4 min read
Call-center agents answer product questions all day, and copied product details go stale the moment someone edits the store. A private knowledge base that reads product data from Shopify fixes that, as long as it really is private and doesn’t hammer the API.
The NaturalSlim Resource Center is that kind of tool: a private, fully custom WordPress site that serves as the knowledge base for NaturalSlim’s call-center team. Next to searchable dictionaries, a document library, tracking of which staff members are connected and two-factor sign-in, it shows product information pulled from NaturalSlim’s Shopify store through its API. This article shows how to build the private site and the product side of a tool like that. The code is a tutorial version, not the project’s code.
Lock the site down first
Privacy has to cover three doors: front-end pages, the REST API and search engines. Start with a role for agents and a capability that every protected request checks:
register_activation_hook( __FILE__, 'rw_kb_add_roles' );
function rw_kb_add_roles(): void {
add_role( 'rw_agent', 'Call-center agent', array( 'read' => true, 'rw_view_kb' => true ) );
get_role( 'administrator' )->add_cap( 'rw_view_kb' );
}
// Every front-end request needs a logged-in user who can view the knowledge base.
add_action( 'template_redirect', 'rw_kb_require_access', 0 );
function rw_kb_require_access(): void {
if ( ! is_user_logged_in() ) {
// Send people back to the page they asked for after they sign in.
wp_safe_redirect( wp_login_url( esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ?? '/' ) ) ) );
exit;
}
if ( ! current_user_can( 'rw_view_kb' ) ) {
wp_die( esc_html__( 'You do not have access to this knowledge base.', 'rw-kb' ), 403 );
}
}
// No anonymous REST API access (pattern from the REST API Handbook).
add_filter( 'rest_authentication_errors', function ( $result ) {
if ( true === $result || is_wp_error( $result ) ) {
return $result;
}
if ( ! is_user_logged_in() ) {
return new WP_Error( 'rest_not_logged_in', __( 'You are not currently logged in.', 'rw-kb' ), array( 'status' => 401 ) );
}
return $result;
} );
// Belt and braces: tell crawlers to stay out.
add_filter( 'wp_robots', 'wp_robots_no_robots' );
add_action( 'send_headers', function (): void {
header( 'X-Robots-Tag: noindex, nofollow', true );
} );
Why each part is there:
- The REST filter is not optional. REST requests never reach
template_redirect, so without it/wp-json/wp/v2/postswould hand your private articles to anyone. The code follows the REST API Handbook’s example. - Agents get
readand one custom capability. They don’t need the dashboard, so hide the toolbar for them with theshow_admin_barfilter. Supervisors who edit content get a separate role with editing capabilities. - The noindex signals are a backup. A login wall already keeps crawlers out, but a misconfigured page or a staging copy can leak, and the header costs nothing.
- Uploads are not covered. The web server serves files in
wp-content/uploadsdirectly, without loading WordPress. If the knowledge base has a document library, store those files outside the public uploads folder, or serve them through a PHP handler that checks the capability first.
Add two-factor sign-in too. The Resource Center has it, and for a site that exposes internal material it should be standard. The Two Factor plugin is a solid base.
Talk to Shopify with the GraphQL Admin API
Shopify has treated the REST Admin API as legacy since October 1, 2024, so new integrations should use the GraphQL Admin API. Create an app for the store with only the read_products access scope, and keep its Admin API access token in wp-config.php with the store domain and a pinned API version. Shopify releases a new version every quarter, so plan to bump that constant at least once a year.
// wp-config.php: RW_SHOPIFY_SHOP ('your-store.myshopify.com'), RW_SHOPIFY_API_VERSION ('2026-07'), RW_SHOPIFY_TOKEN.
function rw_kb_shopify_query( string $query, array $variables = array() ): array|WP_Error {
$response = wp_remote_post(
sprintf( 'https://%s/admin/api/%s/graphql.json', RW_SHOPIFY_SHOP, RW_SHOPIFY_API_VERSION ),
array(
'timeout' => 15,
'headers' => array(
'Content-Type' => 'application/json',
'X-Shopify-Access-Token' => RW_SHOPIFY_TOKEN,
),
// The object cast sends empty variables as {} instead of [].
'body' => wp_json_encode( array( 'query' => $query, 'variables' => (object) $variables ) ),
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code || ! is_array( $body ) ) {
return new WP_Error( 'rw_shopify_http', sprintf( 'Shopify returned HTTP %d.', $code ) );
}
if ( ! empty( $body['errors'] ) ) {
$throttled = 'THROTTLED' === ( $body['errors'][0]['extensions']['code'] ?? '' );
return new WP_Error(
$throttled ? 'rw_shopify_throttled' : 'rw_shopify_graphql',
(string) ( $body['errors'][0]['message'] ?? 'GraphQL error.' ),
$body['extensions']['cost'] ?? null
);
}
return $body;
}
Note the error handling. When the GraphQL API throttles you, the HTTP status is still 200 and the problem is in the errors array, so checking only the status code isn’t enough.
Sync on a schedule, not on page views
Agents open product pages constantly during calls. Calling Shopify on every view adds latency and burns through the rate limit. Instead, a cron job pulls the catalog, keeps what the knowledge base shows and stores it in a transient.
The GraphQL Admin API limits by calculated query cost. Every response reports the cost in extensions.cost, including a throttleStatus with currentlyAvailable points and a restoreRate per second. The sync reads those values and waits before the next page instead of guessing:
const RW_KB_PRODUCTS_QUERY = <<<'GRAPHQL'
query Products($cursor: String) {
products(first: 50, after: $cursor, query: "status:active") {
pageInfo { hasNextPage endCursor }
nodes { handle title descriptionHtml variants(first: 20) { nodes { title sku price } } }
}
}
GRAPHQL;
add_action( 'rw_kb_sync_products', 'rw_kb_sync_products' );
register_activation_hook( __FILE__, function (): void {
if ( ! wp_next_scheduled( 'rw_kb_sync_products' ) ) {
wp_schedule_event( time(), 'hourly', 'rw_kb_sync_products' );
}
} );
function rw_kb_sync_products(): void {
$products = array();
$cursor = null;
do {
$result = rw_kb_shopify_query( RW_KB_PRODUCTS_QUERY, array( 'cursor' => $cursor ) );
if ( is_wp_error( $result ) ) {
error_log( 'Shopify sync stopped: ' . $result->get_error_message() );
return; // Keep the previous cache.
}
$page = $result['data']['products'];
foreach ( $page['nodes'] as $node ) {
$products[ $node['handle'] ] = array(
'title' => sanitize_text_field( $node['title'] ),
'description' => wp_kses_post( $node['descriptionHtml'] ),
'variants' => $node['variants']['nodes'],
);
}
$cursor = $page['pageInfo']['endCursor'];
// Wait if the next page would not fit in the rate-limit bucket.
$cost = $result['extensions']['cost'] ?? null;
if ( $cost && $cost['throttleStatus']['currentlyAvailable'] < $cost['requestedQueryCost'] ) {
$missing = $cost['requestedQueryCost'] - $cost['throttleStatus']['currentlyAvailable'];
sleep( (int) ceil( $missing / max( 1, $cost['throttleStatus']['restoreRate'] ) ) );
}
} while ( $page['pageInfo']['hasNextPage'] );
set_transient( 'rw_kb_products', $products, 6 * HOUR_IN_SECONDS );
update_option( 'rw_kb_products_synced_at', time(), false );
}
The transient lives six hours while the job runs every hour, so one failed run, or even a few, doesn’t empty the knowledge base. A failed page stops the sync without touching the old data. Clear the event with wp_clear_scheduled_hook() on deactivation.
WP-Cron only runs when someone loads a page, and a private site gets little traffic outside working hours. Hook WP-Cron into the system scheduler so the sync runs on time. If agents need changes within seconds, subscribe to Shopify’s product update webhooks, verify their HMAC header and refresh just that product.
Custom post type or render on the fly
With the data cached, you can either render it straight from the cache or mirror each product into a custom post type:
| Approach | Good for | Costs |
|---|---|---|
| Render from the cache | Small catalogs where agents only need current product facts | Products don’t appear in WordPress search, and a very large cache may not fit in an object cache |
| Mirror into a CPT | Adding internal notes and call scripts to products, searching them with the rest of the knowledge base | More code: create, update and trash posts, matched by Shopify ID in post meta |
| Call the API on every view | Nothing, in practice | Slow pages, rate-limit pressure and a broken page whenever Shopify is unreachable |
For the cache approach, a shortcode lets editors drop a product into any article:
add_shortcode( 'rw_product', 'rw_kb_product_shortcode' );
function rw_kb_product_shortcode( $atts ): string {
$atts = shortcode_atts( array( 'handle' => '' ), $atts, 'rw_product' );
$handle = sanitize_title( $atts['handle'] );
$products = get_transient( 'rw_kb_products' );
$product = is_array( $products ) ? ( $products[ $handle ] ?? null ) : null;
if ( ! $product ) {
return '<p>' . esc_html__( 'Product data is not available right now.', 'rw-kb' ) . '</p>';
}
ob_start();
?>
<section class="rw-product" aria-labelledby="rw-product-<?php echo esc_attr( $handle ); ?>">
<h2 id="rw-product-<?php echo esc_attr( $handle ); ?>"><?php echo esc_html( $product['title'] ); ?></h2>
<?php echo wp_kses_post( $product['description'] ); ?>
<table>
<thead><tr><th scope="col">Variant</th><th scope="col">SKU</th><th scope="col">Price</th></tr></thead>
<tbody>
<?php foreach ( $product['variants'] as $variant ) : ?>
<tr>
<td><?php echo esc_html( $variant['title'] ); ?></td>
<td><?php echo esc_html( $variant['sku'] ); ?></td>
<td><?php echo esc_html( $variant['price'] ); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p><?php printf( esc_html__( 'Synced from Shopify %s ago.', 'rw-kb' ), esc_html( human_time_diff( (int) get_option( 'rw_kb_products_synced_at' ) ) ) ); ?></p>
</section>
<?php
return (string) ob_get_clean();
}
The “synced ago” line matters on a call. If a customer quotes a price that doesn’t match, the agent can see how fresh the data is before contradicting them.
What to watch out for
- Sanitize Shopify HTML.
descriptionHtmlis whatever someone typed in the store admin. Run it throughwp_kses_post()before storing it, and escape every other field on output. - Keep the token server-side. Every Shopify call happens in PHP. Never pass the token to JavaScript, and give the app only the scopes it reads.
- Watch cache size. Memcached’s default item limit is 1 MB. A large catalog in one transient can silently fail to store, which is a good reason to switch to a CPT or to Shopify’s bulk operations.
- Prices are in the store’s currency. The
pricefield is a decimal string, so label the currency in the template. - Offboard people. A private tool is only private while old accounts are removed. Remove agents’ accounts when they leave, and review roles every few months.
This is the kind of work I take on for clients: Plugins and integrations