Si vous avez déjà vu un “401 Unauthorized” dans Postman alors que vous êtes connecté à WordPress, vous avez touché du doigt le vrai sujet : créer un endpoint REST, ce n’est pas juste “retourner du JSON”, c’est gérer permissions, validation, cache et compatibilité.

Le problème / Le besoin

Vous voulez exposer (ou consommer) des données WordPress via une API : envoyer un formulaire vers WordPress, alimenter une app mobile, synchroniser un CRM, déclencher une action côté serveur, ou simplement fournir un flux JSON propre à un front headless.

WordPress 6.9.4 embarque une REST API mature, mais dès que vous sortez des routes natives (/wp/v2/posts, /wp/v2/users…), vous devez écrire votre endpoint : définir une route, vérifier qui a le droit d’y accéder, valider les paramètres, et renvoyer des réponses cohérentes.

À la fin, vous saurez créer un endpoint REST personnalisé robuste (lecture + écriture), le sécuriser (capabilities, nonce / Application Passwords), et le tester proprement avec curl ou Postman, sans casser votre site.

Résumé rapide

  • Vous allez enregistrer une route REST avec register_rest_route() sur le hook rest_api_init.
  • Vous allez définir une permission_callback réaliste (capabilities, ou accès public maîtrisé).
  • Vous allez valider et nettoyer les paramètres (sanitize + validate) via args et/ou du code serveur.
  • Vous allez renvoyer des réponses standardisées avec WP_REST_Response et WP_Error.
  • Vous allez gérer un cas concret : créer et lister des “demandes de contact” stockées en base (table custom), avec pagination.
  • Vous allez tester en local/staging et éviter les pièges fréquents (mauvais hook, cache, permaliens, auth).

Quand utiliser cette solution

  • Formulaire custom (front) qui doit enregistrer des données côté WordPress sans recharger la page.
  • Intégration externe (Zapier-like, CRM, ERP) qui pousse des données vers WordPress.
  • Back-office custom (React/Vue) qui consomme une route dédiée plutôt que d’abuser de admin-ajax.php.
  • Headless / découplé : votre front (Next.js, Astro, app mobile) consomme des routes spécifiques.
  • Optimisation : vous voulez une réponse plus légère que les endpoints natifs (moins de champs, moins de jointures).

Quand ne PAS utiliser cette solution

  • Si un endpoint natif fait déjà le travail : commencez par /wp/v2 et ses paramètres (_fields, per_page, etc.). Documentation : REST API Handbook.
  • Si vous voulez juste “déclencher une action” depuis l’admin : un écran d’admin + admin-post.php est parfois plus simple et plus sécurisé.
  • Si vous exposez des données sensibles sans besoin réel : une route REST publique mal filtrée, c’est une fuite de données en production. J’ai souvent vu des sites exposer emails/téléphones “par accident” via une route “temporaire”.
  • Si vous cherchez du temps réel (push) : REST = requête/réponse. Pour du temps réel, regardez plutôt Webhooks sortants + un serveur, ou du polling maîtrisé.

Prérequis / avant de commencer

Versions ciblées : WordPress 6.9.4, PHP 8.1+.

  • Travaillez sur un staging ou en local (WP-CLI + DB dédiée). Évitez de tester ça sur production sans sauvegarde.
  • Activez WP_DEBUG et WP_DEBUG_LOG sur l’environnement de test.
  • Prévoyez un plugin “mu-plugin” ou un plugin standard. Évitez de coller ce code dans functions.php d’un thème si vous voulez de la stabilité (changement de thème = disparition de l’API).
  • Outils de test : curl, Postman/Insomnia, ou le client REST de votre IDE.
  • Auth : pour tester des endpoints protégés, utilisez soit un cookie de session (depuis le navigateur + nonce), soit des Application Passwords. Référence : REST API Authentication.

Sources utiles (officielles) à garder sous la main :

L’approche naïve (et pourquoi l’éviter)

Le code que je vois le plus souvent chez les devs qui “veulent juste un JSON vite fait” : une route REST qui ne vérifie rien, qui prend des paramètres en vrac, et qui écrit en base sans sanitation.

