Si vous avez déjà vu “cURL error 28: Operation timed out” dans vos logs, vous savez que “appeler une API” depuis WordPress n’est pas le vrai problème. Le vrai problème, c’est de le faire sans bloquer le front, sans doublons, avec des retries, des verrous, des signatures de webhooks, et des traces exploitables quand ça casse à 2h du matin.

Ce qu’on va construire

Vous allez mettre en place une intégration complète entre WordPress 6.9.4 (avril 2026) et une API externe, avec deux canaux complémentaires :

  • Pull : WordPress interroge l’API via REST (synchronisation planifiée).
  • Push : l’API externe appelle WordPress via webhook (événements en temps réel).

Résultat final :

  • Un MU-plugin (chargé en priorité) qui encapsule :
    • un client HTTP robuste basé sur l’API HTTP WordPress,
    • un job de sync WP-Cron avec verrou anti-concurrence,
    • un endpoint REST pour recevoir les webhooks avec signature HMAC,
    • un traitement découplé (queue) via Action Scheduler si dispo.
  • Une table (optionnelle) ou des post meta pour stocker l’ID externe et l’état de sync.
  • Un shortcode (pour les builders) qui affiche les données synchronisées.

À la fin, vous saurez :

  • Concevoir une intégration idempotente (mêmes événements = même résultat).
  • Éviter les pièges classiques : timeouts, cache, WP-Cron non déclenché, webhooks non authentifiés.
  • Déboguer proprement (logs structurés, Site Health, traces minimales).

Résumé rapide

  • Utilisez l’API HTTP WordPress (wp_remote_request()) avec timeouts courts et retries contrôlés.
  • Pour les syncs : WP-Cron + verrou transient + pagination + idempotence.
  • Pour les webhooks : endpoint REST + HMAC + rejet des payloads trop gros + réponse rapide.
  • Découplez le traitement : Action Scheduler si dispo, sinon fallback WP-Cron.
  • Ne stockez jamais un secret de webhook dans le code : utilisez wp-config.php ou variables d’environnement.

Quand utiliser cette solution

  • Vous devez synchroniser des objets externes (produits, événements, tickets, CRM) dans WordPress.
  • Vous avez besoin d’un cache local (post/meta) pour éviter de requêter l’API à chaque page.
  • Vous voulez un mode hybride : sync planifiée + webhooks pour les changements.
  • Vous travaillez avec des sites sous Elementor/Divi/Avada et vous voulez exposer les données via shortcode/widget.

Quand ne PAS utiliser cette solution

  • Vous avez juste besoin d’afficher une donnée ponctuelle : un simple bloc “HTML personnalisé” avec fetch côté navigateur peut suffire (mais attention CORS et secrets).
  • Vous devez traiter des volumes massifs (millions d’objets) : WordPress peut servir de vitrine, mais la sync complète doit être externalisée (ETL, worker, DB dédiée).
  • Votre hébergeur bloque les requêtes sortantes ou impose des timeouts très agressifs : vous devrez passer par un proxy ou une fonction serverless.
  • Vous ne pouvez pas sécuriser les webhooks (pas de secret partagé, pas d’IP allowlist, pas de signature) : vous ouvrez une porte aux injections d’événements.

Avant de commencer (prérequis)

Avant de toucher au code :

  • Sauvegarde complète (fichiers + base) et test sur une copie (staging).
  • WordPress 6.9.4+, PHP 8.1+.
  • Accès FTP/SSH ou au gestionnaire de fichiers de l’hébergeur.
  • Accès aux logs PHP et idéalement à un outil type Query Monitor.

Outils utiles :

  • WP-CLI (si possible) pour déclencher les cron et inspecter.
  • Un client HTTP (curl, Postman) pour simuler les webhooks.

Précautions sécurité :

  • Ne mettez jamais de secret dans un repo public.
  • Les endpoints webhook doivent être authentifiés (signature HMAC) et durcis (limite de taille, rate limiting côté infra si possible).

Docs officielles (à garder ouvertes) :


Étape 1 : Choisir votre modèle d’intégration (pull REST, push webhook, ou hybride)

Je vois souvent des intégrations qui font un wp_remote_get() à chaque affichage de page. Ça marche en dev, puis ça explose en prod : latence, quota API, timeouts, pages qui “moulinent”.

Choisissez explicitement :

  • Pull (WP interroge l’API) : stable, contrôlable, mais pas temps réel.
  • Push (webhook) : temps réel, mais vous devez sécuriser et absorber les bursts.
  • Hybride : recommandé dans la plupart des cas (webhook + resync planifiée).

Décision technique pour ce tutoriel : hybride. Le webhook déclenche une mise à jour rapide, et WP-Cron fait un “filet de sécurité” (resync toutes les X minutes/heures).

Résultat attendu après cette étape : vous avez un plan clair sur les flux et où stocker les données (post/meta ou table).

Étape 2 : Créer un MU-plugin structuré (services + DI légère)

Collez ce code au mauvais endroit (par exemple dans functions.php d’un thème parent) et vous allez le perdre à la prochaine mise à jour. J’ai vu ça trop souvent.

On va créer un MU-plugin : il se charge automatiquement et avant la plupart des plugins.

2.1 Créer les fichiers

