# Guide to Storekit Webhooks: Features and Best Practices

[Storekit](https://storekit.com) gives restaurants online ordering, payments and in-store tools such as cloud printers. Its webhooks report what's happening across those stores: new orders and their progress through the kitchen, refunds, a store opening or closing, an item going out of stock, payouts, payment links and printers dropping offline. A POS, a delivery system or a reporting tool can subscribe and stay in sync.

Here you'll find Storekit's 27 events and their three body shapes, the duplicate new-order events, portal setup, and Svix verification and delivery rules.

## What are Storekit webhooks?

Storekit delivers webhooks through Svix. You manage endpoints in a Svix portal embedded in the Storekit dashboard, and every request carries `svix-id`, `svix-timestamp` and `svix-signature` headers. The [webhooks overview](https://storekit.com/docs/developers/webhooks/overview) suggests one endpoint per service, subscribed to the events that service needs.

Most bodies have an `event` field naming the type and a `data` object holding the resource. Two documented exceptions break that pattern, covered below. Monetary values are in the smallest currency unit, such as pence or cents, and timestamps are ISO 8601 UTC.

## Storekit webhook features

| Feature | Details |
| --- | --- |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `svix-signature` |
| Body | JSON |
| Event routing | Body field `event` |
| Delivery platform | Svix (embedded portal) |
| Availability | Plans that include webhooks |
| Secret | `whsec_`, one per endpoint |
| Response | `2xx` within 15 seconds |
| Retries | About three days after a non-`2xx`, timeout or connection failure |
| Auto-disable | After 5 consecutive days of failures |
| Event ID | `svix-id` header; none in the body |
| Ordering | Not promised |

## Events and payloads

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `order.created.v2` | A new order is placed | Send to kitchen or POS, notify staff |
| `order.created` | Legacy form of the same event (bare order body) | Existing integrations only |
| `order.accepted` | Accepted at a store with Auto-accept off (adds `status`, `acceptedAt`) | Confirm to customer |
| `order.rejected` | Rejected at a store with Auto-accept off (adds `status`, `rejectedAt`) | Notify customer, refund from the dashboard |
| `order.canceled` | An order is cancelled (`data.order.status`) | Stop preparation |
| `order.preparing` | An order moves to preparing | Progress display |
| `order.ready_for_pickup` | An order is marked ready for pickup | Notify the customer |
| `order.out_for_delivery` | A delivery order is dispatched | Delivery tracking |
| `order.completed` | An order is marked completed (including bulk completions) | Close out the ticket |
| `order.pos.dispatch.failed` | An order failed to reach the POS (`pos.name`, `pos.error`, `retryCount`) | Alert staff |
| `order.rating.updated` | A customer submits or updates a rating (`rating`, `comments`) | Feedback tracking |
| `order.refund.created` | A refund is issued from the dashboard | Accounting |
| `order.refund.succeeded` | The payment gateway confirms the refund | Mark refunded |
| `order.refund.failed` | The payment gateway rejects the refund | Follow up |
| `store.opened` | A store opens, a snooze ends early, or a timed snooze expires | Sync availability |
| `store.closed` | A store closes or is snoozed (`closedReason`, `closedUntil`, empty strings if unset) | Sync availability |
| `store.opening_hours.updated` | Opening hours change (`openingHours`, `specialHours`, `source`) | Sync listings |
| `item.out_of_stock` | A menu item becomes unavailable (`reason`, `source`, `details`) | Update external menus |
| `payments.payout.created` | A payout is sent to your bank account (`amounts`, `bankAccount`) | Reconciliation |
| `bill.payment.created` | A bill payment is created | Reconciliation (no payload shown in the docs) |
| `payment_link.created` | A payment link is created (`paymentLink`) | Track links |
| `payment_link.paid` | A payment link payment is collected (`payment.transactionId`, `amount`) | Mark invoice paid |
| `payment_link.refund.created` | A payment link refund is initiated (pending) | Accounting |
| `payment_link.refund.succeeded` | The gateway confirms the refund | Mark refunded |
| `payment_link.refund.failed` | The gateway rejects the refund | Follow up |
| `printer.status.offline` | A cloud printer misses check-ins for 5 minutes | Alert staff |
| `printer.status.online` | A printer reconnects after being offline for at least 5 minutes | Clear the alert |

This `item.out_of_stock` body from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/storekit/latest/item.out_of_stock.json) shows the usual `{ event, data }` shape:

```json
{
  "event": "item.out_of_stock",
  "data": {
    "venue": {
      "id": 1234,
      "name": "My Restaurant"
    },
    "item": {
      "id": 5678,
      "name": "Margherita Pizza",
      "plu": "PLU-001",
      "posId": "pos_item_42",
      "sku": "SKU-MARG-001"
    },
    "reason": "snoozed",
    "source": "manual",
    "details": {
      "snoozeEnd": "2024-01-15T18:00:00Z"
    }
  }
}

```

The two exceptions:

* **`order.refund.*`** events have no `data`. `order` (`id`, `total`), `customer` (`id`, `name`, `email`), `venue` and `refund` (`status`, `amount`, `totalAmountRefunded`, `reason`) sit beside `event`. `customer.id` and `refund.reason` are `null` when not available.
* **The legacy `order.created`** sends the order object itself as the body, with no `event` and no `data`. Storekit sends both it and `order.created.v2` for every new order.

The body carries no event ID; `svix-id` identifies each message and stays the same across retries.

## Setting up Storekit webhooks

Your plan must include webhooks; otherwise the page shows an **Upgrade to enable webhooks** button.

1. In the dashboard sidebar, open **Settings > Webhooks** and click **Enable Webhooks** if needed.
2. In the embedded portal, add an endpoint with your HTTPS URL, for example `https://example.com/webhooks/storekit`.
3. Pick the events this service needs. Subscribe to `order.created.v2` rather than `order.created` for new integrations.
4. Save, open the endpoint, and copy its `whsec_` secret into `STOREKIT_WEBHOOK_SECRET`.

Storekit calls out that CSRF protection must be disabled on the webhook route. To try it locally, forward deliveries with the [Hookdeck CLI](/docs/cli) and add its URL as an endpoint:

```bash
npx hookdeck-cli listen 3000 storekit --path /webhooks/storekit

```

**With Hookdeck.** Add a **Storekit** source's URL as an endpoint in the portal, then paste that endpoint's secret into the source. Hookdeck responds `200` to each `POST` inside Storekit's 15-second limit and logs, rather than forwards, any request whose signature doesn't match. It doesn't accept other methods.

## Securing Storekit webhooks

Storekit's [verification page](https://storekit.com/docs/developers/webhooks/verifying-signatures) recommends the Svix libraries and also restates the manual method: sign `${svixId}.${svixTimestamp}.${rawBody}` with HMAC-SHA256 using the base64-decoded secret after `whsec_`, and compare with each `v1,` value in `svix-signature`. Its sample strips the prefix with `secretKey.split('_')[1]`, which gives the same key as the function here:

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

A handler that copes with all three body shapes and skips the legacy duplicate:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

const app = express();

app.post('/webhooks/storekit', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.STOREKIT_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const body = parseJsonObject(req.body);
  if (!body) return res.sendStatus(400); // signed, but not a JSON object
  const message_id = req.headers['svix-id'] ?? req.headers['webhook-id'];

  if (!body.event) return res.sendStatus(200); // legacy order.created; order.created.v2 covers it
  if (typeof body.event !== 'string') return res.sendStatus(400);
  const needs_data = body.event === 'order.created.v2' || body.event === 'item.out_of_stock';
  if (needs_data && (!body.data || typeof body.data !== 'object')) return res.sendStatus(400);

  try {
    if (body.event === 'order.created.v2') {
      await kitchen.sendOrder(message_id, body.data);
    } else if (body.event.startsWith('order.refund.')) {
      await accounting.recordRefund(message_id, body.event, body.order, body.refund);
    } else if (body.event === 'item.out_of_stock') {
      await menus.markUnavailable(message_id, body.data.item, body.data.details);
    } else if (body.event === 'printer.status.offline') {
      await alerts.printerOffline(message_id, body.data);
    }
  } catch {
    return res.sendStatus(503); // not processed, so don't acknowledge
  }
  return res.sendStatus(200);
});