<?php
// Anti-pattern : exemple volontairement mauvais (ne pas utiliser).
add_action('rest_api_init', function () {
	register_rest_route('demo/v1', '/contact', [
		'methods'  => 'POST',
		'callback' => function ($request) {
			// 1) Pas de permission_callback => endpoint potentiellement public.
			// 2) Pas de validation/sanitization => injection de contenu, spam, données cassées.
			// 3) Réponse non standard => difficile à déboguer côté client.

			global $wpdb;
			$name  = $_POST['name'] ?? '';
			$email = $_POST['email'] ?? '';

			$wpdb->query("INSERT INTO {$wpdb->prefix}demo_contacts (name,email) VALUES ('$name','$email')");

			return ['ok' => true];
		},
	]);
});

Pourquoi ça pose problème :

  • Sécurité : endpoint public + écriture en base = spam, flood, et potentiellement injection SQL (ici, c’est caricatural).
  • Données : emails invalides, champs trop longs, encodage bizarre, etc.
  • Maintenance : pas de schéma, pas de codes d’erreur, pas de pagination, pas de logs.
  • Interop : côté client, vous ne savez pas différencier un succès d’un échec (HTTP 200 partout).

La bonne approche — tutoriel pas à pas

Objectif concret

On crée un mini “service” REST bpcab/v1 avec deux endpoints :

  • POST /wp-json/bpcab/v1/contact : créer une demande de contact (public, avec anti-abus minimal).
  • GET /wp-json/bpcab/v1/contact : lister les demandes (réservé aux admins/éditeurs selon votre choix).

On stocke les demandes dans une table personnalisée. Pourquoi pas un custom post type ? Parce que pour des “soumissions” volumineuses (beaucoup de lignes, peu de besoin éditorial), une table dédiée est souvent plus rapide et plus propre. Et ça évite de polluer wp_posts.

Étape 1 — Créer un plugin (recommandé)

Créez un fichier : wp-content/plugins/bpcab-rest-contact/bpcab-rest-contact.php.

Activez-le dans l’admin. J’insiste : coller ça dans functions.php marche… jusqu’au jour où un changement de thème casse votre API.

Étape 2 — Créer la table à l’activation

On utilise dbDelta() pour créer/mettre à jour la table. Référence : dbDelta().

Étape 3 — Enregistrer la route REST

Hook : rest_api_init. C’est le bon moment : WordPress prépare le serveur REST, et vous déclarez vos routes.

Étape 4 — Définir des permissions réalistes

  • Pour le POST public : on autorise tout le monde, mais on ajoute un garde-fou minimal (rate-limit simple via transient + honeypot optionnel). Ce n’est pas un WAF, mais ça évite 80% des bots basiques.
  • Pour le GET : on exige une capability (ex : edit_posts ou manage_options).

Étape 5 — Valider et nettoyer les paramètres

On définit args dans register_rest_route() : sanitize + validate. Puis on revalide côté callback pour les cas limites (longueurs, contenu, etc.).

Étape 6 — Réponses propres

On renvoie :

  • HTTP 201 pour une création, avec l’objet créé (ou un sous-ensemble).
  • HTTP 400/422 pour des erreurs de validation.
  • HTTP 401/403 pour auth/permissions.

Code complet

Copiez-collez ce fichier tel quel dans le plugin. Il est volontairement autonome.

<?php
/**
 * Plugin Name: BPCAB - REST Contact Endpoint
 * Description: Endpoint REST personnalisé (WP 6.9.4+) pour créer et lister des demandes de contact.
 * Version: 1.0.0
 * Author: BPCAB
 * Requires at least: 6.9
 * Requires PHP: 8.1
 */

defined('ABSPATH') || exit;

final class BPCAB_REST_Contact_Endpoint {
	private const DB_VERSION_OPTION = 'bpcab_rest_contact_db_version';
	private const DB_VERSION = '1.0.0';
	private const TABLE_SLUG = 'bpcab_contacts';