Sur votre serveur :

  1. Créez le dossier : wp-content/mu-plugins/ (s’il n’existe pas).
  2. Créez : wp-content/mu-plugins/bpcab-external-api.php
  3. Créez un sous-dossier : wp-content/mu-plugins/bpcab-external-api/
  4. Créez : wp-content/mu-plugins/bpcab-external-api/bootstrap.php
  5. Créez : wp-content/mu-plugins/bpcab-external-api/src/

2.2 Fichier loader MU-plugin

<?php
/**
 * Plugin Name: BPCAB External API (MU)
 * Description: Intégration API externe (REST + webhooks) — WordPress 6.9.4+, PHP 8.1+
 */

declare(strict_types=1);

if (!defined('ABSPATH')) {
	exit;
}

require_once __DIR__ . '/bpcab-external-api/bootstrap.php';

2.3 Bootstrap + mini container

Dans wp-content/mu-plugins/bpcab-external-api/bootstrap.php :

<?php
declare(strict_types=1);

if (!defined('ABSPATH')) {
	exit;
}

// Autoload ultra simple (évite de dépendre de Composer pour le tuto)
spl_autoload_register(static function (string $class): void {
	$prefix = 'BPCAB\ExternalApi\';
	if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
		return;
	}
	$relative = substr($class, strlen($prefix));
	$relative = str_replace('\', DIRECTORY_SEPARATOR, $relative);
	$file = __DIR__ . '/src/' . $relative . '.php';
	if (is_readable($file)) {
		require_once $file;
	}
});

add_action('plugins_loaded', static function (): void {
	// Initialisation tardive : tous les plugins sont chargés, REST API prête.
	$container = new BPCABExternalApiContainer();

	$container->get('plugin')->register();
}, 20);

Créez src/Container.php :

<?php
declare(strict_types=1);

namespace BPCABExternalApi;

use BPCABExternalApiServiceApiClient;
use BPCABExternalApiServiceCronSync;
use BPCABExternalApiServiceLogger;
use BPCABExternalApiServiceQueue;
use BPCABExternalApiServiceWebhookController;
use BPCABExternalApiServicePlugin;

final class Container
{
	/** @var array<string, mixed> */
	private array $services = [];

	public function get(string $id): mixed
	{
		if (isset($this->services[$id])) {
			return $this->services[$id];
		}

		// Fabrique très simple. Pour un projet plus gros, passez à Composer + PSR-11.
		return $this->services[$id] = match ($id) {
			'logger' => new Logger(),
			'api'    => new ApiClient($this->get('logger')),
			'queue'  => new Queue($this->get('logger')),
			'cron'   => new CronSync($this->get('api'), $this->get('queue'), $this->get('logger')),
			'webhook'=> new WebhookController($this->get('queue'), $this->get('logger')),
			'plugin' => new Plugin($this->get('cron'), $this->get('webhook')),
			default  => throw new RuntimeException('Service inconnu: ' . $id),
		};
	}
}

Résultat attendu : WordPress charge le MU-plugin sans erreur fatale. Si vous cassez une parenthèse, vous verrez un écran blanc. Testez tout de suite.

Étape 3 : Implémenter un client REST robuste (WP_Http + retries + timeouts)

Voici ce qui se passe en coulisses : l’API HTTP WordPress gère cURL/streams et les proxies, mais elle ne vous protège pas d’un endpoint lent. Vous devez définir timeout, user-agent, et une stratégie de retry minimale.

3.1 Logger minimal (error_log + contexte)

Créez src/Service/Logger.php :

<?php
declare(strict_types=1);

namespace BPCABExternalApiService;

final class Logger
{
	public function info(string $message, array $context = []): void
	{
		$this->log('INFO', $message, $context);
	}

	public function warning(string $message, array $context = []): void
	{
		$this->log('WARN', $message, $context);
	}

	public function error(string $message, array $context = []): void
	{
		$this->log('ERROR', $message, $context);
	}

	private function log(string $level, string $message, array $context): void
	{
		if (!defined('WP_DEBUG') || WP_DEBUG !== true) {
			// En prod, vous pouvez brancher un logger plus évolué (Monolog via plugin, syslog, etc.)
		}

		$payload = [
			'level'   => $level,
			'message' => $message,
			'context' => $context,
		];

		// Ne logguez jamais de secrets (tokens, signatures).
		error_log('[BPCAB ExternalApi] ' . wp_json_encode($payload));
	}
}

3.2 Client API

Créez src/Service/ApiClient.php :

<?php
declare(strict_types=1);

namespace BPCABExternalApiService;

use WP_Error;

final class ApiClient
{
	private Logger $logger;

	// Exemple : base URL d’une API
	private string $baseUrl = 'https://api.exemple.tld/v1';

	public function __construct(Logger $logger)
	{
		$this->logger = $logger;
	}

