How to Add an AI Chatbot to WordPress Without Exposing Your API Key
Adding an AI chatbot to WordPress looks simple: grab an API key from OpenAI or Anthropic, paste a snippet of JavaScript into your site, done. The problem is that anything in your page's JavaScript is public. Anyone can open DevTools, copy your API key, and run their own traffic on your bill. Here's how to build it properly: the browser talks to your WordPress site, and only your server talks to the AI provider.
If you've already put an API key in front-end code, revoke it now in your provider's dashboard and create a new one. Assume the old one was copied. Also set a monthly spending limit on the account. Both OpenAI and Anthropic let you cap usage.
The secure architecture
Browser (chat widget)
│ POST /wp-json/site-chat/v1/message { message: "Do you ship to Canada?" }
▼
WordPress REST endpoint (your server)
│ • checks a nonce and a per-visitor rate limit
│ • adds your system prompt and store info
│ • reads the API key from wp-config.php
▼
AI provider API (Anthropic / OpenAI)
The API key stays on the server. The browser only ever sees your own endpoint, which enforces your rules: message length, rate limits, and what the bot is allowed to talk about.
Step 1: Store the API key in wp-config.php
Don't store the key in the database or a plugin settings page. Database backups and exports get shared, and admin screens get screenshotted. Add it to wp-config.php, above the "stop editing" line:
define( 'SITE_CHAT_API_KEY', 'sk-ant-...your key...' );
define( 'SITE_CHAT_MODEL', 'claude-haiku-5-5' );
A small, fast model is usually the right choice for a site chatbot: quick replies, low cost per message. Check your provider's docs for current model names, since they change regularly. Keeping the model in a constant means you can switch later without touching code.
Step 2: Create the REST endpoint
Create wp-content/mu-plugins/site-chat.php (a must-use plugin always loads and can't be deactivated by accident):
<?php
/**
* Plugin Name: Site Chat (server-side AI proxy)
*/
add_action( 'rest_api_init', function () {
register_rest_route( 'site-chat/v1', '/message', array(
'methods' => 'POST',
'callback' => 'site_chat_handle_message',
'permission_callback' => '__return_true', // public, protected by nonce + rate limit below
'args' => array(
'message' => array(
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_textarea_field',
),
),
) );
} );
function site_chat_handle_message( WP_REST_Request $request ) {
// 1. Basic CSRF protection: the nonce is printed into the page in Step 3.
if ( ! wp_verify_nonce( $request->get_header( 'x-wp-nonce' ), 'wp_rest' ) ) {
return new WP_Error( 'bad_nonce', 'Please refresh the page and try again.', array( 'status' => 403 ) );
}
// 2. Rate limit: 20 messages per visitor IP per hour.
$ip_key = 'site_chat_' . md5( $_SERVER['REMOTE_ADDR'] ?? '' );
$count = (int) get_transient( $ip_key );
if ( $count >= 20 ) {
return new WP_Error( 'rate_limited', 'You have reached the message limit. Please try again later.', array( 'status' => 429 ) );
}
set_transient( $ip_key, $count + 1, HOUR_IN_SECONDS );
// 3. Keep messages short so nobody can paste a novel on your bill.
$message = mb_substr( trim( $request['message'] ), 0, 1000 );
if ( '' === $message ) {
return new WP_Error( 'empty', 'Please type a message.', array( 'status' => 400 ) );
}
// 4. Call the AI provider from the server.
$response = wp_remote_post( 'https://api.anthropic.com/v1/messages', array(
'timeout' => 30,
'headers' => array(
'x-api-key' => SITE_CHAT_API_KEY,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
),
'body' => wp_json_encode( array(
'model' => SITE_CHAT_MODEL,
'max_tokens' => 400,
'system' => site_chat_system_prompt(),
'messages' => array(
array( 'role' => 'user', 'content' => $message ),
),
) ),
) );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
error_log( 'Site chat API error: ' . print_r( is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_body( $response ), true ) );
return new WP_Error( 'api_error', 'The assistant is unavailable right now.', array( 'status' => 502 ) );
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$reply = $data['content'][0]['text'] ?? '';
return rest_ensure_response( array( 'reply' => $reply ) );
}
function site_chat_system_prompt() {
return "You are the website assistant for Example Store, which sells handmade ceramics.\n"
. "Only answer questions about our products, shipping, returns and orders.\n"
. "Shipping: we ship to the US and Canada; orders ship within 2 business days.\n"
. "Returns: unused items can be returned within 30 days.\n"
. "If you don't know the answer, or the question is about something else, say so and suggest emailing support@example.com.\n"
. "Never make up prices, discounts or policies.";
}
A few details matter here:
- Errors are logged on the server, not sent to the browser. Provider error messages can reveal account details.
max_tokenscaps the length of each reply, and so the cost.- The system prompt is where your bot gets its knowledge and limits. Keep it factual and specific. The more precise your store info is, the less the model has to guess.
Using OpenAI instead? The structure stays the same. Only the URL, the auth header (Authorization: Bearer ...), the request body and the path to the reply text change, as described in their API reference.
Step 3: Add the chat widget to the front end
Print the nonce and a minimal widget in the footer. Add this to the same mu-plugin file:
add_action( 'wp_footer', function () {
$nonce = wp_create_nonce( 'wp_rest' ); // the standard REST nonce works for logged-in and logged-out visitors
$url = esc_url( rest_url( 'site-chat/v1/message' ) );
?>
<div id="site-chat" style="position:fixed;right:16px;bottom:16px;width:320px;max-width:calc(100vw - 32px);background:#fff;border:1px solid #ddd;border-radius:12px;padding:12px;font:14px/1.4 sans-serif;z-index:9999">
<div id="site-chat-log" style="max-height:260px;overflow:auto;margin-bottom:8px"></div>
<form id="site-chat-form" style="display:flex;gap:6px">
<input id="site-chat-input" maxlength="1000" placeholder="Ask a question..." style="flex:1;padding:8px" required>
<button type="submit">Send</button>
</form>
</div>
<script>
(function () {
const form = document.getElementById('site-chat-form');
const input = document.getElementById('site-chat-input');
const log = document.getElementById('site-chat-log');
const add = (who, text) => {
const p = document.createElement('p');
p.textContent = who + ': ' + text; // textContent, never innerHTML
log.appendChild(p);
log.scrollTop = log.scrollHeight;
};
form.addEventListener('submit', async (e) => {
e.preventDefault();
const message = input.value.trim();
if (!message) return;
add('You', message);
input.value = '';
try {
const res = await fetch(<?php echo wp_json_encode( $url ); ?>, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': <?php echo wp_json_encode( $nonce ); ?> },
body: JSON.stringify({ message })
});
const data = await res.json();
add('Assistant', res.ok ? data.reply : (data.message || 'Something went wrong.'));
} catch (err) {
add('Assistant', 'Something went wrong. Please try again.');
}
});
})();
</script>
<?php
} );
The widget uses textContent rather than innerHTML for replies. Model output is untrusted text, and inserting it as HTML would open the door to script injection.
A note on page caching
WordPress nonces stay valid for 12 to 24 hours. If your pages are cached longer than that, visitors can get a stale nonce and see "Please refresh the page". Either keep your page cache lifetime under 12 hours, or fetch a fresh nonce from a small uncached endpoint when the chat opens.
Going further
- Conversation memory: send the last few messages back with each request (in the
messagesarray, alternating user and assistant roles) so the bot can follow up. Cap how many you send to control cost. - Product knowledge: for a store with hundreds of products, don't paste everything into the system prompt. Search your products first (WooCommerce's product search, or Shopify's search for a Shopify store) and include only the few relevant ones in the prompt.
- Human handoff: when the bot can't help, offer a button that emails the conversation to your support inbox.
- Stronger rate limiting: IP-based limits are a start. For busy sites, add a Cloudflare rate limiting rule on
/wp-json/site-chat/as well.
Written by
Sameer Hussain, Senior Shopify & WordPress Engineer
Top Rated on Upwork and the Shopify & WordPress engineer at Inceptus Digital. Short on time, or not comfortable editing code? Send me your URL and the error on Upwork and I'll tell you how I'd fix it before you commit.
Need Custom Engineering or Growth Systems?
Our team helps ambitious brands build bespoke software, AI integrations, and high-conversion platforms.