	public static function init(): void {
		add_action('rest_api_init', [__CLASS__, 'register_routes']);
		register_activation_hook(__FILE__, [__CLASS__, 'activate']);
	}

	public static function activate(): void {
		self::maybe_create_table();
	}

	private static function table_name(): string {
		global $wpdb;
		return $wpdb->prefix . self::TABLE_SLUG;
	}

	private static function maybe_create_table(): void {
		$installed = get_option(self::DB_VERSION_OPTION);

		// Évite de relancer dbDelta à chaque chargement.
		if ($installed === self::DB_VERSION) {
			return;
		}

		global $wpdb;
		$table = self::table_name();

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$charset_collate = $wpdb->get_charset_collate();

		// Table simple : index sur created_at pour la pagination/tri.
		$sql = "CREATE TABLE {$table} (
			id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			name VARCHAR(190) NOT NULL,
			email VARCHAR(190) NOT NULL,
			message TEXT NOT NULL,
			ip_hash CHAR(64) NULL,
			user_agent VARCHAR(255) NULL,
			created_at DATETIME NOT NULL,
			PRIMARY KEY  (id),
			KEY created_at (created_at)
		) {$charset_collate};";

		dbDelta($sql);

		update_option(self::DB_VERSION_OPTION, self::DB_VERSION, true);
	}

	public static function register_routes(): void {
		register_rest_route('bpcab/v1', '/contact', [
			[
				'methods'             => WP_REST_Server::CREATABLE, // POST
				'callback'            => [__CLASS__, 'handle_create_contact'],
				'permission_callback' => [__CLASS__, 'permissions_create_contact'],
				'args'                => self::get_create_args(),
			],
			[
				'methods'             => WP_REST_Server::READABLE, // GET
				'callback'            => [__CLASS__, 'handle_list_contacts'],
				'permission_callback' => [__CLASS__, 'permissions_list_contacts'],
				'args'                => self::get_list_args(),
			],
		]);
	}

	private static function get_create_args(): array {
		return [
			'name' => [
				'type'              => 'string',
				'required'          => true,
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => function ($param) {
					return is_string($param) && mb_strlen(trim($param)) >= 2 && mb_strlen($param) <= 190;
				},
				'description'       => 'Nom de la personne.',
			],
			'email' => [
				'type'              => 'string',
				'required'          => true,
				'sanitize_callback' => 'sanitize_email',
				'validate_callback' => function ($param) {
					return is_string($param) && is_email($param);
				},
				'description'       => 'Adresse email.',
			],
			'message' => [
				'type'              => 'string',
				'required'          => true,
				// sanitize_textarea_field garde les retours à la ligne, retire les balises.
				'sanitize_callback' => 'sanitize_textarea_field',
				'validate_callback' => function ($param) {
					return is_string($param) && mb_strlen(trim($param)) >= 10 && mb_strlen($param) <= 5000;
				},
				'description'       => 'Message.',
			],
			// Honeypot optionnel : un champ que les humains laissent vide.
			'website' => [
				'type'              => 'string',
				'required'          => false,
				'sanitize_callback' => 'sanitize_text_field',
				'validate_callback' => function ($param) {
					// Doit rester vide. Si rempli => bot probable.
					return is_string($param);
				},
				'description'       => 'Champ anti-spam (doit rester vide).',
			],
		];
	}

	private static function get_list_args(): array {
		return [
			'page' => [
				'type'              => 'integer',
				'required'          => false,
				'default'           => 1,
				'sanitize_callback' => 'absint',
				'validate_callback' => function ($param) {
					return is_numeric($param) && (int) $param >= 1 && (int) $param <= 9999;
				},
			],
			'per_page' => [
				'type'              => 'integer',
				'required'          => false,
				'default'           => 20,
				'sanitize_callback' => 'absint',
				'validate_callback' => function ($param) {
					// Limite volontairement basse pour éviter de sortir 50k lignes par erreur.
					return is_numeric($param) && (int) $param >= 1 && (int) $param <= 100;
				},
			],
			'search' => [
				'type'              => 'string',
				'required'          => false,
				'sanitize_callback' => 'sanitize_text_field',
			],
		];
	}

	public static function permissions_create_contact(WP_REST_Request $request): bool|WP_Error {
		// Endpoint public : on autorise, mais on applique un rate-limit simple (IP hash).
		$ip = self::get_client_ip();

		// Si on ne peut pas déterminer l'IP, on ne bloque pas, mais on reste prudent.
		if (!$ip) {
			return true;
		}

		$ip_hash = hash('sha256', $ip);
		$key = 'bpcab_contact_rl_' . $ip_hash;

		$count = (int) get_transient($key);

		// Exemple : max 10 soumissions / 10 minutes par IP.
		if ($count >= 10) {
			return new WP_Error(
				'bpcab_rate_limited',
				'Trop de requêtes. Réessayez dans quelques minutes.',
				['status' => 429]
			);
		}

		set_transient($key, $count + 1, 10 * MINUTE_IN_SECONDS);

		return true;
	}

	public static function permissions_list_contacts(WP_REST_Request $request): bool|WP_Error {
		// Ajustez selon votre besoin.
		if (current_user_can('manage_options')) {
			return true;
		}

		return new WP_Error(
			'bpcab_forbidden',
			'Accès refusé.',
			['status' => 403]
		);
	}

	public static function handle_create_contact(WP_REST_Request $request): WP_REST_Response|WP_Error {
		self::maybe_create_table();

		// Anti-spam : honeypot. Si rempli => on accepte mais on ne stocke pas (ou on renvoie 400).
		$website = (string) $request->get_param('website');
		if (trim($website) !== '') {
			return new WP_Error(
				'bpcab_spam_detected',
				'Requête refusée.',
				['status' => 400]
			);
		}

		$name    = (string) $request->get_param('name');
		$email   = (string) $request->get_param('email');
		$message = (string) $request->get_param('message');

		// Double validation côté serveur (défense en profondeur).
		if (!is_email($email)) {
			return new WP_Error('bpcab_invalid_email', 'Email invalide.', ['status' => 422]);
		}
		if (mb_strlen(trim($message)) < 10) {
			return new WP_Error('bpcab_invalid_message', 'Message trop court.', ['status' => 422]);
		}

		global $wpdb;
		$table = self::table_name();

		$ip = self::get_client_ip();
		$ip_hash = $ip ? hash('sha256', $ip) : null;

		$user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? substr((string) $_SERVER['HTTP_USER_AGENT'], 0, 255) : null;

		$inserted = $wpdb->insert(
			$table,
			[
				'name'       => $name,
				'email'      => $email,
				'message'    => $message,
				'ip_hash'    => $ip_hash,
				'user_agent' => $user_agent,
				'created_at' => current_time('mysql'),
			],
			[
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
			]
		);

		if (!$inserted) {
			return new WP_Error(
				'bpcab_db_insert_failed',
				'Impossible d’enregistrer la demande.',
				['status' => 500]
			);
		}

		$id = (int) $wpdb->insert_id;

		$response = new WP_REST_Response(
			[
				'id'         => $id,
				'name'       => $name,
				'email'      => $email,
				'message'    => $message,
				'created_at' => current_time('mysql'),
			],
			201
		);

		// Location header utile pour une création REST.
		$response->header('Location', rest_url('bpcab/v1/contact/' . $id));

		return $response;
	}

	public static function handle_list_contacts(WP_REST_Request $request): WP_REST_Response|WP_Error {
		self::maybe_create_table();

		global $wpdb;
		$table = self::table_name();

		$page     = max(1, (int) $request->get_param('page'));
		$per_page = min(100, max(1, (int) $request->get_param('per_page')));
		$search   = (string) $request->get_param('search');

		$offset = ($page - 1) * $per_page;

		$where = '1=1';
		$params = [];

		if ($search !== '') {
			// Recherche simple sur nom/email/message.
			$like = '%' . $wpdb->esc_like($search) . '%';
			$where .= " AND (name LIKE %s OR email LIKE %s OR message LIKE %s)";
			$params[] = $like;
			$params[] = $like;
			$params[] = $like;
		}

		// Total pour pagination.
		$sql_count = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
		$total = (int) $wpdb->get_var($wpdb->prepare($sql_count, $params));

		// Résultats paginés.
		$sql = "SELECT id, name, email, message, created_at
				FROM {$table}
				WHERE {$where}
				ORDER BY created_at DESC
				LIMIT %d OFFSET %d";

		$params_with_limit = array_merge($params, [$per_page, $offset]);

		$rows = $wpdb->get_results($wpdb->prepare($sql, $params_with_limit), ARRAY_A);

		// Headers de pagination façon REST WordPress.
		$total_pages = (int) ceil($total / $per_page);

		$response = new WP_REST_Response(
			[
				'items' => $rows,
				'meta'  => [
					'page'        => $page,
					'per_page'    => $per_page,
					'total'       => $total,
					'total_pages' => $total_pages,
				],
			],
			200
		);

		$response->header('X-WP-Total', (string) $total);
		$response->header('X-WP-TotalPages', (string) $total_pages);

		return $response;
	}

	private static function get_client_ip(): ?string {
		// Attention : derrière un proxy/CDN, cette valeur peut être trompeuse.
		// Ne l'utilisez pas comme preuve d'identité. Ici c'est seulement du rate-limit “best effort”.
		$keys = [
			'HTTP_CF_CONNECTING_IP', // Cloudflare
			'HTTP_X_FORWARDED_FOR',
			'REMOTE_ADDR',
		];

		foreach ($keys as $key) {
			if (empty($_SERVER[$key])) {
				continue;
			}

			$value = (string) $_SERVER[$key];

			// X-Forwarded-For peut contenir une liste.
			if ($key === 'HTTP_X_FORWARDED_FOR') {
				$parts = array_map('trim', explode(',', $value));
				$value = $parts[0] ?? '';
			}

			if (filter_var($value, FILTER_VALIDATE_IP)) {
				return $value;
			}
		}

		return null;
	}
}

