テクニック

WordPress REST API 外部連携 実装ガイド|認証から自動投稿まで

この記事の対象: WordPressへの自動投稿・外部連携をこれから実装する制作者
読了時間: 約15分

外部のスクリプトからWordPressへ記事を流し込む。原稿管理ツールと連携させる。CSVから一括で下書きを作る。いずれもWordPressに標準搭載されたREST APIで実装できますが、最初の一歩で止まる人が多いのが認証まわりです。「401が返る」「Basic認証のヘッダが消える」「アイキャッチだけ設定できない」——このあたりが定番のつまずきどころです。

この記事では、アプリケーションパスワードによる認証から、記事の新規投稿・更新、メディアのアップロードとアイキャッチ設定、カスタムフィールドの書き込みまで、コピーしてそのまま動くコードで通します。プラグインは使いません。


この記事の前提

作業を始める前に、環境が条件を満たしているか確認してください。

項目 条件
WordPress本体 5.6以降(アプリケーションパスワードが標準搭載されたバージョン)
通信 HTTPS必須。httpのサイトでは管理画面にアプリケーションパスワード欄が出ません
テーマ ブロックテーマ・クラシックテーマどちらでも可。REST APIはテーマに依存しません
権限 投稿を作るユーザーは編集者(editor)以上。管理者である必要はありません
実行環境 以下のコードはPHP 8系のcURLとNode.js 18以降(fetch同梱)で確認できる形で書いています

WordPress 5.6より前を使っている場合はアプリケーションパスワードが存在しないため、この記事の手順は使えません。まずWordPressを更新してください。REST API本体(/wp-json/)はWordPress 4.7から標準で有効です。

自分のサイトでREST APIが生きているかは、ブラウザで次のURLを開けば分かります。

https://example.com/wp-json/wp/v2/posts

公開済み記事のJSONが返ってくれば正常です。404が返る場合は、パーマリンク設定が「基本」になっている可能性があります。管理画面の「設定 → パーマリンク」で「投稿名」などに変更し、保存し直してください。

{"code":"rest_no_route"} が返る場合は、セキュリティ系プラグインやサーバー側の設定でREST APIが無効化されています。この場合は後述の「うまくいかないとき」を参照してください。


アプリケーションパスワードで認証する

アプリケーションパスワードで認証する

WordPressの外部連携で最初に決めるのが認証方式です。選択肢は複数ありますが、サーバー間の自動投稿ではアプリケーションパスワードが事実上の第一候補になります。

なぜアプリケーションパスワードなのか

外部からの投稿で使う認証方式の比較

アプリケーションパスワード

  • WordPress 5.6以降に標準搭載
  • プラグイン不要
  • 発行元ごとに個別発行・個別失効
  • ログインパスワードは渡さない

OAuth / JWTプラグイン

  • プラグインの導入と保守が必要
  • ユーザーに認可画面を踏ませる用途向き
  • サーバー間の定期実行には過剰

自社のスクリプトから自社のWordPressへ入れるだけなら、認可画面を挟む必要がありません。アプリケーションパスワードで足ります。第三者にユーザーの権限を委譲するサービスを作る場合だけ、OAuthを検討する価値があります。

発行手順

  1. WordPress管理画面にログインする
  2. 「ユーザー → プロフィール」を開く(他人のユーザーに発行するなら「ユーザー一覧」から対象ユーザーを開く)
  3. ページ下部の「アプリケーションパスワード」欄まで下がる
  4. 「新しいアプリケーションパスワード名」に用途が分かる名前を入れる(例: article-importer
  5. 「新しいアプリケーションパスワードを追加」を押す

abcd EFGH ijkl MNOP qrst UVWX のような、4文字×6ブロックの文字列が表示されます。この画面を閉じると二度と表示されません。 その場でコピーして保管してください。

保管したパスワードは、スペースを含んだまま使っても、スペースを除去して使っても、どちらでも認証は通ります。以降のコードではスペースを含んだまま扱います。

認証ヘッダの作り方

アプリケーションパスワードはHTTP Basic認証の形式で送ります。ユーザー名:アプリケーションパスワード をBase64エンコードして Authorization ヘッダに載せる、それだけです。

ターミナルで動作確認するなら、cURLの -u オプションが最短です。

curl -s -u "editor_user:abcd EFGH ijkl MNOP qrst UVWX" \
  https://example.com/wp-json/wp/v2/users/me

変更する箇所は3つです。

  • editor_user … 自分のWordPressのユーザー名(メールアドレスではなく、ログインID)
  • abcd EFGH ijkl MNOP qrst UVWX … 発行したアプリケーションパスワード
  • example.com … 対象サイトのドメイン

自分のユーザー情報のJSONが返れば認証は成功しています。ここで通らないなら、その先のコードを書いても全部落ちます。必ずこのコマンドを先に通してください。

{"code":"rest_not_logged_in"} が返る場合は、認証ヘッダがWordPressまで届いていません。原因はほぼサーバー設定なので、「うまくいかないとき」の項を先に読んでください。


記事を新規投稿する

認証が通ったら投稿に進みます。エンドポイントは POST /wp-json/wp/v2/posts です。

最小構成のリクエスト

まずcURLで1本作ってみます。

curl -s -X POST \
  -u "editor_user:abcd EFGH ijkl MNOP qrst UVWX" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "APIから投稿したテスト記事",
    "content": "<p>本文です。HTMLがそのまま入ります。</p>",
    "status": "draft"
  }' \
  https://example.com/wp-json/wp/v2/posts

