Skip to main content

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
IDs are the same prefixed IDs the API uses, so you can take an ID out of an event and fetch the full record without translating anything.

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.
The generator writes the class, a test, and — the step that’s easy to forget — registers it.
server/app/subscribers/order_placed_subscriber.rb
A subscriber can listen to several events, or to a whole family:
server/app/subscribers/order_placed_subscriber.rb
Subscribers must be registered — they aren’t discovered automatically, because a subscriber that starts running because of where its file sits is hard to reason about:
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
A synchronous subscriber runs while the customer waits, so a slow one slows their checkout. An exception in it is reported to Rails.error and swallowed in production, but re-raised in development and test so you notice it. Reserve it for work that must happen immediately, and keep it fast.

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
Useful when your own domain has moments worth reacting to — a fraud check completing, an approval granted. Every event Spree publishes is declared this way, which is what lets the admin dashboard’s webhook picker, the webhook events endpoint and the typed events in @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. For order.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.placed is 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.
So when a side effect must happen for every placed order — sending it to an ERP or a warehouse, for example — don’t rely on the event alone:
  1. 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.
  2. 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.
Take the next checkpoint from the moment the run started, before reading, and save it only after every page succeeded. Start each run a few minutes before the saved checkpoint: an order’s 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.