2026年6月2日

2026年6月2日

WordPressのWebhookをREST APIで実装する方法|受信エンドポイントと送信処理の作り方

WordPressでWebhookを扱うには、受け取る側は register_rest_route() で専用のREST APIエンドポイントを作り、送る側は投稿の公開などのフックから wp_remote_post() で通知を送ります。この記事では、受信と送信の両方を、署名による検証・重複処理の防止・時間のかかる処理の後回しといった実運用で必要になる要素まで含めて、コード付きで解説します。外部サービスからの通知を受けたい方にも、WordPressの更新を外部に知らせたい方にも使える構成です。

Webhookの全体像と、この記事の構成

Webhookは「何かが起きたら、決められたURLにHTTPのPOSTで知らせる」仕組みです。WordPressが関わる方向は2つあります。

  • 受信:決済サービスやフォームサービス、Gitホスティングなどから通知を受け取り、WordPress側で処理する
  • 送信:記事の公開や会員登録などをきっかけに、Slackや自社システム、ビルドサービスなどへ通知する

どちらの方向でも、「なりすましを防ぐ署名」「同じ通知が複数回届く前提での設計」「すぐに応答を返す」の3点が共通の要件です。コードはテーマの functions.php ではなく、テーマを変えても動き続ける独自プラグインか mu-plugin(wp-content/mu-plugins/ に置くPHPファイル)に書くのがおすすめです。作業前にはバックアップを取り、ステージング環境で試してください。

受信:REST APIでWebhookのエンドポイントを作る

エンドポイントを登録する

REST APIのルートは rest_api_init アクションの中で register_rest_route() を使って登録します。

<?php
/**
 * Plugin Name: My Webhook Receiver
 */

add_action( 'rest_api_init', function () {
	register_rest_route( 'mysite/v1', '/webhook', array(
		'methods'             => 'POST',
		'callback'            => 'mysite_handle_webhook',
		'permission_callback' => 'mysite_verify_webhook_signature',
	) );
} );

これで https://example.com/wp-json/mysite/v1/webhook が受信用のURLになります。パーマリンク設定が「基本」の場合は https://example.com/?rest_route=/mysite/v1/webhook の形になります。

permission_callback は省略するとWordPressが警告を出すため、指定が必要です。公開APIでは __return_true を指定する例をよく見かけますが、Webhookの受信口は誰でも呼べるURLなので、ここで送信元の検証を行います。

署名を検証する

多くのサービスは、共有シークレットと本文からHMAC-SHA256の署名を作り、HTTPヘッダーに付けて送ってきます。ヘッダー名や署名の作り方(本文だけか、タイムスタンプを含めるか、16進数かBase64か)はサービスごとに異なるため、相手のドキュメントを確認してコードを合わせてください。以下は「本文をHMAC-SHA256で署名し、16進数で X-Signature ヘッダーに入れる」方式の例です。

<?php
// wp-config.php に define( 'MYSITE_WEBHOOK_SECRET', 'your-webhook-secret' ); を書いておく

function mysite_verify_webhook_signature( WP_REST_Request $request ) {
	if ( ! defined( 'MYSITE_WEBHOOK_SECRET' ) || '' === MYSITE_WEBHOOK_SECRET ) {
		return new WP_Error( 'webhook_not_configured', 'Not configured', array( 'status' => 500 ) );
	}

	$signature = (string) $request->get_header( 'x_signature' );
	$expected  = hash_hmac( 'sha256', $request->get_body(), MYSITE_WEBHOOK_SECRET );

	if ( '' === $signature || ! hash_equals( $expected, $signature ) ) {
		return new WP_Error( 'invalid_signature', 'Invalid signature', array( 'status' => 401 ) );
	}
	return true;
}

ポイントは3つです。1つ目は、署名の計算には $request->get_body() で取得した受信したままの本文を使うことです。JSONをデコードしてから再エンコードすると、空白やキーの順番が変わって署名が一致しなくなります。2つ目は、比較に === ではなく hash_equals() を使うことで、比較にかかる時間の差から署名を推測される攻撃を防げます。3つ目は、シークレットをコードやデータベースに直書きせず、wp-config.php の定数や環境変数から読み込むことです。Gitに含めないよう注意してください。

サービスがタイムスタンプ付きの署名に対応している場合は、タイムスタンプが現在時刻から数分以内かも確認すると、過去の通知を再送する「リプレイ攻撃」を防げます。

処理本体:重複を防ぎ、すぐに応答する

Webhookの送信側は、応答が遅かったりエラーが返ったりすると同じ通知を再送します。そのため、受信側は「同じイベントが2回届いても1回分しか処理しない」作りにしておき、重い処理は後回しにして素早く応答を返します。

<?php
function mysite_handle_webhook( WP_REST_Request $request ) {
	$payload = json_decode( $request->get_body(), true );
	if ( ! is_array( $payload ) || empty( $payload['id'] ) ) {
		return new WP_Error( 'bad_payload', 'Invalid payload', array( 'status' => 400 ) );
	}

	// イベントIDは大文字小文字を区別するサービスもあるため、加工せずハッシュ化する
	$lock_key = 'mysite_wh_' . md5( (string) $payload['id'] );

	// 処理済みのイベントなら何もせず成功を返す
	if ( get_transient( $lock_key ) ) {
		return new WP_REST_Response( array( 'status' => 'duplicate' ), 200 );
	}
	set_transient( $lock_key, 1, DAY_IN_SECONDS );

	// 重い処理はバックグラウンドへ
	wp_schedule_single_event( time(), 'mysite_process_webhook', array( $payload ) );

	return new WP_REST_Response( array( 'status' => 'accepted' ), 200 );
}

