Créer un endpoint REST dans WordPress prend cinq minutes. En créer un propre, maintenable et sécurisé demande un peu plus de méthode, et c’est précisément là que beaucoup de projets dérapent. On voit passer des routes ouvertes sans permission, des paramètres jamais validés, des requêtes SQL bricolées, des réponses surdimensionnées rappelées à chaque chargement de page. Le résultat est toujours le même : une surface d’attaque inutile, des performances médiocres et une dette technique qui colle au site pendant des années. Dans ce tutoriel, nous allons construire un endpoint concret — retourner les derniers articles d’une catégorie pour alimenter un bloc, une application front ou un composant headless — en suivant une structure saine de bout en bout.

L’objectif n’est pas de réinventer l’API REST native de WordPress, mais d’illustrer une discipline reproductible : namespace versionné, permission_callback explicite, validation et sanitation des entrées, réponses formatées, pagination, cache par transients et gestion des erreurs. Chaque brique répond à une question simple : qui peut appeler la route, avec quelles données, et que renvoie-t-elle exactement ? Si vous savez répondre à ces trois questions pour chacun de vos endpoints, vous avez déjà éliminé la majorité des incidents de production.

Déclarer une route versionnée avec register_rest_route

Le code d’une API doit vivre dans un plugin dédié, pas dans le functions.php d’un thème : si vous changez de thème demain, la route ne doit pas disparaître avec lui. La fonction centrale est register_rest_route(), toujours appelée sur le hook rest_api_init. Elle prend trois arguments : un namespace, un chemin (route) et un tableau d’options décrivant les méthodes, le callback, la permission et les arguments acceptés. Le namespace doit être versionné — monplugin/v1, ici wpadminlab/v1 — pour une raison structurante.

Ce numéro de version n’est pas cosmétique. Le jour où la forme de la réponse change de façon incompatible, vous publiez wpadminlab/v2 sans casser les clients branchés sur v1 : applications front, intégrations tierces et caches CDN continuent de fonctionner pendant la transition. C’est une assurance peu coûteuse contre les migrations douloureuses. Notez aussi les constantes WP_REST_Server::READABLE (GET), CREATABLE (POST), EDITABLE (PUT/PATCH) et DELETABLE (DELETE) : plus lisibles que les chaînes brutes, elles correspondent exactement aux verbes HTTP attendus.

<?php
/**
 * Plugin Name: WPAdminLab REST Toolkit
 */

add_action('rest_api_init', function () {
    register_rest_route('wpadminlab/v1', '/posts', array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'wpadminlab_rest_posts',
        'permission_callback' => 'wpadminlab_rest_can_read_posts',
        'args'                => array(
            'category' => array(
                'type'              => 'integer',
                'required'          => false,
                'sanitize_callback' => 'absint',
                'validate_callback' => function ($value) {
                    return 0 === absint($value)
                        || term_exists(absint($value), 'category');
                },
            ),
            'per_page' => array(
                'type'              => 'integer',
                'default'           => 6,
                'sanitize_callback' => 'absint',
                'validate_callback' => function ($value) {
                    $n = absint($value);
                    return $n >= 1 && $n <= 50;
                },
            ),
            'page' => array(
                'type'              => 'integer',
                'default'           => 1,
                'sanitize_callback' => 'absint',
                'validate_callback' => function ($value) {
                    return absint($value) >= 1;
                },
            ),
        ),
    ));
});

Sécuriser avec permission_callback : l’erreur __return_true

La permission est le pivot de la sécurité d’un endpoint, et le point le plus souvent négligé. Depuis WordPress 5.5, permission_callback est obligatoire : si vous l’omettez, la route déclenche une notice et est traitée comme non sécurisée. Beaucoup « réparent » ce message en écrivant 'permission_callback' => '__return_true'. C’est l’erreur classique, et elle est grave : cette fonction renvoie toujours true et ouvre donc la route à tout le monde, utilisateurs anonymes et scripts inclus. Sur une lecture publique de contenus déjà publiés, c’est assumé. Sur une route exposant des brouillons, des statistiques, des commandes ou des données utilisateurs, c’est une fuite directe.

