Overview
Something happens in a store — an order is placed, a payment clears, a product sells out — and you want something else to happen: notify a warehouse, post to a channel, update a spreadsheet. Events are how you attach that behaviour without editing Spree. Spree announces what happened; your code decides what to do about it. There are two ways to listen, and which you want depends on where your code lives:
If you’re building a headless storefront, webhooks are almost certainly what you want. Subscribers are for when you’re running Spree as your own application and want to add behaviour to it.
What Spree announces
Most records announce their own lifecycle:
Those exist for orders, carts, products, variants, customers, payments, fulfillments, returns, media, price changes and more.
Adding a product to a category or collection, or taking it out, publishes
product_category.created / .deleted and product_collection.created / .deleted.
On top of that, meaningful business moments get their own events:
For the full list of events and their payloads, see Webhook Events & Payloads.
The distinction matters when choosing what to listen for.
order.updated fires whenever anything about an order changes — including an admin editing a note. order.placed fires once, when a customer actually bought something. Sending a confirmation email on the wrong one is how customers receive nine copies.
What an event carries
An event carries the record it’s about, serialized:Event payload
Reacting in another system
Point a webhook at your endpoint and subscribe it to the events you care about. That’s covered fully in Webhooks — including signature verification, which you should not skip.Reacting inside Spree
A subscriber is a small class that names the events it wants and does something when one arrives.server/app/subscribers/order_placed_subscriber.rb
server/app/subscribers/order_placed_subscriber.rb
server/config/initializers/spree.rb
Subscribers run in the background
By default a subscriber runs as a background job, so a slow API call in your code doesn’t slow down the customer’s checkout — and a failure doesn’t roll back their order. That’s almost always what you want. If you genuinely need to run inside the same transaction, you can ask to:server/app/subscribers/order_placed_subscriber.rb
Publishing your own events
Anything in your own code can announce something, and subscribers and webhooks treat it like any built-in event. Declare the event on the model first, then publish it:server/config/initializers/spree_events.rb
server/app/services/fraud_check.rb
@spree/sdk/webhooks list it. Publishing an event nobody declared raises an error in development and test, so a typo is caught before it ships; in production it is logged and delivered anyway.
The payload is the model’s Store API serializer, as for built-in events. To send something else, pass a serializer of your own: publishes_event :flagged_for_review, serializer: 'MyApp::FraudFlagEventSerializer'. Keep it to what a customer could see; back-office fields belong behind the Admin API. Facts about the event rather than the record, such as who triggered it, go in its metadata: order.publish_event('order.flagged_for_review', nil, rule: 'velocity').
Delivery guarantees
Spree hands an event to its subscribers in the process that published it. Events are not saved first and sent later. A background subscriber, and every webhook, then runs as a background job. Fororder.placed, this means:
- It is published once per order, by the completion that placed it — at checkout, or when a draft order is completed in the dashboard.
- At checkout, it is published after the order is saved. The database transaction that places the order has committed before the event goes out, so nothing hears about an order that was then rolled back.
- A repeated completion doesn’t publish it again. A double-clicked Place Order or a retried request finds the order already placed and returns it without publishing.
- It can be missed. If the process stops after the order is saved but before the event is published — a crash, a deploy, a stopped container — the order stays placed and no
order.placedis ever published for it. - Failures after publishing are retried. A background subscriber that raises an error runs again, up to 3 attempts in all. A webhook delivery that fails is sent again, up to 5 attempts. A synchronous subscriber is not retried.
- Make the handler safe to run twice. Key the work on the order’s ID. Checking whether you already did it and then doing it is not enough on its own: a webhook and a reconciliation run can both pass the check at the same time, and a crash after the write repeats it on retry. Pass the order’s ID as an idempotency key to the external system, so it ignores a second write for the same order. Check how long the external system keeps keys: some prune them after a day or so, and a reconciliation run that replays older orders would then write them again. Here the order’s ID is the right key, because the write must happen once per order however many events or runs ask for it. If the external system can’t take a key, or can’t keep it as long as you might replay, claim the order atomically with a lease: an insert guarded by a unique constraint that records who claimed it and until when. Only the claim holder writes, and it records completion separately when it’s done. Another worker may take over an unfinished claim only after the lease has expired, and since the first write may have landed, it must check the external system before writing again.
- Reconcile from orders. On a schedule, list the orders placed since your last check and handle any you haven’t seen. The event gives you speed; the reconciliation makes sure nothing is lost.
- Admin SDK
- cURL
completed_at is set before its transaction commits, so an order can become visible after a run that started later has already read past it. The overlap is safe as long as no order takes longer than that to commit; the idempotent handler makes the overlap harmless.
Guidance
Listen for the specific event.order.placed rather than order.updated with a status check.
Assume events can arrive more than once. A retry after a network blip can redeliver. Make handlers safe to run twice: pass the event’s ID (event.id in a subscriber, the id field of a webhook delivery) as an idempotency key to whatever you call, rather than checking whether you’ve already acted and then acting, which two deliveries can both pass at once. Don’t use the record’s ID (event.payload['id']) as the key for repeatable events: two order.updated events share an order ID but are different changes. Use the record’s ID only for work that must happen once per record, like sending a placed order to an ERP. See Delivery guarantees.
Don’t chain long sequences of subscribers. When one event triggers a subscriber that triggers another, working out what happened after the fact becomes archaeology. Prefer one handler that does the sequence.
Keep failures contained. A subscriber that raises shouldn’t take down anything else. Handle your own errors and log them.
Related
- Webhooks — reacting from outside Spree
- Orders — the order lifecycle these events describe
- Imports & Exports — completion events for bulk work