statusdraft(下書き)・publish(公開)・pending(レビュー待ち)・private(非公開)・future(予約投稿)から選びます。

最初は必ず draft で試してください。 検証中の記事がそのまま公開されると、フィードやSNS連携プラグイン経由で外部に配信されてしまいます。公開直後に削除しても、RSSリーダーや配信先のタイムラインには残ります。

成功すると、作成された投稿のJSONが丸ごと返ります。この中の id を控えておくと、あとで更新できます。

使えるパラメータ

投稿作成でよく使うパラメータをまとめます。

パラメータ 内容
title 文字列 記事タイトル
content 文字列 本文HTML
excerpt 文字列 抜粋。空だと本文から自動生成される
status 文字列 draft / publish / pending / private / future
slug 文字列 URLスラッグ。省略するとタイトルから自動生成
date 文字列 投稿日時(サイトのタイムゾーン基準・ISO 8601形式)
date_gmt 文字列 投稿日時(UTC基準)
categories 数値の配列 カテゴリーのID。スラッグや名前では受け付けない
tags 数値の配列 タグのID
featured_media 数値 アイキャッチ画像のメディアID
author 数値 投稿者のユーザーID
comment_status 文字列 open / closed

日本語のスラッグを避けたい場合は slug を明示的に英数字で指定してください。省略するとタイトルがそのままURLエンコードされ、%e3%83%86%e3%82%b9%e3%83%88 のような長いURLになります。

author を別ユーザーのIDにしたい場合は、実行しているユーザーに他人名義で投稿する権限が必要です。編集者(editor)以上なら通りますが、投稿者(author)権限のユーザーが author を他人のIDにして送ると rest_cannot_edit_others が返ります。

予約投稿の書き方

statusfuture にして、date に未来の日時を入れます。

curl -s -X POST \
  -u "editor_user:abcd EFGH ijkl MNOP qrst UVWX" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "来週公開される記事",
    "content": "<p>本文</p>",
    "status": "future",
    "date": "2026-09-15T10:00:00"
  }' \
  https://example.com/wp-json/wp/v2/posts

date の日時はサイトの管理画面で設定したタイムゾーンで解釈されます。UTCで指定したいときは date ではなく date_gmt を使ってください。両方を同時に送ると挙動が読みにくくなるので、どちらか片方だけにします。

statusfuture にしたのに date が過去だと、WordPressは即座に公開します。日時の生成ロジックにはタイムゾーンのズレが入りやすいので、実装したら必ず管理画面の投稿一覧で「予約済み」と表示されているか確認してください。

なお、WordPressの予約投稿はサイトへのアクセスをきっかけに動くWP-Cronで実行されます。アクセスの少ないサイトでは、指定時刻を過ぎても公開されずに「予約済み」のまま止まることがあります。時刻の精度が要るなら、WP-Cronを無効化してサーバーのcronから wp-cron.php を叩く構成に変えてください。


PHPで投稿スクリプトを書く

PHPで投稿スクリプトを書く

実運用ではスクリプトから叩くことになります。素のPHPとcURLだけで書きます。外部ライブラリは入れません。

ファイルの配置場所

WordPressの外に置きます。テーマの中やプラグインディレクトリではありません。

/home/user/tools/wp-import/
├── config.php      ← 認証情報。Gitに入れない
├── wp-client.php   ← wp_request() などの共通関数
└── post.php        ← 実行スクリプト

サーバーのドキュメントルート外に置いてください。public_html の中に置くと、設定ミスでブラウザから config.php が読まれる可能性があります。

config.php

/home/user/tools/wp-import/config.php

<?php
return [
    'site'     => 'https://example.com',
    'user'     => 'editor_user',
    'app_pass' => 'abcd EFGH ijkl MNOP qrst UVWX',
];

変更する箇所は3行すべてです。site の末尾にスラッシュは付けません。付けると後続のURL結合で //wp-json になり、リダイレクトを挟んで認証ヘッダが落ちる環境があります。

wp-client.php

