Gareth Wilson Gareth Wilson

Guide to Storekit Webhooks: Features and Best Practices

Published


Storekit 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 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

FeatureDetails
SignatureStandard Webhooks HMAC-SHA256
Signature headersvix-signature
BodyJSON
Event routingBody field event
Delivery platformSvix (embedded portal)
AvailabilityPlans that include webhooks
Secretwhsec_, one per endpoint
Response2xx within 15 seconds
RetriesAbout three days after a non-2xx, timeout or connection failure
Auto-disableAfter 5 consecutive days of failures
Event IDsvix-id header; none in the body
OrderingNot promised

Events and payloads

EventTriggered WhenCommon Use Cases
order.created.v2A new order is placedSend to kitchen or POS, notify staff
order.createdLegacy form of the same event (bare order body)Existing integrations only
order.acceptedAccepted at a store with Auto-accept off (adds status, acceptedAt)Confirm to customer
order.rejectedRejected at a store with Auto-accept off (adds status, rejectedAt)Notify customer, refund from the dashboard
order.canceledAn order is cancelled (data.order.status)Stop preparation
order.preparingAn order moves to preparingProgress display
order.ready_for_pickupAn order is marked ready for pickupNotify the customer
order.out_for_deliveryA delivery order is dispatchedDelivery tracking
order.completedAn order is marked completed (including bulk completions)Close out the ticket
order.pos.dispatch.failedAn order failed to reach the POS (pos.name, pos.error, retryCount)Alert staff
order.rating.updatedA customer submits or updates a rating (rating, comments)Feedback tracking
order.refund.createdA refund is issued from the dashboardAccounting
order.refund.succeededThe payment gateway confirms the refundMark refunded
order.refund.failedThe payment gateway rejects the refundFollow up
store.openedA store opens, a snooze ends early, or a timed snooze expiresSync availability
store.closedA store closes or is snoozed (closedReason, closedUntil, empty strings if unset)Sync availability
store.opening_hours.updatedOpening hours change (openingHours, specialHours, source)Sync listings
item.out_of_stockA menu item becomes unavailable (reason, source, details)Update external menus
payments.payout.createdA payout is sent to your bank account (amounts, bankAccount)Reconciliation
bill.payment.createdA bill payment is createdReconciliation (no payload shown in the docs)
payment_link.createdA payment link is created (paymentLink)Track links
payment_link.paidA payment link payment is collected (payment.transactionId, amount)Mark invoice paid
payment_link.refund.createdA payment link refund is initiated (pending)Accounting
payment_link.refund.succeededThe gateway confirms the refundMark refunded
payment_link.refund.failedThe gateway rejects the refundFollow up
printer.status.offlineA cloud printer misses check-ins for 5 minutesAlert staff
printer.status.onlineA printer reconnects after being offline for at least 5 minutesClear the alert

This item.out_of_stock body from the webhook samples library shows the usual { event, data } shape:

{
  "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 and add its URL as an endpoint:

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

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:

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 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 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 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 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.

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 absorbs busy periods, removes legacy duplicates and routes each event to the right system. Get started by adding a Storekit source as an endpoint.


Gareth Wilson

Gareth Wilson

Product Marketing

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