docs/developer/core-concepts/webhooks.mdx
Webhooks allow your Spree store to send real-time HTTP POST notifications to external services when events occur. When an order is completed, a product is updated, or inventory changes, Spree can automatically notify your CRM, fulfillment service, analytics platform, or any other system.
Webhooks are built on top of Spree's event system, providing:
flowchart LR
subgraph Spree Store
A[Event Published] --> B[WebhookEventSubscriber]
B --> C{Find Matching
Endpoints}
C --> D[Create WebhookDelivery]
D --> E[Queue DeliveryJob]
end
subgraph Delivery
E --> F[Build JSON Payload]
F --> G[Sign with HMAC-SHA256]
G --> H[HTTP POST]
end
subgraph External Service
H --> I{Response}
I -->|2xx| J[Success]
I -->|Error| K[Retry with Backoff]
K -->|Max Retries| L[Mark Failed]
K -->|Retry| H
end
order.completed)WebhookEventSubscriber receives all eventsWebhookDelivery record and queues a jobNavigate to Settings → Developers → Webhooks in the admin panel to create and manage webhook endpoints.
endpoint = Spree::WebhookEndpoint.create!(
store: Spree::Store.default,
url: 'https://example.com/webhooks/spree',
subscriptions: ['order.*', 'product.created'],
active: true
)
# The secret_key is auto-generated
endpoint.secret_key # => "a1b2c3d4e5f6..." (64-character hex string)
| Attribute | Type | Description |
|---|---|---|
url | String | The HTTPS endpoint URL to receive webhooks |
active | Boolean | Enable/disable delivery to this endpoint |
subscriptions | Array | Event patterns to subscribe to |
secret_key | String | Auto-generated key for HMAC signature verification |
store_id | Integer | The store this endpoint belongs to |
The subscriptions attribute controls which events trigger webhooks to this endpoint.
endpoint.subscriptions = ['order.completed', 'order.canceled']
Use wildcards to subscribe to multiple related events:
# All order events
endpoint.subscriptions = ['order.*']
# All creation events
endpoint.subscriptions = ['*.created']
# Multiple patterns
endpoint.subscriptions = ['order.*', 'payment.*', 'shipment.shipped']
Leave subscriptions empty or use * to receive all events:
endpoint.subscriptions = [] # All events
endpoint.subscriptions = ['*'] # All events (explicit)
Each webhook delivery sends a JSON payload with the following structure. The data object uses the same Store API V3 serializers as the REST API, so webhook payloads and API responses share the same schema:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "order.completed",
"created_at": "2025-01-15T10:30:00Z",
"data": {
"id": "or_m3Rp9wXz",
"number": "R123456789",
"state": "complete",
"total": "99.99",
"display_total": "$99.99",
"item_count": 3,
"currency": "USD",
"items": [ ... ],
"shipments": [ ... ],
"payments": [ ... ]
},
"metadata": {
"spree_version": "5.1.0"
}
}
| Field | Description |
|---|---|
id | Unique UUID for this event |
name | Event name (e.g., order.completed) |
created_at | ISO8601 timestamp when event occurred |
data | Serialized resource data (V3 API format with prefixed IDs) |
metadata | Additional context including Spree version |
For complete payload schemas for each event type, see Webhook Events & Payloads.
Each webhook request includes these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | Spree-Webhooks/1.0 |
X-Spree-Webhook-Event | Event name (e.g., order.completed) |
X-Spree-Webhook-Signature | HMAC-SHA256 signature for verification |
To ensure webhooks are genuinely from your Spree store, verify the signature.
The Spree Storefront includes a ready-made webhook route handler with signature verification and event routing. See the storefront email docs for details.
Use @spree/sdk/webhooks for framework-agnostic verification:
import { verifyWebhookSignature } from '@spree/sdk/webhooks'
import type { WebhookEvent } from '@spree/sdk/webhooks'
import type { Order } from '@spree/sdk'
// Hono, Cloudflare Workers, or any Web Fetch API-based framework
app.post('/webhooks/spree', async (req, res) => {
const body = await req.text()
const signature = req.headers['x-spree-webhook-signature']
const timestamp = req.headers['x-spree-webhook-timestamp']
if (!verifyWebhookSignature(body, signature, timestamp, process.env.SPREE_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'Invalid signature' })
}
const event: WebhookEvent<Order> = JSON.parse(body)
// handle event...
res.json({ received: true })
})
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def receive
unless verify_signature
head :unauthorized
return
end
event = JSON.parse(request.body.read)
case event['name']
when 'order.completed'
handle_order_completed(event['data'])
when 'product.updated'
handle_product_updated(event['data'])
end
head :ok
end
private
def verify_signature
payload = request.body.read
request.body.rewind
signature = request.headers['X-Spree-Webhook-Signature']
timestamp = request.headers['X-Spree-Webhook-Timestamp']
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV['SPREE_WEBHOOK_SECRET'], "#{timestamp}.#{payload}")
ActiveSupport::SecurityUtils.secure_compare(signature.to_s, expected)
end
end
Failed webhook deliveries automatically retry up to 5 times with exponential backoff. This handles temporary network issues and endpoint downtime.
endpoint = Spree::WebhookEndpoint.find(id)
# Recent deliveries
endpoint.webhook_deliveries.recent
# Filter by status
endpoint.webhook_deliveries.successful
endpoint.webhook_deliveries.failed
endpoint.webhook_deliveries.pending
# Filter by event
endpoint.webhook_deliveries.for_event('order.completed')
| Attribute | Description |
|---|---|
event_name | Name of the event delivered |
payload | Complete webhook payload sent |
response_code | HTTP status code (nil if pending) |
success | Boolean indicating 2xx response |
execution_time | Delivery time in milliseconds |
error_type | 'timeout', 'connection_error', or nil |
request_errors | Error message details |
response_body | Response from endpoint (truncated) |
delivered_at | Timestamp of delivery attempt |
Webhooks are enabled by default. To disable globally:
# config/initializers/spree.rb
Spree::Api::Config.webhooks_enabled = false
SSL verification is enabled by default in production. In development, it's disabled to allow testing with self-signed certificates:
# config/initializers/spree.rb
Spree::Api::Config.webhooks_verify_ssl = true # Force SSL verification
Spree::Api::Config.webhooks_verify_ssl = false # Disable (not recommended for production)
Webhooks can subscribe to any event in Spree's event system. See Events for a complete list.
Common webhook events include:
| Event | Description |
|---|---|
order.completed | Order checkout finished |
order.canceled | Order was canceled |
order.paid | Order is fully paid |
shipment.shipped | Shipment was shipped |
payment.paid | Payment was completed |
product.created | New product created |
product.updated | Product was modified |
customer.created | New customer registered |
Use tools like ngrok or webhook.site to test webhooks locally:
# Create a test endpoint
endpoint = Spree::WebhookEndpoint.create!(
store: Spree::Store.default,
url: 'https://your-ngrok-url.ngrok.io/webhooks',
subscriptions: ['order.*'],
active: true
)
# Trigger an event
order = Spree::Order.complete.last
order.publish_event('order.completed')
# Check delivery
endpoint.webhook_deliveries.last.success?
RSpec.describe 'Webhook delivery' do
let(:store) { create(:store) }
let(:endpoint) { create(:webhook_endpoint, store: store, subscriptions: ['order.completed']) }
let(:order) { create(:completed_order_with_totals, store: store) }
it 'delivers webhook when order completes' do
stub_request(:post, endpoint.url).to_return(status: 200)
expect {
order.publish_event('order.completed')
}.to have_enqueued_job(Spree::WebhookDeliveryJob)
end
end
Spree::Api::Config.webhooks_enabledendpoint.active?endpoint.subscribed_to?('order.completed')store_id matching the endpoint's storesecret_key for this endpointCheck the delivery record for details:
delivery = endpoint.webhook_deliveries.failed.last
delivery.error_type # => 'timeout' or 'connection_error'
delivery.request_errors # => Error message
delivery.response_code # => HTTP status code
delivery.response_body # => Response from your endpoint