2026年7月20日
2026年7月20日
WordPressでWebhookを受信・処理する方法|署名検証と非同期処理の実装
WordPressで外部サービスのWebhookを受け取るには、REST APIに受信用のルートを作り、permission_callback でリクエストの署名を検証し、重い処理はバックグラウンドに回してすぐ200を返す、という3点を押さえれば安全で安定した受信口になります。この記事では、決済サービスやGitHub、フォームサービスなどからの通知を受ける想定で、HMAC署名の検証、再送による二重処理の防止、Action Scheduler または WP-Cron への処理の移譲、そしてcurlでの動作確認までを実装します。
Webhook受信で守るべき3つのこと
Webhookは、外部サービスがイベント発生時にあなたのサイトのURLへHTTPのPOSTを送ってくる仕組みです。受け取る側として気をつけたいのは次の3点です。
- 送り主の確認:受信用のURLは誰でもPOSTできます。URLを推測されにくくしても、送り主がそのサービスである証明にはなりません。多くのサービスは共有シークレットで作ったHMAC署名をヘッダーに付けて送ってくるので、それを検証します。
- すばやい応答:送信側は一定時間内に2xxの応答が返らないと、失敗とみなして再送します。受信処理の中でメール送信や外部APIの呼び出しをすると時間切れになり、同じイベントが何度も届く原因になります。
- 二重処理の防止:正しく処理していても、ネットワークの都合で同じイベントが再送されることはあります。イベントIDで重複を判定し、同じイベントは一度だけ処理します。
受信用のRESTルートを作る
admin-ajax.php や独自のPHPファイルでも受信はできますが、REST API を使うとURL設計・メソッド制限・応答の形式をWordPressの仕組みに任せられます。以下は独自の小さなプラグインとして置く想定です。
<?php
/**
* Plugin Name: My Webhook Receiver
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_action( 'rest_api_init', function () {
register_rest_route(
'mywh/v1',
'/receive',
array(
'methods' => 'POST',
'callback' => 'mywh_receive',
'permission_callback' => 'mywh_verify_signature',
)
);
} );
これで https://example.com/wp-json/mywh/v1/receive が受信用のURLになります。パーマリンク設定が「基本」のサイトでは /?rest_route=/mywh/v1/receive の形になるので、送信側に登録するURLに注意してください。
permission_callback に __return_true を指定して、コールバックの中で検証する書き方もよく見かけます。動作はしますが、検証を書き忘れたり、検証の前に処理が走ったりする事故が起きやすくなります。検証そのものを permission_callback に置けば、署名が正しくないリクエストはコールバックに到達する前に拒否されます。
署名を検証する
シークレットの置き場所
送信側の管理画面でWebhookを登録すると、署名用のシークレット(共有鍵)が発行されるか、自分で設定できます。この値はコードやデータベースに直接書かず、wp-config.php に定数として置きます。
define( 'MYWH_SECRET', 'your-webhook-secret' );
検証の実装
署名の方式はサービスごとに異なります。ここでは、送信側が X-Webhook-Timestamp に送信時刻(UNIX時間)、X-Webhook-Signature に「時刻 + . + 本文」のHMAC-SHA256を16進数で付けてくる、という一般的な形を例にします。
function mywh_verify_signature( WP_REST_Request $request ) {
if ( ! defined( 'MYWH_SECRET' ) || '' === MYWH_SECRET ) {
return new WP_Error( 'mywh_not_configured', 'Webhook secret is not configured.', array( 'status' => 500 ) );
}
$timestamp = $request->get_header( 'x_webhook_timestamp' );
$signature = $request->get_header( 'x_webhook_signature' );
if ( ! $timestamp || ! $signature || ! ctype_digit( $timestamp ) ) {
return new WP_Error( 'mywh_missing_signature', 'Missing signature.', array( 'status' => 401 ) );
}
// 5分以上ずれたリクエストは再送攻撃とみなして拒否する
if ( abs( time() - (int) $timestamp ) > 5 * MINUTE_IN_SECONDS ) {
return new WP_Error( 'mywh_expired', 'Timestamp out of range.', array( 'status' => 401 ) );
}
$payload = $timestamp . '.' . $request->get_body();
$expected = hash_hmac( 'sha256', $payload, MYWH_SECRET );
if ( ! hash_equals( $expected, strtolower( trim( $signature ) ) ) ) {
return new WP_Error( 'mywh_invalid_signature', 'Invalid signature.', array( 'status' => 401 ) );
}
return true;
}
実装上のポイントを説明します。
- 生の本文で計算する:署名は送られてきたバイト列そのものに対して作られています。
get_json_params()でデコードした配列をwp_json_encode()し直すと、空白やエスケープの違いで一致しなくなるため、get_body()の値をそのまま使います。 - **
hash_equals()で比較する**:===や==で比較すると、一致しない位置によって処理時間がわずかに変わり、それを手がかりに署名を推測される余地が生まれます。hash_equals()は長さが同じなら一定時間で比較します。第1引数に自分で計算した値、第2引数に受け取った値を渡します。 - ヘッダー名:
WP_REST_Request::get_header()はヘッダー名を小文字にし、ハイフンをアンダースコアに置き換えて扱います。X-Webhook-Signatureはx_webhook_signatureで取得できます。 - 時刻のチェック:署名だけだと、過去の正しいリクエストを第三者が盗み見てそのまま送り直す「リプレイ攻撃」を防げません。署名対象に時刻を含め、許容範囲を外れたものを拒否します。サーバーの時計がずれていると正当なリクエストまで拒否されるので、NTPで時刻を合わせておきます。
主なサービスの署名方式の違い
実際には、各サービスのドキュメントに従って計算方法を合わせます。たとえば GitHub は X-Hub-Signature-256 ヘッダーに sha256= を前に付けた本文のHMAC-SHA256を送ってきます。この場合は接頭辞を付けて比較します。
$expected = 'sha256=' . hash_hmac( 'sha256', $request->get_body(), MYWH_SECRET );
$received = (string) $request->get_header( 'x_hub_signature_256' );
$valid = hash_equals( $expected, $received );
Stripe のように時刻と署名を1つのヘッダーにまとめて送るサービスもあります。公式のSDKが署名検証の関数を提供している場合は、それを使うほうが仕様変更にも追随しやすくなります。いずれのサービスも方式は変わることがあるため、実装時点のドキュメント(2026年10月現在の仕様)を確認してください。
受信したらすぐ応答し、処理は後回しにする
署名が通ったら、コールバックではイベントを保存して処理を予約するだけにし、すぐに200を返します。
function mywh_receive( WP_REST_Request $request ) {
$data = $request->get_json_params();
if ( ! is_array( $data ) || empty( $data['id'] ) || ! is_string( $data['id'] ) ) {
return new WP_Error( 'mywh_bad_payload', 'Invalid payload.', array( 'status' => 400 ) );
}
$event_id = preg_replace( '/[^A-Za-z0-9_\-]/', '', $data['id'] );
if ( '' === $event_id || strlen( $event_id ) > 100 ) {
return new WP_Error( 'mywh_bad_payload', 'Invalid event id.', array( 'status' => 400 ) );
}
// add_option() は同名のオプションが既にあると false を返す(重複判定に使う)
$stored = add_option( 'mywh_event_' . $event_id, $request->get_body(), '', false );
if ( ! $stored ) {
// 既に受け取ったイベント。再送なので成功として返す
return new WP_REST_Response( array( 'received' => true, 'duplicate' => true ), 200 );
}
if ( function_exists( 'as_enqueue_async_action' ) ) {
as_enqueue_async_action( 'mywh_process_event', array( $event_id ), 'mywh' );
} else {
wp_schedule_single_event( time(), 'mywh_process_event', array( $event_id ) );
}
return new WP_REST_Response( array( 'received' => true ), 200 );
}
重複判定に add_option() を使っているのは、オプション名がデータベース上で一意になっていて、同名のものが既にあれば追加に失敗するからです。「存在を確認してから保存する」2段階の処理だと、ほぼ同時に届いた再送を両方とも新規と判定してしまうことがありますが、この方法ならその隙間が小さくなります。第4引数の false は自動読み込み(autoload)をしない指定で、受信したイベントが毎ページの読み込みに混ざるのを防ぎます。
重複したイベントにも200を返すのは、送信側に「受け取れなかった」と判断されて再送が続くのを止めるためです。
処理の予約には、WooCommerce などが同梱している Action Scheduler があればそちらを、なければ WordPress 標準の wp_schedule_single_event() を使います。Action Scheduler は管理画面で実行結果や失敗を確認でき、失敗時の調査がしやすいのが利点です。予約の引数には本文そのものではなくイベントIDだけを渡し、本文はオプションから読み出します。大きなデータを予約の引数に入れると、保存できる長さの制限に引っかかることがあるためです。
予約した処理を実行する
add_action( 'mywh_process_event', 'mywh_process_event' );
function mywh_process_event( $event_id ) {
$option = 'mywh_event_' . $event_id;
$body = get_option( $option );
if ( false === $body ) {
return;
}
$data = json_decode( $body, true );
if ( ! is_array( $data ) ) {
return;
}
$type = isset( $data['type'] ) ? sanitize_key( $data['type'] ) : '';
switch ( $type ) {
case 'order_created':
// 例: 管理者に通知する。外部のデータは表示や保存の前にサニタイズする
$name = isset( $data['customer']['name'] ) ? sanitize_text_field( $data['customer']['name'] ) : '';
wp_mail( get_option( 'admin_email' ), '新しい注文の通知', '顧客名: ' . $name );
break;
default:
// 想定外のイベントは記録だけして何もしない
error_log( 'mywh: unhandled event type ' . $type );
}
// 本文は処理後に削除し、重複判定用の印だけ残す
update_option( $option, 'processed', false );
}
処理後に本文を processed という印に置き換えているのは、重複判定のための記録を残しつつ、個人情報などを含む本文をデータベースに残さないためです。記録が増え続けるのが気になる場合は、日次の定期処理で一定期間より古い mywh_event_ オプションを削除するか、受信件数が多いサイトでは専用のデータベーステーブルに切り替えてください。
WP-Cron はサイトへのアクセスをきっかけに動くため、アクセスが少ないサイトでは処理が遅れることがあります。Webhookの処理を数分以内に終わらせたい場合は、wp-config.php で DISABLE_WP_CRON を true にしたうえで、サーバーの cron から定期的に WP-Cron を実行する設定に変えると安定します。
curlで動作を確認する
送信側の「テスト送信」機能を使う前に、手元から署名付きのリクエストを送って確認すると、問題の切り分けがしやすくなります。
SECRET='your-webhook-secret'
BODY='{"id":"evt_test_001","type":"order_created","customer":{"name":"Test User"}}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -i -X POST "https://example.com/wp-json/mywh/v1/receive" \
-H "Content-Type: application/json" \
-H "X-Webhook-Timestamp: $TS" \
-H "X-Webhook-Signature: $SIG" \
--data-raw "$BODY"
確認したいのは次の4パターンです。
- 正しい署名 → 200 と
"received":trueが返る - 同じコマンドをもう一度実行 → 200 と
"duplicate":trueが返る(TSが変わってもidが同じなら重複扱い) SECRETを変えて実行 → 401 が返る- 予約した処理が実行され、通知メールが届く(Action Scheduler なら「ツール」→「予定されたアクション」、WooCommerce 導入サイトでは「WooCommerce」→「ステータス」→「予定されたアクション」で確認できます)
届かない・401になるときの確認点
- セキュリティプラグインやWAF:REST APIへの未ログインアクセスを制限するプラグインや、サーバーのWAFがPOSTを遮断していることがあります。受信用のルートだけ許可するよう設定します。
- REST API の無効化:
rest_authentication_errorsフィルターで未ログインのREST利用を一律に拒否するコードがあると、Webhookも届きません。拒否の対象から受信用のルートを外します。 - Basic認証やメンテナンスモード:ステージング環境でBasic認証をかけていると、送信側は認証を通れません。
- 本文の変形:プロキシやプラグインが本文を書き換えると、署名が一致しなくなります。401が続く場合は、受信した
get_body()の長さと送信側が記録している本文を比べます。
ログに出すときは、シークレットや署名の値そのもの、個人情報を含む本文をそのまま記録しないように注意してください。
まとめ
- Webhookは
register_rest_route()で受信用のPOSTルートを作り、permission_callbackで署名を検証する - 署名は
get_body()の生の本文で計算し、hash_equals()で比較する。時刻を署名に含めてリプレイ攻撃を防ぐ - シークレットは
wp-config.phpの定数に置き、コードやデータベースに直接書かない - 受信時は保存と予約だけしてすぐ200を返し、重い処理は Action Scheduler か
wp_schedule_single_event()に任せる - イベントIDで重複を判定し、再送にも200を返して同じ処理を繰り返さない
- curlで正常・重複・不正署名の応答と後続処理の実行を確かめてから、送信側に本番のURLを登録する