add_action( 'mysite_process_webhook', function ( $payload ) {
	// ここで投稿の作成やメール送信などを行う。値は用途に応じてサニタイズする
	$title = isset( $payload['title'] ) ? sanitize_text_field( $payload['title'] ) : '';
	// ...
} );

トランジェントはオブジェクトキャッシュの設定によっては早めに消えることがあるため、決済のように重複が許されない処理では、イベントIDを専用のテーブルや投稿メタに保存して判定するほうが堅実です。また、wp_schedule_single_event() はWP-Cronで実行されるため、アクセスの少ないサイトでは実行が遅れます。サーバーのcronから wp-cron.php を定期実行する設定にしておくと安定します。

送信:WordPressのイベントを外部に通知する

投稿が公開されたときに通知する

「記事が公開されたら外部に知らせる」場合は、transition_post_status アクションが扱いやすいです。新しいステータスと古いステータスを受け取れるので、「初めて公開されたとき」だけを判定できます。save_post はブロックエディターで保存したときに複数回呼ばれることがあり、更新のたびにも発火するため、公開の判定には向きません。

<?php
add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {
	if ( 'publish' !== $new_status || 'publish' === $old_status ) {
		return;
	}
	if ( 'post' !== $post->post_type || wp_is_post_revision( $post ) ) {
		return;
	}
	// 編集画面の保存を遅くしないよう、送信は後回しにする
	wp_schedule_single_event( time(), 'mysite_send_webhook', array( $post->ID ) );
}, 10, 3 );

署名付きで送信し、結果を確認する

送信には WordPress の HTTP API である wp_remote_post() を使います。受け取る側が検証できるよう、受信のときと同じ考え方で署名を付けます。

<?php
add_action( 'mysite_send_webhook', function ( $post_id ) {
	$post = get_post( $post_id );
	if ( ! $post || ! defined( 'MYSITE_OUTGOING_URL' ) || ! defined( 'MYSITE_WEBHOOK_SECRET' ) ) {
		return;
	}

	$body = wp_json_encode( array(
		'event' => 'post.published',
		'id'    => $post->ID,
		'title' => get_the_title( $post ),
		'url'   => get_permalink( $post ),
	) );

	$response = wp_remote_post( MYSITE_OUTGOING_URL, array(
		'timeout' => 10,
		'headers' => array(
			'Content-Type' => 'application/json',
			'X-Signature'  => hash_hmac( 'sha256', $body, MYSITE_WEBHOOK_SECRET ),
		),
		'body'    => $body,
	) );

	if ( is_wp_error( $response ) ) {
		error_log( 'Webhook送信失敗: ' . $response->get_error_message() );
		return;
	}
	$code = wp_remote_retrieve_response_code( $response );
	if ( $code < 200 || $code >= 300 ) {
		error_log( 'Webhook送信先がエラーを返しました: HTTP ' . $code );
	}
} );

送信先のURLやシークレットは wp-config.php の定数にしておくと、ステージングと本番で切り替えやすくなります。失敗時の再送が必要なら、失敗回数を投稿メタに記録し、間隔を空けて wp_schedule_single_event() で再登録する形にします。ログには送信先のエラー内容だけを残し、シークレットや個人情報を書き出さないようにしてください。

動作確認とつまずきやすい点

受信エンドポイントは、curlで署名付きのリクエストを送って確認できます。

BODY='{"id":"evt_test_001","title":"テスト"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'your-webhook-secret' | sed 's/^.* //')
curl -i -X POST https://example.com/wp-json/mysite/v1/webhook \
  -H "Content-Type: application/json" \
  -H "X-Signature: $SIG" \
  --data "$BODY"

うまく動かないときは、次の順で確認します。

  1. 404(rest_no_route):コードが読み込まれていない、ルート名の綴りが違う、パーマリンク設定の再保存が必要
  2. 401や403:署名の不一致のほか、セキュリティプラグインやWAFがREST APIへのアクセスを制限していないか
  3. 署名が一致しない:本文を加工してから計算していないか、16進数とBase64の違い、シークレットの前後の空白
  4. 処理が実行されない:WP-Cronが動いているか(DISABLE_WP_CRON の設定、サーバーcronの有無)

WooCommerce のように、プラグイン自体にWebhook送信機能を持つものもあります。使えるイベントや設定画面はバージョンで変わるため(2026年10月現在)、自作する前に利用中のプラグインの機能を確認すると、コードを書かずに済む場合があります。

まとめ

  • 受信は register_rest_route() でエンドポイントを作り、permission_callback で署名を検証する
  • 署名は受信したままの本文(get_body())でHMACを計算し、hash_equals() で比較する。シークレットは wp-config.php などに置く
  • 同じ通知が複数回届く前提で、イベントIDで重複を判定し、重い処理は wp_schedule_single_event() で後回しにして素早く200を返す
  • 送信は transition_post_status で公開を判定し、wp_remote_post() で署名付きのJSONを送る。結果は is_wp_error() と応答コードで確認する
  • コードは独自プラグインか mu-plugin に置き、ステージング環境でcurlを使って検証してから本番に反映する

お気軽にご相談ください

お見積りへ お問い合わせへ
▲