BPCAB_REST_Contact_Endpoint::init();

Explication du code

Vue d’ensemble (simple)

  • Le plugin crée une table wp_bpcab_contacts à l’activation (et au besoin si la table manque).
  • Il déclare une route REST /wp-json/bpcab/v1/contact avec deux méthodes : GET et POST.
  • Le POST est public mais freiné (rate-limit + honeypot).
  • Le GET exige manage_options (donc admin par défaut).

Enregistrement de la route

register_rest_route() prend un namespace (bpcab/v1), un chemin (/contact), et une liste de définitions par méthode. Référence officielle : register_rest_route().

Le point clé, c’est permission_callback. Sans ça, vous créez facilement un endpoint public sans le vouloir. WordPress l’exige de plus en plus dans les exemples, et j’ai de bons souvenirs de sites “ouverts” par oubli de ce callback.

Validation / sanitation via args

Dans args, on définit :

  • sanitize_callback : transforme le paramètre en valeur propre (ex : sanitize_email).
  • validate_callback : décide si la valeur est acceptable (ex : longueur minimale).

Ça a deux avantages : vous centralisez les règles, et WordPress renvoie une erreur REST cohérente si un paramètre est invalide.

Réponses REST propres

On utilise WP_REST_Response pour contrôler le statut HTTP et les headers. Référence : WP_REST_Response.

