dist/codex/spree-commerce/skills/spree-events-webhooks/SKILL.md
Build with Spree's event bus and Webhooks 2.0 — `Spree::Events` publication, `Spree::Subscriber` DSL with `subscribes_to` and `on`, wildcard matching, lifecycle events (`{model}.created/.updated/.deleted` via `publishes_lifecycle_events`), the canonical event catalog (order.*, payment.*, shipment.*, product.*), Webhooks 2.0 endpoints, HMAC-SHA256 signing (`X-Spree-Webhook-Signature`), exponential-backoff retries, and Sidekiq job orchestration. Use when wiring event-driven business logic, building webhook consumers, or replacing ActiveSupport callback chains.
npx skillsauth add orcaqubits/agentic-commerce-claude-plugins spree-events-webhooksInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Fetch live docs:
lib/spree/event.rb / lib/spree/subscriber.rb and app/subscribers/ in the spree gem for the canonical event names per release.@spree/sdk if you're consuming webhooks in TypeScript.Spree's event bus (Spree::Events) replaces ad-hoc ActiveSupport::Notifications and after_* callbacks for cross-cutting concerns. Benefits:
order.*, *.created, * for cross-cutting loggingIn core code:
Spree::Bus.publish('order.completed', order: order, user: order.user)
Or via publishes_lifecycle_events:
class Spree::Product < ApplicationRecord
publishes_lifecycle_events # auto-emits product.created/.updated/.deleted
end
# app/subscribers/order_completed_subscriber.rb
class OrderCompletedSubscriber < Spree::Subscriber
subscribes_to 'order.completed'
on 'order.completed', :handle_completed
def handle_completed(event)
order = event.order
AccountingSync.enqueue(order_id: order.id)
end
end
Subscribers in app/subscribers/ auto-register on app boot. Otherwise:
# config/initializers/spree.rb
Spree.subscribers << CustomSubscriber
subscribes_to 'order.*' # all order events
subscribes_to '*.created' # all lifecycle creations
subscribes_to '*' # everything (use for logging only)
| Domain | Events |
|--------|--------|
| Order | order.created, order.updated, order.completed, order.canceled, order.resumed, order.paid, order.shipped |
| Payment | payment.created, payment.updated, payment.paid |
| Shipment | shipment.created, shipment.updated, shipment.shipped, shipment.canceled, shipment.resumed |
| Product | product.activate, product.archive, product.out_of_stock, product.back_in_stock |
| Lifecycle | {model}.created, {model}.updated, {model}.deleted for any model with publishes_lifecycle_events |
| Cart | cart.add_item, cart.remove_item, cart.update |
| User | user.created, user.password_reset_requested |
This list isn't exhaustive — releases add events. Always re-check.
An Event object exposes the payload keys as methods:
on 'order.completed', :handle
def handle(event)
event.order # Spree::Order
event.user # Spree::User
event.firing_class # Spree::Order (the publisher)
end
Webhooks subscribe to Spree events and forward HMAC-signed POSTs to external URLs. Configured per Store in admin (Settings → Webhooks).
A webhook endpoint declares:
order.completed, payment.paid)WebhookEventSubscriber matches active endpoints{ event, data, timestamp }X-Spree-Webhook-Signature: sha256=<hex> (HMAC-SHA256 of body using shared secret)def verify_signature(body, signature_header, secret)
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
ActiveSupport::SecurityUtils.secure_compare(expected, signature_header.sub(/^sha256=/, ''))
end
import { createHmac, timingSafeEqual } from 'crypto';
function verify(body: string, header: string, secret: string) {
const expected = createHmac('sha256', secret).update(body).digest('hex');
const received = header.replace(/^sha256=/, '');
return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}
Spree retries on non-2xx. Make your handler idempotent — keyed by event ID or order ID + state.
Pattern: one subscriber class per concern, not per event.
class AnalyticsSubscriber < Spree::Subscriber
subscribes_to 'order.completed', 'order.canceled', 'product.activate'
on 'order.completed', :track_purchase
on 'order.canceled', :track_cancellation
on 'product.activate', :track_launch
private
def track_purchase(event)
Analytics.track(
user_id: event.order.user_id,
event: 'purchase',
properties: { revenue: event.order.total }
)
end
def track_cancellation(event)
Analytics.track(user_id: event.order.user_id, event: 'cancellation')
end
def track_launch(event)
Analytics.track(event: 'product_launched', properties: { id: event.product.id })
end
end
Don't block the request — enqueue Sidekiq jobs:
on 'order.completed', :handle
def handle(event)
EmailJob.perform_later(order_id: event.order.id)
end
Webhooks 2.0 are already async via Sidekiq — your custom subscriber doesn't need to re-async unless it's heavy.
For extension code:
# In a service object
Spree::Bus.publish('my_app.special_discount_applied', order: order, amount: amount)
# Subscribe
class MyAppSubscriber < Spree::Subscriber
subscribes_to 'my_app.special_discount_applied'
on 'my_app.special_discount_applied', :log_it
end
Use a my_app. prefix to avoid collisions with core events.
In admin → Settings → Webhooks:
https://your-app.com/webhooks/spreeVerify against the live admin UI — Webhooks 2.0 management may have moved.
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def spree
body = request.body.read
unless verify_signature(body, request.headers['X-Spree-Webhook-Signature'], ENV['SPREE_WEBHOOK_SECRET'])
head :unauthorized and return
end
payload = JSON.parse(body)
case payload['event']
when 'order.completed' then OrderCompletedHandler.perform_later(payload['data'])
end
head :ok
end
end
Respond 2xx immediately — process async. Slow handlers hit the retry threshold.
// app/api/webhooks/spree/route.ts
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('x-spree-webhook-signature') ?? '';
if (!verify(body, signature, process.env.SPREE_WEBHOOK_SECRET!)) {
return new Response('Unauthorized', { status: 401 });
}
const payload = JSON.parse(body);
// Enqueue async — Inngest, Trigger.dev, BullMQ, etc.
return new Response('OK');
}
app/subscribers/ location requires Spree.subscribers << ....* in production — performance hazard. Use for dev/diagnostics only.Always cross-reference the live app/subscribers/ directory and the published events in the spree gem source — the event taxonomy evolves with new features.
development
Build with Spree's headless Next.js storefront — the official `spree/storefront` repo (Next.js 16 App Router with Server Actions and Turbopack, React 19 Server Components, Tailwind CSS 4, TypeScript 5, `@spree/sdk`, Sentry), server-only auth (httpOnly JWT cookies + publishable key), MeiliSearch faceted catalog, one-page checkout with Apple/Google Pay/Klarna/Affirm/SEPA, multi-region market routing, GA4 + JSON-LD SEO, and Vercel/Docker deployment. Use when forking or customizing the storefront, or evaluating headless adoption.
tools
Build Spree extensions as Rails engines — gem scaffolding, `bin/rails g spree:extension`, mounting routes/migrations/assets, the modern `prepend` decorator pattern (`*_decorator.rb` with `self.prepended(base)`), generators (`spree:model_decorator`, `spree:controller_decorator`), the four customization surfaces in preference order (Events > Webhooks > Dependencies > Decorators), Spree::Dependencies for swapping service objects, gem release/versioning, and the deprecated Deface engine. Use when building a reusable Spree extension or adding non-trivial customization to an app.
tools
Cross-cutting Spree development patterns — the customization preference hierarchy (Events > Webhooks > Dependencies > Decorators), `Spree::Dependencies` service-object swapping, the `_decorator.rb` + `prepend` + `self.prepended` idiom, idempotent subscribers and webhook receivers, multi-store scoping discipline, prefixed IDs, calculator polymorphism (shipping/promotion/tax share the base), service-object composition with `dry-monads` or simple results, why to avoid `class_eval` reopening and Deface, and Spree-on-Rails idioms (Hotwire/Turbo Stimulus, ActiveStorage, Action Cable, Sidekiq). Use when designing the architecture of a Spree extension or solving cross-cutting concerns.
development
Deploy Spree to production — PostgreSQL + Redis + Sidekiq stack, Docker multi-arch images on GHCR, the `spree-starter` Dockerfile + Compose, Heroku/Render/Fly.io/AWS targets, env-var conventions, RAILS_MASTER_KEY, asset precompilation (Tailwind 4 + Propshaft), Action Cable, MeiliSearch indexing, S3 / ActiveStorage for media, log/observability setup, zero-downtime deploys, and migration strategy. Use when going from local dev to production, scaling Spree, or troubleshooting deploys.