spree-events-webhooks について
spree-events-webhooks は、Spree のイベントやアウトバウンド Webhook を扱う開発者向けスキルです。アプリ内のサブスクライバーと外部システムへの HTTPS 配信を区別し、登録、配信、署名検証を説明します。
主な機能
- 01サブスクライバーと Webhook の区別
- 02ライフサイクルイベントと業務イベントの解説
- 03登録と非同期処理の説明
- 04イベント条件と Webhook エンドポイントの設定
- 05署名検証とイベント重複排除
活用シーン
- 01注文確定イベントの ERP 同期
- 02通知送信やステータス履歴の記録
- 03Spree イベントの外部システムへの配信
- 04データ移行中のイベント配信抑制
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-events-webhooksインストール後、Claude Code で/spree-events-webhooksを入力して読み込まれたか確認します。
同梱ファイルreferences/events.md
詳細
Spree のイベントと Webhook
Spree は、何が起きたか(order.placed、payment.completed、fulfillment.fulfilled、…)を 1 つのイベントバスで通知します。このイベントバスには 2 種類のコンシューマーが接続されています。
| コンシューマー | 実装場所 | 用途 |
|---|---|---|
| サブスクライバー | Spree アプリ内の Ruby クラス | 独自の副作用 — ERP 同期、カスタムメール、分析、監査履歴 |
| Webhook | 外部 URL への HTTPS POST | 別のシステムがコンシューマーの場合 — Next.js ストアフロント、n8n/Zapier/Make、パートナーアプリ |
反応するコードが Rails アプリの外部にある場合は、Webhook を使用してください。アプリ内にある場合は、サブスクライバーを使用してください。
イベントとワークフック
イベントは処理後に発火し、通常はバックグラウンドで実行されます。処理を止めたり、変更したりすることはできません。処理の実行中に拒否(workflow.reject!)したり、ステップを追加したり、計算にデータを渡したりする必要がある場合は、代わりにワークフックを登録してください — Spree.hooks.register('<flow>.<hook>', Handler) — spree-workflows スキルを参照してください。
| 要件 | 使用するもの |
|---|---|
| ビジネスルールに基づいてカートの完了/返品/キャンセルを阻止する | フック(*.validate) |
| 同じトランザクション内でフローに処理を追加する | フック(after_*) |
| 注文確定を WMS に送信する、Slack メッセージを送る | サブスクライバー |
| Spree 外部のシステムに通知する | Webhook |
| ステータス変更の履歴を保持する | 独自のテーブルに書き込むサブスクライバー |
イベントは監査証跡です。 Spree は StateChange/LogEntry の行を保持しません。意味のある状態遷移はすべて、すでにイベントを発行しています(order.placed、order.canceled、payment.completed、payment.voided、fulfillment.fulfilled、fulfillment.delivered、fulfillment.canceled、return.* …)。永続的な履歴が必要な場合は、サブスクライバーから書き込んでください。
Spree が発行するイベント
イベントは 2 種類あります。
- ライフサイクルイベント —
<prefix>.created、.updated、.deleted。publishes_lifecycle_eventsを宣言しているモデル(注文、カート、明細、商品、バリアント、価格、支払い、フルフィルメント、在庫レベル、返品、顧客、メディアなど)がコミット後に発行します。顧客にはuser.*プレフィックス(user.created、…)が使われます。単なるtouchでは.updatedは発行されません。 - ビジネスイベント — 重要なタイミングで明示的に発行されます。
| イベント | 発行タイミング |
|---|---|
order.placed | チェックアウト完了時(または管理者が下書き注文を確定したとき)。ペイロードには notify_customer が含まれます |
order.paid | 支払いが注文の合計金額を満たしたとき |
order.fulfilled/order.delivered | すべてのフルフィルメントが引き渡された/配達されたとき |
order.canceled | 注文がキャンセルされたとき(最終状態 — 注文を再開することはできません) |
order.approved | リスクのある注文がスタッフによって承認されたとき |
cart.created/.updated/.deleted | カートのライフサイクル — カートは注文とは別のリソースです |
payment.completed/.captured/.voided/.refunded | 支払いに関する各タイミング |
payment_session.completed/.failed/.expired … | プロバイダーセッションの結果(payment_setup_session.* でも同じ種類のイベントが発行されます) |
fulfillment.fulfilled/.delivered/.canceled | 荷物が発送された/到着した/中止されたとき(fulfilled のメタデータには notify_customer が含まれます) |
product.out_of_stock/.back_in_stock/.activated/.archived | カタログの在庫状況 |
return.*、exchange.*、claim.* | 購入後のフロー(return.received、return.refunded、…) |
digital_link.downloaded | 顧客がデジタルアセットをダウンロードしたとき |
import.completed、import_row.failed | 一括インポート |
ペイロードに関する注記を含む全一覧は、references/events.md を参照してください。正式なリファレンスはドキュメントの webhooks-events.md です(下部を参照)。
具体的なイベントを選んでください。 order.updated はメモを編集するたびに発火しますが、order.placed は 1 回だけ発火します。order.updated で注文確認を送信すると、顧客にメールが 9 通届くことになります。
名前変更されたイベント(1 リリース分のエイリアス)
order.completed → order.placed、order.shipped → order.fulfilled、shipment.shipped → fulfillment.fulfilled、shipment.canceled → fulfillment.canceled、stock_item.* → stock_level.*、wished_item.* → wishlist_item.*、digital.* → digital_asset.*。古い名前は 6.1 まで新しい名前と並行して公開されるため、order.* のサブスクライバーには order.placed と order.completed の両方が届き、fulfillment.* と shipment.* の両方を購読する場合も同様に両方の名前が届きます。新しい名前を購読してください。metadata.deprecated_alias_of を持つのは order.completed だけです。ワイルドカードハンドラーでは、古い名前を明示的にスキップしてください(監査レシピを参照)。5.x から移行する場合は、spree-upgrade-5-to-6 を参照してください。
パート 1:サブスクライバー
生成する
spree generate subscriber OrderPlaced order.placed order.canceled # classic: bin/rails g spree:subscriber …
# --sync → subscribes_to …, async: false
# --skip-spec → no spec/subscribers/… file
app/subscribers/order_placed_subscriber.rb と spec、そして忘れられがちな登録行を config/initializers/spree.rb に生成します。
Rails.application.config.after_initialize do
Spree.subscribers << OrderPlacedSubscriber
end
サブスクライバーは自動検出されません。登録されていないサブスクライバーは何もせず、エラーも発生しません。
クラス定数を登録してください。クラス名の文字列('OrderPlacedSubscriber')も受け付けられ、サブスクライバーの登録時に起動時解決されます。ただし、6.0.0.beta4 までは文字列が解決されるのは開発環境とテスト環境のみでした。本番環境では NoMethodError: undefined method 'subscription_patterns' for String が発生して起動に失敗し、この問題は spec では検出できません。
# app/subscribers/order_placed_subscriber.rb
class OrderPlacedSubscriber < Spree::Subscriber
subscribes_to 'order.placed'
def handle(event)
order = Spree::Order.find_by_prefix_id(event.payload['id'])
return unless order
WarehouseClient.new.submit(order)
end
end
handle(event)を実装します。callはオーバーライドしないでください。ベースクラスのcallがonハンドラーにルーティングし、該当しない場合はhandleにフォールバックします。event.name、event.payload(文字列キー。API v3 シリアライザーでシリアライズされ、プレフィックス付き ID や文字列形式の金額を含みます)、event.metadata(常にspree_versionを含みます)、event.store_id、event.id(UUID)、event.created_atを使用できます。- ペイロードにはレコードのトップレベル属性のみが含まれます。関連データが必要な場合は、レコードを読み込んでください(
find_by_prefix_id)。
複数のイベント、ワイルドカード、ルーティング
class PaymentSubscriber < Spree::Subscriber
subscribes_to 'payment.completed', 'payment.voided', 'return.*'
on 'payment.completed', :handle_complete
on 'payment.voided', :handle_void
# anything without an `on` falls through to #handle
def handle(event)
Rails.logger.info("[returns] #{event.name}")
end
private
def handle_complete(event)
# ...
end
def handle_void(event)
# ...
end
end
非同期(デフォルト)と同期
デフォルトでは、各サブスクライバーは Spree::Events::SubscriberJob で Spree.queues.events(変更しない限り :default)を使って実行されるため、遅い API 呼び出しによってチェックアウトが遅くなることはありません。ジョブはエラー時に最大 3 回リトライされます。ハンドラーは冪等にしてください(すでに処理済みかどうかを確認します)。
subscribes_to 'order.placed', async: false は、イベントを発行したプロセス内でインライン実行します。同期サブスクライバーが発生させたエラーは Rails.error 経由で報告され、開発環境/テスト環境でのみ再発生します。本番環境では握りつぶされるため、同期サブスクライバーによる処理の中断に頼らないでください(その用途にはフックを使います)。
独自イベントの発行
Spree の任意のモデルからイベントを発行できます。
order.publish_event('order.flagged_for_review') # payload = serialized order
order.publish_event('order.flagged_for_review', { 'id' => order.prefixed_id, 'score' => 87 })
Spree::Events.publish('brand.synced', { 'id' => brand.prefixed_id }) # from anywhere
カスタムイベントはサブスクライバーとWebhook エンドポイントに配信されます。書き込みのコミット後に発行してください。publish_event はすぐにディスパッチするため、非同期サブスクライバーがコミット前のデータを読み取る可能性があります。
独自モデルのライフサイクルイベント
module Spree
class Brand < Spree.base_class
publishes_lifecycle_events # brand.created / .updated / .deleted
# publishes_lifecycle_events only: [:create, :delete]
# publishes_lifecycle_events except: [:update]
# self.event_prefix = 'brand' # override the prefix if needed
end
end
Spree::Api::V3::BrandSerializer が存在する場合(spree:api_resource ジェネレーターで作成されます)はそれを使い、存在しない場合は最小限の { id, created_at, updated_at } を使います。.deleted は破棄前にペイロードを取得します。
イベントの抑制
Spree::Events.disable { bulk_fix! } # nothing published in the block
Spree::Events.disable_lifecycle { import_rows! } # suppress *.created/updated/deleted only
Webhook が大量に送信されないよう、データマイグレーションやバックフィルで使用してください。
ストアコンテキストが重要です
すべてのイベントには Spree::Current.store から取得した store_id が記録されます。Webhook の配信ではストアがないイベントはスキップされ、エンドポイントにはそのエンドポイントと同じストアのイベントのみが届きます。ジョブ、rake タスク、コンソールスクリプトでは明示的に設定してください。
Spree::Current.store = order.store
order.publish_event('order.flagged_for_review')
パート 2:Webhook(送信 HTTPS)
event published ─▶ Spree::WebhookEventSubscriber (subscribes to '*', ships with spree_api)
─▶ for each enabled Spree::WebhookEndpoint in event.store_id that subscribed_to?(name)
─▶ Spree::WebhookDelivery (whd_…) ─▶ Spree::WebhookDeliveryJob (Spree.queues.webhooks)
─▶ Spree::Webhooks::DeliverWebhook: signed POST, 30s timeout, result recorded
エンドポイントを作成する
ダッシュボード:Settings → Developer → Webhooks。または Admin API:
spree api post /webhook_endpoints -d '{
"name": "ERP sync",
"url": "https://erp.example.com/spree-webhooks",
"subscriptions": ["order.placed", "order.canceled", "fulfillment.*"],
"active": true
}'
const endpoint = await admin.webhookEndpoints.create({
url: 'https://erp.example.com/spree-webhooks',
subscriptions: ['order.placed', 'fulfillment.*'],
})
endpoint.secret_key // 64-char hex — returned ONLY on create; store it now
subscriptions:完全一致の名前、ワイルドカード(order.*)、または"*"/空欄=すべて。- エンドポイントは現在のストアに属します(リクエストから設定され、渡されることはありません)。
- エンドポイント ID は
whe_…、配信 ID はwhd_…です。 spree_apiの設定:SPREE_WEBHOOKS_ENABLED(全体のキルスイッチ)、SPREE_WEBHOOKS_VERIFY_SSL(開発環境以外ではデフォルトで有効)。- 管理者ユーザーおよび販売者ユーザーのパスワードリセットイベントは、
*のエンドポイントにも配信されません。 customer.password_reset_requestedには有効なリセットトークンが含まれるため、Spree::WebhookEndpoint::CREDENTIAL_EVENTSに含まれます。名前を指定して登録したエンドポイントにのみ届きます。*、空の購読リスト、customer.*のようなパターンには届きません。このイベントを指定するエンドポイントの作成、またはそのエンドポイントの URL/購読内容の変更には、write_webhooksに加えてwrite_customersが必要です(それ以外は 403)。- 顧客レコードでは
user.created/user.updated/user.deleted(およびcustomer.anonymized、customer.password_reset*)が発行されます。customer.createdはありません。
エンベロープとヘッダー
POST /spree-webhooks
Content-Type: application/json
User-Agent: Spree-Webhooks/1.0
X-Spree-Webhook-Signature: <hex hmac-sha256>
X-Spree-Webhook-Timestamp: 1767225600
X-Spree-Webhook-Event: order.placed
{ "id": "<event uuid>", "name": "order.placed", "created_at": "…",
"data": { "id": "or_m3Rp9wXz", "number": "R123456789", "total": "129.99", "notify_customer": true, … },
"metadata": { "spree_version": "6.0.0" } }
id はイベントの UUID です。受信側で重複を排除するために使用してください。
署名を検証する
エンドポイントの secret_key を使い、"#{timestamp}.#{raw_body}" に対する HMAC-SHA256 を計算します。
def verified?(request)
ts = request.headers['X-Spree-Webhook-Timestamp'].to_i
sig = request.headers['X-Spree-Webhook-Signature'].to_s
body = request.raw_post
return false if (Time.current.to_i - ts).abs > 300 # replay window
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('SPREE_WEBHOOK_SECRET'), "#{ts}.#{body}")
ActiveSupport::SecurityUtils.secure_compare(expected, sig)
end
import crypto from 'node:crypto'
export function verified(rawBody: string, ts: string, sig: string, secret: string) {
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const expected = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex')
return expected.length === sig.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
}
生のボディを使って検証してください(JSON を再シリアライズしたものでは一致しません)。必ずタイミングセーフな比較を行い、タイムスタンプも必ず確認してください。Next.js のルートハンドラーでは、パースする前に await req.text() を読み取ってください。
失敗、再配信、自動無効化
- 2xx 以外の応答、タイムアウト、接続エラーがあると、配信は失敗として記録され(
response_code、response_body、error_type、request_errors、execution_time)、Spree::WebhookDeliveryJobがバックオフ付きで再試行します(retry_on …, wait: :polynomially_longer, attempts: 5)。およそ 6 分間に合計 5 回試行され、同じ配信レコードの結果が毎回上書きされます(ログには最新の試行結果が表示されます)。エンドポイントが手動または自動で無効化されると、再試行は直ちに停止します。再試行を使い切っても、キューバックエンドに例外は再送出されません。すべての試行に同じイベントidが付与されるため、この ID で重複を排除してください。 - 再試行を使い切った後に手動で再送信するには、ダッシュボードの配信ログ、
delivery.redeliver!、POST /api/v3/admin/webhook_endpoints/:id/deliveries/:id/redeliver、またはadmin.webhookEndpoints.deliveries.redeliver(whe, whd)を使います。 - 15 回連続で配信に失敗すると(
Spree::WebhookEndpoint::AUTO_DISABLE_THRESHOLD。再試行回数ではなく、配信レコードごとにカウント)、エンドポイントが無効化され(active: false、disabled_atが設定され)、スタッフにSpree::WebhookMailer.endpoint_disabledが通知されます。endpoint.enable!、PATCH …/webhook_endpoints/:id/enable、またはダッシュボードでactive: trueに設定すると、いずれの場合も無効化状態が解除されます。 - 配信ログは実際の送信ペイロードではありません。
spree_webhook_deliveries.payloadに書き込む前に認証情報がマスキングされ、送信時にメモリ上で再度付与されます(Spree::WebhookPayloadRedaction):token(ゲストカートのトークン)、reset_token、unsubscribe_token、verification_token、download_url、決済セッションのクライアントシークレット、ギフトカードのcodeが対象です。受信側には実際の値が届きます(自動再試行も含む)。ログと Admin API では[REDACTED]と表示されます。手動で再送信すると保存済みのペイロードが再送されるため、これらのフィールドは[REDACTED]として届きます。パスワードリセットや決済セッションのイベントを再送信に頼らないでください。 - Admin API が配信の
payloadを返すのは、呼び出し元がイベント対象のレコードを読み取れる場合のみです(delivery.payload_permission_key。例:order.*にはread_orders、customer.*/user.*およびマッピングされていない対象にはread_customers)。それ以外ではpayloadはnullとなり、ステータスと応答のみが表示されます。 - 配信を確実に行う必要がある場合は、受信側の処理を速くしてください(2xx を返してからキューに入れる)。また、定期的に Admin API を使って照合してください。
エンドポイントをテストする
endpoint.send_test! # queues a synthetic 'webhook.test' delivery
または POST /api/v3/admin/webhook_endpoints/:id/send_test を実行するか、ダッシュボードの「テストを送信」ボタンをクリックします。
SSRF と追加ヘッダー
開発環境以外では、プライベートアドレスまたはループバックアドレスに解決されるエンドポイント URL は保存時に拒否され、リクエストは ssrf_filter を通過します。開発環境では、localhost / host.docker.internal を使用できます。送信ヘッダー(例:トレースの伝播)を追加するには、config.to_prepare に呼び出し可能なオブジェクトを追加します。
Spree::Webhooks::DeliverWebhook.header_decorators << ->(headers, delivery) { headers['X-Env'] = Rails.env }
レシピ
監査証跡を永続化する
class OrderAuditSubscriber < Spree::Subscriber
subscribes_to 'order.*', 'payment.*', 'fulfillment.*'
LEGACY_ALIASES = %w[order.completed order.shipped].freeze
def handle(event)
return if LEGACY_ALIASES.include?(event.name) # 6.0 dual-emitted old names
AuditEntry.create_or_find_by!(event_id: event.id) do |e|
e.name = event.name
e.resource_id = event.payload['id']
e.payload = event.payload
end
end
end
決済セッション失敗時に Slack に通知する
class PaymentFailureSubscriber < Spree::Subscriber
subscribes_to 'payment_session.failed'
def handle(event)
Slack.notify("Payment session #{event.payload['id']} failed")
end
end
デバッグ
サブスクライバーが実行されない
Spree.subscribers.include?(MySubscriber)— 登録されていますか?- イベント名は正確ですか?(
order.completeではなく、order.placedです。)ログを確認します:[Spree Event] order.placed | payload: …(Spree::Config.events_log_enabled = falseで無効にできます)。 - 非同期ですか?ジョブワーカー(Solid Queue / Sidekiq)は実行中ですか?また、
Spree::Events::SubscriberJobは失敗していませんか? - テスト:コアのテストスイートではイベントがグローバルに無効化されています。独自のテストスイートでも無効化している場合は、テスト例に
events: trueを指定するか、Spree::Events.enable { … }で囲みます。
エンドポイントが何も受信しない
endpoint.active? && !endpoint.auto_disabled?endpoint.subscribed_to?('order.placed')- イベントにストアがありましたか?(ジョブ/スクリプトで
Spree::Current.storeが設定されていますか。)エンドポイントは同じストアに属していますか? endpoint.send_test!を実行し、endpoint.webhook_deliveries.recent.firstでresponse_code/request_errorsを確認します。SPREE_WEBHOOKS_ENABLEDが false に設定されていないこと、ジョブワーカーが実行中であることを確認します。
署名の不一致 — 生のリクエストボディではなく解析済みのボディを使用している、別のエンドポイントのシークレットを使用している、または時刻のずれが許容範囲を超えている可能性があります。
さらに読むには
- イベント:
node_modules/@spree/docs/dist/developer/core-concepts/events.md - Webhook:
node_modules/@spree/docs/dist/developer/core-concepts/webhooks.md - すべてのイベントとペイロード:
node_modules/@spree/docs/dist/api-reference/webhooks-events.md(https://spreecommerce.org/docs/api-reference/webhooks-events) - ソース:
Spree::Subscriber、Spree::Publishable、Spree::Events、Spree::WebhookEndpoint、Spree::WebhookDelivery、Spree::Webhooks::DeliverWebhook、Spree::WebhookEventSubscriber - 関連スキル:
spree-workflows(フック)、spree-security(Webhook の受信側)、spree-testing(サブスクライバーのテスト)