Si vous avez déjà vu un “401 Unauthorized” sur /wp-json/wc/v3/orders alors que “les identifiants sont bons”, vous avez touché le vrai sujet : l’API REST WooCommerce marche très bien, mais elle est impitoyable sur l’auth, les permissions, et les détails de payload.
Ce tutoriel cible WordPress 6.9.4 (avril 2026), PHP 8.1+ et WooCommerce récent. On va créer des produits et piloter des commandes par API, avec une approche que j’utilise en production : endpoints WooCommerce standards + un petit “bridge” côté WordPress pour valider, journaliser et sécuriser.
Ce qu’on va réaliser
Vous allez mettre en place une intégration API capable de :
- créer et mettre à jour des produits WooCommerce (prix, stock, images, catégories) via WooCommerce REST API ;
- créer des commandes (lignes, client, adresses), puis faire évoluer leur statut (processing/completed/cancelled) ;
- recevoir des événements (webhooks WooCommerce) pour synchroniser un ERP/CRM, un outil logistique, un SaaS d’abonnement, ou un PIM.
À la fin, vous saurez :
- choisir une méthode d’authentification adaptée (API keys, Application Passwords, ou proxy interne) ;
- écrire des requêtes
curlreproductibles pour vos tests ; - ajouter des contrôles côté WordPress (permissions, validation, logs) sans modifier WooCommerce.
Typiquement utile pour une boutique B2C/B2B qui reçoit un flux produits (PIM), ou qui pousse les commandes vers un logisticien. J’ai souvent vu ce besoin sur des sites avec Divi/Elementor/Avada où la mise en page est gérée par builder, mais la donnée produit doit venir d’ailleurs.
Résumé rapide
- Utilisez WooCommerce REST API v3 :
/wp-json/wc/v3/productset/wp-json/wc/v3/orders. - Préférez les API keys WooCommerce pour une intégration serveur-à-serveur, et un bridge interne si vous devez filtrer/valider.
- Créez un produit simple avec
regular_price,manage_stock,stock_quantity,images. - Créez une commande avec
line_items,billing,shipping, puis changez le statut viaPUT /orders/{id}. - Ajoutez des webhooks WooCommerce et vérifiez la signature (HMAC) côté réception.
Quand utiliser cette solution
- Vous avez un système externe (ERP/PIM/WMS) qui doit créer/mettre à jour des produits et des stocks.
- Vous voulez automatiser la création de commandes (ex. commandes importées depuis un marketplace B2B).
- Vous devez synchroniser les statuts de commande vers un outil externe.
- Vous avez besoin d’une couche de contrôle : validation stricte, limitation de champs, journalisation, blocage de certaines actions selon le contexte.
Quand ne PAS utiliser cette solution
- Vous faites une intégration “front” depuis un navigateur : exposer des clés API côté client est un anti-pattern. Passez par un backend.
- Vous avez juste besoin d’importer un catalogue une fois : un import CSV natif WooCommerce ou un plugin d’import (WP All Import, etc.) sera plus simple.
- Vous cherchez une synchronisation temps réel “exactement une fois” : la REST API + webhooks demandent une gestion d’idempotence côté intégrateur (sinon doublons).
- Vous êtes sur un hébergement qui bloque les requêtes entrantes/sortantes (WAF strict) : commencez par valider l’infra.
Avant de commencer (prérequis)
Préparez ceci avant de toucher à l’API :
- WordPress : 6.9.4 (ou plus récent).
- PHP : 8.1 minimum (8.2/8.3 recommandé selon votre hébergeur).
- WooCommerce : version récente (branche 9.x+ en 2026 sur beaucoup de sites). Vérifiez dans Extensions > Extensions installées.
- HTTPS obligatoire sur le domaine (sinon, évitez toute auth basique).
- Un environnement de test (staging) + une sauvegarde. Tester l’API sur production sans filet est une cause classique de désastre (produits en doublon, stocks à zéro, commandes “fantômes”).
- Un thème enfant si vous prévoyez des overrides de templates WooCommerce (pas obligatoire pour l’API, mais souvent nécessaire dans un projet WooCommerce).
Références officielles utiles :
- Documentation officielle WooCommerce REST API
- WordPress REST API Handbook
- Développer des endpoints REST (WordPress)
- Nonces (sécurité WordPress)
- PHP hash_hmac()
Les hooks WooCommerce utilisés
La REST API WooCommerce fonctionne sans que vous écriviez le moindre hook. Par contre, dès que vous synchronisez des données, vous avez besoin de points d’extension fiables.
Dans ce tutoriel, on s’appuie surtout sur des hooks WooCommerce “métier” (produits/commandes) et des hooks WordPress REST :
- woocommerce_new_order : déclenché lors de la création d’une commande (utile pour journaliser / pousser vers un service externe).
- woocommerce_order_status_changed : quand le statut change (processing → completed, etc.).
- woocommerce_product_set_stock : quand le stock est modifié (utile pour audit).
- rest_api_init : pour enregistrer nos endpoints “bridge” (WordPress core).
- rest_pre_dispatch (optionnel) : pour court-circuiter/inspecter des requêtes REST, pratique pour du debug ciblé.
Différence pratique que je vois souvent : un hook WooCommerce réagit à un événement métier (commande, stock), alors qu’un hook REST WordPress réagit au transport HTTP (requête/réponse). Mélanger les deux sans stratégie mène à des boucles (ex. une maj de commande déclenche un webhook qui rappelle votre API, etc.).
Étape 1 : choisir une authentification REST API propre (et la tester)
WooCommerce expose ses endpoints sous :
/wp-json/wc/v3/products/wp-json/wc/v3/orders
Pour l’auth serveur-à-serveur, le plus courant reste Consumer Key / Consumer Secret générés dans WooCommerce :
- WooCommerce > Réglages > Avancé > REST API
- Créer une clé (permissions : Read/Write si nécessaire)
Test rapide en curl (Basic Auth). Faites-le uniquement en HTTPS :
# Remplacez par votre domaine et vos clés
curl -u ck_xxxxxxxxxxxxxxxxx:cs_xxxxxxxxxxxxxxxxx
https://example.com/wp-json/wc/v3/system_status
Si vous obtenez 200 avec un JSON, l’auth est bonne. Si vous obtenez 401 :
- vérifiez que vous êtes bien en HTTPS ;
- vérifiez les permissions de la clé ;
- regardez si un plugin de sécurité/WAF bloque l’Authorization header (cas fréquent).
Alternative moderne côté WordPress : les Application Passwords (utile pour des endpoints WP, moins direct pour WooCommerce v3). Doc officielle :
Étape 2 : créer un mini-plugin “bridge” (endpoints internes + journalisation)
Pourquoi un bridge alors que WooCommerce a déjà ses endpoints ? Parce que dans la vraie vie, vous voulez :
- limiter les champs autorisés (éviter qu’un intégrateur écrase un produit entier) ;
- ajouter une clé partagée (HMAC) différente des clés WooCommerce ;
- journaliser proprement les payloads et les erreurs ;
- faire de l’idempotence (éviter les doublons) via un
external_id.
Créez un fichier wp-content/plugins/bpcab-wc-api-bridge/bpcab-wc-api-bridge.php puis activez le plugin.
<?php
/**
* Plugin Name: BPCAB - Woo API Bridge
* Description: Endpoints internes pour créer/mettre à jour produits et commandes via WooCommerce REST API, avec validation et logs.
* Version: 1.0.0
* Requires at least: 6.9
* Requires PHP: 8.1
*/
defined('ABSPATH') || exit;
add_action('rest_api_init', function () {
// Endpoint interne: création/maj produit via payload restreint
register_rest_route('bpcab/v1', '/product/upsert', [
'methods' => 'POST',
'permission_callback' => 'bpcab_api_bridge_permission',
'callback' => 'bpcab_api_bridge_upsert_product',
]);
// Endpoint interne: création commande via payload restreint
register_rest_route('bpcab/v1', '/order/create', [
'methods' => 'POST',
'permission_callback' => 'bpcab_api_bridge_permission',
'callback' => 'bpcab_api_bridge_create_order',
]);
});
/**
* Vérifie une signature HMAC simple (X-BPCAB-Signature) sur le corps brut.
* Risque si mal fait: si vous logguez des secrets, vous les exposez. Ne logguez jamais la signature.
*/
function bpcab_api_bridge_permission(WP_REST_Request $request): bool {
$secret = defined('BPCAB_API_SHARED_SECRET') ? BPCAB_API_SHARED_SECRET : '';
if ($secret === '') {
return false;
}
$signature = (string) $request->get_header('x-bpcab-signature');
if ($signature === '') {
return false;
}
$raw = $request->get_body(); // Corps brut pour éviter les variations de JSON encodé
$expected = hash_hmac('sha256', $raw, $secret);
// Comparaison en temps constant
return hash_equals($expected, $signature);
}
/**
* Log minimal dans debug.log (WP_DEBUG_LOG doit être activé).
* En production, préférez un logger dédié (Monolog, service externe, etc.).
*/
function bpcab_api_bridge_log(string $message, array $context = []): void {
if (!defined('WP_DEBUG_LOG') || !WP_DEBUG_LOG) {
return;
}
$line = '[BPCAB Bridge] ' . $message;
if (!empty($context)) {
$line .= ' ' . wp_json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
error_log($line);
}
Ce bridge ne remplace pas l’API WooCommerce : il la complète. Vous pouvez l’utiliser depuis un serveur tiers, sans donner vos clés WooCommerce, et en contrôlant strictement ce qui a le droit de passer.
Étape 3 : créer un produit par REST API (simple + images + stock)
On va faire deux choses :
- montrer la requête directe WooCommerce (
/wc/v3/products) ; - implémenter l’upsert côté bridge en utilisant les classes WooCommerce (plus robuste que de “faker” une requête HTTP).
Requête directe WooCommerce (référence)
curl -u ck_xxx:cs_xxx
-H "Content-Type: application/json"
-X POST https://example.com/wp-json/wc/v3/products
-d '{
"name": "T-shirt API - Bleu",
"type": "simple",
"regular_price": "19.90",
"description": "Produit créé via REST API",
"manage_stock": true,
"stock_quantity": 15,
"sku": "TSHIRT-API-BLEU",
"images": [
{ "src": "https://example.com/wp-content/uploads/2026/04/tshirt-bleu.jpg" }
]
}'
Note vécue : l’erreur la plus fréquente ici n’est pas le JSON, c’est l’image. Si l’URL est inaccessible publiquement (hotlink bloqué, 403, redirection), WooCommerce ne pourra pas sideloader correctement.
Upsert produit via le bridge (idempotence par external_id)
On accepte un payload minimal (ex. external_id, name, price, sku, stock, image_url) et on mappe vers WooCommerce.
function bpcab_api_bridge_upsert_product(WP_REST_Request $request): WP_REST_Response {
if (!class_exists('WooCommerce')) {
return new WP_REST_Response(['error' => 'WooCommerce non chargé'], 500);
}
$data = $request->get_json_params();
if (!is_array($data)) {
return new WP_REST_Response(['error' => 'JSON invalide'], 400);
}
$external_id = isset($data['external_id']) ? sanitize_text_field((string) $data['external_id']) : '';
$name = isset($data['name']) ? sanitize_text_field((string) $data['name']) : '';
$sku = isset($data['sku']) ? sanitize_text_field((string) $data['sku']) : '';
$price = isset($data['price']) ? wc_format_decimal((string) $data['price']) : '';
$stock = isset($data['stock']) ? (int) $data['stock'] : null;
$image_url = isset($data['image_url']) ? esc_url_raw((string) $data['image_url']) : '';
if ($external_id === '' || $name === '' || $sku === '' || $price === '') {
return new WP_REST_Response([
'error' => 'Champs requis: external_id, name, sku, price'
], 422);
}
// Recherche par méta external_id (idempotence)
$query = new WP_Query([
'post_type' => 'product',
'post_status' => ['publish', 'draft', 'private'],
'posts_per_page' => 1,
'fields' => 'ids',
'meta_query' => [
[
'key' => '_bpcab_external_id',
'value' => $external_id,
]
],
]);
$product_id = !empty($query->posts) ? (int) $query->posts[0] : 0;
$product = $product_id ? wc_get_product($product_id) : new WC_Product_Simple();
if (!$product) {
return new WP_REST_Response(['error' => 'Impossible d’instancier le produit'], 500);
}
$product->set_name($name);
$product->set_sku($sku);
$product->set_regular_price((string) $price);
if ($stock !== null) {
$product->set_manage_stock(true);
$product->set_stock_quantity($stock);
$product->set_stock_status($stock > 0 ? 'instock' : 'outofstock');
}
// Enregistre le produit pour obtenir un ID
$new_id = $product->save();
// Stocke l'external_id pour les prochains upserts
update_post_meta($new_id, '_bpcab_external_id', $external_id);
// Associe une image si fournie (sideload)
if ($image_url !== '') {
$attachment_id = bpcab_api_bridge_sideload_image($image_url, $new_id);
if ($attachment_id) {
$product = wc_get_product($new_id);
if ($product) {
$product->set_image_id($attachment_id);
$product->save();
}
}
}
bpcab_api_bridge_log('Produit upsert', [
'product_id' => $new_id,
'external_id' => $external_id,
'created_new' => $product_id === 0,
]);
return new WP_REST_Response([
'product_id' => $new_id,
'created_new' => $product_id === 0,
], 200);
}
/**
* Télécharge une image distante et l’attache au produit.
* Attention: si l’URL est volumineuse ou lente, vous pouvez timeouter. Sur gros flux, préférez un traitement async.
*/
function bpcab_api_bridge_sideload_image(string $url, int $post_id): int {
if (!function_exists('media_sideload_image')) {
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
}
// media_sideload_image retourne HTML ou WP_Error, pas l'ID. On passe par un hack contrôlé: récupérer le dernier attachment.
$tmp = media_sideload_image($url, $post_id, null, 'src');
if (is_wp_error($tmp)) {
bpcab_api_bridge_log('Sideload image échoué', ['url' => $url, 'error' => $tmp->get_error_message()]);
return 0;
}
$attachments = get_posts([
'post_type' => 'attachment',
'posts_per_page' => 1,
'post_parent' => $post_id,
'orderby' => 'ID',
'order' => 'DESC',
'fields' => 'ids',
]);
return !empty($attachments) ? (int) $attachments[0] : 0;
}
Résultat attendu : un produit visible dans le back-office, avec un SKU unique, un prix, un stock, et une image à la une.
Étape 4 : mettre à jour un produit (prix, stock, catégories) sans casser le cache
Mettre à jour un produit via REST est simple. Le piège, c’est l’écosystème : cache page, cache objet, indexation, plugins de prix dynamiques, etc.
Requête directe :
curl -u ck_xxx:cs_xxx
-H "Content-Type: application/json"
-X PUT https://example.com/wp-json/wc/v3/products/123
-d '{
"regular_price": "21.90",
"stock_quantity": 9
}'
Via notre bridge, vous pouvez étendre l’upsert pour gérer les catégories par slug (et éviter d’exposer category_id externes). Exemple d’ajout : si category_slugs existe, on résout/ crée.
function bpcab_api_bridge_set_categories_by_slug(int $product_id, array $slugs): void {
$term_ids = [];
foreach ($slugs as $slug) {
$slug = sanitize_title((string) $slug);
if ($slug === '') {
continue;
}
$term = get_term_by('slug', $slug, 'product_cat');
if (!$term) {
// Création contrôlée (à activer seulement si votre métier l’autorise)
$created = wp_insert_term(ucwords(str_replace('-', ' ', $slug)), 'product_cat', ['slug' => $slug]);
if (!is_wp_error($created) && isset($created['term_id'])) {
$term_ids[] = (int) $created['term_id'];
}
continue;
}
$term_ids[] = (int) $term->term_id;
}
if (!empty($term_ids)) {
wp_set_object_terms($product_id, $term_ids, 'product_cat', false);
// Nettoyage cache terme/produit
clean_post_cache($product_id);
}
}
Performance : si vous importez 10 000 produits, évitez de créer des catégories à la volée sans contrôle, et évitez les appels répétés à get_term_by sans cache applicatif.
Étape 5 : créer une commande par API (lignes, taxes, frais, client)
Créer une commande via /wc/v3/orders marche bien, mais le payload doit être cohérent. Le champ line_items accepte des product_id et quantity. Vous pouvez aussi passer un customer_id si vous rattachez à un compte.
Requête directe WooCommerce
curl -u ck_xxx:cs_xxx
-H "Content-Type: application/json"
-X POST https://example.com/wp-json/wc/v3/orders
-d '{
"status": "processing",
"billing": {
"first_name": "Camille",
"last_name": "Durand",
"email": "[email protected]",
"address_1": "10 rue des Tests",
"city": "Lyon",
"postcode": "69000",
"country": "FR"
},
"shipping": {
"first_name": "Camille",
"last_name": "Durand",
"address_1": "10 rue des Tests",
"city": "Lyon",
"postcode": "69000",
"country": "FR"
},
"line_items": [
{ "product_id": 123, "quantity": 2 }
]
}'
Création via bridge (avec external_id et contrôle)
Je recommande fortement d’ajouter une idempotence (sinon un retry réseau vous crée 2 commandes). Ici on stocke _bpcab_external_order_id.
function bpcab_api_bridge_create_order(WP_REST_Request $request): WP_REST_Response {
if (!class_exists('WooCommerce')) {
return new WP_REST_Response(['error' => 'WooCommerce non chargé'], 500);
}
$data = $request->get_json_params();
if (!is_array($data)) {
return new WP_REST_Response(['error' => 'JSON invalide'], 400);
}
$external_id = isset($data['external_id']) ? sanitize_text_field((string) $data['external_id']) : '';
$items = isset($data['items']) && is_array($data['items']) ? $data['items'] : [];
$billing = isset($data['billing']) && is_array($data['billing']) ? $data['billing'] : [];
$shipping = isset($data['shipping']) && is_array($data['shipping']) ? $data['shipping'] : [];
if ($external_id === '' || empty($items)) {
return new WP_REST_Response(['error' => 'Champs requis: external_id, items'], 422);
}
// Idempotence: si la commande existe déjà, on renvoie son ID
$existing = new WP_Query([
'post_type' => 'shop_order',
'post_status' => array_keys(wc_get_order_statuses()),
'posts_per_page' => 1,
'fields' => 'ids',
'meta_query' => [
[
'key' => '_bpcab_external_order_id',
'value' => $external_id,
]
],
]);
if (!empty($existing->posts)) {
$order_id = (int) $existing->posts[0];
return new WP_REST_Response(['order_id' => $order_id, 'created_new' => false], 200);
}
$order = wc_create_order();
if (is_wp_error($order)) {
return new WP_REST_Response(['error' => $order->get_error_message()], 500);
}
// Ajout des lignes
foreach ($items as $item) {
$product_id = isset($item['product_id']) ? (int) $item['product_id'] : 0;
$qty = isset($item['quantity']) ? (int) $item['quantity'] : 0;
if ($product_id <= 0 || $qty <= 0) {
continue;
}
$product = wc_get_product($product_id);
if (!$product) {
continue;
}
$order->add_product($product, $qty);
}
// Adresses (validation minimale)
$order->set_address(bpcab_api_bridge_sanitize_address($billing), 'billing');
$order->set_address(bpcab_api_bridge_sanitize_address($shipping), 'shipping');
// Recalcul totaux (taxes, frais, etc. selon votre config WooCommerce)
$order->calculate_totals();
// Statut initial
$order->set_status('processing', 'Créée via API bridge', true);
$order_id = $order->save();
update_post_meta($order_id, '_bpcab_external_order_id', $external_id);
bpcab_api_bridge_log('Commande créée', [
'order_id' => $order_id,
'external_id' => $external_id,
]);
return new WP_REST_Response(['order_id' => $order_id, 'created_new' => true], 201);
}
function bpcab_api_bridge_sanitize_address(array $address): array {
$allowed = [
'first_name', 'last_name', 'company',
'address_1', 'address_2',
'city', 'state', 'postcode', 'country',
'email', 'phone',
];
$clean = [];
foreach ($allowed as $key) {
if (!isset($address[$key])) {
continue;
}
$value = (string) $address[$key];
if ($key === 'email') {
$clean[$key] = sanitize_email($value);
} elseif ($key === 'country') {
$clean[$key] = strtoupper(sanitize_text_field($value));
} else {
$clean[$key] = sanitize_text_field($value);
}
}
return $clean;
}
Résultat attendu : une commande visible dans WooCommerce > Commandes, avec ses lignes, ses totaux, et un statut Processing.
Étape 6 : faire évoluer une commande (statut, note, remboursement partiel)
Changer un statut via REST est simple :
curl -u ck_xxx:cs_xxx
-H "Content-Type: application/json"
-X PUT https://example.com/wp-json/wc/v3/orders/456
-d '{
"status": "completed"
}'
Pour un remboursement partiel, WooCommerce expose des endpoints de remboursements. Selon votre flux, vous pouvez aussi créer des remboursements via les méthodes PHP (plus contrôlable). Côté API, vérifiez la doc officielle des refunds dans la REST API WooCommerce :
Point d’attention : certains plugins (paiement, facturation, points de fidélité) accrochent woocommerce_order_status_changed. Si vous passez une commande en completed par API, vous déclenchez exactement les mêmes effets qu’un changement manuel. C’est souvent souhaité… jusqu’au jour où vous doublez un envoi d’email ou un export compta.
Étape 7 : recevoir les événements (webhooks WooCommerce) et sécuriser la réception
Les webhooks WooCommerce sont votre “push” : commande créée, produit mis à jour, etc. Vous les configurez dans :
WooCommerce > Réglages > Avancé > Webhooks
Créez un webhook sur l’événement Order updated ou Order created, et pointez vers votre endpoint externe (ou vers un endpoint WordPress si vous faites un relais).
Sécurité : WooCommerce signe les webhooks. Votre endpoint doit vérifier la signature HMAC. Le principe : recalculer hash_hmac sur le corps brut avec le secret du webhook, puis comparer.
function bpcab_verify_wc_webhook_signature(string $raw_body, string $provided_signature, string $secret): bool {
// WooCommerce envoie souvent une signature base64 de l'HMAC SHA256
$computed = base64_encode(hash_hmac('sha256', $raw_body, $secret, true));
return hash_equals($computed, $provided_signature);
}
J’ai souvent croisé un faux négatif ici à cause d’un proxy qui “normalise” le JSON (espaces, ordre des clés). D’où la règle : vérifiez sur le corps brut, pas sur un JSON ré-encodé.
Compatibilité Divi 5 / Elementor / Avada
REST API et page builders cohabitent bien, mais il y a des effets secondaires :
Divi 5
- Si vous utilisez des modules Woo (produits, panier, checkout), ils lisent les données WooCommerce classiques. Les produits créés par API apparaissent normalement.
- Si vous appliquez du cache (Divi Performance), après import massif, forcez une purge (sinon vous “ne voyez pas” le nouveau prix sur le front).
- Si vous avez des templates Divi pour la fiche produit, évitez d’upserter des champs qui pilotent le rendu (ex. attributs utilisés dans des modules dynamiques) sans validation.
Elementor
- Les widgets WooCommerce (Product Title, Price, Add to Cart) se basent sur l’objet produit. API OK.
- Sur des sites avec cache agressif, j’ai vu des incohérences de stock affiché. La cause était un cache fragment/objet non purgé. Après une mise à jour de stock par API, testez en navigation privée.
Avada (Fusion Builder)
- Les éléments WooCommerce d’Avada affichent les produits sans souci.
- Si vous utilisez l’indexation/performance Avada, prévoyez une purge après import (ou planifiez l’import en heures creuses).
Template overrides : si vous avez des overrides WooCommerce dans un thème enfant (yourtheme/woocommerce/...), ils n’impactent pas l’API, mais ils peuvent masquer des erreurs (ex. prix affiché via un champ custom). Gardez une fiche produit “debug” avec un template standard pour comparer.
Le code complet
Voici le plugin complet (bridge + hooks de journalisation). Ajoutez-le tel quel dans wp-content/plugins/bpcab-wc-api-bridge/bpcab-wc-api-bridge.php.
Ajoutez aussi une constante secrète dans wp-config.php (exemple plus bas).
<?php
/**
* Plugin Name: BPCAB - Woo API Bridge
* Description: Endpoints internes pour créer/mettre à jour produits et commandes via WooCommerce, avec validation, idempotence et logs.
* Version: 1.0.0
* Requires at least: 6.9
* Requires PHP: 8.1
*/
defined('ABSPATH') || exit;
add_action('rest_api_init', function () {
register_rest_route('bpcab/v1', '/product/upsert', [
'methods' => 'POST',
'permission_callback' => 'bpcab_api_bridge_permission',
'callback' => 'bpcab_api_bridge_upsert_product',
]);
register_rest_route('bpcab/v1', '/order/create', [
'methods' => 'POST',
'permission_callback' => 'bpcab_api_bridge_permission',
'callback' => 'bpcab_api_bridge_create_order',
]);
});
add_action('woocommerce_new_order', function ($order_id) {
// Journalisation légère: utile pour corréler avec vos appels API
bpcab_api_bridge_log('Hook woocommerce_new_order', ['order_id' => (int) $order_id]);
}, 10, 1);
add_action('woocommerce_order_status_changed', function ($order_id, $old_status, $new_status) {
bpcab_api_bridge_log('Hook woocommerce_order_status_changed', [
'order_id' => (int) $order_id,
'old_status' => (string) $old_status,
'new_status' => (string) $new_status,
]);
}, 10, 3);
add_action('woocommerce_product_set_stock', function ($product) {
if (!is_object($product) || !method_exists($product, 'get_id')) {
return;
}
bpcab_api_bridge_log('Hook woocommerce_product_set_stock', [
'product_id' => (int) $product->get_id(),
]);
}, 10, 1);
function bpcab_api_bridge_permission(WP_REST_Request $request): bool {
$secret = defined('BPCAB_API_SHARED_SECRET') ? (string) BPCAB_API_SHARED_SECRET : '';
if ($secret === '') {
return false;
}
$signature = (string) $request->get_header('x-bpcab-signature');
if ($signature === '') {
return false;
}
$raw = $request->get_body();
$expected = hash_hmac('sha256', $raw, $secret);
return hash_equals($expected, $signature);
}
function bpcab_api_bridge_log(string $message, array $context = []): void {
if (!defined('WP_DEBUG_LOG') || !WP_DEBUG_LOG) {
return;
}
$line = '[BPCAB Bridge] ' . $message;
if (!empty($context)) {
$line .= ' ' . wp_json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
error_log($line);
}
function bpcab_api_bridge_upsert_product(WP_REST_Request $request): WP_REST_Response {
if (!class_exists('WooCommerce')) {
return new WP_REST_Response(['error' => 'WooCommerce non chargé'], 500);
}
$data = $request->get_json_params();
if (!is_array($data)) {
return new WP_REST_Response(['error' => 'JSON invalide'], 400);
}
$external_id = isset($data['external_id']) ? sanitize_text_field((string) $data['external_id']) : '';
$name = isset($data['name']) ? sanitize_text_field((string) $data['name']) : '';
$sku = isset($data['sku']) ? sanitize_text_field((string) $data['sku']) : '';
$price = isset($data['price']) ? wc_format_decimal((string) $data['price']) : '';
$stock = array_key_exists('stock', $data) ? (int) $data['stock'] : null;
$image_url = isset($data['image_url']) ? esc_url_raw((string) $data['image_url']) : '';
$cats = isset($data['category_slugs']) && is_array($data['category_slugs']) ? $data['category_slugs'] : [];
if ($external_id === '' || $name === '' || $sku === '' || $price === '') {
return new WP_REST_Response(['error' => 'Champs requis: external_id, name, sku, price'], 422);
}
$query = new WP_Query([
'post_type' => 'product',
'post_status' => ['publish', 'draft', 'private'],
'posts_per_page' => 1,
'fields' => 'ids',
'meta_query' => [
[
'key' => '_bpcab_external_id',
'value' => $external_id,
]
],
]);
$product_id = !empty($query->posts) ? (int) $query->posts[0] : 0;
$product = $product_id ? wc_get_product($product_id) : new WC_Product_Simple();
if (!$product) {
return new WP_REST_Response(['error' => 'Produit introuvable / instanciation impossible'], 500);
}
$product->set_name($name);
$product->set_sku($sku);
$product->set_regular_price((string) $price);
if ($stock !== null) {
$product->set_manage_stock(true);
$product->set_stock_quantity($stock);
$product->set_stock_status($stock > 0 ? 'instock' : 'outofstock');
}
$new_id = $product->save();
update_post_meta($new_id, '_bpcab_external_id', $external_id);
if (!empty($cats)) {
bpcab_api_bridge_set_categories_by_slug($new_id, $cats);
}
if ($image_url !== '') {
$attachment_id = bpcab_api_bridge_sideload_image($image_url, $new_id);
if ($attachment_id) {
$p = wc_get_product($new_id);
if ($p) {
$p->set_image_id($attachment_id);
$p->save();
}
}
}
bpcab_api_bridge_log('Produit upsert', [
'product_id' => $new_id,
'external_id' => $external_id,
]);
return new WP_REST_Response(['product_id' => $new_id], 200);
}
function bpcab_api_bridge_set_categories_by_slug(int $product_id, array $slugs): void {
$term_ids = [];
foreach ($slugs as $slug) {
$slug = sanitize_title((string) $slug);
if ($slug === '') {
continue;
}
$term = get_term_by('slug', $slug, 'product_cat');
if (!$term) {
$created = wp_insert_term(ucwords(str_replace('-', ' ', $slug)), 'product_cat', ['slug' => $slug]);
if (!is_wp_error($created) && isset($created['term_id'])) {
$term_ids[] = (int) $created['term_id'];
}
continue;
}
$term_ids[] = (int) $term->term_id;
}
if (!empty($term_ids)) {
wp_set_object_terms($product_id, $term_ids, 'product_cat', false);
clean_post_cache($product_id);
}
}
function bpcab_api_bridge_sideload_image(string $url, int $post_id): int {
if (!function_exists('media_sideload_image')) {
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
}
$result = media_sideload_image($url, $post_id, null, 'src');
if (is_wp_error($result)) {
bpcab_api_bridge_log('Sideload image échoué', ['url' => $url, 'error' => $result->get_error_message()]);
return 0;
}
$attachments = get_posts([
'post_type' => 'attachment',
'posts_per_page' => 1,
'post_parent' => $post_id,
'orderby' => 'ID',
'order' => 'DESC',
'fields' => 'ids',
]);
return !empty($attachments) ? (int) $attachments[0] : 0;
}
function bpcab_api_bridge_create_order(WP_REST_Request $request): WP_REST_Response {
if (!class_exists('WooCommerce')) {
return new WP_REST_Response(['error' => 'WooCommerce non chargé'], 500);
}
$data = $request->get_json_params();
if (!is_array($data)) {
return new WP_REST_Response(['error' => 'JSON invalide'], 400);
}
$external_id = isset($data['external_id']) ? sanitize_text_field((string) $data['external_id']) : '';
$items = isset($data['items']) && is_array($data['items']) ? $data['items'] : [];
$billing = isset($data['billing']) && is_array($data['billing']) ? $data['billing'] : [];
$shipping = isset($data['shipping']) && is_array($data['shipping']) ? $data['shipping'] : [];
if ($external_id === '' || empty($items)) {
return new WP_REST_Response(['error' => 'Champs requis: external_id, items'], 422);
}
$existing = new WP_Query([
'post_type' => 'shop_order',
'post_status' => array_keys(wc_get_order_statuses()),
'posts_per_page' => 1,
'fields' => 'ids',
'meta_query' => [
[
'key' => '_bpcab_external_order_id',
'value' => $external_id,
]
],
]);
if (!empty($existing->posts)) {
return new WP_REST_Response(['order_id' => (int) $existing->posts[0], 'created_new' => false], 200);
}
$order = wc_create_order();
if (is_wp_error($order)) {
return new WP_REST_Response(['error' => $order->get_error_message()], 500);
}
foreach ($items as $item) {
$product_id = isset($item['product_id']) ? (int) $item['product_id'] : 0;
$qty = isset($item['quantity']) ? (int) $item['quantity'] : 0;
if ($product_id <= 0 || $qty <= 0) {
continue;
}
$product = wc_get_product($product_id);
if (!$product) {
continue;
}
$order->add_product($product, $qty);
}
$order->set_address(bpcab_api_bridge_sanitize_address($billing), 'billing');
$order->set_address(bpcab_api_bridge_sanitize_address($shipping), 'shipping');
$order->calculate_totals();
$order->set_status('processing', 'Créée via API bridge', true);
$order_id = $order->save();
update_post_meta($order_id, '_bpcab_external_order_id', $external_id);
bpcab_api_bridge_log('Commande créée', ['order_id' => $order_id, 'external_id' => $external_id]);
return new WP_REST_Response(['order_id' => $order_id, 'created_new' => true], 201);
}
function bpcab_api_bridge_sanitize_address(array $address): array {
$allowed = [
'first_name', 'last_name', 'company',
'address_1', 'address_2',
'city', 'state', 'postcode', 'country',
'email', 'phone',
];
$clean = [];
foreach ($allowed as $key) {
if (!isset($address[$key])) {
continue;
}
$value = (string) $address[$key];
if ($key === 'email') {
$clean[$key] = sanitize_email($value);
} elseif ($key === 'country') {
$clean[$key] = strtoupper(sanitize_text_field($value));
} else {
$clean[$key] = sanitize_text_field($value);
}
}
return $clean;
}
Ajoutez la constante secrète dans wp-config.php :
// Secret partagé pour signer les requêtes vers /wp-json/bpcab/v1/*
// Générer une valeur longue et aléatoire (au moins 32 caractères).
define('BPCAB_API_SHARED_SECRET', 'remplacez-par-une-cle-longue-et-aleatoire');
Exemple d’appel vers le bridge (signature HMAC SHA256 hex) :
BODY='{"external_id":"PIM-1001","name":"Mug API","sku":"MUG-API","price":"12.50","stock":30}'
SIG=$(php -r "echo hash_hmac('sha256', '$BODY', 'remplacez-par-une-cle-longue-et-aleatoire');")
curl -H "Content-Type: application/json"
-H "X-BPCAB-Signature: $SIG"
-X POST https://example.com/wp-json/bpcab/v1/product/upsert
-d "$BODY"
Explication du code
Pourquoi HMAC au lieu d’un simple token
Un token statique dans un header (type X-API-Key) fuit plus facilement (logs, proxies). HMAC signe le corps : si le payload change, la signature ne correspond plus. Ça ne remplace pas HTTPS, mais ça évite une classe d’erreurs et d’abus.
Pourquoi instancier WC_Product_Simple au lieu d’appeler l’API WooCommerce depuis WordPress
J’ai vu des projets qui font des requêtes HTTP vers localhost/wp-json/wc/v3 depuis le même WordPress. Ça marche… puis ça timeoute, ça boucle, ou ça se casse avec un plugin de sécurité. Utiliser les classes WooCommerce (CRUD) est plus direct, plus rapide, et plus débogable.
Idempotence via meta external_id
Sans idempotence, un retry réseau (ou un worker qui relance) crée des doublons. Ici :
_bpcab_external_idsur les produits ;_bpcab_external_order_idsur les commandes.
Edge case : si vous supprimez un produit/commande, l’external_id disparaît. Si vous voulez “réserver” un external_id, stockez-le dans une table dédiée (plus avancé).
Images via media_sideload_image
media_sideload_image() est pratique, mais pas idéale pour un import massif. Elle télécharge l’image à la volée, ce qui peut saturer PHP-FPM. Pour un gros flux, je passe souvent par :
- un traitement asynchrone (Action Scheduler, queue) ;
- ou une étape séparée “import médias”.
Référence WordPress (médias) :
Vérification finale
- Activez
WP_DEBUG_LOGsur staging et vérifiezwp-content/debug.log(vous devez voir les logs [BPCAB Bridge]). - Testez la création produit via bridge, puis vérifiez :
- le SKU est bien présent et unique ;
- le stock s’affiche correctement ;
- l’image est bien dans la médiathèque et attachée au produit.
- Testez la création commande via bridge, puis vérifiez :
- les lignes sont correctes ;
- les totaux sont cohérents ;
- le statut est processing.
- Si vous utilisez un cache (serveur, plugin, builder), purge + test en navigation privée.
Si ça ne marche pas
Voici un tableau de diagnostic basé sur ce que je dépanne le plus souvent.
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| 401 Unauthorized sur /wc/v3/* | Header Authorization bloqué / mauvaise clé | Tester /system_status en curl, regarder logs WAF | HTTPS + autoriser Authorization header, régénérer une clé REST |
| Produit créé sans image | URL image inaccessible (403/redirect/hotlink) | Ouvrir l’URL dans un navigateur non connecté | Héberger l’image publiquement, ou importer via média d’abord |
| Stock affiché incorrect | Cache / plugin de stock / pas de manage_stock | Back-office produit + test en privé | Purger cache, vérifier manage_stock et stock_status |
| Commande créée mais totaux à 0 | calculate_totals non appelé / lignes invalides | Inspecter line_items / vérifier produits existants | Appeler calculate_totals, valider product_id, qty |
| Signature HMAC refusée (bridge) | Corps modifié (espaces, encodage) ou mauvais secret | Logger côté client le BODY exact envoyé | Signer le corps brut exact, vérifier secret, éviter JSON reformaté |
- Erreur classique : copier le code dans
functions.phpd’un thème parent, puis perdre le code à la mise à jour. Utilisez un plugin ou un thème enfant. - Erreur classique : activer le plugin mais oublier la constante
BPCAB_API_SHARED_SECRET→ toutes les requêtes sont refusées. - Si vous avez une page blanche, vérifiez d’abord la version PHP (8.1+), puis
debug.log. Un point-virgule manquant arrive plus vite qu’on ne le croit.
Pièges et erreurs courantes
| Erreur | Cause | Solution |
|---|---|---|
| Tester sur production “pour aller vite” | Pas de staging, pas d’idempotence | Staging + external_id + logs + plan de rollback |
| Utiliser un ancien snippet (pré-HP 8) | Code obsolète (warnings/fatals) | Cibler PHP 8.1+, activer WP_DEBUG sur staging |
| Créer des commandes sans calculate_totals() | Totaux incohérents | Appeler calculate_totals() après ajout des lignes |
| Changer le statut en “completed” trop tôt | Déclenche emails/exports/plugins | Définir un statut intermédiaire + workflow clair |
| Importer des images massivement en synchrone | Timeouts, saturation CPU/RAM | Queue asynchrone (Action Scheduler) ou import médias séparé |
| Confondre endpoints WP et WooCommerce | /wp-json/wp/v2 vs /wp-json/wc/v3 | Documenter vos routes et vos scopes d’auth |
Variante / alternative
Alternative 1 : utiliser uniquement l’API WooCommerce (sans bridge)
Si votre intégration est simple et que vous contrôlez le serveur appelant, vous pouvez vous contenter de :
- API keys WooCommerce (Read/Write) ;
- IP allowlist au niveau firewall ;
- webhooks WooCommerce vers votre backend.
C’est plus rapide à mettre en place. Vous perdez le contrôle fin (validation/idempotence/logs côté WP), mais pour un MVP, c’est acceptable.
Alternative 2 : plugin d’automatisation
Pour des flux basiques (quand une commande est créée → envoyer vers X), un plugin d’automatisation peut suffire. Mais dès que vous devez gérer des retries, des signatures, et des mappings complexes, vous revenez vite à du code.
Conseils sécurité, performance et maintenance
- Ne mettez jamais une Consumer Secret dans du JavaScript front. Jamais.
- Limitez les permissions : créez une clé REST dédiée à l’intégration, pas celle d’un admin “fourre-tout”.
- Rate limiting : si vous subissez des bursts (import), implémentez une limitation côté client, et/ou côté serveur (WAF, reverse proxy).
- Logs : logguez les IDs (external_id, product_id, order_id), pas les secrets ni les données sensibles (emails/phones en clair) si vous pouvez l’éviter.
- Idempotence partout : produits, commandes, remboursements. Les retries arrivent toujours (timeouts, 502, etc.).
- Performance import : batcher les appels, éviter les sideload images en synchrone, et surveiller la table
wp_postmeta(elle grossit vite). - Compatibilité : après mise à jour WooCommerce, re-testez vos endpoints bridge. Les classes CRUD sont stables, mais les plugins autour peuvent changer le comportement (taxes, stock, statuts).
Ressources
- WooCommerce REST API (docs)
- Authentification WooCommerce REST API
- WordPress REST API Handbook
- register_rest_route()
- wp_json_encode()
- media_sideload_image()
- Code source WooCommerce (GitHub)
- PHP hash_equals()
- PHP hash_hmac()
- Sécurité côté WordPress (APIs)
FAQ
Quelle différence entre /wc/v2 et /wc/v3 ?
Sur la plupart des boutiques modernes, /wc/v3 est la référence pour produits/commandes. Si vous tombez sur un ancien intégrateur en v2, migrez vers v3 et revalidez les champs, surtout autour des taxes, des images et des statuts.
Est-ce que je peux créer des variations (produits variables) par API ?
Oui, via les endpoints des variations (/products/{id}/variations) ou via CRUD WooCommerce côté PHP. Commencez par maîtriser les produits simples, puis ajoutez attributs + variations. Les erreurs de mapping d’attributs sont une source énorme de “variations invisibles”.
Pourquoi mon appel REST marche en local mais pas en production ?
Très souvent : un WAF qui supprime le header Authorization, un proxy qui modifie le corps JSON (signature cassée), ou un HTTPS mal configuré. Faites un test curl depuis un serveur externe et comparez les headers.
Est-ce que je dois régénérer les permaliens ?
Pas pour l’API REST en général. Mais si vous avez changé des règles de réécriture, ou si un plugin a cassé la réécriture, un “Enregistrer” dans Réglages > Permaliens peut débloquer des 404 sur /wp-json/.
Le bridge est-il obligatoire ?
Non. Il devient utile dès que vous voulez : idempotence, validation stricte, journalisation, ou éviter de partager les clés WooCommerce avec un tiers.
Comment éviter les doublons de produits si le SKU existe déjà ?
Ne comptez pas uniquement sur le SKU si votre source externe peut le changer. Dans les intégrations sérieuses, je garde un external_id stable (PIM ID) et je mappe SKU comme un champ “modifiable”.
Les webhooks WooCommerce sont-ils fiables à 100% ?
Ils sont corrects, mais pas “exactement une fois”. Vous devez gérer les retries et l’idempotence côté réception. Stockez un identifiant d’événement (ou un hash du payload) pour ignorer les doublons.
Comment déboguer proprement une requête REST ?
Gardez une commande curl de référence, logguez l’ID corrélé, et activez temporairement WP_DEBUG_LOG sur staging. Évitez de logguer des données personnelles en clair.
Puis-je utiliser un plugin de snippets au lieu d’un plugin dédié ?
Vous pouvez, mais j’ai vu trop de sites où un snippet cassé bloque l’admin, ou où une mise à jour désactive le snippet. Un mini-plugin versionné est plus stable et déployable.
Que faire si un plugin modifie les totaux lors de calculate_totals() ?
C’est normal : coupons, taxes, frais, plugins B2B, etc. Si vous devez imposer un total, vous entrez dans un sujet plus complexe (lignes de frais, overrides, ou création de commande “manuelle”). Testez avec la config fiscale réelle de la boutique.
Puis-je exposer ces endpoints bridge publiquement ?
Exposez-les uniquement si vous avez : HTTPS, secret long, rate limiting, logs, et idéalement une allowlist IP. Sinon, vous ouvrez une surface d’attaque directe sur la création de produits/commandes.