	/**
	 * Récupère une liste d’objets avec pagination.
	 *
	 * @return array{items: array<array>, next_cursor: string|null}
	 */
	public function fetchItems(?string $cursor = null): array
	{
		$query = [];
		if ($cursor) {
			$query['cursor'] = $cursor;
		}

		$url = add_query_arg($query, $this->baseUrl . '/items');

		$response = $this->requestWithRetry('GET', $url, [
			'headers' => $this->authHeaders(),
		]);

		$code = wp_remote_retrieve_response_code($response);
		$body = wp_remote_retrieve_body($response);

		if ($code < 200 || $code >= 300) {
			$this->logger->error('Réponse API non-2xx', [
				'code' => $code,
				'body_excerpt' => substr((string) $body, 0, 500),
			]);
			return ['items' => [], 'next_cursor' => null];
		}

		$data = json_decode((string) $body, true);
		if (!is_array($data)) {
			$this->logger->error('JSON invalide', ['body_excerpt' => substr((string) $body, 0, 200)]);
			return ['items' => [], 'next_cursor' => null];
		}

		$items = isset($data['items']) && is_array($data['items']) ? $data['items'] : [];
		$next = isset($data['next_cursor']) && is_string($data['next_cursor']) ? $data['next_cursor'] : null;

		return ['items' => $items, 'next_cursor' => $next];
	}

	/**
	 * @return array|WP_Error
	 */
	private function requestWithRetry(string $method, string $url, array $args)
	{
		$defaults = [
			'method'      => $method,
			'timeout'     => 8, // Court : on préfère retenter plutôt que bloquer PHP-FPM
			'redirection' => 2,
			'headers'     => [],
			'user-agent'  => 'WordPress/' . get_bloginfo('version') . '; ' . home_url('/'),
		];

		$args = array_replace_recursive($defaults, $args);

		$attempts = 0;
		$maxAttempts = 3;

		do {
			$attempts++;

			$response = wp_remote_request($url, $args);

			if (!is_wp_error($response)) {
				return $response;
			}

			/** @var WP_Error $response */
			$code = $response->get_error_code();
			$msg  = $response->get_error_message();

			$this->logger->warning('Erreur HTTP (tentative)', [
				'attempt' => $attempts,
				'code'    => $code,
				'message' => $msg,
				'url'     => $url,
			]);

			// Backoff très simple (évite de marteler l’API)
			usleep(200000 * $attempts); // 200ms, 400ms, 600ms

		} while ($attempts < $maxAttempts);

		return $response;
	}

	private function authHeaders(): array
	{
		// Exemple avec token ; stockez-le hors DB si possible.
		$token = defined('BPCAB_EXTERNAL_API_TOKEN') ? (string) BPCAB_EXTERNAL_API_TOKEN : '';

		return [
			'Authorization' => 'Bearer ' . $token,
			'Accept'        => 'application/json',
		];
	}
}

Où mettre le token

Dans wp-config.php (ou mieux : variable d’environnement injectée par l’hébergeur) :

<?php
// wp-config.php
define('BPCAB_EXTERNAL_API_TOKEN', 'remplacez-par-un-token-long-et-secret');

Résultat attendu : aucune requête n’est encore lancée, mais le client est prêt, avec timeouts et retries.

Étape 4 : Synchroniser via WP-Cron (verrous, pagination, idempotence)

WP-Cron n’est pas un vrai cron. Il dépend du trafic. Sur des sites faibles en visites, j’ai souvent vu des syncs “qui ne tournent jamais”. On va quand même l’utiliser, mais proprement, et vous pourrez le remplacer par un cron système.

4.1 Service CronSync

Créez src/Service/CronSync.php :

<?php
declare(strict_types=1);

namespace BPCABExternalApiService;

final class CronSync
{
	private const CRON_HOOK = 'bpcab_external_api_sync';
	private const LOCK_KEY  = 'bpcab_external_api_sync_lock';
	private const LOCK_TTL  = 120; // secondes

	private ApiClient $api;
	private Queue $queue;
	private Logger $logger;

	public function __construct(ApiClient $api, Queue $queue, Logger $logger)
	{
		$this->api = $api;
		$this->queue = $queue;
		$this->logger = $logger;
	}

	public function register(): void
	{
		add_filter('cron_schedules', [$this, 'addSchedule']);
		add_action(self::CRON_HOOK, [$this, 'run']);

		// Planification : toutes les 10 minutes (exemple)
		if (!wp_next_scheduled(self::CRON_HOOK)) {
			wp_schedule_event(time() + 60, 'bpcab_10min', self::CRON_HOOK);
		}
	}

	public function addSchedule(array $schedules): array
	{
		$schedules['bpcab_10min'] = [
			'interval' => 10 * 60,
			'display'  => 'Toutes les 10 minutes (BPCAB)',
		];
		return $schedules;
	}

	public function run(): void
	{
		if (!$this->acquireLock()) {
			$this->logger->warning('Sync déjà en cours, abandon (lock actif)');
			return;
		}

		try {
			$this->logger->info('Début sync cron');

			$cursor = null;
			$pages = 0;

			do {
				$pages++;
				$result = $this->api->fetchItems($cursor);
				$items = $result['items'];
				$cursor = $result['next_cursor'];

				foreach ($items as $item) {
					// Idempotence : on pousse un job "upsert" basé sur l’ID externe
					$this->queue->enqueueUpsert($item);
				}

				// Garde-fou : évite une boucle infinie si l’API renvoie un cursor bugué
				if ($pages > 50) {
					$this->logger->error('Arrêt sync: trop de pages (cursor suspect)');
					break;
				}

			} while (!empty($cursor));

			$this->logger->info('Fin sync cron', ['pages' => $pages]);

		} finally {
			$this->releaseLock();
		}
	}