APIを叩く共通関数はこのファイルにまとめます。以降のスクリプトはすべてこれを require して使い回します。

/home/user/tools/wp-import/wp-client.php

<?php
/**
 * WordPress REST API を叩く共通関数。
 * $method: GET / POST
 * $path:   /wp/v2/posts のような、/wp-json より後ろの部分
 * $body:   配列(JSONとして送る)または null
 */
function wp_request(array $config, string $method, string $path, ?array $body = null): array
{
    $url = $config['site'] . '/wp-json' . $path;

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST  => $method,
        // Basic認証はここで組み立てる。CURLOPT_USERPWD でも同じ結果になるが、
        // 明示的にヘッダを作っておくと、サーバー側で落ちたときの切り分けがしやすい
        CURLOPT_HTTPHEADER     => [
            'Authorization: Basic ' . base64_encode($config['user'] . ':' . $config['app_pass']),
            'Content-Type: application/json',
            'Accept: application/json',
        ],
        CURLOPT_TIMEOUT        => 30,
    ]);

    if ($body !== null) {
        // JSON_UNESCAPED_UNICODE を付けないと日本語が \u30c6 形式に膨らみ、
        // 本文が長い記事でリクエストサイズが無駄に増える
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
    }

    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err    = curl_error($ch);
    curl_close($ch);

    if ($raw === false) {
        throw new RuntimeException('通信に失敗しました: ' . $err);
    }

    $json = json_decode($raw, true);

    if ($status >= 400) {
        $message = $json['message'] ?? $raw;
        throw new RuntimeException("APIエラー HTTP {$status}: {$message}");
    }

    return $json;
}

変更する箇所はありません。 そのまま使い回せます。

post.php

/home/user/tools/wp-import/post.php

<?php
$config = require __DIR__ . '/config.php';
require __DIR__ . '/wp-client.php';

try {
    $post = wp_request($config, 'POST', '/wp/v2/posts', [
        'title'   => 'PHPスクリプトから投稿した記事',
        'content' => "<p>1段落目です。</p>\n<p>2段落目です。</p>",
        'excerpt' => 'スクリプトから投入した記事の抜粋です。',
        'status'  => 'draft',
        'slug'    => 'api-test-post',
    ]);

    echo "投稿しました\n";
    echo "  ID:  {$post['id']}\n";
    echo "  URL: {$post['link']}\n";
} catch (RuntimeException $e) {
    // 標準エラーに出しておくと、cron から回したときログに残る
    fwrite(STDERR, $e->getMessage() . "\n");
    exit(1);
}

実行します。

php /home/user/tools/wp-import/post.php

変更する箇所title / content / excerpt / slug の4つです。

エラー時に例外を投げてHTTPステータスとWordPress側のメッセージを両方出しているのが要点です。WordPressのREST APIは失敗時に {"code":"...","message":"..."} という形でかなり具体的な理由を返してくれるので、これを握り潰さないでください。「なぜか投稿されない」で長時間詰まる原因の大半が、レスポンスを見ていないことです。


記事を更新する・重複を防ぐ

自動投稿の仕組みを作ると、必ず「二重投稿」の問題に当たります。cronが二度走った、リトライが走った、といった理由で同じ記事が2本並ぶ状態です。

更新はPOSTで投稿IDを指定する

更新のエンドポイントは POST /wp/v2/posts/{id} です。PUTでも通りますが、WordPressの実装ではPOSTが正式なルートなのでPOSTで揃えます。

$updated = wp_request($config, 'POST', '/wp/v2/posts/123', [
    'title'   => '差し替えた新しいタイトル',
    'content' => '<p>本文も差し替えます。</p>',
]);

123 を対象の投稿IDに変えてください。送ったフィールドだけが更新され、送っていないフィールドは元の値のままです。タイトルだけ直したいなら title だけ送れば済みます。

ただし配列を取るフィールドは例外です。categoriestags は差分ではなく丸ごと置き換えなので、タグを1つ追加したいときも既存のタグIDを全部含めた配列を送る必要があります。既存分を取得せずに新しいIDだけ送ると、それ以外のタグが外れます。

スラッグで既存記事を探してから決める

外部システム側のIDとWordPressの投稿IDを対応させる管理テーブルを持たない場合、スラッグを一意キーにするのが手軽です。「同じスラッグの記事があれば更新、なければ新規」という分岐を書きます。

/home/user/tools/wp-import/upsert.php

<?php
$config = require __DIR__ . '/config.php';
require __DIR__ . '/wp-client.php';

/**
 * スラッグで既存記事を探し、あれば更新・なければ新規作成する。
 */
