Loading...
Loading...
Use when the user wants Spree 6 to react to something that happened — sync placed orders to an ERP/WMS, send a notification, keep an audit trail, fire outbound webhooks, configure webhook endpoints, verify webhook signatures, or debug a subscriber or delivery that never arrived. Common phrasings include "when an order is placed", "react to X", "Spree events", "subscriber", "publish_event", "publishes_lifecycle_events", "order.placed", "order.completed", "webhook endpoint", "X-Spree-Webhook-Signature", "HMAC", "webhook retry", "webhook disabled", "webhook.test", "audit log / state changes". Covers in-process subscribers AND outbound webhooks, and when to use a workflow hook instead.
npx skill4agent add spree/agent-skills spree-events-webhooksorder.placedpayment.completedfulfillment.fulfilled| Consumer | Lives | Use for |
|---|---|---|
| Subscribers | Ruby classes in your Spree app | Your own side effects — ERP sync, custom email, analytics, audit history |
| Webhooks | HTTPS POSTs to external URLs | Another system is the consumer — Next.js storefront, n8n/Zapier/Make, partner apps |
workflow.reject!Spree.hooks.register('<flow>.<hook>', Handler)spree-workflows| Need | Use |
|---|---|
| Block a cart completion / return / cancel under a business rule | Hook ( |
| Do something extra inside the flow, same transaction | Hook ( |
| Push a placed order to a WMS, send a Slack message | Subscriber |
| Notify a system outside Spree | Webhook |
| Keep a history of status changes | Subscriber writing your own table |
StateChangeLogEntryorder.placedorder.canceledpayment.completedpayment.voidedfulfillment.fulfilledfulfillment.deliveredfulfillment.canceledreturn.*<prefix>.created.updated.deletedpublishes_lifecycle_eventsuser.*user.createdtouch.updated| Event | When |
|---|---|
| Checkout completed (or an admin placed a draft order). Payload includes |
| Payments cover the order total |
| Every fulfillment handed over / delivered |
| Order canceled (final — orders can't be resumed) |
| Risky order approved by staff |
| Cart lifecycle — carts are a separate resource from orders |
| Payment moments |
| Provider session outcomes (same set on |
| Parcel went out / arrived / stood down ( |
| Catalog availability |
| Post-purchase flows ( |
| Customer downloaded a digital asset |
| Bulk imports |
webhooks-events.mdorder.updatedorder.placedorder.updatedorder.completedorder.placedorder.shippedorder.fulfilledshipment.shippedfulfillment.fulfilledshipment.canceledfulfillment.canceledstock_item.*stock_level.*wished_item.*wishlist_item.*digital.*digital_asset.*order.*order.placedorder.completedfulfillment.*shipment.*order.completedmetadata.deprecated_alias_ofspree-upgrade-5-to-6spree generate subscriber OrderPlaced order.placed order.canceled # classic: bin/rails g spree:subscriber …
# --sync → subscribes_to …, async: false
# --skip-spec → no spec/subscribers/… fileapp/subscribers/order_placed_subscriber.rbconfig/initializers/spree.rbRails.application.config.after_initialize do
Spree.subscribers << OrderPlacedSubscriber
end'OrderPlacedSubscriber'NoMethodError: undefined method 'subscription_patterns' for String# app/subscribers/order_placed_subscriber.rb
class OrderPlacedSubscriber < Spree::Subscriber
subscribes_to 'order.placed'
def handle(event)
order = Spree::Order.find_by_prefix_id(event.payload['id'])
return unless order
WarehouseClient.new.submit(order)
end
endhandle(event)callcallonhandleevent.nameevent.payloadevent.metadataspree_versionevent.store_idevent.idevent.created_atfind_by_prefix_idclass PaymentSubscriber < Spree::Subscriber
subscribes_to 'payment.completed', 'payment.voided', 'return.*'
on 'payment.completed', :handle_complete
on 'payment.voided', :handle_void
# anything without an `on` falls through to #handle
def handle(event)
Rails.logger.info("[returns] #{event.name}")
end
private
def handle_complete(event)
# ...
end
def handle_void(event)
# ...
end
endSpree::Events::SubscriberJobSpree.queues.events:defaultsubscribes_to 'order.placed', async: falseRails.errororder.publish_event('order.flagged_for_review') # payload = serialized order
order.publish_event('order.flagged_for_review', { 'id' => order.prefixed_id, 'score' => 87 })
Spree::Events.publish('brand.synced', { 'id' => brand.prefixed_id }) # from anywherepublish_eventmodule Spree
class Brand < Spree.base_class
publishes_lifecycle_events # brand.created / .updated / .deleted
# publishes_lifecycle_events only: [:create, :delete]
# publishes_lifecycle_events except: [:update]
# self.event_prefix = 'brand' # override the prefix if needed
end
endSpree::Api::V3::BrandSerializerspree:api_resource{ id, created_at, updated_at }.deletedSpree::Events.disable { bulk_fix! } # nothing published in the block
Spree::Events.disable_lifecycle { import_rows! } # suppress *.created/updated/deleted onlystore_idSpree::Current.storeSpree::Current.store = order.store
order.publish_event('order.flagged_for_review')event published ─▶ Spree::WebhookEventSubscriber (subscribes to '*', ships with spree_api)
─▶ for each enabled Spree::WebhookEndpoint in event.store_id that subscribed_to?(name)
─▶ Spree::WebhookDelivery (whd_…) ─▶ Spree::WebhookDeliveryJob (Spree.queues.webhooks)
─▶ Spree::Webhooks::DeliverWebhook: signed POST, 30s timeout, result recordedspree api post /webhook_endpoints -d '{
"name": "ERP sync",
"url": "https://erp.example.com/spree-webhooks",
"subscriptions": ["order.placed", "order.canceled", "fulfillment.*"],
"active": true
}'const endpoint = await admin.webhookEndpoints.create({
url: 'https://erp.example.com/spree-webhooks',
subscriptions: ['order.placed', 'fulfillment.*'],
})
endpoint.secret_key // 64-char hex — returned ONLY on create; store it nowsubscriptionsorder.*"*"whe_…whd_…spree_apiSPREE_WEBHOOKS_ENABLEDSPREE_WEBHOOKS_VERIFY_SSL*customer.password_reset_requestedSpree::WebhookEndpoint::CREDENTIAL_EVENTS*customer.*write_customerswrite_webhooksuser.createduser.updateduser.deletedcustomer.anonymizedcustomer.password_reset*customer.createdPOST /spree-webhooks
Content-Type: application/json
User-Agent: Spree-Webhooks/1.0
X-Spree-Webhook-Signature: <hex hmac-sha256>
X-Spree-Webhook-Timestamp: 1767225600
X-Spree-Webhook-Event: order.placed
{ "id": "<event uuid>", "name": "order.placed", "created_at": "…",
"data": { "id": "or_m3Rp9wXz", "number": "R123456789", "total": "129.99", "notify_customer": true, … },
"metadata": { "spree_version": "6.0.0" } }id"#{timestamp}.#{raw_body}"secret_keydef verified?(request)
ts = request.headers['X-Spree-Webhook-Timestamp'].to_i
sig = request.headers['X-Spree-Webhook-Signature'].to_s
body = request.raw_post
return false if (Time.current.to_i - ts).abs > 300 # replay window
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('SPREE_WEBHOOK_SECRET'), "#{ts}.#{body}")
ActiveSupport::SecurityUtils.secure_compare(expected, sig)
endimport crypto from 'node:crypto'
export function verified(rawBody: string, ts: string, sig: string, secret: string) {
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const expected = crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex')
return expected.length === sig.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
}await req.text()response_coderesponse_bodyerror_typerequest_errorsexecution_timeSpree::WebhookDeliveryJobretry_on …, wait: :polynomially_longer, attempts: 5iddelivery.redeliver!POST /api/v3/admin/webhook_endpoints/:id/deliveries/:id/redeliveradmin.webhookEndpoints.deliveries.redeliver(whe, whd)Spree::WebhookEndpoint::AUTO_DISABLE_THRESHOLDactive: falsedisabled_atSpree::WebhookMailer.endpoint_disabledendpoint.enable!PATCH …/webhook_endpoints/:id/enableactive: truespree_webhook_deliveries.payloadSpree::WebhookPayloadRedactiontokenreset_tokenunsubscribe_tokenverification_tokendownload_urlcode[REDACTED][REDACTED]payloaddelivery.payload_permission_keyread_ordersorder.*read_customerscustomer.*user.*payloadnullendpoint.send_test! # queues a synthetic 'webhook.test' deliveryPOST /api/v3/admin/webhook_endpoints/:id/send_testssrf_filterhost.docker.internalconfig.to_prepareSpree::Webhooks::DeliverWebhook.header_decorators << ->(headers, delivery) { headers['X-Env'] = Rails.env }class OrderAuditSubscriber < Spree::Subscriber
subscribes_to 'order.*', 'payment.*', 'fulfillment.*'
LEGACY_ALIASES = %w[order.completed order.shipped].freeze
def handle(event)
return if LEGACY_ALIASES.include?(event.name) # 6.0 dual-emitted old names
AuditEntry.create_or_find_by!(event_id: event.id) do |e|
e.name = event.name
e.resource_id = event.payload['id']
e.payload = event.payload
end
end
endclass PaymentFailureSubscriber < Spree::Subscriber
subscribes_to 'payment_session.failed'
def handle(event)
Slack.notify("Payment session #{event.payload['id']} failed")
end
endSpree.subscribers.include?(MySubscriber)order.placedorder.complete[Spree Event] order.placed | payload: …Spree::Config.events_log_enabled = falseSpree::Events::SubscriberJobevents: trueSpree::Events.enable { … }endpoint.active? && !endpoint.auto_disabled?endpoint.subscribed_to?('order.placed')Spree::Current.storeendpoint.send_test!endpoint.webhook_deliveries.recent.firstresponse_coderequest_errorsSPREE_WEBHOOKS_ENABLEDnode_modules/@spree/docs/dist/developer/core-concepts/events.mdnode_modules/@spree/docs/dist/developer/core-concepts/webhooks.mdnode_modules/@spree/docs/dist/api-reference/webhooks-events.mdSpree::SubscriberSpree::PublishableSpree::EventsSpree::WebhookEndpointSpree::WebhookDeliverySpree::Webhooks::DeliverWebhookSpree::WebhookEventSubscriberspree-workflowsspree-securityspree-testing