ブログを更新したら、その記事を自動でX(旧Twitter)などに投稿する。
やりたいことは単純ですが、2026年のX APIはこの1年で料金体系も管理画面も大きく変わっており、少し前の解説記事がほとんど当てにならない状態になっています。
この記事は、WordPressの記事公開をトリガーにn8n経由でXへ自動投稿する仕組みを、実際に手を動かして構築した記録です。
最初に結論を書いておきます。Webhookの受信、投稿文の生成まではすべて動作しましたが、最後のX APIへの投稿だけが403エラーで通りませんでした。 これは設定ミスではなく、X側の従量課金移行に伴う既知の問題である可能性が高いことが調査で判明しています。詳細は記事の後半に書きます。
現時点では手動投稿で運用し、X側で問題が解消され次第この記事を更新します。同じ構成を試そうとしている方は、後半の「ぶつかった壁」から先に読んで下さい。
システムの全体図
今回、組み立てた自動投稿システムは、4つの部分から成り立っています。
WordPress(記事公開)
│ Webhook(POST / JSON)
▼
n8n ─ Webhook受信 → 公開/更新の振り分け → 投稿文の生成
│ X API v2(POST /2/tweets)
▼
X(自動投稿)
WordPressで記事が公開された瞬間に、記事のタイトル・URL・抜粋・カテゴリなどをまとめてn8nへ送信します。n8nはそれを受け取り、新規公開かどうかを判定し、X向けの投稿文を組み立てて、X APIを呼び出す。シンプルな構成です。
実装するには色々な方法がありますが、n8nを選んだ理由は、既に別用途でn8nを契約していたこと、投稿文の作り込みや条件分岐が自由にできること、そして将来的にYouTubeや他のSNSへ横展開しやすいことです。WordPressのSNS連携プラグインで済ませる方法もありますが、そちらについては後述の比較で触れます。

X APIの料金は「定額」から「従量課金」へ変わった
構築の前にまずは料金の話をしておきます。ここでは最近、大きな変更がありました。
かつてX APIには無料枠と、月200ドルのBasic、月5,000ドルのProという定額プランがありました。「自動投稿したいだけなのに月200ドルは払えない。」と諦めた人も多かったかもしれません。
しかし、2026年2月からX APIの料金体系が変わり、新規開発者は従量課金(Pay Per Use)がデフォルトになり、BasicとProは新規申し込みができなくなりました。つまり今から始める人は使った分だけ支払う方式になっています。
単価は次のとおりです。
| 操作 | 単価 |
|---|---|
| 投稿(URLなし) | 0.015ドル |
| 投稿(URLを含む) | 0.20ドル |
| 読み取り | 0.005ドル |
注目すべきは、URLを含む投稿だけ単価が10倍以上に跳ね上がっている点です。ブログ記事の自動投稿は必ずURLを含むので、実質的に1投稿あたり0.20ドルと考えることになります。
月間コストの目安を出すと、記事30本で約6ドル、100本でも約20ドル。かつての月200ドルと比べれば料金の桁が違います。個人ブログや小規模サイトの自動投稿であれば、10ドル程度で十分に収まります。
投稿頻度が極端に高い場合は、本文にURLを入れず返信でリンクを示すことで単価を下げる手もありますが、クリック率とのトレードオフになるため、まずは素直にURL付きで始めるのが妥当でしょう。
手順1:X Developer Console でアプリを作る
developer.x.com から開発者登録を行い、アプリを作成します。
ここで最初の落とし穴があります。現在のDeveloper Consoleには「プロジェクト」という階層がありません。 少し前までは「Projects & Apps」という2階層構造で、プロジェクトを作ってからその中にアプリを作る、という手順でした。解説記事の多くはこの前提で書かれていますが、現行のコンソールでは左メニューに「Apps」があるだけのフラットな構造になっています。
アプリ作成時に選ぶ項目として、Environment(Development / Staging / Production)があります。実運用のアプリなので Production を選びます。
作成後、アプリの詳細画面から Keys & Tokens タブを開きます。ここも名称が変わっているので注意が必要です。かつて「API Key / API Key Secret」と呼ばれていたものは、現在「OAuth 1.0 Keys」セクションの Consumer Key に相当します。
そして今回n8nで使うのは、こちらではなく OAuth 2.0 の Client ID / Client Secret です。この2つは別物で、取り違えると認証が通りません。OAuth 2.0の鍵は、次の「User authentication settings」を設定して初めて発行されます。

