mcpField
S
spree-events-webhooks

spree-events-webhooks

github.com/spree開発10223 回インストール4 週間前に更新
リポジトリ

spree-events-webhooks について

spree-events-webhooks は、Spree のイベントやアウトバウンド Webhook を扱う開発者向けスキルです。アプリ内のサブスクライバーと外部システムへの HTTPS 配信を区別し、登録、配信、署名検証を説明します。

主な機能

  1. 01サブスクライバーと Webhook の区別
  2. 02ライフサイクルイベントと業務イベントの解説
  3. 03登録と非同期処理の説明
  4. 04イベント条件と Webhook エンドポイントの設定
  5. 05署名検証とイベント重複排除

活用シーン

  1. 01注文確定イベントの ERP 同期
  2. 02通知送信やステータス履歴の記録
  3. 03Spree イベントの外部システムへの配信
  4. 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 種類あります。

  1. ライフサイクルイベント — <prefix>.created、.updated、.deleted。publishes_lifecycle_events を宣言しているモデル(注文、カート、明細、商品、バリアント、価格、支払い、フルフィルメント、在庫レベル、返品、顧客、メディアなど)がコミット後に発行します。顧客には user.* プレフィックス(user.created、…)が使われます。単なる touch では .updated は発行されません。
  2. ビジネスイベント — 重要なタイミングで明示的に発行されます。
イベント発行タイミング
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

デバッグ

サブスクライバーが実行されない

  1. Spree.subscribers.include?(MySubscriber) — 登録されていますか?
  2. イベント名は正確ですか?(order.complete ではなく、order.placed です。)ログを確認します:[Spree Event] order.placed | payload: …(Spree::Config.events_log_enabled = false で無効にできます)。
  3. 非同期ですか?ジョブワーカー(Solid Queue / Sidekiq)は実行中ですか?また、Spree::Events::SubscriberJob は失敗していませんか?
  4. テスト:コアのテストスイートではイベントがグローバルに無効化されています。独自のテストスイートでも無効化している場合は、テスト例に events: true を指定するか、Spree::Events.enable { … } で囲みます。

エンドポイントが何も受信しない

  1. endpoint.active? && !endpoint.auto_disabled?
  2. endpoint.subscribed_to?('order.placed')
  3. イベントにストアがありましたか?(ジョブ/スクリプトで Spree::Current.store が設定されていますか。)エンドポイントは同じストアに属していますか?
  4. endpoint.send_test! を実行し、endpoint.webhook_deliveries.recent.first で response_code / request_errors を確認します。
  5. 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(サブスクライバーのテスト)

コメント