Pour les erreurs, WP_Error avec un status dans data. WordPress convertit ça en JSON d’erreur correct.

Écriture SQL sans se tirer une balle dans le pied

Pour l’insert, on utilise $wpdb->insert() avec formats. Pour les requêtes, on utilise $wpdb->prepare() + esc_like(). Ça évite les injections SQL et les erreurs d’échappement sur les % dans les LIKE.

Le rate-limit “best effort”

On stocke un compteur dans un transient par IP hash. Ce n’est pas une protection parfaite (NAT, proxy, bots distribués), mais ça réduit la casse. Et surtout, c’est simple à maintenir.

Note : ne traitez jamais l’IP comme une identité. Ici, c’est un signal pour limiter le bruit.

Variantes et cas d’usage

Variante 1 — Endpoint public en lecture (catalogue, listing)

Si vous exposez des données publiques (ex : liste de ressources), vous pouvez mettre permission_callback à __return_true. Mais gardez un contrôle sur les champs sortis (_fields côté WP natif, ou un schéma minimal côté custom).

J’ai souvent vu un endpoint public renvoyer accidentellement des champs internes (IDs, emails, meta sensibles). Le mieux : construisez explicitement le tableau de sortie.

Variante 2 — Auth via nonce (front connecté)

Pour un front WordPress classique (utilisateur connecté), vous pouvez appeler votre endpoint avec le nonce REST (X-WP-Nonce). Référence : Cookie Authentication.