function wp_upsert_post(array $config, string $slug, array $fields): array
{
    // status=any を付けないと下書きが検索対象から漏れ、下書きがあるのに新規作成してしまう
    $found = wp_request(
        $config,
        'GET',
        '/wp/v2/posts?slug=' . rawurlencode($slug) . '&status=any&per_page=1'
    );

    $fields['slug'] = $slug;

    if (!empty($found)) {
        $id = $found[0]['id'];
        return wp_request($config, 'POST', '/wp/v2/posts/' . $id, $fields);
    }

    return wp_request($config, 'POST', '/wp/v2/posts', $fields);
}

// 使用例
$post = wp_upsert_post($config, 'api-test-post', [
    'title'   => '同じスラッグなら上書きされる記事',
    'content' => '<p>2回実行しても記事は1本のままです。</p>',
    'status'  => 'draft',
]);

echo "ID {$post['id']} を書き込みました\n";

ここでの落とし穴が status=any です。これを付けずに検索すると公開済み記事しかヒットせず、下書きが既にあっても新規作成してしまいます。下書きを作る運用なら必須です。なお status=any を指定した検索には認証が必要なので、認証ヘッダなしのGETでは通りません。

もう一つ、ゴミ箱の中の記事もスラッグを保持したまま残ります。同じスラッグで新規作成すると、WordPress側でスラッグの末尾に -2 が付き、意図しないURLになります。ゴミ箱を空にする運用を決めておくか、status=any の検索結果に trash が混じっていないかを見て分岐してください。

二重投稿を防ぐ処理の流れ
スラッグで検索status=any を付けて下書きも対象に含める
件数で分岐0件なら新規、1件以上なら先頭のIDを取る
書き込む新規はposts、更新はposts/{id}へPOST
IDを記録次回以降は検索せずID直指定でもよい

一度書き込んだらそのIDを外部システム側に保存しておくと、2回目以降は検索を挟まず直接更新できます。リクエストが1本減るぶん速くなります。


メディアをアップロードしてアイキャッチに設定する

つまずく人がいちばん多いのがここです。記事本文と違い、メディアのアップロードはJSONではなくバイナリを直接送る必要があります。

手順は2ステップ

  1. POST /wp/v2/media で画像をアップロードし、メディアIDを受け取る
  2. 記事の featured_media にそのIDを入れる

1回のリクエストでは終わりません。記事作成と同時にアイキャッチを付けたい場合は、先に画像を上げてIDを取ってから記事を作ります。

アップロード処理

/home/user/tools/wp-import/media.php

<?php
/**
 * 画像ファイルをメディアライブラリにアップロードする。
 * 戻り値はメディアID。
 */
function wp_upload_media(array $config, string $filePath, string $altText = ''): int
{
    if (!is_readable($filePath)) {
        throw new RuntimeException("ファイルが読めません: {$filePath}");
    }

    $fileName = basename($filePath);
    $mime     = mime_content_type($filePath);

    $ch = curl_init($config['site'] . '/wp-json/wp/v2/media');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Basic ' . base64_encode($config['user'] . ':' . $config['app_pass']),
            // このヘッダがないとWordPressはファイル名を判別できず、
            // 「アップロードするファイルが指定されていません」で弾かれる
            'Content-Disposition: attachment; filename="' . $fileName . '"',
            'Content-Type: ' . $mime,
        ],
        // JSONではなく、ファイルの中身そのものをボディに入れる
        CURLOPT_POSTFIELDS     => file_get_contents($filePath),
        CURLOPT_TIMEOUT        => 60,
    ]);

    $raw    = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $json = json_decode($raw, true);

    if ($status >= 400) {
        throw new RuntimeException(
            'アップロード失敗 HTTP ' . $status . ': ' . ($json['message'] ?? $raw)
        );
    }

    $mediaId = (int) $json['id'];

    // alt属性は別リクエストで更新する。アップロード時には受け付けられない
    if ($altText !== '') {
        wp_request($config, 'POST', '/wp/v2/media/' . $mediaId, [
            'alt_text' => $altText,
        ]);
    }

    return $mediaId;
}

変更する箇所はありません。 そのまま使えます。

Content-Disposition ヘッダが要点です。ここでファイル名を渡さないと、WordPressは受け取ったバイナリをどんな名前で保存すべきか判断できず、rest_upload_no_data エラーを返します。JSONの感覚で {"file": "..."} のようなボディを送っても通りません。

ファイル名は保存時にサニタイズされます。日本語ファイル名はそのまま通ることもありますが、サーバーの文字コード設定によっては壊れます。アップロード前に英数字のファイル名へ変えておくのが安全です。同名のファイルが既にある場合はWordPress側が -1 -2 と連番を付けて別ファイルとして保存するため、同じ画像を繰り返し上げるとメディアライブラリが太ります。同じ画像を使い回すなら、初回のメディアIDを外部システム側に持っておいてください。

