2026年5月28日
2026年5月28日
AirtableのレコードをWordPressへ自動同期するRESTフックの実装方法
Airtableで管理しているレコードを、更新のたびにWordPressの投稿へ自動反映するには、Airtableのオートメーションから「どのレコードが変わったか」だけをWordPressの独自RESTエンドポイントに通知し、WordPress側がAirtable APIから最新の内容を取りに行く構成が安全で扱いやすい方法です。この記事では、その構成の考え方、WordPress側のエンドポイント実装、Airtable側のスクリプト、運用上の注意までを説明します。
なぜ「通知だけ受けて、取りに行く」構成にするのか
AirtableからWordPressへ連携する方法はいくつかあります。
- Airtableのオートメーションからレコードの中身を丸ごと送る:手軽ですが、受け取ったデータが本当にAirtableから来たものか、WordPress側で確かめる手段が必要になります
- WordPressが定期的にAirtable APIを読みに行く(ポーリング):WP-Cronで実装できますが、反映まで待ち時間があり、変更が無くてもAPIを呼び続けます
- 通知(レコードID)だけ受け、WordPressがAPIから取得する:本記事の方法です
3つ目の方法では、エンドポイントに届くのはレコードIDだけです。仮に第三者が通知を偽装しても、WordPressは自分が持つトークンでAirtableから正規のデータを取り直すため、内容を差し替えられることはありません。受け付けるのは「このIDを同期し直して」という依頼だけなので、エンドポイントが悪用されたときの影響を小さくできます。
コードを書かずに済ませたい場合は、Airtableの内容をWordPressへ同期するプラグイン(例:Air WP Sync)や、ZapierやMakeといった連携サービスもあります。対応するフィールドの種類や無料版で使える範囲は製品ごとに異なり変更もあるため、導入前に各公式サイトで確認してください(2026年10月現在)。
事前準備
Airtableのパーソナルアクセストークン
Airtableの開発者向け画面でパーソナルアクセストークンを発行します。スコープはレコードの読み取り(data.records:read)だけ、対象ベースも同期に使うベースだけに絞ります。書き込み権限は不要です。
続いて、同期するベースのID(app で始まる値)と、テーブル名またはテーブルID(tbl で始まる値)を控えます。どちらもAirtableのAPIドキュメント画面やURLから確認できます。
秘密情報はwp-config.phpに置く
トークン類はデータベースやテーマファイルではなく、wp-config.php に定数として書きます。値はダミーで示しています。
// wp-config.php(「編集が必要なのはここまでです」の行より上に追記)
define( 'AIRTABLE_PAT', 'your-airtable-personal-access-token' );
define( 'AIRTABLE_BASE_ID', 'appXXXXXXXXXXXXXX' );
define( 'AIRTABLE_TABLE', 'Articles' );
define( 'AIRTABLE_SYNC_SECRET', 'long-random-string-for-webhook' );
AIRTABLE_SYNC_SECRET は、Airtableからの通知であることを確かめるための共有の合言葉です。パスワード生成ツールなどで長いランダム文字列を作ってください。wp-config.php をGitで管理している場合は、これらの値をコミットしないよう別ファイルや環境変数に分けます。
Airtableのテーブル設計
例として、テーブルに次のフィールドを用意します。
- Title(1行テキスト):投稿タイトル
- Slug(1行テキスト):投稿スラッグ
- Body(長文テキスト):本文
- Status(単一選択):
draftまたはpublish
AirtableのレコードIDを投稿メタに保存し、レコードと投稿を1対1で結びつけます。スラッグを照合キーにするとAirtable側でスラッグを直したときに別の投稿が作られてしまうため、変わらないレコードIDを使うのが安全です。
WordPress側:エンドポイントの実装
以下は単独のプラグインまたはmu-pluginとして置く想定のコードです。テーマを切り替えても同期が止まらないよう、テーマのfunctions.phpではなくプラグインとして分けることをおすすめします。まずステージング環境で動作を確認してください。
<?php
/**
* Plugin Name: Airtable Sync Endpoint
*/
add_action( 'rest_api_init', function () {
register_rest_route( 'airtable-sync/v1', '/ping', array(
'methods' => 'POST',
'callback' => 'my_airtable_sync_ping',
'permission_callback' => 'my_airtable_sync_permission',
'args' => array(
'record_id' => array(
'required' => true,
'type' => 'string',
'validate_callback' => function ( $value ) {
return is_string( $value ) && preg_match( '/^rec[A-Za-z0-9]+$/', $value );
},
),
),
) );
} );
function my_airtable_sync_permission( WP_REST_Request $request ) {
if ( ! defined( 'AIRTABLE_SYNC_SECRET' ) ) {
return false;
}
$token = (string) $request->get_header( 'X-Sync-Token' );
if ( '' === $token || ! hash_equals( AIRTABLE_SYNC_SECRET, $token ) ) {
return new WP_Error( 'forbidden', 'Invalid token', array( 'status' => 403 ) );
}
// 1分あたりの受付回数を制限する(簡易的なレート制限)
$count = (int) get_transient( 'my_airtable_sync_count' );
if ( $count >= 60 ) {
return new WP_Error( 'too_many', 'Too many requests', array( 'status' => 429 ) );
}
set_transient( 'my_airtable_sync_count', $count + 1, MINUTE_IN_SECONDS );
return true;
}
function my_airtable_sync_ping( WP_REST_Request $request ) {
$record_id = $request->get_param( 'record_id' );
$result = my_airtable_sync_record( $record_id );
if ( is_wp_error( $result ) ) {
return $result;
}
return rest_ensure_response( array( 'post_id' => $result ) );
}
permission_callback は省略できません。省略するとWordPressが警告を出し、誰でも呼べるエンドポイントになります。合言葉の比較に hash_equals() を使っているのは、文字列比較にかかる時間の差から値を推測されるのを防ぐためです。get_header() はヘッダー名の大文字小文字やハイフンを内部で正規化するので、上のように書いて問題ありません。
トランジェントを使ったレート制限は簡易的なもので、同時に大量のリクエストが来ると数えもれが起きます。外部に公開するエンドポイントでは、サーバーやWAF側の制限も併用すると安心です。
WordPress側:レコードを取得して投稿に反映する
<?php
function my_airtable_sync_record( $record_id ) {
$url = sprintf(
'https://api.airtable.com/v0/%s/%s/%s',
rawurlencode( AIRTABLE_BASE_ID ),
rawurlencode( AIRTABLE_TABLE ),
rawurlencode( $record_id )
);
$response = wp_remote_get( $url, array(
'timeout' => 15,
'headers' => array( 'Authorization' => 'Bearer ' . AIRTABLE_PAT ),
) );
if ( is_wp_error( $response ) ) {
return new WP_Error( 'airtable_error', 'Airtable request failed', array( 'status' => 502 ) );
}
$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
error_log( 'Airtable sync: HTTP ' . $code . ' for ' . $record_id );
return new WP_Error( 'airtable_error', 'Record not available', array( 'status' => 502 ) );
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$fields = isset( $data['fields'] ) ? $data['fields'] : array();
$title = isset( $fields['Title'] ) ? sanitize_text_field( $fields['Title'] ) : '';
$slug = isset( $fields['Slug'] ) ? sanitize_title( $fields['Slug'] ) : '';
$body = isset( $fields['Body'] ) ? wp_kses_post( $fields['Body'] ) : '';
$status = ( isset( $fields['Status'] ) && 'publish' === $fields['Status'] ) ? 'publish' : 'draft';
if ( '' === $title ) {
return new WP_Error( 'invalid_record', 'Title is empty', array( 'status' => 422 ) );
}
$existing = get_posts( array(
'post_type' => 'post',
'post_status' => 'any',
'meta_key' => '_airtable_record_id',
'meta_value' => $record_id,
'fields' => 'ids',
'posts_per_page' => 1,
'no_found_rows' => true,
) );
$postarr = array(
'post_type' => 'post',
'post_title' => $title,
'post_content' => $body,
'post_status' => $status,
);
if ( '' !== $slug ) {
$postarr['post_name'] = $slug;
}
if ( $existing ) {
$postarr['ID'] = $existing[0];
$post_id = wp_update_post( wp_slash( $postarr ), true );
} else {
$post_id = wp_insert_post( wp_slash( $postarr ), true );
if ( ! is_wp_error( $post_id ) ) {
update_post_meta( $post_id, '_airtable_record_id', $record_id );
}
}
return $post_id;
}
実装上のポイントは次のとおりです。
- 外部から来た値は、タイトルを
sanitize_text_field()、スラッグをsanitize_title()、本文をwp_kses_post()で整えてから保存します wp_insert_post()とwp_update_post()は受け取ったデータのスラッシュを取り除くため、本文中のバックスラッシュが消えないようwp_slash()で渡していますStatusが想定外の値のときは下書きに倒し、意図しない公開を防ぎます- Slugが空のときは
post_nameを渡さず、WordPressの自動生成に任せます - 長文テキストで書式設定(リッチテキスト)を有効にしている場合、APIからはMarkdown形式の文字列で返ります。HTMLとして表示したいときは、保存前にMarkdownの変換処理を挟んでください
- エラーログにはHTTPステータスとレコードIDだけを書き、トークンやレスポンス本文は出力しません
AirtableのAPIにはベースごとのリクエスト数の上限があります。大量のレコードを一度に更新すると通知が集中し、制限に達することがあるため、一括更新のときはオートメーションを一時停止し、後から順に同期するなどの運用を考えておきます。上限の値は公式ドキュメントで確認してください。
Airtable側:オートメーションの設定
Airtableのオートメーションで、トリガーに「レコードが更新されたとき」などを選び、特定のフィールド(Title・Slug・Body・Status)の変更だけを監視するよう設定します。アクションには「スクリプトを実行」を選び、入力変数としてトリガーのレコードIDを recordId という名前で渡します。トリガーや入力変数の画面表記はAirtable側の更新で変わることがあるため、目的に合う項目を選んでください。
const { recordId } = input.config();
const response = await fetch('https://example.com/wp-json/airtable-sync/v1/ping', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Sync-Token': 'long-random-string-for-webhook',
},
body: JSON.stringify({ record_id: recordId }),
});
if (!response.ok) {
throw new Error(`WordPress returned ${response.status}`);
}
console.log(await response.text());
失敗時に例外を投げておくと、Airtableのオートメーション履歴にエラーとして残り、原因を追いやすくなります。スクリプト内の合言葉は、ベースの編集権限を持つ人には見える可能性があります。Airtableのスクリプトで秘密情報を安全に扱う機能が使えるかは公式ヘルプで確認し、使えない場合でも「漏れてもできるのは同期の再実行だけ」という今回の設計で被害を限定します。漏えいが疑われたら AIRTABLE_SYNC_SECRET を変更してください。
動作確認と運用のポイント
手動で通知を送って確かめる
Airtableのオートメーションを有効にする前に、curlでエンドポイントを直接呼んで確認します。
curl -X POST https://staging.example.com/wp-json/airtable-sync/v1/ping \
-H "Content-Type: application/json" \
-H "X-Sync-Token: long-random-string-for-webhook" \
-d '{"record_id":"recXXXXXXXXXXXXXX"}'
post_id が返り、管理画面に投稿ができていれば成功です。403なら合言葉の不一致、502ならAirtable側(トークンのスコープ、ベースIDやテーブル名の誤り)を疑います。
削除されたレコードの扱い
レコードの削除は、更新系のトリガーでは検知できません。削除したレコードの投稿は残り続けるため、定期的にAirtable側の一覧とWordPress側の _airtable_record_id を突き合わせ、見つからない投稿を下書きに戻すといった処理をWP-Cronで別途用意します。いきなり削除せず、下書きやゴミ箱に移す形にしておくと、誤判定のときに戻せます。
二重編集を避ける
Airtableを正本にするなら、WordPressの管理画面で本文を直接編集しない運用にします。WordPress側で直しても、次の同期でAirtableの内容に上書きされるためです。
まとめ
- Airtableからはレコードの中身ではなくIDだけを通知し、WordPressが自分のトークンでAirtable APIから取得する構成にすると、通知を偽装されても内容は差し替えられません
- 独自エンドポイントは
register_rest_route()で作り、permission_callbackで合言葉をhash_equals()で照合し、簡易的なレート制限をかけます - Airtableのトークンは読み取り専用・対象ベース限定で発行し、
wp-config.phpの定数に置きます - 受け取った値はサニタイズしてから保存し、想定外のステータスは下書きにします
- 削除の検知は別の仕組みで補い、最初はステージングで試してから本番に適用します