```

`kitchen`, `accounting`, `menus` and `alerts` stand for your own services, each ignoring a `message_id` it has processed.

Storekit asks receivers to reject timestamps more than five minutes old, and the Svix libraries do so by default. Behind Hookdeck that check gets in the way: Hookdeck itself has no timestamp tolerance, and its retries and replays carry the original `svix-timestamp`. Verify without the age check in services that receive Storekit events through Hookdeck.

> **Make Storekit webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) answers Storekit inside 15 seconds during busy service and fans each event out to your POS, delivery and reporting systems.

## Storekit webhook limitations and pain points

### Every new order is sent twice

**The Problem:** Orders are sent to the kitchen twice.

**Why It Happens:** Storekit sends both the legacy `order.created` and `order.created.v2` for each new order, with different shapes and different `svix-id` values.

**Workarounds:**

* Subscribe the endpoint to `order.created.v2` only. If you receive both, ignore bodies without an `event` field.

**How Hookdeck Can Help:** A [filter](/docs/filters) that requires an `event` field drops the legacy copy before it reaches your service.

### Three body shapes

**The Problem:** Code that reads `body.data` fails on refunds and legacy orders.

**Why It Happens:** Refund events put their objects at the top level, and the legacy order has no envelope at all.

**Workarounds:**

* Branch on `event` first and read fields according to the event family.

**How Hookdeck Can Help:** Hookdeck's [transformations](/docs/transformations) can reshape refund bodies into a common `{ event, data }` form before delivery.

### A 15-second timeout during service

**The Problem:** At peak times a slow handler times out and Storekit retries, adding more load.

**Why It Happens:** Storekit expects a `2xx` within 15 seconds.

**Workarounds:**

* Acknowledge immediately and process orders from a queue.

**How Hookdeck Can Help:** Hookdeck acknowledges straight away and controls the [delivery rate](/docs/destinations) to your service.

### Disabled after five days

**The Problem:** A long outage disables the endpoint.

**Why It Happens:** Storekit retries for about three days, and endpoints can be disabled after five consecutive days of failures.

**Workarounds:**

* Watch the portal for disabled endpoints and re-enable them after a fix.

**How Hookdeck Can Help:** Hookdeck accepts Storekit's deliveries while your service is down, so the endpoint isn't disabled.

## Best practices

### Keep processed IDs for 72 hours

Storekit deliveries can repeat. Store `svix-id` values for at least 72 hours, which covers the retry period. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Don't assume order

There's no ordering promise, so `order.completed` may arrive before `order.preparing`. Only move an order's status forward.

### Handle money in minor units

Amounts are in pence or cents. Convert only for display.

### Act on `succeeded`, not `created`, for refunds

`order.refund.created` means a refund was issued; `order.refund.succeeded` means the gateway confirmed it.

## Conclusion

Storekit's webhooks keep POS, delivery and reporting tools current with what's happening in each restaurant. Subscribe to `order.created.v2`, branch on the three body shapes, answer within 15 seconds and keep processed `svix-id` values for 72 hours.

[Hookdeck Event Gateway](https://hookdeck.com) absorbs busy periods, removes legacy duplicates and routes each event to the right system. [Get started](https://dashboard.hookdeck.com/signup) by adding a Storekit source as an endpoint.