Spree Events & Webhooks
Before writing code
Fetch live docs:
- Fetch https://spreecommerce.org/docs/developer/core-concepts/events for the event bus.
- Fetch https://spreecommerce.org/docs/developer/core-concepts/webhooks for Webhooks 2.0 (HMAC, retries).
- Inspect the live
lib/spree/event.rb/lib/spree/subscriber.rbandapp/subscribers/in thespreegem for the canonical event names per release. - Check the v5.4 announcement for any Webhooks 2.0 changes.
- For verifying signatures, also check the
@spree/sdkif you're consuming webhooks in TypeScript.
Conceptual Architecture
Why an Event Bus?
Spree's event bus (Spree::Events) replaces ad-hoc ActiveSupport::Notifications and after_* callbacks for cross-cutting concerns. Benefits:
- Decoupled — subscribers don't know about each other
- Testable — assert on event publication, not on side effects
- Webhook-friendly — Webhooks 2.0 piggybacks on the same events
- Wildcard subscriptions —
order.*,*.created,*for cross-cutting logging
Publishing Events
In 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
Subscribing
# 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
Wildcards
subscribes_to 'order.*' # all order events
subscribes_to '*.created' # all lifecycle creations
subscribes_to '*' # everything (use for logging only)
Canonical Event Catalog (verify against live source)
| 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.
Event Payload Shape
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 2.0
Webhooks subscribe to Spree events and forward HMAC-signed POSTs to external URLs. Configured per Store in admin (Settings → Webhooks).
A webhook endpoint declares:
- URL — your receiver
- Event subscriptions — pick events (e.g.,
order.completed,payment.paid) - Secret — used to sign payloads
Delivery Mechanics
- Spree event publishes
WebhookEventSubscribermatches active endpoints- For each match, enqueues a Sidekiq job
- Worker POSTs to the endpoint URL with body
{ event, data, timestamp } - Signs with
X-Spree-Webhook-Signature: sha256=<hex>(HMAC-SHA256 of body using shared secret) - Expects 2xx; otherwise retries with exponential backoff up to 5 attempts
- After 5 failures, marks the delivery dead-letter for manual replay
Signature Verification (Consumer Side)
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));
}
Idempotency on the Consumer
Spree retries on non-2xx. Make your handler idempotent — keyed by event ID or order ID + state.
Implementation Guidance
Designing a Subscriber
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
Async Subscribers
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.
Publishing Custom Events
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.
Wiring Webhooks 2.0
In admin → Settings → Webhooks:
- URL:
https://your-app.com/webhooks/spree - Events: pick from the catalog
- Secret: generated; store in your consumer's env
Verify against the live admin UI — Webhooks 2.0 management may have moved.
Receiving a Webhook (Rails)
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.
Receiving a Webhook (Next.js)
// 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');
}
Common Pitfalls
- Subscriber that doesn't enqueue async work — slows down the request and stalls the bus.
- Forgetting to register a subscriber — non-
app/subscribers/location requiresSpree.subscribers << .... - Hardcoding event names — make a constant, since event names occasionally rename across releases.
- Verifying signature against the wrong secret — multi-store deployments have a secret per Webhook endpoint, not per Store.
- Slow webhook receiver — Spree retries; you get a duplicate-handling p