Gareth Wilson Gareth Wilson

Guide to Spree Commerce Webhooks: Features and Best Practices

Published


Spree Commerce webhooks tell your systems what's happening in a store: an order is placed or paid, a parcel ships, a product goes out of stock, or a customer registers. Spree is an open-source, headless e-commerce platform, and webhooks let you feed those events to a CRM, a fulfilment service, an analytics pipeline or your own storefront.

This guide covers how Spree webhook endpoints and subscriptions work, how deliveries are signed, how Spree retries and disables endpoints, and the practices that keep a Spree receiver reliable.

What are Spree Commerce webhooks?

Spree webhooks are built on its event system. Each store has its own webhook endpoints, and each endpoint has a URL, an active flag and a list of event subscriptions. When an event matches an endpoint, Spree records a delivery and sends an HTTP POST with a JSON payload from a background job.

Every request carries X-Spree-Webhook-Signature, a hex HMAC-SHA256 of the X-Spree-Webhook-Timestamp header, a ., and the raw body. The key is the endpoint's secret_key, which Spree returns only once, when you create the endpoint.

Spree Commerce webhook features

FeatureDetails
ConfigurationSettings > Developer > Webhooks in the admin, or the Admin API
ScopeOne store per endpoint
MethodPOST with a JSON body
Signature headerX-Spree-Webhook-Signature, hex
Timestamp headerX-Spree-Webhook-Timestamp, Unix seconds at send time
Event headerX-Spree-Webhook-Event
User agentSpree-Webhooks/1.0
SchemeHMAC-SHA256
Signed content<timestamp>.<raw body>
Secretsecret_key, a 64-character hex string returned only in the create response
SubscriptionsExact names and wildcards such as order.* and *.created, up to 200 entries
Event IDid in the body, the same on every attempt
RetriesUp to 5 attempts in total over about six minutes, with growing waits
Auto-disableAfter 15 failed deliveries in a row, with an email to store staff
Manual re-sendRedeliver from the delivery log
Test eventsend_test sends webhook.test

Common events

Spree publishes many events, and installed extensions add more. You can list every event for a store through the Admin API with GET /webhook_events, which also flags deprecated names and the events that replace them. These are some of the common ones:

EventDescription
order.placedA customer completed checkout
order.paidThe order is fully paid
order.canceledThe order was canceled
order.fulfilledEverything on the order has shipped
fulfillment.fulfilledOne parcel went out
payment.completedA payment succeeded
product.created, product.updatedCatalogue changes
product.out_of_stock, product.back_in_stockAvailability changed
user.createdA new customer registered
return.receivedA return arrived

The payload's data uses the same serializers as Spree's Store API, so webhook payloads and API responses share one schema. This is Spree's example, with the item, fulfilment and payment arrays emptied:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "order.placed",
  "created_at": "2025-01-15T10:30:00Z",
  "data": {
    "id": "or_m3Rp9wXz",
    "number": "R123456789",
    "fulfillment_status": "unfulfilled",
    "payment_status": "paid",
    "total": "99.99",
    "display_total": "$99.99",
    "total_quantity": 3,
    "currency": "USD",
    "items": [],
    "fulfillments": [],
    "payments": []
  },
  "metadata": {
    "spree_version": "6.0.0"
  }
}

Route on name, which also arrives in the X-Spree-Webhook-Event header. Payloads carry only what a customer could see, never back-office fields such as costs or internal notes. Fetch the record from the Admin API with the id in data when you need those.

Subscriptions and credential events

The subscriptions array takes exact event names and patterns where * stands for a whole segment, so order.* and *.created work but order.pl* doesn't. [] or ['*'] subscribes to everything.

Two events carry a credential: customer.password_reset_requested, which includes a reset token, and data_request.confirmation_requested. No pattern matches them. An endpoint receives them only when it names them, and naming them needs permission to manage customers.

Setting up Spree Commerce webhooks

  1. In the Spree admin, go to Settings > Developer > Webhooks and create an endpoint with your receiver's URL and the events to subscribe to. You can also call POST /api/v3/admin/webhook_endpoints with an X-Spree-API-Key header.
  2. Copy the secret_key straight away and store it, for example as SPREE_WEBHOOK_SECRET. Spree returns it only in the create response and you can't retrieve it later.
  3. Call send_test on the endpoint to send a webhook.test event and confirm your verification code works.

With Hookdeck. Spree Commerce is a supported source type in Hookdeck. Spree shows the endpoint secret only once, when you create the webhook endpoint, so paste it into a Hookdeck source of the Spree Commerce type at that point. Use the Hookdeck source URL as the endpoint url in Spree. The source accepts POST only and checks X-Spree-Webhook-Signature against <X-Spree-Webhook-Timestamp>.<raw body>. A request that fails the check still gets a 200 from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

Securing Spree Commerce webhooks

Join the X-Spree-Webhook-Timestamp header, a literal ., and the raw body bytes. Compute an HMAC-SHA256 with the secret_key as a UTF-8 string, and compare the result with X-Spree-Webhook-Signature in constant time. The secret looks like hex, but don't decode it: Spree's own Ruby sample passes it to OpenSSL::HMAC.hexdigest as a string.

const crypto = require('crypto');
const express = require('express');

