Syncing WooCommerce order status to LiveChat in real time
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
To show a LiveChat agent who they are talking to before the first message, send two kinds of context. Send the cart from the shopper's browser as LiveChat session variables, which are display-only because the shopper can change them. Send identity as a signed, expiring customer reference that only your store can create. A Details widget in LiveChat's Agent App passes the agent's LiveChat token and the customer ID to a small context service. The service checks that the token belongs to your organization and your app, reads the session variables from LiveChat itself, verifies the signature, and returns the customer's latest WooCommerce orders through a read-only REST key that never reaches a browser. Allow CORS only for your widget's origin. Don't rely on CORS to protect WooCommerce keys: WordPress's REST API reflects any origin. Our tested setup (22 of 22 checks, including the real LiveChat Agent App SDK in a browser) shows purchase history, order status and the live cart in the agent's sidebar. LiveChat starts at $19 per agent per month billed annually.
The first minute of most support chats is spent finding out who the customer is: "What's your order number?", "Which email did you use?", a tab switch to WooCommerce, a search. If the agent already sees the customer's name, their last orders and what's in their cart when the chat opens, that minute disappears and the first reply can be the answer. This guide wires WooCommerce into LiveChat's agent panel, and spends most of its time on the part that's easy to get wrong: deciding which data the agent can trust.
We tested the store side on a real WordPress 7.1.3 and WooCommerce 11.2.1 site in headless Edge, with LiveChat's tracking code replaced by a stub that records every call. We tested the context service against the real WooCommerce REST API and a mock of LiveChat's Global Accounts and Agent Chat APIs. We ran the widget on the real @livechat/agent-app-sdk 1.16.0 inside a test page that played the Agent App. We didn't connect a live LiveChat account. API shapes, scopes and prices come from LiveChat's (Text's) developer documentation and help center, checked in October 2026.
1. The short answer
| Context | How it reaches the agent | Can the agent trust it? |
|---|---|---|
| Cart contents and total | Session variables set from the shopper's browser | As a hint: the shopper can change them, but it's their own cart |
| Name and email typed into the chat | LiveChat's pre-chat form or set_customer_email | No: anyone can type anyone's email |
| Who the customer is, and their orders | A signed customer reference in session variables, checked by your service, shown by a Details widget | Yes: only your store can sign it, and orders come from WooCommerce |
2. The path the context takes
3. Why session variables alone aren't enough
LiveChat's chat widget has setters for this: set_session_variables and update_session_variables attach key-value pairs to the visitor, and agents see them with the chat. The obvious move is to put the customer's email and order list there. Don't. The values are set by JavaScript in the visitor's browser, so a visitor can open the console and set any email, customer ID or order list they like. LiveChat's own help center warns that the setters are visible in your tracking code and shouldn't carry sensitive data.
The fix is to separate what the browser says from what you verify:
- The cart is fine to send from the browser. It's the shopper's own cart, the agent uses it as a hint, and nothing is decided on it.
- Identity is sent as a signed reference:
customer ID.expiry.HMAC, signed with a secret only WordPress and your service know. Change one digit and the signature fails. It contains no email or name, and it expires after 12 hours. - Orders never travel through the browser. Your service reads them from WooCommerce when an agent opens the chat.
4. The store side: a small mu-plugin
Save as wp-content/mu-plugins/livechat-context.php, add LC_CONTEXT_SECRET to wp-config.php, and keep LiveChat's tracking code (or plugin) on the site.
<?php
/**
* Plugin Name: LiveChat store context
* Description: Puts the shopper's cart and a signed customer reference into LiveChat session variables, so agents see
* them before the first message. The cart is read from the Store API in the browser (safe with page caching); the
* reference is only printed for logged-in customers, whose pages are not page-cached.
*
* wp-config.php: LC_CONTEXT_SECRET (long random string, shared with the context service)
*/
add_action( 'wp_footer', function () {
$ref = '';
if ( is_user_logged_in() && defined( 'LC_CONTEXT_SECRET' ) ) {
// "customer ID.expiry.signature": the context service accepts it only with a valid signature and before expiry.
$payload = get_current_user_id() . '.' . ( time() + 12 * HOUR_IN_SECONDS );
$ref = $payload . '.' . hash_hmac( 'sha256', $payload, LC_CONTEXT_SECRET );
}
?>
<script>
(function () {
if (!window.LiveChatWidget) return; // LiveChat's tracking code must load first
var ref = <?php echo wp_json_encode( $ref ); ?>;
var cartUrl = <?php echo wp_json_encode( rest_url( 'wc/store/v1/cart' ) ); ?>;
function push() {
fetch(cartUrl, { credentials: 'same-origin' }).then(function (r) { return r.json(); }).then(function (c) {
var unit = Math.pow(10, c.totals.currency_minor_unit);
var vars = {
cart_items: String(c.items_count),
cart_total: (c.totals.total_price / unit).toFixed(c.totals.currency_minor_unit) + ' ' + c.totals.currency_code,
cart_products: c.items.slice(0, 5).map(function (i) { return i.name + ' x' + i.quantity; }).join(', ')
};
if (ref) vars.wc_ref = ref;
LiveChatWidget.call('update_session_variables', vars); // merges; set_session_variables would drop other keys
}).catch(function () {});
}
LiveChatWidget.on('ready', push);
// Classic themes fire jQuery events; cart and checkout blocks fire DOM events.
if (window.jQuery) jQuery(document.body).on('added_to_cart removed_from_cart updated_cart_totals', push);
['wc-blocks_added_to_cart', 'wc-blocks_removed_from_cart'].forEach(function (e) { document.body.addEventListener(e, push); });
})();
</script>
<?php
}, 100 );
- Page caching. The cart is fetched from WooCommerce's Store API in the browser, so a cached page never shows one shopper's cart to another. The signed reference is printed only for logged-in customers, whose pages caching plugins don't cache. Our test confirmed the logged-out HTML contains neither. If your cache does serve logged-in pages, move the reference to an uncached endpoint.
- Update, don't set.
set_session_variablesreplaces every variable, including ones another script set.update_session_variablesmerges. - Cart changes. Classic themes fire jQuery events and the cart and checkout blocks fire DOM events (
wc-blocks_added_to_cart). The script listens to both and pushes a fresh cart. - Timing. One developer write-up reports that variables changed after a chat has started weren't saved. We couldn't confirm that with LiveChat, but the script sends everything on
ready, so the start of the chat is covered either way.
5. The context service
The service answers one question: "an agent of my organization has LiveChat customer X open; what should they see?" It trusts nothing in the request except the agent's LiveChat token, and even that it checks first.
"""Store context for LiveChat agents. The agent's Details widget calls GET /context?customer=<LiveChat customer ID> with
the agent's LiveChat access token. This service:
1. checks the token belongs to your LiveChat organization and your widget app (Global Accounts API /v2/info),
2. reads the customer's session variables from LiveChat itself (Agent Chat API get_customer, as that agent),
3. accepts the wc_ref variable only if its signature is valid and it hasn't expired,
4. reads the customer and their latest orders from WooCommerce with a read-only key that never leaves this server.
Env: WC_URL, WC_KEY, WC_SECRET, LC_CONTEXT_SECRET (same as wp-config.php), LC_ORGANIZATION_ID, LC_CLIENT_ID (your
widget app's client ID), WIDGET_ORIGIN (where the widget page is served, e.g. https://lc-widget.example.com)."""
import base64, hashlib, hmac, json, os, time, urllib.error, urllib.parse, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
E = os.environ.get
WC = E('WC_URL', 'https://shop.example.com').rstrip('/')
WC_AUTH = 'Basic ' + base64.b64encode(f"{E('WC_KEY', '')}:{E('WC_SECRET', '')}".encode()).decode()
ACCOUNTS = E('LC_ACCOUNTS_URL', 'https://accounts.livechat.com')
AGENT_API = E('LC_API_URL', 'https://api.livechatinc.com') + '/v3.6/agent/action'
tokens = {} # sha256(token) -> valid until; ponytail: per process and never pruned, fine for a team of agents
def call(url, auth, body=None, timeout=5):
rq = urllib.request.Request(url, json.dumps(body).encode() if body is not None else None,
{'Authorization': auth, 'Content-Type': 'application/json'})
with urllib.request.urlopen(rq, timeout=timeout) as r:
return json.load(r), r.headers
def agent_ok(auth):
key = hashlib.sha256(auth.encode()).hexdigest()
if tokens.get(key, 0) > time.time():
return True
try:
info, _ = call(ACCOUNTS + '/v2/info', auth)
except (OSError, ValueError):
return False
# Anyone with a LiveChat account can get a token; accept only tokens issued to your app, in your organization.
if info.get('organization_id') != E('LC_ORGANIZATION_ID') or info.get('client_id') != E('LC_CLIENT_ID'):
return False
tokens[key] = time.time() + min(300, int(info.get('expires_in', 0)))
return True
def customer_id(ref):
try:
uid, exp, sig = ref.split('.')
good = hmac.new(os.environ['LC_CONTEXT_SECRET'].encode(), f'{uid}.{exp}'.encode(), hashlib.sha256).hexdigest()
return int(uid) if hmac.compare_digest(sig.encode(), good.encode()) and int(exp) > time.time() else None
except ValueError:
return None
def store(uid):
c, _ = call(f'{WC}/wp-json/wc/v3/customers/{uid}', WC_AUTH)
orders, h = call(f'{WC}/wp-json/wc/v3/orders?customer={uid}&per_page=5&orderby=date&order=desc', WC_AUTH)
return {'verified': True, 'name': f"{c['first_name']} {c['last_name']}".strip() or c['username'], 'email': c['email'],
'customer_since': c['date_created'][:10], 'orders_count': int(h['X-WP-Total']),
'orders': [{'id': o['id'], 'number': o['number'], 'status': o['status'], 'total': f"{o['total']} {o['currency']}",
'date': o['date_created'][:10],
'items': ', '.join(f"{i['name']} x{i['quantity']}" for i in o['line_items'][:3])} for o in orders]}
class Handler(BaseHTTPRequestHandler):
def cors(self):
if self.headers.get('Origin') == E('WIDGET_ORIGIN'): # only the widget's page may read the answers
self.send_header('Access-Control-Allow-Origin', E('WIDGET_ORIGIN'))
self.send_header('Vary', 'Origin')
def reply(self, code, obj):
b = json.dumps(obj).encode()
self.send_response(code)
self.cors()
self.send_header('Cache-Control', 'no-store')
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(b)))
self.end_headers()
self.wfile.write(b)
def do_OPTIONS(self): # CORS preflight: sending an Authorization header makes every request need one
self.send_response(204)
self.cors()
if self.headers.get('Origin') == E('WIDGET_ORIGIN'):
self.send_header('Access-Control-Allow-Headers', 'Authorization')
self.send_header('Access-Control-Allow-Methods', 'GET')
self.send_header('Access-Control-Max-Age', '600')
self.end_headers()
def do_GET(self):
u = urllib.parse.urlsplit(self.path)
auth = self.headers.get('Authorization', '')
if u.path != '/context':
return self.reply(404, {'error': 'not found'})
if not auth.startswith('Bearer ') or not agent_ok(auth):
return self.reply(401, {'error': 'not a signed-in agent of this LiveChat organization'})
try:
lc, _ = call(AGENT_API + '/get_customer', auth, {'id': urllib.parse.parse_qs(u.query).get('customer', [''])[0]})
except urllib.error.HTTPError as e:
return self.reply(404 if e.code in (400, 404) else 502, {'error': 'LiveChat customer not found or unavailable'})
except (OSError, ValueError):
return self.reply(502, {'error': 'LiveChat unavailable'})
fields = {k: str(v) for f in lc.get('session_fields', []) for k, v in f.items()}
cart = {k: fields[k] for k in ('cart_items', 'cart_total', 'cart_products') if k in fields}
uid = customer_id(fields.get('wc_ref', ''))
if not uid: # guest, or a ref that was edited, forged or has expired
return self.reply(200, {'verified': False, 'cart': cart})
try:
return self.reply(200, {**store(uid), 'cart': cart})
except urllib.error.HTTPError as e:
if e.code == 404: # signed ref, but the customer account was deleted
return self.reply(200, {'verified': False, 'cart': cart})
return self.reply(502, {'error': 'store unavailable'})
except (OSError, ValueError):
return self.reply(502, {'error': 'store unavailable'})
if __name__ == '__main__':
ThreadingHTTPServer(('0.0.0.0', int(E('PORT', 8080))), Handler).serve_forever()
Four checks, in order:
- Is this one of my agents, using my app? Anyone can create a LiveChat account and get a valid token.
GET https://accounts.livechat.com/v2/inforeturns the token'sorganization_idandclient_id, and the service accepts only yours. The result is cached for up to 5 minutes, so the check doesn't add a round-trip to every chat. - What did the customer's browser report? The service doesn't take session variables from the widget. It reads them from LiveChat with the Agent Chat API's
get_customer(session_fields), using the agent's own token. Your app needs thecustomers:roscope for this. - Is the reference genuine and current? The HMAC is checked in constant time and the expiry is enforced. A guest, an edited reference or an expired one gives
verified: false, and the agent is told to confirm the order number and email first. - Read WooCommerce, minimally. The customer record and the 5 latest orders, with the total order count from the
X-WP-Totalheader. The service sends no addresses or phone numbers, and the key has Read permission only.
6. The Agent App widget
In LiveChat's Developer Console, create an app with an Agent App widget in the Details placement and an authorization block, and request the customers:ro scope. Give the widget an initial state with a customer details section titled Store customer; the widget replaces that section's content with modifySection, and the title must stay the same or LiveChat ignores the update. Host these two files on your own HTTPS domain:
// LiveChat Agent App Details widget: shows WooCommerce context for the customer the agent has open.
export const TITLE = 'Store customer'; // must match the section title in the widget's initial state
const ADMIN = 'https://shop.example.com/wp-admin/admin.php?page=wc-orders&action=edit&id='; // post.php?action=edit&post= without HPOS
export function section(ctx) {
const c = [];
if (ctx.error) {
c.push({ type: 'title', data: { title: 'Store data unavailable', description: 'Look the customer up in WooCommerce.' } });
} else if (ctx.verified) {
c.push({ type: 'title', data: { title: ctx.name, value: ctx.email,
description: `Signed-in customer since ${ctx.customer_since}, ${ctx.orders_count} orders` } });
for (const o of ctx.orders) {
c.push({ type: 'link', data: { url: ADMIN + o.id, value: `#${o.number} ${o.status}, ${o.total}, ${o.date}` } });
if (o.items) c.push({ type: 'label_value', data: { label: '', value: o.items } });
}
} else {
c.push({ type: 'title', data: { title: 'Not verified',
description: 'Guest or not signed in. Confirm the order number and email before discussing an order.' } });
}
const cart = ctx.cart || {};
if (cart.cart_items && cart.cart_items !== '0') {
c.push({ type: 'line' });
c.push({ type: 'label_value', data: { label: `Cart now (${cart.cart_items} items, ${cart.cart_total})`, value: cart.cart_products } });
}
return { title: TITLE, components: c };
}
export function start(widget, getToken, api) {
let token, latest = 0;
async function load(profile) {
const seq = ++latest;
const get = () => fetch(`${api}/context?customer=${encodeURIComponent(profile.id)}`, { headers: { Authorization: `Bearer ${token}` } });
token ??= await getToken();
let r = await get();
if (r.status === 401) { token = await getToken(); r = await get(); } // expired token: get a new one once
const ctx = r.ok ? await r.json() : { error: true };
if (seq === latest) await widget.modifySection(section(ctx)); // the agent may have opened another chat meanwhile
}
widget.on('customer_profile', (p) => load(p).catch(() => widget.modifySection(section({ error: true }))));
const p = widget.getCustomerProfile();
if (p) load(p);
}
<!doctype html>
<meta charset="utf-8">
<title>Store customer</title>
<script type="module">
import { createDetailsWidget } from 'https://cdn.jsdelivr.net/npm/@livechat/[email protected]/+esm';
import AccountsSDK from 'https://cdn.jsdelivr.net/npm/@livechat/[email protected]/+esm';
import { start } from './widget.js';
// Your widget app's client ID from the Developer Console. popup_flow 'auto': no window when the agent is signed in.
const accounts = new AccountsSDK({ client_id: 'YOUR_CLIENT_ID', popup_flow: 'auto' });
const getToken = () => accounts.popup().authorize().then((d) => d.access_token);
start(await createDetailsWidget(), getToken, 'https://lc-context.example.com');
</script>
- The agent's token comes from LiveChat's Accounts SDK. With
popup_flow: 'auto', no window appears for an agent who is already signed in. When the service returns 401 (an expired token), the widget gets a fresh one once and retries. - Chat switching. Agents jump between chats faster than a lookup completes. Each request gets a sequence number and only the latest one may draw the section. Our test opened a slow customer, then a fast one, and only the second was rendered.
- Order links open the order in WooCommerce's admin. The URL in the code is for HPOS order storage (the default on new stores). On older post-based storage, use
post.php?post=ID&action=edit.
7. CORS and security: what blocks what
- Your widget page must be allowed in an iframe. The Agent App loads it in an iframe, so a
X-Frame-Options: DENYheader or aframe-ancestorsrule that excludes LiveChat's domains leaves the section empty. If your host adds those headers site-wide, serve the widget from its own subdomain with its own headers. - The SDK only listens to LiveChat.
agent-app-sdk1.16.0 ignores messages that don't come from LiveChat's domains (livechatinc.com, livechat.com, text.com, helpdesk.com). A page that frames your widget elsewhere gets nothing. - The widget's request to your service is cross-origin, with an Authorization header, so the browser sends a CORS preflight first. The service answers it only for
WIDGET_ORIGIN, never with*, and sendsVary: OriginandCache-Control: no-store. Our browser test went through exactly that preflight. - Never call WooCommerce's REST API from the widget. It would put the key in a browser. And CORS wouldn't save you: in our test, WordPress's REST API answered a request from
https://evil.examplewithAccess-Control-Allow-Origin: https://evil.exampleand credentials allowed. That's WordPress core behavior; the key is the only protection. - Third-party cookie blocking. The Accounts SDK's README notes its iframe flow only works where the browser doesn't apply tracking prevention (ITP). The popup flow used here avoids that.
8. What our test showed
All 22 checks passed:
- Store side (real WooCommerce, headless Edge): a guest page sent an empty cart and no reference. After adding 2 hoodies through the Store API and the block event, the variables updated to 2 items, "80.00 USD", "Hoodie x2". A signed-in customer's page carried a reference with the right customer ID, a valid signature and a 12-hour expiry. The logged-out HTML contained no reference and no cart.
- Context service (real WooCommerce REST, mock LiveChat): a signed-in customer returned name, 3 orders newest first with items, and the cart. The response held no address, phone or key. The token was checked once and then cached. Guest, edited reference, expired reference and deleted account all returned "not verified". Missing, unknown, other-organization and other-app tokens got 401, an unknown LiveChat customer 404, and a LiveChat outage 502. The preflight from the widget origin got 204 with the right headers; another origin got no CORS headers.
- Widget (real SDK in Edge): it connected to the parent page, recovered from an expired token, rendered a section that passed the SDK's own validators (3 order links, items, cart line), and drew only the latest customer when chats were switched mid-lookup.
9. LiveChat costs
| Plan | Per agent per month, billed annually |
|---|---|
| Starter | $19 |
| Team | $49 |
| Business | $79 |
| Enterprise | Custom |
Prices are from LiveChat's help center; monthly billing costs more. Check which plan includes the features your team relies on. The context service is small enough for the cheapest VPS or serverless tier.
10. Our opinion: trade-offs
- Start with the cart in session variables. It's about 45 lines of PHP and JavaScript, and agents see what the shopper is buying from day one.
- Add the widget and service when agents keep asking "what's your order number?". That's the question signed identity removes, and it's the one that costs the most time.
- Keep the trust line sharp. Browser data is a hint, signed references are identity, and orders come from WooCommerce on the server. Every shortcut across that line turns a support tool into a way to read other people's orders.
- LiveChat fits when people do the support and you want the agent's screen to carry context. If you'd rather have a bot answer order status first, see the order status chatbot guide: the same read-only key and lookup ideas apply.
Related: caching WooCommerce without leaking carts and capturing abandoned carts through the Store API.
11. Tools
Top picks
| Tool | Role | Why it fits |
|---|---|---|
| Live chat for support and sales teams | Session variables from your store, Agent App widgets in the Details panel, documented Agent Chat and Accounts APIs for server-side checks, and a WooCommerce integration. |
Alternatives: free tools and references
We don't earn anything from these links.
- Agent App SDK: Details widgets,
customer_profile,modifySectionand section components. - Chat Widget JS API: session variable setters and widget events.
- WooCommerce Store API: cart: the cart endpoint the mu-plugin reads.
12. Pitfalls
- Trusting
set_customer_email. It's whatever the visitor typed. Show it, but never look up orders by it without the visitor confirming an order number. - Skipping the client ID check. Checking only the organization accepts tokens from any app one of your agents ever authorized. Check both.
- Long-lived references. A reference that never expires is a permanent pass to that customer's context through any agent. Keep the expiry short and rotate
LC_CONTEXT_SECRETif it leaks; all old references die with it. - Logging tokens. The service caches a hash of the agent token, not the token. Keep it out of access logs too.
- Too much in the sidebar. Five orders and the cart are enough to start a conversation. Full addresses and payment details belong in WooCommerce, behind its own login.
- A
set_session_variablescall elsewhere on the site. It wipes the cart and reference. Useupdate_session_variableseverywhere.
Give LiveChat agents context before the first message, but keep the trust line clear. The cart can come from the browser as session variables. Identity comes as a signed, expiring reference only your store can make. Orders come from WooCommerce, server to server, after your service has checked the agent's token for your organization and app. Allow CORS for your widget's origin only, and never put a WooCommerce key where a browser can see it. LiveChat's session variables and Agent App widgets make this a few hundred lines of tested code.