Dans ce cas, votre permission_callback peut vérifier une capability (read, edit_posts, etc.). C’est généralement plus propre que de rendre le POST public.

Exemple JS (fetch) avec nonce

// Exemple : à exécuter dans un contexte WordPress où wpApiSettings.nonce est disponible.
async function sendContact(data) {
  const res = await fetch('/wp-json/bpcab/v1/contact', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-WP-Nonce': wpApiSettings.nonce
    },
    body: JSON.stringify(data)
  });

  const json = await res.json();
  if (!res.ok) throw json;
  return json;
}

Pour injecter wpApiSettings.nonce, vous pouvez wp_localize_script() ou utiliser les mécanismes déjà fournis par WordPress selon votre contexte. Référence : wp_create_nonce().

Variante 3 — Ajouter un endpoint “GET /contact/{id}”

Le code complet met déjà un header Location pointant vers /contact/{id}, mais la route n’est pas déclarée. Si vous en avez besoin, ajoutez une route avec un paramètre (?P<id>d+) et une permission stricte.

Extrait à ajouter dans register_routes()

// Exemple : route supplémentaire (lecture d'une demande).
register_rest_route('bpcab/v1', '/contact/(?P<id>d+)', [
	'methods'             => WP_REST_Server::READABLE,
	'callback'            => [__CLASS__, 'handle_get_contact'],
	'permission_callback' => [__CLASS__, 'permissions_list_contacts'],
	'args'                => [
		'id' => [
			'type'              => 'integer',
			'required'          => true,
			'sanitize_callback' => 'absint',
		],
	],
]);

Compatibilité Divi 5 / Elementor / Avada

Un endpoint REST vit côté serveur, donc il est globalement indépendant du builder. Les différences apparaissent quand vous appelez l’endpoint depuis le front (formulaire, widget, module).

Divi 5

Divi 5 permet des formulaires et des modules custom. Deux approches courantes :

  • Formulaire Divi : souvent plus simple d’utiliser son système d’email, puis un webhook sortant. Mais si vous voulez stocker en base via REST, vous devrez ajouter un script qui intercepte la soumission et poste vers /wp-json/bpcab/v1/contact.
  • Module custom Divi 5 : exposez les champs (name/email/message) et faites un fetch vers l’endpoint. Dans mon expérience, le piège vient du cache/minify : vérifiez que votre JS est bien enqueued et non bloqué par un réglage de performance.

Elementor

Elementor Pro Forms peut envoyer vers un webhook, mais pour appeler une route REST interne, vous avez généralement besoin d’un petit script. Deux points à surveiller :

  • Si vous utilisez l’auth par nonce, assurez-vous que votre script est chargé pour les utilisateurs connectés et que le nonce est injecté.
  • En public, gardez le rate-limit (ou mettez une solution anti-spam plus robuste côté formulaire).

Avada (Fusion Builder)

Avada propose aussi des formulaires et des options d’optimisation agressives. Les problèmes que je croise le plus :

  • JS combiné/minifié qui casse un fetch (erreur silencieuse) : testez avec l’optimisation désactivée.
  • Cache serveur/CDN qui sert une page sans le bon nonce (si vous êtes connecté) : invalidez le cache ou excluez les pages concernées.

Vérifications après mise en place

1) Vérifier que la route existe