function verifySpreeSignature(rawBody, signature, timestamp, secret) {
  if (!signature || !timestamp || !secret) return false;
  if (!/^[0-9a-f]{64}$/i.test(signature)) return false; // validate before decoding

  const signed = Buffer.concat([Buffer.from(`${timestamp}.`, 'latin1'), rawBody]);
  const expected = crypto.createHmac('sha256', secret).update(signed).digest();
  const received = Buffer.from(signature, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

// Optional replay check, off by default (0). Hookdeck retries and replays resend the original timestamp.
const TOLERANCE_SECONDS = Number(process.env.SPREE_WEBHOOK_TOLERANCE_SECONDS || 0);

const app = express();

app.post('/webhooks/spree', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.SPREE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const timestamp = req.get('x-spree-webhook-timestamp');
  if (!verifySpreeSignature(req.body, req.get('x-spree-webhook-signature'), timestamp, secret)) {
    return res.sendStatus(401);
  }
  if (TOLERANCE_SECONDS > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, route on event.name, then process asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac
import re

HEX_64 = re.compile(r"[0-9a-fA-F]{64}")


def verify_spree_signature(raw_body: bytes, signature, timestamp, secret: str) -> bool:
    if not signature or not timestamp or not secret:
        return False
    if not HEX_64.fullmatch(signature):
        return False
    signed = timestamp.encode("latin-1") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    return hmac.compare_digest(bytes.fromhex(signature), expected)

Spree's JavaScript SDK has a helper, verifyWebhookSignature in @spree/sdk/webhooks. In version 1.2.1 its toleranceSeconds argument defaults to 300, so it rejects any request whose timestamp is more than five minutes old. Behind Hookdeck that includes retries and replays, which resend the original headers, while Hookdeck itself applies no timestamp tolerance. If you use the helper behind Hookdeck, pass a tolerance long enough for your retry and replay window, or verify by hand as above.

Make Spree Commerce webhooks production-ready. Hookdeck Event Gateway verifies the X-Spree-Webhook-Signature header, accepts every order and product event while your service is down, and keeps your endpoint clear of Spree's auto-disable rule.

Spree Commerce webhook limitations and pain points

A short retry window

The Problem: A deploy or a brief outage loses events.

Why It Happens: Spree treats any non-2xx response, timeout or connection error as a failure and makes up to 5 attempts in total over about six minutes. After that, the delivery stays failed until someone re-sends it from the delivery log.

Workarounds:

  • Check the delivery log after an outage, filter it for success_eq=false, and redeliver what failed.
  • Keep the receiver's work small so it answers quickly.

How Hookdeck Can Help: Hookdeck accepts each delivery as it arrives and stores it, then delivers to your service with automatic retries over a window you choose, so an outage longer than six minutes doesn't cost you orders.

Endpoints are disabled after 15 failures in a row

The Problem: A broken handler takes the whole endpoint offline, and later events aren't sent.

Why It Happens: Spree disables an endpoint after 15 consecutive failed deliveries and emails the store staff. It stays off until someone marks it active again, and retries stop as soon as the endpoint is turned off.

Workarounds:

  • Watch for the email and alert on failures in your own logs.
  • After fixing the receiver, mark the endpoint active and redeliver the failed deliveries.

How Hookdeck Can Help: Hookdeck keeps answering Spree while your service is down, so the endpoint doesn't hit the failure threshold, and you can replay the stored events once your service recovers.

The secret is shown once

The Problem: You can't verify signatures because nobody saved the secret.

Why It Happens: Spree generates secret_key and returns it only in the create response. It can't be retrieved later.

Workarounds:

  • Store the secret in your secret manager as part of creating the endpoint.
  • If it's lost, create a new endpoint and delete the old one.

How Hookdeck Can Help: Once the secret is in a Hookdeck source, Hookdeck verifies every request and your service no longer needs its own copy.

Internal receivers are blocked by default

The Problem: Deliveries to a service on a private network fail with has no public ip addresses.

Why It Happens: Outside development, Spree refuses webhook URLs that resolve to a private or internal address, both when you save the endpoint and on each delivery.

Workarounds:

  • Allow the host with SPREE_WEBHOOKS_ALLOWED_INTERNAL_HOSTS, which installation config controls and store admins can't change.
  • Or expose the receiver on a public URL.

How Hookdeck Can Help: Point Spree at a public Hookdeck source URL, and use the Hookdeck CLI to forward events to a local or private service while you develop.

Best practices

Verify against the raw body

The signature covers the body exactly as sent. Use express.raw() in Express, await request.arrayBuffer() in a Next.js route handler, and await request.body() in FastAPI. Spree's troubleshooting list starts with "Ensure you're using the raw request body (not parsed JSON)".

Deduplicate on the event id

Every attempt and every re-sent delivery carries the same id. Record the IDs you've handled and skip repeats. See our guide to webhook idempotency.

Subscribe selectively

Use specific names or patterns such as order.* rather than *. Fewer deliveries means fewer failures counting towards the auto-disable rule.

Expect deprecated names

The event list marks deprecated names and the events that replace them. Check it when you upgrade Spree, and route on the new names.

Acknowledge fast and process asynchronously

Verify, store the event, return 200, then do the work. See why to process webhooks asynchronously.

Conclusion

Spree Commerce signs every webhook with X-Spree-Webhook-Signature, a hex HMAC-SHA256 of <timestamp>.<raw body> keyed with the endpoint's secret_key. Save the secret when you create the endpoint, verify against the raw bytes, watch for the SDK's five-minute default window, deduplicate on id, and answer quickly so the endpoint stays active.

Hookdeck Event Gateway sits in front of your receiver, checks every Spree signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Spree Commerce webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.