	private function acquireLock(): bool
	{
		// add_option est atomique côté MySQL, pratique pour un lock basique.
		$lockValue = (string) time();

		if (add_option(self::LOCK_KEY, $lockValue, '', 'no')) {
			return true;
		}

		// Si le lock existe mais est trop vieux, on le casse (cas: fatal error au milieu)
		$existing = get_option(self::LOCK_KEY);
		if (is_string($existing) && ctype_digit($existing)) {
			$age = time() - (int) $existing;
			if ($age > self::LOCK_TTL) {
				update_option(self::LOCK_KEY, $lockValue, false);
				$this->logger->warning('Lock expiré, reprise', ['age' => $age]);
				return true;
			}
		}

		return false;
	}

	private function releaseLock(): void
	{
		delete_option(self::LOCK_KEY);
	}
}

4.2 Enregistrement du cron via Plugin service

Créez src/Service/Plugin.php :

<?php
declare(strict_types=1);

namespace BPCABExternalApiService;

final class Plugin
{
	private CronSync $cron;
	private WebhookController $webhook;

	public function __construct(CronSync $cron, WebhookController $webhook)
	{
		$this->cron = $cron;
		$this->webhook = $webhook;
	}

	public function register(): void
	{
		$this->cron->register();
		$this->webhook->register();
	}
}

Résultat attendu : un événement WP-Cron planifié. Si vous avez WP-CLI, vérifiez avec wp cron event list.

Étape 5 : Exposer un endpoint webhook (REST API + signature HMAC)

Un webhook non signé, c’est une invitation au spam logique : création/modification de contenu à volonté. Le minimum : HMAC (secret partagé) + contrôle du timestamp + idempotence.

5.1 Définir le secret webhook

Dans wp-config.php :

<?php
define('BPCAB_WEBHOOK_SECRET', 'remplacez-par-un-secret-long-aleatoire');

5.2 Contrôleur REST

Créez src/Service/WebhookController.php :

<?php
declare(strict_types=1);

namespace BPCABExternalApiService;

use WP_REST_Request;
use WP_REST_Response;
use WP_Error;

final class WebhookController
{
	private Queue $queue;
	private Logger $logger;

	public function __construct(Queue $queue, Logger $logger)
	{
		$this->queue = $queue;
		$this->logger = $logger;
	}

	public function register(): void
	{
		add_action('rest_api_init', function (): void {
			register_rest_route('bpcab/v1', '/webhook', [
				'methods'             => 'POST',
				'callback'            => [$this, 'handle'],
				'permission_callback' => '__return_true', // Auth gérée par signature, pas par session WP
				'args'                => [],
			]);
		});
	}

	public function handle(WP_REST_Request $request): WP_REST_Response|WP_Error
	{
		// 1) Limite de taille (évite les payloads énormes)
		$raw = $request->get_body();
		if (strlen($raw) > 200000) { // 200KB, ajustez selon votre cas
			return new WP_Error('payload_too_large', 'Payload trop volumineux', ['status' => 413]);
		}

		// 2) Vérification signature + timestamp (anti-replay)
		$timestamp = (string) $request->get_header('x-bpcab-timestamp');
		$signature = (string) $request->get_header('x-bpcab-signature');

		if (!$this->verifySignature($raw, $timestamp, $signature)) {
			$this->logger->warning('Webhook rejeté (signature invalide)', [
				'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
			]);
			return new WP_Error('forbidden', 'Signature invalide', ['status' => 403]);
		}

		$data = json_decode($raw, true);
		if (!is_array($data)) {
			return new WP_Error('bad_request', 'JSON invalide', ['status' => 400]);
		}

		// 3) Idempotence événement : event_id requis
		$eventId = isset($data['event_id']) && is_string($data['event_id']) ? $data['event_id'] : '';
		if ($eventId === '') {
			return new WP_Error('bad_request', 'event_id manquant', ['status' => 400]);
		}

		if ($this->isDuplicateEvent($eventId)) {
			// On répond 200 pour ne pas déclencher de retry côté fournisseur
			return new WP_REST_Response(['ok' => true, 'duplicate' => true], 200);
		}

		$type = isset($data['type']) && is_string($data['type']) ? $data['type'] : 'unknown';
		$payload = isset($data['payload']) && is_array($data['payload']) ? $data['payload'] : [];

		// 4) Réponse rapide : on enfile, on traite ensuite
		$this->queue->enqueueWebhookEvent($eventId, $type, $payload);

		return new WP_REST_Response(['ok' => true], 202);
	}

	private function verifySignature(string $rawBody, string $timestamp, string $signature): bool
	{
		if (!defined('BPCAB_WEBHOOK_SECRET') || BPCAB_WEBHOOK_SECRET === '') {
			// Sans secret, on refuse tout.
			return false;
		}

		if ($timestamp === '' || !ctype_digit($timestamp)) {
			return false;
		}

		$ts = (int) $timestamp;

		// Fenêtre anti-replay : 5 minutes
		if (abs(time() - $ts) > 300) {
			return false;
		}

		// Signature attendue : hex(hash_hmac('sha256', timestamp + '.' + body, secret))
		$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, (string) BPCAB_WEBHOOK_SECRET);