Ouvrez : /wp-json/ et cherchez bpcab/v1 dans l’index.

2) Tester le POST avec curl

curl -i -X POST "https://example.com/wp-json/bpcab/v1/contact" 
  -H "Content-Type: application/json" 
  --data '{"name":"Alice","email":"[email protected]","message":"Bonjour, je souhaite un devis."}'

Vous devez obtenir un 201 et un JSON avec id.

3) Tester le GET (protégé)

Le plus simple pour tester rapidement : Application Passwords. Référence : Application Passwords.

curl -i "https://example.com/wp-json/bpcab/v1/contact?per_page=5" 
  -u "admin:APPLICATION_PASSWORD_ICI"

Vous devez obtenir un 200 + headers X-WP-Total et X-WP-TotalPages.

Tableau de diagnostic rapide

Symptôme Cause probable Vérification Solution
404 sur /wp-json/bpcab/v1/contact Plugin inactif ou code au mauvais endroit Admin > Extensions, ou /wp-json/ ne liste pas bpcab/v1 Activer le plugin, éviter functions.php, vérifier erreurs PHP
403 “Accès refusé” sur GET Capability insuffisante Tester avec un admin Adapter permissions_list_contacts() (ex: edit_posts)
429 “Trop de requêtes” Rate-limit déclenché Rejouer plusieurs POST d’affilée Augmenter le seuil, réduire la fenêtre, ou désactiver en staging
500 “Impossible d’enregistrer” Table absente ou DB error Regarder wp-content/debug.log, vérifier table dans phpMyAdmin Réactiver le plugin, vérifier droits DB, relancer création table

Si ça ne marche pas

  1. Regardez les logs : wp-content/debug.log. Une parenthèse oubliée ou un point-virgule manquant arrive plus vite qu’on ne l’admet.
  2. Vérifiez où vous avez collé le code : un snippet dans un plugin de snippets peut être désactivé par erreur, ou chargé trop tard.
  3. Testez l’index REST : /wp-json/. Si votre namespace n’apparaît pas, le hook rest_api_init n’a pas tourné (fatal error, plugin inactif).
  4. Désactivez temporairement le cache (plugin cache + cache serveur/CDN). J’ai déjà vu des CDN renvoyer un vieux /wp-json/ sans la nouvelle route.
  5. Confirmez la version PHP : si vous êtes en PHP 7.4/8.0 par erreur, certains types/retours peuvent casser. WordPress 6.9.4 tourne, mais votre code vise PHP 8.1+.
  6. Auth : si vous testez le GET, utilisez Application Passwords ou un cookie + nonce. Un simple “être connecté dans un autre onglet” ne suffit pas pour curl.
  7. DB : vérifiez que la table existe bien et que l’utilisateur DB a les droits CREATE/INSERT.

Pièges et erreurs courantes

Erreur Cause Solution
Route non trouvée (404) Code placé dans un fichier non chargé, ou plugin non activé Créer un vrai plugin, vérifier l’activation, ouvrir /wp-json/
rest_no_route Mauvais namespace/chemin, ou permaliens/caches confus Vérifier l’URL exacte, purger cache, retester
401 / 403 inattendu permission_callback trop strict, ou auth mal faite Tester avec Application Passwords, ajuster capability
Paramètres “vides” dans le callback Vous lisez $_POST au lieu de $request Utiliser $request->get_param() et args
Spam massif via l’endpoint public POST public sans anti-abus Rate-limit, honeypot, CAPTCHA côté front, ou exiger auth
Erreur SQL sur la recherche LIKE mal échappé (oubli de esc_like) Utiliser $wpdb->esc_like() + prepare()
Erreur fatale après une mise à jour Code d’un ancien tutoriel (API obsolète, PHP trop vieux) Cibler WP 6.9.4+ et PHP 8.1+, éviter les snippets datés
Le JS ne s’exécute pas (front) Mauvais enqueue, minification, conflit builder Désactiver minify, vérifier console, charger le script correctement

