WordPress & WooCommerce
REST API reference
The plugin and the Pitchbar server talk to each other in two directions over HTTP, with two different auth schemes. This reference documents every endpoint involved — request shape, response shape, status codes, and how authentication is enforced.
Auth at a glance
| Direction | Credential | Where it lives | Replay window |
|---|---|---|---|
| Plugin → Pitchbar | Bearer API token pbar_… |
Pitchbar stores only the SHA-256 hash. Plugin keeps plaintext in wp_options. |
— |
| Plugin → Pitchbar (mutating) | Bearer + HMAC body signature | HMAC key is the bearer plaintext itself. | 5 min |
| Pitchbar → Plugin | HMAC body signature | Per-token shopper_signing_secret (plaintext on both sides). |
5 min |
HMAC signature scheme
X-Pitchbar-Signature: t=<unix_ts>,v1=<hex_sig>
sig = hmac_sha256(secret, "{t}.{raw_body}")
Both sides reject requests whose t is more than 300
seconds away from the verifying server's clock. Both sides
constant-time-compare the signature with hash_equals.
The same scheme is used for outbound webhooks documented under
Outgoing webhooks.
Pitchbar endpoints (plugin → Pitchbar)
Base: your Pitchbar workspace URL. All routes are POST and
require Authorization: Bearer pbar_… with the
wp:integration ability. Mutating routes
additionally require X-Pitchbar-Signature.
POST /api/v1/wp/handshake
Called by the plugin's Test connection button to
discover the workspace, list available agents, and capture the
token's shopper_signing_secret. No HMAC required
(the bearer is sufficient — the handshake doesn't mutate state
beyond writing last_used_at).
Request:
{
"site_url": "https://shop.example.com",
"plugin_version": "2.0.4",
"woocommerce_active": true,
"wordpress_version": "6.6"
}
Response 200:
{
"data": {
"workspace": { "id": "01HZ…", "name": "Acme" },
"agents": [
{ "id": "01HZ…", "name": "Storefront bot", "site_type": "ecommerce", "language_default": "en", "is_published": true }
],
"token": {
"id": "01HZ…",
"name": "shop.example.com",
"abilities": ["wp:integration"],
"shopper_signing_secret": "sek_…"
},
"recommended_site_type": "ecommerce",
"echo": { "site_url": "https://shop.example.com", "plugin_version": "2.0.4" }
}
}
recommended_site_type hints at which vertical the
agent should adopt: ecommerce when WooCommerce is
active on the calling site, otherwise null.
shopper_signing_secret is plaintext — the plugin
stashes it in wp_options silently.
Errors:
401 invalid_token— bearer missing, malformed, or revoked.403 insufficient_ability— token doesn't grantwp:integration.
POST /api/v1/wp/posts/sync
Bulk post upsert. Up to 50 posts per request. Requires HMAC.
{
"agent_id": "01HZ…",
"site_url": "https://shop.example.com",
"plugin_version": "2.0.4",
"posts": [
{
"wp_id": 142,
"post_type": "page",
"permalink": "https://shop.example/pricing",
"title": "Pricing",
"content_html": "<div class=\"elementor-…\">…</div>",
"excerpt": "Three plans, two outcomes…",
"content_hash": "ab12…ef90",
"modified_at": "2026-05-09T14:30:00+00:00",
"language": "en-us",
"taxonomy_terms": ["pricing", "plans"]
}
]
}
Response 200:
{
"data": {
"queued": 1,
"skipped_unchanged": 0,
"deleted": 0
}
}
queued = number of posts whose hash differed from
the stored Document and were queued for embedding.
skipped_unchanged = number whose hash matched (no
cost). The vector store is updated asynchronously by
IndexDocumentJob; subsequent retrievals start
finding the new content within seconds.
POST /api/v1/wp/posts/changed
Single-post delta. Same shape as posts/sync but with
a posts array of length 1 and an extra
action field of "upsert" or
"delete". HMAC required.
POST /api/v1/wp/products/sync
Bulk WooCommerce product upsert. Up to 50 per batch. HMAC required.
{
"agent_id": "01HZ…",
"site_url": "https://shop.example.com",
"plugin_version": "2.0.4",
"products": [
{
"wp_id": 9001,
"sku": "T-BLU-M",
"name": "Blue tee",
"permalink": "https://shop.example/product/blue-tee",
"image_url": "https://shop.example/wp-content/uploads/2026/05/blue-tee-300x300.jpg",
"short_description": "<p>Crew-neck cotton tee.</p>",
"description": "<p>100% combed ring-spun cotton…</p>",
"price": "29.00",
"regular_price": "39.00",
"sale_price": "29.00",
"currency": "USD",
"stock_status": "instock",
"on_sale": true,
"content_hash": "cd34…12ef",
"modified_at": "2026-05-09T14:30:00+00:00",
"categories": ["tees", "summer"],
"attributes": ["color: blue", "size: S, M, L"]
}
]
}
On first upsert against an agent with site_type =
"generic", the agent is silently switched to
"ecommerce". See
Content sync for the
full rules.
POST /api/v1/wp/products/changed
Single-product delta. Same as posts/changed but for
WC products. HMAC required.
POST /api/v1/wp/coupons/sync
Snapshots the store's active coupons. Idempotent (full-replace).
{
"agent_id": "01HZ…",
"site_url": "https://shop.example.com",
"plugin_version": "2.0.4",
"coupons": [
{ "code": "WELCOME10", "label": "10% off", "discount": "10%", "expires_at": null },
{ "code": "FREESHIP", "label": "5 off your order", "discount": "5", "expires_at": "2026-12-31T00:00:00+00:00" }
]
}
The list is persisted on the agent's woocommerce_products
source under config['coupons']. Subsequent prompt
assembly includes the codes verbatim so the LLM never invents
them.
POST /api/v1/widget/coupon/apply
Called by the widget's Apply button on a
<coupon/> chat block. Auth is the widget JWT
(not a bearer token), throttle 30/min/IP.
{ "code": "WELCOME10" }
Pitchbar resolves the agent's WordPress / WooCommerce source,
HMAC-signs the body with the workspace's shopper signing secret,
and forwards to /wp-json/pitchbar/v1/cart/coupon on
the WP site. The forwarded body includes
conversation_id so the plugin can stage the coupon
in a per-conversation transient.
Plugin endpoints (Pitchbar → plugin)
Base: {wp_site_url}/wp-json/pitchbar/v1/. All routes
are POST. Auth: X-Pitchbar-Signature verified against
the plugin's stored shopper_signing_secret. No
WordPress nonce or cookie auth — the caller is the Pitchbar
server, not a logged-in browser.
Verification path
Every plugin REST controller extends
Pitchbar\Rest\RestController which gates each request
via verifyOrReject($request):
- Read the
X-Pitchbar-Signatureheader. If missing → 401missing_signature. - Read the plugin's stored
shopper_signing_secretfromwp_options. If empty → 401plugin_unconfigured. - Compute the expected signature over the raw request body. If
hash_equalsfails → 401signature_mismatch. - If the timestamp
tis > 300s away fromtime()→ reject withsignature_mismatchtoo (the secret never matched the replayed timestamp).
The error response is always JSON-wrapped:
{ "error": { "code": "signature_mismatch", "message": "HMAC signature did not verify." } }
POST /wp-json/pitchbar/v1/orders/lookup
Looks up a customer's recent WooCommerce orders.
{
"wp_user_id": 42,
"limit": 5,
"order_number": "WC-1234" // optional — filter the result set
}
Response 200:
{
"data": {
"count": 2,
"orders": [
{
"id": 9001,
"number": "9001",
"status": "completed",
"total": "49.99",
"currency": "USD",
"date_created": "2026-05-08T11:23:00+00:00",
"items": [{ "name": "Blue tee", "qty": 1, "sku": "T-BLU-M", "total": "29.00" }],
"tracking_url": "https://aftership.com/…",
"order_url": "https://shop.example/my-account/view-order/9001/"
}
]
}
}
tracking_url is best-effort: the controller checks
_aftership_tracking_url,
_tracking_url, and _st_tracking_link
order meta keys. If none match, the field is an empty string and
the LLM falls back to surfacing order_url.
When WooCommerce isn't active, the controller returns 200 with
{ "orders": [], "count": 0, "note": "woocommerce_inactive" }
so the agent can answer gracefully.
POST /wp-json/pitchbar/v1/leads
Pushes a captured Pitchbar lead back into WordPress.
{
"email": "shopper@example.com",
"name": "Alex Visitor",
"phone": "+1-555-0123",
"conversation_id": "01HZ…",
"pitchbar_lead_id": "01HZ…"
}
Response 200:
{ "data": { "user_id": 199 } }
Behaviour:
- If a WP user with that email exists, update its first_name, billing_phone, and Pitchbar meta keys.
- If no user, create a WC customer (
wc_create_new_customer) when Woo is active, otherwise a WP subscriber viawp_create_userwith a random 24-char password. - Username is derived from the local part of the email + a numeric suffix until unique.
pitchbar_lead_id+pitchbar_conversation_idare written to user meta so the store owner can correlate.
POST /wp-json/pitchbar/v1/cart/coupon
Stages a coupon code for the visitor's next cart load.
{
"code": "WELCOME10",
"conversation_id": "01HZ…"
}
Response 200:
{
"data": {
"applied": false,
"pending": true,
"message": "Coupon staged. It will apply when the visitor opens their cart."
}
}
Behaviour:
- Validates the coupon exists via
new WC_Coupon($code)+get_id()≠ 0. If not, returns 400invalid_coupon. - Stages the code in a 15-minute transient:
pitchbar_pending_coupon_{conversation_id}. - The plugin's
woocommerce_load_cart_from_sessionhook reads the transient on the visitor's next cart load (located by thepitchbar_conv_idcookie the widget writes at init), callsWC()->cart->apply_coupon($code), and clears the transient.
When WooCommerce isn't active, returns 400
woocommerce_inactive. The coupon-card Apply button
in the widget is hidden via a feature flag in that case.
Error envelope
Every Pitchbar endpoint returns errors as:
{ "message": "…", "code": "…", "errors": { "field": ["…"] } }
Every plugin endpoint returns errors as:
{ "error": { "code": "…", "message": "…" } }
The shape difference is intentional — Pitchbar follows Laravel's validator convention, plugin follows WP REST convention. Both sides parse the other transparently.
Throttling
Pitchbar's /api/v1/wp/* routes throttle by API token
(default 60 req/min per token). The plugin's
/wp-json/pitchbar/v1/* routes don't enforce a quota
— the HMAC check + 5-minute replay window already prevents abuse,
and the upstream Pitchbar timeout (5s on
OrderLookupController) bounds runtime.
Status codes summary
| Code | Meaning |
|---|---|
| 200 | Success or "ignored as no-op" (delete of unknown post). |
| 400 | Validation error — body shape wrong, missing field, invalid coupon. |
| 401 | Auth failed (missing token / signature / wrong secret). |
| 403 | Token missing required ability. |
| 404 | Resource doesn't exist on this workspace (most often the agent_id). |
| 422 | Validation error from Laravel's validator (Pitchbar side). |
| 429 | Throttled. |