手順2:認証設定と権限
Keys & Tokens の最下部にある「User authentication settings」のSet upを押し、次のように設定します。
- App permissions:Read and write(投稿するために必須)
- Type of App:Web App, Automated App or Bot
- Callback URI:
https://oauth.n8n.cloud/oauth2/callback - Website URL:自分のサイトのURL

Callback URIは、これから使うツール側が指定する値です。n8nクラウドの場合は oauth.n8n.cloud という共通ドメインで受けます。セルフホスト版のn8nだと https://(自分のドメイン)/rest/oauth2-credential/callback という形式になるので、ここを取り違えると「Something went wrong」と表示されて認証に失敗します。
保存するとOAuth 2.0 の Client ID と Client Secret が表示されます。Client Secretは初回のみの表示ですが、閉じてしまっても再生成できるので慌てる必要はありません。
アプリの権限を後から Read and write に変更した場合、それ以前に発行されたアクセストークンには古い権限(読み取りのみ)が残ります。設定画面上は書き込み可能に見えるのに、実際のトークンは読み取り専用、という状態になり、投稿だけが403で失敗します。権限を変更したら、必ずトークンを取り直してください。
手順3:クレジットの購入と上限設定
従量課金はプリペイド式なので、クレジットを購入しないとAPIが動きません。残高ゼロの状態でAPIを呼び出すと403が返ります。
Dashboardの「Buy Credits」、または Billing → Credits から購入します。金額は最小額で十分です。1投稿0.20ドルなので、10ドルあれば約50投稿分になります。
購入したら、Spend Cap(利用上限) を設定しすることをお奨めします。Billing → Credits の「Manage Spend Cap」から、1請求サイクルあたりの上限額を指定できます。上限に達するとAPIリクエストが次のサイクルまでブロックされるため、ワークフローの不具合で記事の連投が発生しても被害が限定されます。
同じ画面にある Auto Recharge(自動チャージ)は、最初はオフのままにしておくことを勧めます。上限設定と自動チャージを両方有効にすると、安全装置の意味が薄れます。運用が安定して消費ペースが読めてから検討すれば十分です。

手順4:n8nでWebhookを受ける
n8nで新規ワークフローを作り、Webhook ノードを追加します。設定は次のとおりです。
- HTTP Method:
POST - Path:任意(例:
wp-gyomusystem) - Respond:
Immediately - Authentication:
Header Auth
Header Authは、外部から誰でもこのWebhookを叩けてしまう状態を防ぐための認証です。認証情報を新規作成し、Name に X-Webhook-Secret、Value に32文字程度のランダムな文字列を設定します。この文字列は後でWordPress側にも同じものを設定します。
WebhookノードにはTest URLとProduction URLの2種類が表示されます。
- Test URL(
/webhook-test/...):「Listen for test event」を押している間だけ有効 - Production URL(
/webhook/...):ワークフローをActiveにしている間だけ有効
開発中はTest URL、本番はProduction URL。WordPress側に設定するURLをどちらにするかを間違えると、「送っているのに何も届かない」という状態になります。