La bonne approche exprime le vrai besoin métier. Pour une route en écriture ou administrative, contrôlez une capability avec current_user_can() — jamais un secret maison ni un rôle codé en dur. Les capabilities (edit_posts, manage_options, edit_others_posts…) respectent la matrice de droits du site. Le permission_callback reçoit l’objet WP_REST_Request, ce qui permet d’affiner selon les paramètres, par exemple vérifier que l’utilisateur peut éditer ce post précis. Renvoyez un booléen, ou mieux un WP_Error pour transmettre un code HTTP explicite (401 si non authentifié, 403 si authentifié mais non autorisé).

// Lecture publique de contenus publiés : choix explicite, pas un oubli.
function wpadminlab_rest_can_read_posts(WP_REST_Request $request) {
    return true;
}

// Route privée : on s'appuie sur les capabilities WordPress.
function wpadminlab_rest_can_manage(WP_REST_Request $request) {
    if (!is_user_logged_in()) {
        return new WP_Error(
            'rest_not_logged_in',
            'Authentification requise.',
            array('status' => 401)
        );
    }

    if (!current_user_can('edit_posts')) {
        return new WP_Error(
            'rest_forbidden',
            'Droits insuffisants.',
            array('status' => 403)
        );
    }

    return true;
}

Valider et nettoyer chaque paramètre

Tout ce qui entre par une requête est suspect par défaut. L’API REST cadre les entrées via la clé args : pour chaque paramètre, vous déclarez un sanitize_callback (qui transforme la valeur en forme sûre) et un validate_callback (qui l’accepte ou la rejette). La distinction compte. La sanitation nettoie — absint force un entier positif, sanitize_text_field une chaîne, sanitize_email une adresse. La validation vérifie une règle métier et retourne true, false ou un WP_Error ; en cas d’échec, WordPress répond automatiquement par un 400 Bad Request avant même d’exécuter votre callback principal.

Dans l’exemple, category est sanitisée avec absint puis validée par term_exists() : on refuse tout identifiant qui ne correspond pas à une catégorie réelle. Les paramètres per_page et page sont bornés pour empêcher un appel du type ?per_page=100000 qui ferait exploser la mémoire et la base. C’est une protection essentielle : sans plafond, un seul client maladroit transforme votre endpoint en vecteur de déni de service. Renseignez aussi le type dans le schéma, car il alimente la documentation auto-générée et le bloc OPTIONS de la route.

Construire une réponse propre sans fuite de données

Un endpoint propre ne renvoie que les champs nécessaires. Ne sérialisez jamais l’objet WP_Post complet si le front a seulement besoin d’un titre, d’une URL et d’un extrait : moins de données, c’est moins de risques d’exposition, moins de poids réseau et un parsing plus rapide. Construisez explicitement un tableau avec les seuls champs utiles, puis enveloppez-le dans un objet WP_REST_Response (ou via rest_ensure_response()). Cet objet permet de fixer le code HTTP avec set_status() et d’ajouter des en-têtes via header() — par exemple le total d’éléments et le nombre de pages.

La gestion des erreurs suit la même logique. En cas de problème dans le callback, retournez un WP_Error en précisant le statut HTTP dans les données (array('status' => 404)). WordPress le convertit en réponse JSON normalisée (code, message, data), que les clients savent interpréter. Ne renvoyez jamais un 200 contenant un message d’erreur : un consommateur d’API se fie d’abord au code HTTP. Respecter cette sémantique — 200/201 en succès, 400 pour une entrée invalide, 401/403 pour les permissions, 404 pour l’absence, 500 côté serveur — rend votre endpoint prévisible et facile à intégrer.

Paginer proprement les collections

Une route qui retourne une collection doit être paginée, sinon elle finit par rapatrier des milliers d’objets d’un coup. La convention de l’API native est solide : exposer le nombre total d’éléments dans l’en-tête X-WP-Total et le nombre de pages dans X-WP-TotalPages. Le client lit ces en-têtes pour afficher une pagination ou charger la page suivante. Côté serveur, vous récupérez page et per_page depuis la requête (déjà bornés par la validation), vous les passez à WP_Query via paged et posts_per_page, puis vous lisez found_posts et max_num_pages.