alt_text をアップロード時のヘッダに混ぜようとして詰まる人がいますが、alt属性はアップロード後に POST /wp/v2/media/{id} で入れます。上のコードはその2段階を内側で済ませています。

記事作成と組み合わせる

<?php
$config = require __DIR__ . '/config.php';
require __DIR__ . '/wp-client.php';
require __DIR__ . '/media.php';

try {
    // 1. 画像を上げてIDを取る
    $mediaId = wp_upload_media(
        $config,
        '/home/user/tools/wp-import/images/eyecatch.jpg',
        'デスクに置かれたノートパソコンとコーヒー'
    );

    // 2. そのIDを featured_media に入れて記事を作る
    $post = wp_request($config, 'POST', '/wp/v2/posts', [
        'title'          => 'アイキャッチ付きで投稿した記事',
        'content'        => '<p>本文</p>',
        'status'         => 'draft',
        'featured_media' => $mediaId,
        'categories'     => [5],   // ← カテゴリーIDに差し替える
    ]);

    echo "投稿ID {$post['id']} / アイキャッチ {$mediaId}\n";
} catch (RuntimeException $e) {
    fwrite(STDERR, $e->getMessage() . "\n");
    exit(1);
}

変更する箇所は画像パス・alt文言・categories のIDです。

カテゴリーIDの調べ方

categories は名前やスラッグではなくIDでしか受け付けません。IDはブラウザで次のURLを開けば一覧できます。

https://example.com/wp-json/wp/v2/categories?per_page=100

返ってきたJSONの idname を突き合わせてください。スクリプト側でスラッグから引きたいなら、こう書きます。

/**
 * カテゴリースラッグからIDを引く。見つからなければ null。
 */
function wp_category_id(array $config, string $slug): ?int
{
    $list = wp_request($config, 'GET', '/wp/v2/categories?slug=' . rawurlencode($slug));
    return $list[0]['id'] ?? null;
}

存在しないカテゴリーを自動で作りたい場合は POST /wp/v2/categoriesnameslug を送ります。ただしカテゴリーの新規作成には manage_categories 権限が要るため、投稿者(author)権限のユーザーでは通りません。自動投稿用のユーザーを編集者(editor)にしておくべき理由のひとつがこれです。

タグは /wp/v2/tags、カスタムタクソノミーは /wp/v2/{taxonomy} で同じ形になります。


Node.jsから投稿する

原稿管理側がJavaScriptで動いているなら、Node.jsから直接叩けます。Node.js 18以降は fetch が標準搭載なので、axiosnode-fetch も不要です。

/home/user/tools/wp-import/post.mjs

const SITE = 'https://example.com';
const USER = 'editor_user';
const APP_PASS = 'abcd EFGH ijkl MNOP qrst UVWX';

const AUTH = 'Basic ' + Buffer.from(`${USER}:${APP_PASS}`).toString('base64');

/**
 * WordPress REST API を叩く。
 * body は object(JSONとして送る)か Buffer(メディア用)。
 */