		// Comparaison en temps constant
		return hash_equals($expected, $signature);
	}

	private function isDuplicateEvent(string $eventId): bool
	{
		$key = 'bpcab_webhook_event_' . md5($eventId);

		// Transient: évite re-traitement sur retries
		if (get_transient($key)) {
			return true;
		}

		// TTL 24h (ajustez selon les retries possibles de votre fournisseur)
		set_transient($key, 1, DAY_IN_SECONDS);
		return false;
	}
}

5.3 Générer la signature côté fournisseur (exemple)

Si vous contrôlez le service qui envoie le webhook, la signature doit être calculée exactement comme côté WordPress : hash_hmac('sha256', timestamp + '.' + rawBody, secret). Exemple Node.js :

import crypto from "crypto";

function signWebhook(secret, timestamp, rawBody) {
  return crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex");
}

Résultat attendu : un endpoint disponible sur /wp-json/bpcab/v1/webhook qui renvoie 403 si signature invalide, 202 si accepté.

Étape 6 : Découpler le traitement (Action Scheduler ou fallback WP-Cron)

Traiter un webhook dans la requête HTTP, c’est le meilleur moyen de faire timeouter le fournisseur et de recevoir le même événement 15 fois. Je préfère répondre vite (202) et traiter en arrière-plan.

6.1 Service Queue

Créez src/Service/Queue.php :

<?php
declare(strict_types=1);

namespace BPCABExternalApiService;

final class Queue
{
	private const AS_HOOK_UPSERT = 'bpcab_external_api_upsert';
	private const AS_HOOK_WEBHOOK = 'bpcab_external_api_webhook_event';

	private Logger $logger;

	public function __construct(Logger $logger)
	{
		$this->logger = $logger;

		add_action(self::AS_HOOK_UPSERT, [$this, 'handleUpsert'], 10, 1);
		add_action(self::AS_HOOK_WEBHOOK, [$this, 'handleWebhookEvent'], 10, 3);
	}

	public function enqueueUpsert(array $item): void
	{
		// Si Action Scheduler est disponible (WooCommerce, certains stacks), on l’utilise.
		if (function_exists('as_enqueue_async_action')) {
			as_enqueue_async_action(self::AS_HOOK_UPSERT, [$item], 'bpcab');
			return;
		}

		// Fallback : traitement immédiat (moins idéal) ou via WP-Cron single event
		wp_schedule_single_event(time() + 5, self::AS_HOOK_UPSERT, [$item]);
	}

	public function enqueueWebhookEvent(string $eventId, string $type, array $payload): void
	{
		if (function_exists('as_enqueue_async_action')) {
			as_enqueue_async_action(self::AS_HOOK_WEBHOOK, [$eventId, $type, $payload], 'bpcab');
			return;
		}

		wp_schedule_single_event(time() + 5, self::AS_HOOK_WEBHOOK, [$eventId, $type, $payload]);
	}

	/**
	 * Upsert : crée ou met à jour un post "bpcab_item" à partir d’un item externe.
	 */
	public function handleUpsert(array $item): void
	{
		$externalId = isset($item['id']) ? (string) $item['id'] : '';
		if ($externalId === '') {
			$this->logger->warning('Upsert ignoré: id externe manquant');
			return;
		}

		$postId = $this->findPostIdByExternalId($externalId);

		$title = isset($item['title']) ? (string) $item['title'] : 'Sans titre';
		$status = 'publish';

		$postarr = [
			'post_type'   => 'bpcab_item',
			'post_title'  => $title,
			'post_status' => $status,
		];

		if ($postId) {
			$postarr['ID'] = $postId;
			$newId = wp_update_post($postarr, true);
		} else {
			$newId = wp_insert_post($postarr, true);
		}

		if (is_wp_error($newId)) {
			$this->logger->error('Échec upsert', [
				'external_id' => $externalId,
				'error' => $newId->get_error_message(),
			]);
			return;
		}

		update_post_meta((int) $newId, '_bpcab_external_id', $externalId);
		update_post_meta((int) $newId, '_bpcab_payload', wp_json_encode($item));
		update_post_meta((int) $newId, '_bpcab_synced_at', time());
	}

	public function handleWebhookEvent(string $eventId, string $type, array $payload): void
	{
		// Exemple : si webhook "item.updated", on upsert directement
		if ($type === 'item.updated' || $type === 'item.created') {
			$this->handleUpsert($payload);
			return;
		}

		$this->logger->info('Webhook reçu (type non géré)', [
			'event_id' => $eventId,
			'type' => $type,
		]);
	}

	private function findPostIdByExternalId(string $externalId): int
	{
		$q = new WP_Query([
			'post_type'      => 'bpcab_item',
			'post_status'    => 'any',
			'fields'         => 'ids',
			'posts_per_page' => 1,
			'meta_query'     => [
				[
					'key'   => '_bpcab_external_id',
					'value' => $externalId,
				],
			],
			'no_found_rows'  => true,
		]);

		return !empty($q->posts[0]) ? (int) $q->posts[0] : 0;
	}
}

6.2 Déclarer le Custom Post Type

On a besoin d’un conteneur pour stocker les objets. Ajoutez src/Service/ContentModel.php :

<?php
declare(strict_types=1);

namespace BPCABExternalApiService;

final class ContentModel
{
	public function register(): void
	{
		add_action('init', function (): void {
			register_post_type('bpcab_item', [
				'label' => 'Items API (BPCAB)',
				'public' => false,
				'show_ui' => true,
				'show_in_menu' => true,
				'supports' => ['title'],
				'show_in_rest' => true,
				'menu_icon' => 'dashicons-database',
			]);
		});
	}
}