手順5:WordPressからWebhookを送る
WordPressには標準でWebhook送信機能がないので、コードを自分で追加します。テーマの functions.php に書くこともできますが、テーマを更新した際に消えるリスクがあるので、Code Snippets プラグインを使うのが安全です。
/* ---------- 設定 ---------- */
defined( 'NSW_WEBHOOK_URL' ) || define( 'NSW_WEBHOOK_URL', 'https://(n8nのWebhook URL)' );
defined( 'NSW_WEBHOOK_SECRET' ) || define( 'NSW_WEBHOOK_SECRET', '(32文字のランダム文字列)' );
defined( 'NSW_NOTIFY_ON_UPDATE' ) || define( 'NSW_NOTIFY_ON_UPDATE', false );
defined( 'NSW_BLOCKING' ) || define( 'NSW_BLOCKING', true );
defined( 'NSW_POST_TYPES' ) || define( 'NSW_POST_TYPES', 'post' );
/* ---------- Webhook 送信本体 ---------- */
if ( ! function_exists( 'nsw_send_webhook' ) ) {
function nsw_send_webhook( $event, $post ) {
if ( ! ( $post instanceof WP_Post ) ) {
return;
}
// 対象投稿タイプ以外は無視
$types = array_map( 'trim', explode( ',', NSW_POST_TYPES ) );
if ( ! in_array( $post->post_type, $types, true ) ) {
return;
}
// 自動保存・リビジョンは無視
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( wp_is_post_revision( $post->ID ) ) {
return;
}
// パスワード保護記事は流さない
if ( ! empty( $post->post_password ) ) {
return;
}
$excerpt = has_excerpt( $post )
? $post->post_excerpt
: wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ), 80, '' );
$payload = array(
'event' => $event,
'id' => (int) $post->ID,
'title' => get_the_title( $post ),
'url' => get_permalink( $post ),
'excerpt' => wp_strip_all_tags( $excerpt ),
'categories' => wp_get_post_categories( $post->ID, array( 'fields' => 'names' ) ),
'tags' => wp_get_post_tags( $post->ID, array( 'fields' => 'names' ) ),
'thumbnail' => get_the_post_thumbnail_url( $post->ID, 'large' ),
'published' => get_the_date( 'c', $post ),
'modified' => get_the_modified_date( 'c', $post ),
'site' => get_bloginfo( 'name' ),
);
$response = wp_remote_post(
NSW_WEBHOOK_URL,
array(
'method' => 'POST',
'timeout' => 8,
'blocking' => (bool) NSW_BLOCKING,
'headers' => array(
'Content-Type' => 'application/json',
'X-Webhook-Secret' => NSW_WEBHOOK_SECRET,
),
'body' => wp_json_encode( $payload ),
)
);
// デバッグ用ログ(WP_DEBUG 有効時のみ)
if ( NSW_BLOCKING && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
if ( is_wp_error( $response ) ) {
error_log( '[NSW] error: ' . $response->get_error_message() );
} else {
error_log( '[NSW] status: ' . wp_remote_retrieve_response_code( $response ) );
}
}
}
}
/* ---------- フック登録 ---------- */
// 新規公開(下書き・予約・レビュー待ち → 公開)
add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {
if ( 'publish' === $new_status && 'publish' !== $old_status ) {
nsw_send_webhook( 'publish', $post );
}
}, 10, 3 );
// 公開済み記事の更新(公開 → 公開)
add_action( 'post_updated', function ( $post_id, $post_after, $post_before ) {
if ( ! NSW_NOTIFY_ON_UPDATE ) {
return;
}
if ( 'publish' === $post_after->post_status && 'publish' === $post_before->post_status ) {
nsw_send_webhook( 'update', $post_after );
}
}, 10, 3 );
Code Snippetsに貼る際は、Scopeを「Run snippet everywhere」にするのを忘れないでください。「Only run on site front-end」にすると、管理画面での記事公開時に発火せず、まったく動きません。
設計上のポイントをいくつか補足します。
transition_post_status を使い、「公開以外 → 公開」の遷移だけを新規公開として拾っています。これにより、下書きからの公開、予約投稿の自動公開、レビュー待ちからの公開が、すべて同じイベントとして扱えます。
更新時の通知は既定でオフにしています。理由はAPIコストです。URL付きの投稿は1件0.20ドルなので、誤字修正のたびに投稿されては費用がかさみます。実用上も、同じ記事が何度も流れるアカウントは敬遠されます。
なお、この設計には1つ注意点があります。公開中の記事を一度下書きに戻してから再公開すると、新規公開として発火します。 大幅なリライトのために非公開を挟む運用をしていると、意図しない投稿が発生します。リライト時は非公開を挟まず、そのまま更新するのが安全です。
手順6:投稿文を組み立てる──Xの文字数計算という罠
Webhookで受け取ったデータから投稿文を組み立てます。ここで実装者がぶつかるのが、Xの文字数計算が単純な文字数ではないという点です。
Xの上限は280文字ですが、これは「重み付き」の値です。
- 半角英数字・記号:1文字あたり 1
- 日本語などの全角文字:1文字あたり 2
- URL:実際の長さに関わらず一律 23
つまり日本語だけなら実質140文字が上限で、URLは何文字だろうと23として計算されます。JavaScriptの String.length で制御すると、日本語では必ず超過エラーになります。
これを踏まえたCodeノードの実装が以下です。
// 重み付き文字数を計算する
function weightedLength(str) {
let w = 0;
for (const ch of str) {
const c = ch.codePointAt(0);
const isNarrow =
(c >= 0x0000 && c <= 0x10ff) ||
(c >= 0x2000 && c <= 0x200d) ||
(c >= 0x2010 && c <= 0x201f) ||
(c >= 0x2032 && c <= 0x2037);
w += isNarrow ? 1 : 2;
}
return w;
}
// 重み基準で切り詰める
function trimByWeight(str, budget) {
let w = 0;
let out = '';
for (const ch of str) {
const add = weightedLength(ch);
if (w + add > budget) return out + '…';
out += ch;
w += add;
}
return out;
}
const MAX = 280;
const URL_WEIGHT = 23;
const d = $input.first().json.body;
const title = (d.title || '').trim();
const url = d.url || '';
const hashtags = '#業務システム #DX #業務効率化';
let excerpt = (d.excerpt || '').replace(/\s+/g, ' ').trim();
// タイトル + URL + ハッシュタグ + 区切りの改行(\n\n × 3 = 6)
const fixed =
weightedLength(title) + URL_WEIGHT + weightedLength(hashtags) + 6;
const budget = MAX - fixed;
excerpt = budget > 10 ? trimByWeight(excerpt, budget) : '';
const text = [title, excerpt, url, hashtags].filter(Boolean).join('\n\n');
return [{ json: { text } }];
固定要素(タイトル・URL・ハッシュタグ・改行)の重みを先に計算し、残った予算のぶんだけ抜粋を入れる、という考え方です。抜粋が入りきらない場合は省略します。
Webhookノードの前に If ノードを挟み、{{ $json.body.event }} が publish のときだけ通す条件を入れておくと、更新イベントを確実に除外できます。

