# Guide to Spree Commerce Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Configuration | Settings > Developer > Webhooks in the admin, or the Admin API |
| Scope | One store per endpoint |
| Method | `POST` with a JSON body |
| Signature header | `X-Spree-Webhook-Signature`, hex |
| Timestamp header | `X-Spree-Webhook-Timestamp`, Unix seconds at send time |
| Event header | `X-Spree-Webhook-Event` |
| User agent | `Spree-Webhooks/1.0` |
| Scheme | HMAC-SHA256 |
| Signed content | `<timestamp>.<raw body>` |
| Secret | `secret_key`, a 64-character hex string returned only in the create response |
| Subscriptions | Exact names and wildcards such as `order.*` and `*.created`, up to 200 entries |
| Event ID | `id` in the body, the same on every attempt |
| Retries | Up to 5 attempts in total over about six minutes, with growing waits |
| Auto-disable | After 15 failed deliveries in a row, with an email to store staff |
| Manual re-send | Redeliver from the delivery log |
| Test event | `send_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:

| Event | Description |
| --- | --- |
| `order.placed` | A customer completed checkout |
| `order.paid` | The order is fully paid |
| `order.canceled` | The order was canceled |
| `order.fulfilled` | Everything on the order has shipped |
| `fulfillment.fulfilled` | One parcel went out |
| `payment.completed` | A payment succeeded |
| `product.created`, `product.updated` | Catalogue changes |
| `product.out_of_stock`, `product.back_in_stock` | Availability changed |
| `user.created` | A new customer registered |
| `return.received` | A 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:

```json
{
  "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.

```javascript
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:

```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](/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](/docs/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](/docs/use-cases/receive-webhooks#replay-for-debugging-and-recovery) 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](/docs/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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) sits in front of your receiver, checks every Spree signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Spree Commerce webhooks reliably in minutes.