Et branchez-le dans le container (modifiez src/Container.php) :

<?php
// Extrait à insérer dans match()
'content' => new BPCABExternalApiServiceContentModel(),
'plugin'  => new BPCABExternalApiServicePlugin(
	$this->get('cron'),
	$this->get('webhook')
),
// Puis dans Plugin::register(), appelez aussi content:

Modifiez src/Service/Plugin.php pour inclure ContentModel :

<?php
declare(strict_types=1);

namespace BPCABExternalApiService;

final class Plugin
{
	private CronSync $cron;
	private WebhookController $webhook;
	private ContentModel $content;

	public function __construct(CronSync $cron, WebhookController $webhook, ContentModel $content)
	{
		$this->cron = $cron;
		$this->webhook = $webhook;
		$this->content = $content;
	}

	public function register(): void
	{
		$this->content->register();
		$this->cron->register();
		$this->webhook->register();
	}
}

Et adaptez le container pour passer content :

<?php
// Container.php (extrait)
'content' => new ContentModel(),
'plugin'  => new Plugin($this->get('cron'), $this->get('webhook'), $this->get('content')),

Résultat attendu : dans l’admin, vous voyez “Items API (BPCAB)”. Si vous ne le voyez pas, vous avez probablement collé un fichier dans le mauvais dossier, ou cassé l’autoload.

Étape 7 : Observabilité (logs, WP_DEBUG, Site Health, métriques simples)

Une intégration API sans observabilité, c’est un ticket support infini. Le but : savoir quoi a échoué, quand, et combien.

7.1 Ajouter un indicateur Site Health

Ajoutez src/Service/SiteHealth.php :

<?php
declare(strict_types=1);

namespace BPCABExternalApiService;

final class SiteHealth
{
	public function register(): void
	{
		add_filter('site_status_tests', function (array $tests): array {
			$tests['direct']['bpcab_external_api'] = [
				'label' => 'Synchronisation API externe (BPCAB)',
				'test'  => [$this, 'test'],
			];
			return $tests;
		});
	}

	public function test(): array
	{
		$last = get_option('bpcab_last_sync_ts');
		$age = $last ? (time() - (int) $last) : null;

		$status = 'good';
		$label = 'Sync récente';
		$description = 'La synchronisation semble fonctionner.';

		if ($age === null || $age > 3600) {
			$status = 'recommended';
			$label = 'Sync trop ancienne';
			$description = 'Aucune sync récente détectée. Vérifiez WP-Cron ou votre cron système.';
		}

		return [
			'label'       => $label,
			'status'      => $status,
			'badge'       => ['label' => 'BPCAB', 'color' => 'blue'],
			'description' => '<p>' . esc_html($description) . '</p>',
			'actions'     => '',
			'test'        => 'bpcab_external_api',
		];
	}
}

Puis, dans CronSync::run(), en fin de sync, ajoutez :

// À la fin de run(), après "Fin sync cron"
update_option('bpcab_last_sync_ts', time(), false);

Branchez SiteHealth au container/Plugin de la même manière que ContentModel.

Résultat attendu : Outils → Santé du site affiche un test “Synchronisation API externe (BPCAB)”.

Le résultat complet

Si vous voulez tout copier d’un coup, voici une version assemblée des points critiques. Je n’inclus pas ici chaque fichier déjà montré (sinon vous allez rater une différence), mais les éléments à ne pas oublier :

  • MU-plugin loader + bootstrap + autoload
  • Container (services)
  • ContentModel (CPT)
  • ApiClient (HTTP)
  • CronSync (planification + lock)
  • WebhookController (REST route + HMAC)
  • Queue (Action Scheduler ou fallback)
  • Optionnel : SiteHealth

Personnalisation rapide

  • Stockage : remplacez le CPT par une table custom si vous avez beaucoup de champs filtrables (sinon, les meta_query vont devenir lentes).
  • Fréquence : ajustez 10min selon quota API et criticité.
  • Idempotence : gardez external_id + event_id (transient) au minimum. Pour du sérieux, stockez les event_id en table avec TTL.

Adapter pour Divi 5 / Elementor / Avada

Les builders aiment les sorties simples. Le pattern le plus robuste : shortcode qui lit vos données locales (post/meta) et ne fait jamais d’appel API direct.

Shortcode de rendu

Ajoutez src/Service/Shortcodes.php :

<?php
declare(strict_types=1);

namespace BPCABExternalApiService;

final class Shortcodes
{
	public function register(): void
	{
		add_shortcode('bpcab_item', [$this, 'renderItem']);
	}