ぶつかった壁:403 Forbidden
ここまでの部分はすべて正常に動作しました。
WordPressで記事を公開するとWebhookがトリガーされ、n8nがそれを受信し、条件分岐を通り、投稿文が正しく組み立てられる。Executionsの履歴でも、最後のノードまで緑で到達します。
しかし、X APIを叩く最後の一手だけが通りませんでした。
{
"detail": "You are not permitted to perform this action.",
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}403は「認証は通っているが、その操作をする権限がない」という意味です。順に確認しましたが、いずれも問題ありませんでした。
- App permissions:Read and write(Xの連携アプリ画面でも「Post and repost for you.」と明記されていることを確認)
- アクセストークン:権限変更後に再認証済み
- クレジット残高:あり
- アプリの配置:Pay Per Use プロジェクトの Production 環境に active 状態で存在
設定側でできることはすべて確認済みで、それでも通らない。ここで調査に切り替えました。
調査してわかったこと
X開発者フォーラム(devcommunity.x.com)を調べたところ、まったく同じ症状の報告が2026年2月頃から現在まで継続して投稿されていることがわかりました。
報告内容はどれも似通っています。権限はRead and Write、クレジット残高あり、Spend Cap設定済みで使用量ゼロ、それでもPOST /2/tweetsが403を返す。読み取り系のエンドポイントは正常に動くのに、書き込みだけが通らない、というパターンです。
そして根本原因についても、複数の報告で同じ指摘がされています。X API v2の書き込みエンドポイントは、アプリが「プロジェクト」に紐づいていることを要求するのですが、新しいDeveloper Consoleには「Projects & Apps」セクションもプロジェクト作成ボタンも存在しません。その結果、アプリが「Standalone App(単独アプリ)」の扱いのまま残り、書き込みが拒否される。ユーザー側から紐づけを修正する手段がない、という構造的な問題です。
エラーメッセージが client-not-enrolled という形で返るケースもあり、こちらはより直接的に「プロジェクトに紐づいたアプリの鍵を使う必要がある」と述べています。
n8nを使っている人の報告もありました。6月下旬までは正常に投稿できていたのに、ある日を境に403を返すようになった、というケースです。つまり設定が正しくても、ある日突然動かなくなることがあるということです。
一方で、フォーラムではX側のスタッフが個別にアプリのプロジェクト紐づけを手動で修正し、「これで直ったはずです」と返信して解決した事例も複数確認できました。つまり、現状の唯一の確実な解決ルートはフォーラムへの報告ということになります。
現時点での運用と回避策
以上を踏まえ、現在は次のように運用しています。
- 手動投稿に切り替えました
当然ですがXのアプリやブラウザからの通常投稿にAPIの課金は一切かかりません。 - n8nのワークフローはそのまま残しています
X側が修正されれば、何も触らずに自動投稿が復活します。ただし記事を公開するたびに403で失敗した実行履歴が溜まるので、気になる場合は一時的に非アクティブにしておく手もあります。 - 急ぐ場合の代替案
Buffer や Publer といった外部のSNS予約投稿サービスにブログのRSSフィードを登録する方法があります。これらのサービスは自社でX APIアクセスを保持しているため、この問題の影響を受けません。自動化をどうしても止めたくない場合は、一時的にこちらへ逃がすのが現実的です。
自動化そのものを急がないなら、手動投稿の期間はむしろ投稿の型を試す機会になります。記事URLを貼るだけの投稿より、一言コメントを添えたほうが反応が良い、といった感触をつかんでおけば、自動化が復活したときにCodeノードのテンプレートへ反映がスムーズにできます。
副産物:OGPタグの自動出力とカードキャッシュ
手動投稿に切り替えた際、リンクを貼ってもサムネイル画像が表示されないという別の問題にぶつかりました。これも実用的な話なので記録しておきます。
WordPressは標準ではOGPタグを出力しません。 SEO系プラグインを入れていれば自動で出力されますが、入れていない場合、Xがカードを生成するための情報が存在しないことになります。
プラグインを増やしたくなかったので、これもCode Snippetsで対応しました。記事のタイトル・抜粋・アイキャッチ画像から、og:title og:description og:image と twitter:card を自動生成して wp_head に出力するだけの短いコードです。アイキャッチが無い記事用に既定画像を指定できるようにしておくと、どの記事を投稿してもカードが空になりません。
そしてもう一つ、Xのカードキャッシュという罠があります。Xは一度取得したURLのカード情報をキャッシュするため、OGPタグを追加する前にそのURLがXに読み込まれていると、タグを追加した後も「カードなし」という古い結果が表示され続けます。
以前はCard Validatorで手動更新できましたが、この機能は廃止されており、現在キャッシュを明示的に消す手段はありません。時間経過を待つか、URLにクエリパラメータを付けて別URLとして認識させる(?v=2 など)ことで回避できます。表示されるページは同一なので実害はありません。
まとめ
2026年7月時点で、この構成について言えることを整理します。
- 正常に動く部分
WordPressからのWebhook送信、n8nでの受信と条件分岐、投稿文の生成。ここまでは安定して動作し、設計上の課題もほぼありません。X APIの料金も、従量課金への移行によって個人ブログでも十分に現実的な水準になりました。 - 動作しない部分
X API v2への投稿。これは設定の問題ではなく、従量課金移行に伴うプロジェクト紐づけの問題である可能性が高く、ユーザー側で解決する手段が現状ありません。
これから同じ構成を作ろうとしている方は、まずX APIの投稿が通るかどうかだけを最小構成で確認することをお勧めします。Webhookやワークフローを作り込む前に、curlなりn8nの単体実行なりで1回投稿してみる。そこが通らないなら、先に進んでも最後で止まります。
X側で問題が解決し次第、自動投稿をアクティブにしてこの記事を更新します。


コメント