Attention à un piège de performance : WP_Query calcule par défaut le total des résultats via SQL_CALC_FOUND_ROWS, coûteux sur de grosses tables. Vous gardez ce calcul si vous paginez ; sinon, no_found_rows => true l’économise. Voici le callback principal, qui combine pagination, en-têtes et réponse formatée — la mise en cache, traitée juste après, y est déjà intégrée pour montrer l’ordre logique des opérations.

function wpadminlab_rest_posts(WP_REST_Request $request) {
    $category = absint($request->get_param('category'));
    $per_page = absint($request->get_param('per_page')) ?: 6;
    $page     = max(1, absint($request->get_param('page')));

    $cache_key = 'wpalab_rest_posts_' . md5("{$category}_{$per_page}_{$page}");
    $cached    = get_transient($cache_key);

    if (false !== $cached) {
        $response = new WP_REST_Response($cached['items']);
        $response->header('X-WP-Total', $cached['total']);
        $response->header('X-WP-TotalPages', $cached['pages']);
        $response->header('X-Cache', 'HIT');
        return $response;
    }

    $query_args = array(
        'post_type'      => 'post',
        'post_status'    => 'publish',
        'posts_per_page' => $per_page,
        'paged'          => $page,
    );

    if ($category) {
        $query_args['cat'] = $category;
    }

    $query = new WP_Query($query_args);
    $items = array();

    foreach ($query->posts as $post) {
        $items[] = array(
            'id'      => $post->ID,
            'title'   => get_the_title($post),
            'url'     => get_permalink($post),
            'excerpt' => wp_trim_words(wp_strip_all_tags($post->post_content), 24),
            'date'    => get_the_date(DATE_ATOM, $post),
        );
    }

    $payload = array(
        'items' => $items,
        'total' => (int) $query->found_posts,
        'pages' => (int) $query->max_num_pages,
    );

    set_transient($cache_key, $payload, 10 * MINUTE_IN_SECONDS);

    $response = new WP_REST_Response($items);
    $response->header('X-WP-Total', $payload['total']);
    $response->header('X-WP-TotalPages', $payload['pages']);
    $response->header('X-Cache', 'MISS');

    return $response;
}

Mettre en cache avec les transients et Cache-Control

Une liste d’articles n’a pas besoin d’être recalculée à chaque appel. Les transients sont le cache natif de WordPress : set_transient() stocke une valeur avec une durée de vie, get_transient() la relit, l’expiration est automatique. Avec un cache objet persistant (Redis, Memcached), les transients vivent en mémoire et deviennent très rapides ; sans lui, ils retombent dans la table wp_options, utile mais moins performant. La clé doit être déterministe et inclure tous les paramètres qui influencent la réponse — catégorie, pagination — d’où le md5() sur leur concaténation dans l’exemple précédent.

Deux compléments rendent ce cache vraiment efficace. D’abord la purge : à chaque sauvegarde d’article, on supprime les transients liés à la route, car WordPress n’offre pas d’effacement natif par préfixe. Ensuite le cache HTTP, navigateur et CDN, via l’en-tête Cache-Control. Sur une route publique, $response->header('Cache-Control', 'public, max-age=300') autorise les intermédiaires à servir la réponse cinq minutes sans toucher à PHP. Ne le faites jamais sur une route privée ou personnalisée par utilisateur : vous serviriez les données d’un visiteur à un autre. Pour ces routes, préférez private, no-store.

// Purge ciblée des transients de la route à chaque changement de contenu.
add_action('save_post_post', function ($post_id) {
    if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
        return;
    }

    global $wpdb;

    $wpdb->query(
        $wpdb->prepare(
            "DELETE FROM {$wpdb->options}
             WHERE option_name LIKE %s
                OR option_name LIKE %s",
            $wpdb->esc_like('_transient_wpalab_rest_posts_') . '%',
            $wpdb->esc_like('_transient_timeout_wpalab_rest_posts_') . '%'
        )
    );
});