	public function renderItem(array $atts): string
	{
		$atts = shortcode_atts([
			'external_id' => '',
		], $atts, 'bpcab_item');

		$externalId = (string) $atts['external_id'];
		if ($externalId === '') {
			return '';
		}

		$q = new WP_Query([
			'post_type'      => 'bpcab_item',
			'post_status'    => 'publish',
			'posts_per_page' => 1,
			'meta_query'     => [
				[
					'key'   => '_bpcab_external_id',
					'value' => $externalId,
				],
			],
			'no_found_rows'  => true,
		]);

		if (!$q->have_posts()) {
			return '';
		}

		$postId = (int) $q->posts[0]->ID;
		$json = (string) get_post_meta($postId, '_bpcab_payload', true);
		$data = json_decode($json, true);
		if (!is_array($data)) {
			return '';
		}

		$title = esc_html((string) ($data['title'] ?? get_the_title($postId)));
		$updated = (int) get_post_meta($postId, '_bpcab_synced_at', true);

		$html  = '<div class="bpcab-item">';
		$html .= '<strong>' . $title . '</strong><br>';
		$html .= '<span class="bpcab-item-meta">Sync: ' . esc_html(gmdate('Y-m-d H:i:s', $updated)) . ' UTC</span>';
		$html .= '</div>';

		return $html;
	}
}

Branchez le service dans Plugin/Container, comme les autres.

Divi 5

  • Ajoutez un module “Code” ou “Texte”.
  • Insérez : [bpcab_item external_id="123"]
  • Si Divi cache agressivement, videz le cache Divi et le cache page.

Elementor

  • Widget “Shortcode” → collez [bpcab_item external_id="123"].
  • Si vous ne voyez rien, vérifiez que l’item existe bien en CPT “Items API (BPCAB)”.

Avada (Fusion Builder)

  • Élément “Code Block” ou “Shortcode”.
  • Collez le shortcode.
  • Attention aux optimisations JS/CSS d’Avada : elles ne doivent pas impacter un simple rendu HTML, mais j’ai déjà vu des minifiers casser des attributs si vous injectez du HTML complexe.

Vérification finale

  1. Dans l’admin, vérifiez que le CPT Items API (BPCAB) existe.
  2. Déclenchez une sync :
    • WP-CLI : wp cron event run bpcab_external_api_sync
    • Sans WP-CLI : attendez l’exécution cron (ou chargez quelques pages pour déclencher WP-Cron).
  3. Vérifiez que des posts bpcab_item sont créés, avec meta _bpcab_external_id.
  4. Testez le webhook via curl (exemple) :
TIMESTAMP=$(date +%s)
BODY='{"event_id":"evt_001","type":"item.updated","payload":{"id":"123","title":"Titre via webhook"}}'
SECRET='remplacez-par-un-secret-long-aleatoire'
SIG=$(php -r "echo hash_hmac('sha256', '$TIMESTAMP.$BODY', '$SECRET');")

curl -i -X POST "https://votre-site.tld/wp-json/bpcab/v1/webhook" 
  -H "Content-Type: application/json" 
  -H "X-BPCAB-Timestamp: $TIMESTAMP" 
  -H "X-BPCAB-Signature: $SIG" 
  --data "$BODY"
  1. Dans les logs, vous devez voir l’acceptation (ou au minimum l’absence d’erreurs).
  2. Placez le shortcode dans une page builder et vérifiez l’affichage.

Si le résultat n’est pas celui attendu

Les causes que je rencontre le plus :

  • Le MU-plugin n’est pas chargé : vous avez créé mu-plugins au mauvais endroit, ou le fichier loader ne pointe pas vers le bon chemin.
    • Vérification : ajoutez temporairement un error_log('MU loaded'); dans le loader.
  • WP-Cron ne tourne pas : site sans trafic, ou DISABLE_WP_CRON à true.
    • Vérification : regardez wp-config.php. Consultez WP-Cron.
    • Solution : mettez un cron système qui appelle wp-cron.php.
  • 403 sur webhook : signature mal calculée, timestamp hors fenêtre, mauvais encodage du body.
    • Vérification : assurez-vous que la signature est calculée sur le raw body exact, sans reformatage JSON.
  • Écran blanc : point-virgule manquant, namespace faux, autoload qui ne trouve pas le fichier.
    • Vérification : activez WP_DEBUG et WP_DEBUG_LOG.
  • Rien ne s’affiche dans Divi/Elementor : l’item n’existe pas localement (pas de sync), ou cache de page.
    • Solution : forcez une sync, puis videz cache plugin/CDN/navigateur.

Tableau de diagnostic

Symptôme Cause probable Vérification Solution
Les items ne se créent pas WP-Cron ne s’exécute pas wp cron event list / Santé du site Cron système + vérifier DISABLE_WP_CRON
Timeouts API Endpoint lent / timeout trop haut Logs “cURL error 28” Timeout court + retries + réduire payload/pagination
Webhook 403 Signature HMAC invalide Recalcul local avec raw body Aligner algo, headers, timestamp, encodage
Doublons de contenu Upsert non idempotent Plusieurs posts avec même external_id Requête unique par meta + verrou + event_id
Shortcode n’affiche rien external_id incorrect ou cache Rechercher le post dans CPT Corriger ID + vider cache + vérifier meta

Pièges et erreurs courantes