Conseils sécurité, performance et maintenance

  • Ne rendez pas un endpoint d’écriture public “par défaut”. Si vous le faites, assumez une stratégie anti-spam (rate-limit, honeypot, CAPTCHA, ou auth).
  • Capabilities : pour les endpoints admin, manage_options est sûr mais restrictif. Souvent, edit_posts suffit (éditorial). Ajustez selon votre besoin.
  • Validation stricte : limitez les tailles (190 chars, 5000 chars). Sans limite, vous finirez avec des lignes énormes et des lenteurs.
  • Pagination obligatoire : ne renvoyez jamais “tout” par défaut. Même en admin. Un jour, quelqu’un mettra 100000 lignes et votre endpoint tombera.
  • Évitez de stocker l’IP en clair si vous n’en avez pas besoin. Ici, on stocke un hash pour un usage minimal (diagnostic/rate-limit). Vérifiez vos obligations RGPD selon votre contexte.
  • Observabilité : en prod, prévoyez une stratégie de logs (sans données personnelles en clair) et des alertes sur les 429/500.
  • Compat future : gardez un namespace versionné (v1) et ne le cassez pas. Ajoutez v2 si vous devez changer le contrat.

Ressources

FAQ

Pourquoi passer par REST plutôt que admin-ajax.php ?

REST vous donne des statuts HTTP, une structure d’erreurs standard, une meilleure intégration avec des clients externes, et un modèle clair (routes, méthodes). admin-ajax.php reste utile pour des cas historiques, mais je le réserve aux besoins très spécifiques.

Dois-je créer une table custom ou un Custom Post Type ?

Si vous devez éditer/relire dans l’admin avec l’UI WordPress, un CPT est souvent plus rapide à livrer. Si vous avez beaucoup de soumissions et un besoin “données” (listing, export, purge), la table custom est plus saine.

Comment sécuriser un endpoint public d’écriture correctement ?

Le minimum : rate-limit + honeypot. Le mieux : exiger une auth (nonce si utilisateur connecté, Application Passwords pour intégrations), ou ajouter un anti-spam côté front (CAPTCHA/hCaptcha) et vérifier côté serveur.

Pourquoi utiliser args au lieu de valider dans le callback uniquement ?

args standardise la validation et évite de réécrire la plomberie. Vous gardez quand même une validation “métier” côté callback pour les règles plus complexes.

Je reçois rest_cookie_invalid_nonce, que faire ?

Vous appelez l’API avec une auth cookie (session) sans envoyer le header X-WP-Nonce (ou avec un nonce expiré). Injectez un nonce via wp_create_nonce('wp_rest') et envoyez-le. Sinon, utilisez Application Passwords pour les tests en ligne de commande.

Pourquoi mon endpoint n’apparaît pas dans /wp-json/ ?

Le plugin ne se charge pas (fatal error), ou votre code n’est pas exécuté. Vérifiez debug.log, et assurez-vous d’être sur le hook rest_api_init, pas sur init en espérant que ça suffise.

Dois-je “régénérer les permaliens” pour un endpoint REST ?

Normalement non. Mais si votre site a des règles de réécriture cassées ou un plugin de sécurité qui filtre /wp-json/, vous pouvez voir des 404. Dans ce cas, un passage par Réglages > Permaliens peut aider, mais cherchez surtout un blocage serveur (WAF, règles nginx/Apache).

Comment versionner mon API ?

Gardez bpcab/v1 stable. Si vous devez changer le contrat (champs renommés, comportement différent), créez bpcab/v2. Évitez de casser v1 “en douce”.

Comment éviter que la liste (GET) soit trop lente ?

Index DB (ici created_at), pagination, limites strictes (per_page <= 100), et un champ search raisonnable. Si la recherche devient critique, vous finirez par ajouter un index fulltext ou une stratégie de recherche dédiée.

Puis-je appeler cet endpoint depuis un site externe (CORS) ?

Oui, mais CORS se gère côté serveur (headers). Ne l’ouvrez pas globalement sans raison. Si vous avez un vrai besoin cross-domain, implémentez une politique CORS stricte (origines autorisées) et testez avec attention, parce que c’est un point d’entrée classique pour des abus.