docs/developer/tutorial/events.mdx
Almost every real store talks to other systems: an order management system (OMS), a warehouse (WMS), an ERP, a CRM, a marketing platform. In Spree, the integration surface is the events system — models publish events as things happen, and you react to them without touching core code.
There are two ways to consume events, and they serve different audiences:
| Mechanism | Code lives | Best for |
|---|---|---|
| Subscriber | In your Spree app | Calling external APIs with your own client code, internal side effects, anything needing app context |
| Webhook | In the external system | Letting a third party receive HTTP callbacks — no Ruby in your app, endpoints managed from the admin |
We'll do both: push completed orders to an OMS with a subscriber, give the Brand model its own lifecycle events, and set up an outbound webhook.
When an order completes, send it to the OMS. Generate a subscriber:
<CodeGroup>spree generate subscriber OmsOrderSync order.completed
bin/rails g spree:subscriber OmsOrderSync order.completed
This creates the subscriber, a spec stub, and — crucially — registers it in config/initializers/spree.rb (injected into the existing after_initialize block every Spree app ships with). Subscribers are not auto-discovered; a subscriber that never gets appended to Spree.subscribers is a silent no-op, which is why the generator owns that step (re-runs are idempotent, and each new subscriber appends to the same initializer).
Fill in the handler:
class OmsOrderSyncSubscriber < Spree::Subscriber
subscribes_to 'order.completed'
def handle(event)
order = Spree::Order.find_by_prefix_id(event.payload['id'])
return unless order
OmsClient.create_order(
number: order.number,
email: order.email,
line_items: order.line_items.map { |li| { sku: li.sku, quantity: li.quantity } }
)
end
end
Two things worth understanding:
find_by_prefix_id. The payload itself is the resource serialized with its v3 API serializer, so event.payload['number'], ['email'], etc. are available directly when you don't need the full record.handle call is an ActiveJob on the events queue, so a slow OMS API never blocks checkout. Pass subscribes_to 'order.completed', async: false only when you genuinely need synchronous execution.Restart the server, complete a test order, and watch the job fire (spree logs worker — or your job backend's UI).
Core models like Payment and Shipment publish *.created / *.updated / *.deleted events automatically. Your models can too — add one line to the Brand model:
module Spree
class Brand < Spree.base_class
publishes_lifecycle_events
# ... existing code ...
end
end
Now brand.created, brand.updated, and brand.deleted fire after the matching transactions commit — and because you created Spree::Api::V3::BrandSerializer in the API step, the payloads automatically use it (the events system resolves the serializer by naming convention). Anyone — subscriber or webhook — can react to brand changes with the same JSON shape your API serves.
spree generate subscriber BrandSync brand.created brand.updated
bin/rails g spree:subscriber BrandSync brand.created brand.updated
class BrandSyncSubscriber < Spree::Subscriber
subscribes_to 'brand.created', 'brand.updated'
def handle(event)
SearchIndexer.upsert_brand(event.payload)
end
end
Lifecycle events cover persistence; custom events express domain moments. Say featuring a brand should notify the marketing platform:
brand.publish_event('brand.featured')
The payload defaults to the serializer output; pass your own hash as the second argument when the event needs different data. Subscribers consume it like any other event name.
When the consumer is an external system you don't deploy code into, use webhooks. In the admin, go to Settings → Webhooks, add an endpoint with the destination URL, and pick the events to deliver — order.completed, brand.created, anything publishing in your store. The endpoint's signing secret is shown once on creation — store it in the receiving system.
Each delivery is an HTTP POST with this envelope:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "order.completed",
"created_at": "2026-06-11T12:00:00Z",
"data": { "id": "or_m3Rp9wXz", "number": "R123456789", "...": "..." },
"metadata": {}
}
And three headers the receiver should use:
| Header | Contents |
|---|---|
X-Spree-Webhook-Event | The event name |
X-Spree-Webhook-Timestamp | Unix timestamp of the delivery |
X-Spree-Webhook-Signature | HMAC-SHA256(secret, "{timestamp}.{body}") |
Verify the signature before trusting a payload:
def verified?(request, secret)
timestamp = request.headers['X-Spree-Webhook-Timestamp']
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{request.raw_post}")
ActiveSupport::SecurityUtils.secure_compare(expected, request.headers['X-Spree-Webhook-Signature'])
end
Delivery semantics to design around: failed deliveries (timeouts, connection errors, non-2xx responses) are recorded, not retried automatically — redeliver from the endpoint's delivery history in the admin, or via POST /api/v3/admin/webhook_endpoints/:webhook_endpoint_id/deliveries/:id/redeliver. After 15 consecutive failures the endpoint auto-disables and store staff get an email; a successful delivery resets the counter.
Subscribers are plain Ruby — test handle directly with a constructed event:
require 'rails_helper'
RSpec.describe OmsOrderSyncSubscriber do
it 'pushes completed orders to the OMS' do
order = create(:completed_order_with_totals)
event = Spree::Event.new(name: 'order.completed', payload: { 'id' => order.prefixed_id })
expect(OmsClient).to receive(:create_order).with(hash_including(number: order.number))
described_class.new.handle(event)
end
end