async function wpRequest(method, path, body = null, extraHeaders = {}) {
  const headers = { Authorization: AUTH, ...extraHeaders };
  let payload;

  if (body instanceof Buffer) {
    payload = body;
  } else if (body) {
    headers['Content-Type'] = 'application/json';
    payload = JSON.stringify(body);
  }

  const res = await fetch(`${SITE}/wp-json${path}`, {
    method,
    headers,
    body: payload,
  });

  const json = await res.json();

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${json.message ?? JSON.stringify(json)}`);
  }

  return json;
}

// ---- 実行 ----

const post = await wpRequest('POST', '/wp/v2/posts', {
  title: 'Node.jsから投稿した記事',
  content: '<p>fetch だけで投稿できます。</p>',
  status: 'draft',
  slug: 'node-api-test',
});

console.log(`投稿しました: ${post.id} / ${post.link}`);

実行します。

node /home/user/tools/wp-import/post.mjs

変更する箇所は先頭3行の定数と、実行部分の投稿内容です。

メディアをNode.jsから送る

バイナリ送信も同じ関数で通せます。Buffer をそのまま渡し、ヘッダだけ差し替えます。

import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';

async function uploadMedia(filePath, mimeType) {
  const buffer = await readFile(filePath);

  const media = await wpRequest('POST', '/wp/v2/media', buffer, {
    'Content-Disposition': `attachment; filename="${basename(filePath)}"`,
    'Content-Type': mimeType,
  });

  return media.id;
}

const mediaId = await uploadMedia('./images/eyecatch.jpg', 'image/jpeg');

fetch のボディに Buffer を渡すとそのままバイト列として送られます。FormData を使う必要はありません——WordPressのメディアエンドポイントはmultipartではなく生のバイナリを期待しているので、FormData で包むとかえって弾かれます。

認証情報を環境変数に逃がす

本番で回すなら認証情報はコードに直書きせず、環境変数から読んでください。

const SITE = process.env.WP_SITE;
const USER = process.env.WP_USER;
const APP_PASS = process.env.WP_APP_PASS;

if (!SITE || !USER || !APP_PASS) {
  throw new Error('WP_SITE / WP_USER / WP_APP_PASS を設定してください');
}

起動前に値が揃っているかを確認して落とすのが要点です。未設定のまま走ると Basic dW5kZWZpbmVk... という無意味なヘッダで401が返り、認証情報が間違っているのか設定漏れなのか切り分けられなくなります。


カスタムフィールドを書き込む

ここも定番のつまずきどころです。meta にキーを入れて送っても、何のエラーも出ないまま無視されることがあります。REST API経由で書き込めるカスタムフィールドは、register_post_meta() で明示的に登録されたものだけだからです。

functions.phpに登録を追加する

/wp-content/themes/子テーマ名/functions.php

<?php
/**
 * REST API から読み書きできるカスタムフィールドを登録する。
 */
add_action( 'init', function () {
    register_post_meta( 'post', 'external_article_id', array(
        'type'          => 'string',
        'single'        => true,
        'show_in_rest'  => true,   // ← これがないとREST APIから見えない
        'auth_callback' => function () {
            return current_user_can( 'edit_posts' );
        },
    ) );
} );

変更する箇所'post'(投稿タイプ)と 'external_article_id'(メタキー)です。カスタム投稿タイプに付けるなら第1引数をその投稿タイプ名に変えてください。

show_in_resttrue でないとREST APIの世界に存在しない扱いになります。auth_callback は書き込み権限の判定で、これを省くと編集者でも書き込めないことがあります。

アンダースコアで始まるメタキー(_external_id のような形)はWordPressが保護されたメタとして扱い、auth_callback を書いても素直には通りません。REST API経由で書き込むメタキーは、アンダースコアを付けない名前にしておくのが確実です。

親テーマのfunctions.phpは直接触らないでください。テーマ更新で消えます。子テーマがまだ無い環境なら、先に子テーマを作るところからです。テーマをどう選ぶかの段階から迷っているなら、下の記事で有料・無料・自作の判断基準を整理しています。

書き込む

登録が済めば、投稿時に meta として送れます。

$post = wp_request($config, 'POST', '/wp/v2/posts', [
    'title'   => 'カスタムフィールド付きの記事',
    'content' => '<p>本文</p>',
    'status'  => 'draft',
    'meta'    => [
        'external_article_id' => 'CMS-2026-0912',
    ],
]);

書き込めたかは、レスポンスJSONの meta を見れば分かります。ここに値が入っていなければ登録が効いていません。

このメタキーを外部システムの記事IDとして使うと、スラッグに頼らない突合ができます。検索は次の形です。

GET /wp-json/wp/v2/posts?meta_key=external_article_id&meta_value=CMS-2026-0912&status=any

ただし meta_key / meta_value によるクエリは、標準では REST API のフィルタとして有効になっていません。有効化には rest_post_query フィルタでの追加実装が必要になるため、実装の手間を避けたいならスラッグを一意キーにする方式のほうが早いです。

判断の目安はこうなります。記事のURLが後から変わらない運用ならスラッグで足ります。外部システム側でタイトルを直すたびにスラッグが変わる、あるいは同じ記事を別スラッグで再公開する可能性があるなら、メタキー方式に追加実装のコストを払う価値があります。

ACFのフィールドを書き込む場合

Advanced Custom Fields(ACF)で作ったフィールドは、フィールドグループ側の設定でREST APIへの露出を有効にすると acf というキーの下に現れます。この場合は meta ではなく acf に入れて送ります。

'acf' => [
    'client_name' => '株式会社サンプル',
],

meta に入れても無視されるので、レスポンスJSONを開いて acfmeta のどちらに値が並んでいるかを先に確認してください。プラグインのバージョンによって挙動が変わる部分なので、実装前にレスポンスの形を目で見るのが早道です。


うまくいかないとき

実装中に当たる典型的な症状と、その原因を並べます。上から順に確認してください。

401が返る/rest_not_logged_in が出る

原因の切り分け順です。

  1. HTTPSで叩いているか。 httpだとアプリケーションパスワード自体が機能しません
  2. ユーザー名が正しいか。 メールアドレスではなくログインIDです
  3. 認証ヘッダがPHPに届いているか。 これが最頻出の原因です

3番の確認方法です。テーマの functions.php に一時的に次を足し、https://example.com/?authcheck=1 を開きます。

// 確認が終わったら必ず消すこと
add_action( 'init', function () {
    if ( isset( $_GET['authcheck'] ) ) {
        var_dump( isset( $_SERVER['HTTP_AUTHORIZATION'] ) );
        exit;
    }
} );

bool(false) が出るなら、Apacheが Authorization ヘッダをPHPに渡していません。.htaccess の先頭に次を追加します。

/.htaccess(WordPressルート、# BEGIN WordPress より

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTP:Authorization} ^(.*)
RewriteRule .* - [E=HTTP_AUTHORIZATION:%1]
</IfModule>

Nginx+PHP-FPMの構成では、この .htaccess は読まれません。サーバー設定側で fastcgi_param HTTP_AUTHORIZATION $http_authorization; を渡す必要があります。共用サーバーで設定を触れない場合は、契約先のサポートに「REST APIのBasic認証ヘッダを通したい」と問い合わせるのが早道です。

確認用コードは検証が終わったら必ず削除してください。

rest_no_route が返る

REST APIが無効化されています。心当たりを順に潰します。

  • セキュリティ系プラグインの「REST APIを無効化」設定
  • functions.php に書かれた rest_authentication_errors フィルタでの一律拒否
  • WAFがJSON形式のPOSTを弾いている(サーバー管理画面でWAFを一時的にオフにして再試行すると切り分けられる)
  • カスタム投稿タイプのエンドポイントを叩いている場合、その投稿タイプが show_in_rest を持っていない

rest_cannot_create が返る

認証は通っているが、権限が足りていません。そのユーザーの権限グループを確認してください。投稿者(author)権限では、他人の記事の更新やカテゴリーの新規作成ができません。 自動投稿用のユーザーは編集者(editor)にしておくのが無難です。

日本語が文字化けする

送信時のJSONエンコードを疑います。PHPなら json_encode()JSON_UNESCAPED_UNICODE を付けているか、ソースファイルがUTF-8(BOMなし)で保存されているかの2点です。BOM付きで保存されていると、レスポンスの先頭に見えない3バイトが混じって json_decode()null を返します。

アイキャッチが設定されない

featured_media に入れているのがメディアIDかどうかを確認してください。画像のURLを入れても無視されます。メディアIDはアップロード時のレスポンスJSONの id です。

IDを入れているのに反映されない場合は、そのメディアが本当に存在するか GET /wp/v2/media/{id} で確かめてください。削除済みのIDを指定してもエラーにならず、アイキャッチが空のまま作成されます。

画像アップロードで413が返る

サーバーのアップロード上限を超えています。php.iniupload_max_filesizepost_max_size、Nginxなら client_max_body_size の3つが関係します。上限を上げるより、送る前に画像を縮めるほうが確実です。アイキャッチなら横幅1600px程度あれば足ります。

本文のHTMLが勝手に消える

WordPressのコンテンツフィルタが、許可されていないタグを除去しています。<script><iframe> は編集者権限では通りません。管理者権限なら通ることがありますが、unfiltered_html 権限に依存するためマルチサイト環境では管理者でも落ちます。埋め込みが必要なら、ショートコード経由に変えるのが安全です。

ショートコードの実装は下の記事で扱っています。プラグインなしで独自ブロックを差し込む具体例として、そのまま流用できます。

ブロックエディタで開くと「このブロックには問題が含まれています」と出る

APIから流し込んだHTMLは、ブロックエディタから見ると「クラシックブロック」として扱われます。これ自体は壊れていませんが、ブロック単位で編集したいなら、本文をブロックコメント付きのHTMLで送ってください。

<!-- wp:paragraph -->
<p>これはブロックとして認識される段落です。</p>
<!-- /wp:paragraph -->

コメントの形式が崩れているとエラー表示になります。手で組むのが面倒なら、素のHTMLで入れてクラシックブロックのまま運用するほうが事故は少ないです。

動いていたのに突然401になった

パスワードを持っているユーザーのログインパスワードが変更されると、そのユーザーのアプリケーションパスワードは無効になります。管理画面の「ユーザー → プロフィール」でアプリケーションパスワードが残っているか確認し、消えていれば再発行してください。自動投稿を止めないために、発行時点で「どのユーザーのどの用途か」をメモに残しておくと復旧が早くなります。


運用に載せるときの注意

検証が通ったあと、実運用に移す段階で決めておくべきことです。

項目 判断
認証情報の置き場所 環境変数か、ドキュメントルート外の設定ファイル。Gitには入れない
専用ユーザー 人間のアカウントと分ける。用途が終わったらそのユーザーごと削除できる
権限 編集者。管理者権限は渡さない
失敗時 標準エラーへ出す。cronならログにリダイレクトして残す
リトライ タイムアウト時の自動リトライは二重投稿を生む。スラッグ突合を必ず挟む
実行頻度 共用サーバーでは大量のリクエストを一気に投げるとWAFに弾かれることがある。1件ずつ間隔を空ける
実行ユーザーの削除 ユーザーを消すとその記事の投稿者が消える。削除前に記事の引き継ぎ先を指定する

アプリケーションパスワードは用途ごとに発行できます。「原稿ツール用」「CSV一括投入用」のように分けておけば、片方だけ失効させられます。全部を1本で回していると、漏洩時に止められる範囲が広がります。

失敗したものを取りこぼさない

一括投入で怖いのは、途中で落ちたときにどこまで入ったか分からなくなることです。実装の段階で次の3つを決めておいてください。

  • 1件ごとに結果を記録する。 成功なら投稿ID、失敗ならエラーメッセージを外部システム側かログファイルに残す
  • 途中で止まっても最初からやり直せる形にする。 スラッグ突合を挟んでおけば、全件を再実行しても既に入ったものは更新されるだけで済む
  • エラーの種類で止めるか続けるかを分ける。 401や rest_no_route は設定の問題なので、1件目で落ちたらそこで止める。個別記事の内容起因のエラーは記録して次へ進む

401が返っている状態で数百件を投げ続けても、全部落ちるだけでサーバーに負荷をかけます。認証系のエラーは即座に止める設計にしてください。

サイトのバックアップ体制も併せて確認してください。自動投稿が暴走してデータを荒らしたときに戻せる状態かどうかは、実装より先に決まっているべきことです。バックアップの取り方と保守ツールの構成は下の記事で扱っています。

外部連携の設計そのものを検討している段階なら、そもそもWordPressで進めるかの判断も含めてCMS比較|WordPress・STUDIO・Wixの選び方と向き不向き【2026年】が参考になります。ヘッドレス構成にするか通常のテーマで組むかで、REST APIの使い方も変わってきます。


よくある質問

アプリケーションパスワードとログインパスワードは何が違いますか

アプリケーションパスワードは外部アプリ専用に発行する鍵で、管理画面へのログインには使えません。用途ごとに複数発行でき、不要になったものだけを個別に失効できます。漏洩したときの被害範囲を狭められる点が最大の違いです。ログインパスワードを外部サービスに渡すと、管理画面ごと乗っ取られる状態になります。

REST APIで投稿するとセキュリティ上のリスクはありますか

認証情報の管理さえ間違えなければ、通常のログインより安全に運用できます。HTTPSで通信し、認証情報をGit管理下やドキュメントルート内に置かず、専用ユーザーの権限を編集者に留めるという3点を守ってください。管理者権限のアプリケーションパスワードを外部サービスに渡すのは避けます。

カスタム投稿タイプにもREST APIで投稿できますか

できますが、投稿タイプの登録時に show_in_resttrue にし、rest_base でエンドポイント名を指定しておく必要があります。この設定がない場合はエンドポイント自体が存在せず、rest_no_route が返ります。プラグインで作られた投稿タイプは、そのプラグインの設定で対応状況が変わります。

一度に何件まで投稿できますか

REST API側に件数の上限はありませんが、サーバーのリソースとWAFの制限が実質的な上限になります。共用サーバーでは1件ごとに間隔を空け、失敗したものを記録して後で再投入する形にしておくと安全です。大量の一括投入なら、下書き状態で入れてから管理画面で確認する流れを推奨します。

画像のURLを指定するだけでアイキャッチを設定できませんか

REST API単体ではできません。先に POST /wp/v2/media でメディアライブラリへ登録し、返ってきたメディアIDを featured_media に入れる2ステップが必要です。外部URLの画像をそのまま使う仕組みが必要なら、スクリプト側で画像をダウンロードしてからアップロードする処理を挟むことになります。

ヘッドレス構成でもこの記事の内容は使えますか

読み出し側は別物になりますが、書き込み側はそのまま同じです。フロントをNext.jsなどで組む場合でも、記事を投入する経路は本記事のアプリケーションパスワード+REST APIで成立します。違いが出るのは読み出しで、公開データの取得だけなら認証は不要ですし、GraphQLプラグインを載せる選択肢も出てきます。


まとめ

WordPressのREST API連携でつまずくのは、ほとんどが決まった場所です。

  • 認証ヘッダがPHPまで届いていない.htaccess かサーバー設定で通す)
  • メディアはJSONではなくバイナリで送るContent-Disposition でファイル名を渡す)
  • カテゴリーはIDでしか受け付けない(スラッグから引く関数を用意する)
  • カスタムフィールドは register_post_meta() の登録が必要show_in_rest を忘れない)
  • 二重投稿はスラッグ突合で防ぐstatus=any を付けないと下書きが漏れる)

この5つを先に潰しておけば、あとは投げるJSONを組み立てるだけの作業になります。まずは curl/wp/v2/users/me を通すところから始めてください。そこが通れば、残りは一本道です。