Sur un site à très fort trafic, cette suppression SQL directe mérite une stratégie plus fine — un index de clés, ou un cache groupé invalidé en bloc. Pour un site vitrine, un média léger ou un back-office interne, elle reste lisible et parfaitement efficace.

Appeler la route en JavaScript avec le nonce REST

Côté front, l’API REST authentifie un utilisateur connecté grâce au nonce REST, envoyé dans l’en-tête X-WP-Nonce. Pour une route publique en lecture il n’est pas obligatoire ; pour une route privée il est indispensable, car il prouve que la requête vient d’une page WordPress légitime et protège contre les attaques CSRF. La bonne pratique consiste à exposer l’URL racine de l’API et le nonce depuis PHP avec wp_localize_script(), puis à les consommer dans le script. Générez le nonce avec wp_create_nonce('wp_rest') : c’est l’action attendue par le cœur pour valider l’appel.

add_action('wp_enqueue_scripts', function () {
    wp_enqueue_script(
        'wpalab-front',
        plugin_dir_url(__FILE__) . 'front.js',
        array(),
        '1.0',
        true
    );

    wp_localize_script('wpalab-front', 'WPALabRest', array(
        'root'  => esc_url_raw(rest_url('wpadminlab/v1')),
        'nonce' => wp_create_nonce('wp_rest'),
    ));
});
async function loadPosts(page = 1) {
    const url = `${WPALabRest.root}/posts?per_page=4&page=${page}`;

    const response = await fetch(url, {
        headers: { 'X-WP-Nonce': WPALabRest.nonce },
    });

    if (!response.ok) {
        throw new Error(`Erreur REST WordPress : ${response.status}`);
    }

    const total = response.headers.get('X-WP-Total');
    const data  = await response.json();

    return { data, total };
}

Durcir la production : rate limiting, CORS et checklist

Quelques garde-fous séparent un endpoint correct d’un endpoint robuste en production. Le rate limiting protège des abus : WordPress ne le fournit pas nativement, mais vous pouvez compter les requêtes par IP dans un transient court et renvoyer un 429 Too Many Requests au-delà d’un seuil, ou mieux le déléguer à une couche amont (reverse proxy, WAF, CDN). Le CORS mérite la même prudence : il est tentant de renvoyer Access-Control-Allow-Origin: *, mais ne le faites que pour une API réellement publique. Pour une route privée, restreignez l’origine à vos domaines de confiance, sinon n’importe quel site tiers pourra solliciter votre API depuis le navigateur d’un utilisateur connecté.

N’exposez jamais de données sensibles « par commodité » : clés d’API, e-mails, métadonnées privées, identifiants internes. Le principe de moindre privilège s’applique au contenu comme aux permissions. Avant la mise en ligne, déroulez une checklist : namespace versionné ; chaque paramètre a une sanitation et une validation ; le permission_callback reflète le vrai besoin métier et n’est pas un __return_true par défaut ; la réponse ne renvoie que les champs utiles ; les requêtes passent par WP_Query ou $wpdb->prepare() ; la pagination est en place ; le cache est purgé aux changements de contenu ; les routes privées utilisent le nonce ou une authentification adaptée.

Terminez par un test manuel intégré au déploiement : appelez la route connecté, déconnecté, avec un paramètre invalide, puis avec une limite hors bornes. Si l’endpoint répond proprement — bons codes HTTP, pas de fuite, pas d’erreur PHP — dans ces quatre cas, vous avez éliminé l’essentiel des incidents. Un endpoint REST WordPress propre n’est pas compliqué : il demande de la discipline. Versionnez vos routes, validez les entrées, limitez les données renvoyées, respectez les capabilities et cachez ce qui peut l’être. Ce sont ces détails qui distinguent un bricolage fragile d’une API maintenable pendant des années.

Sources

W
WP Admin Lab

Architecte web full-stack. WordPress, performance, data et sécurité. Notes de terrain, tests reproductibles et retours d'expérience.