Erreur Cause Solution
Code collé dans le thème parent Mise à jour du thème = perte MU-plugin ou plugin custom + thème enfant si besoin
Hook inadapté (init vs plugins_loaded) Services instanciés trop tôt Bootstrap sur plugins_loaded, REST sur rest_api_init
Oublier un point-virgule Fatal error immédiate Activer WP_DEBUG_LOG, tester sur staging
Confusion actions/filtres Retour attendu vs exécution Relire la signature du hook, suivre la doc officielle
Permaliens non régénérés Rare, mais REST peut sembler “cassé” sur certains setups Réglages → Permaliens → Enregistrer
Cache/CDN sert une vieille page Le shortcode affiche une donnée ancienne Comparer HTML source / logs de sync Purger cache, baisser TTL, invalider sur update
Snippet cassé par un plugin de snippets Ordre de chargement / erreurs de parsing Désactiver snippet, passer en MU-plugin Standardiser le déploiement (Git/CI)
PHP trop ancien Typed properties / match / strict_types Vérifier version PHP Mettre à jour vers PHP 8.1+
Code d’ancien tutoriel Patterns obsolètes (auth REST bricolée, etc.) Comparer avec doc WP 6.9.x Utiliser REST API + signatures + bonnes pratiques

Variante / alternative

Option sans code (ou presque) : WP Webhooks / Uncanny Automator / Zapier

Si votre besoin est “quand X arrive, créez un post”, un plugin d’automatisation peut suffire. Vous gagnez du temps, mais vous perdez :

  • le contrôle fin sur l’idempotence,
  • la maîtrise des retries,
  • la performance sur gros volumes.

Dans mon expérience, ces outils sont parfaits pour prototyper, puis on migre vers un plugin custom dès qu’il y a des quotas API ou des règles métier.

Option plus avancée : table custom + WP REST interne

Si vous avez besoin de filtrer/ordonner sur 10+ champs, passez à une table dédiée et exposez vos données via un endpoint REST interne. Vous évitez les meta_query coûteuses.

Conseils sécurité, performance et maintenance

  • Webhooks :
    • Signature HMAC obligatoire.
    • Fenêtre anti-replay (timestamp) + rejet si hors fenêtre.
    • Rate limiting côté WAF/CDN si possible (Cloudflare, etc.).
  • HTTP sortant :
    • Timeout court (5–10s) + retries limités.
    • Respectez les quotas : backoff, pagination, cache.
  • Stockage :
    • Évitez de stocker des payloads énormes en post meta si vous n’en avez pas besoin.
    • Si vous stockez du JSON, versionnez-le (ajoutez payload_version) pour les migrations.
  • Maintenance :
    • Ajoutez une commande WP-CLI (si vous pouvez) pour relancer une sync.
    • Surveillez : âge de dernière sync, taux d’erreurs HTTP, volume d’events webhook.

Pour aller plus loin

  • Ajouter une page d’admin (Settings API) pour configurer base URL, fréquence, mode “dry-run”.
  • Gérer les suppressions : webhook item.deleted qui passe le post en brouillon ou le supprime.
  • Mettre en place une dead-letter queue : si un job échoue 5 fois, on le marque “à revoir”.
  • Ajouter une stratégie ETag/If-Modified-Since si l’API le supporte (réduit la bande passante).
  • Remplacer le lock optionnel par un lock objet persistant (Redis) si votre infra le permet.

Ressources

FAQ

Pourquoi un MU-plugin plutôt qu’un plugin classique ?

Pour une intégration critique, j’aime la prédictibilité : un MU-plugin est chargé automatiquement, ne dépend pas d’une activation, et est moins sujet aux “désactivations accidentelles”. Pour un produit distribuable, un plugin classique est plus adapté.

Pourquoi ne pas appeler l’API directement au rendu de la page ?

Parce que vous transférez la latence et les pannes de l’API sur votre front. Avec un cache local, votre site continue d’afficher quelque chose même si l’API externe est en panne.

WP-Cron est-il fiable ?

Fiable sur les sites à trafic régulier. Sur les sites à faible trafic, non. La solution propre : désactiver WP-Cron et déclencher wp-cron.php via cron système.

Comment éviter les doublons lors des retries webhook ?

Avec un event_id unique côté fournisseur, stocké côté WordPress (transient ou table). Répondez 200/202 même si duplicate, sinon le fournisseur retentera.

Pourquoi HMAC plutôt qu’un “token” dans l’URL ?

Un token dans l’URL fuite dans les logs, les referers, les caches. HMAC signe le contenu et limite les attaques par replay avec timestamp.

Comment gérer la rotation du secret webhook ?

Gardez deux secrets actifs pendant une période de transition (secret A et B). Votre verifySignature() accepte les deux. Une fois la rotation terminée, retirez l’ancien.

Action Scheduler est-il obligatoire ?

Non. Le code ci-dessus fonctionne sans. Mais si vous avez WooCommerce, vous l’avez souvent déjà, et c’est nettement plus robuste qu’un simple WP-Cron pour la queue.

Peut-on exposer les items via la REST API WordPress ?

Oui : le CPT est show_in_rest. Pour un contrôle fin, créez un endpoint custom qui sort uniquement les champs nécessaires et applique un cache.

Que faire si l’API impose OAuth2 ?

Implémentez un service “TokenProvider” qui rafraîchit le token et le stocke (option chiffrée côté infra si possible). Évitez de rafraîchir sur chaque requête : utilisez un TTL.

Comment tester sans impacter la production ?

Staging + secrets séparés + endpoint webhook de test. J’évite de tester un webhook réel sur prod sans limiter le traitement : une boucle de retries peut saturer vos workers.

Dois-je stocker le payload complet en base ?

Seulement si vous en avez besoin. Sinon, extrayez les champs utiles et stockez le minimum. Stocker tout le JSON aide au debug, mais gonfle la DB et complique la conformité (données personnelles).