# Storekit Webhooks

[Storekit](https://storekit.com) is online ordering, payments and in-store tooling for restaurants:
orders, store status, menu availability, payouts, payment links and cloud printers. It delivers
webhooks through Svix, which implements Standard Webhooks. Every delivery
is an HTTP `POST` with a JSON body and three signature headers: `svix-id`, `svix-timestamp`, and
`svix-signature`. The body is `{ "event": "<type>", "data": { ... } }`, with two documented
exceptions covered below.

## When to Use This Skill

* How do I receive Storekit webhooks?
* How do I verify Storekit webhook signatures (`svix-signature`)?
* How do I handle `order.created.v2`, `order.accepted` or `order.completed` events?
* How do I react to `order.refund.succeeded`, `item.out_of_stock` or `payment_link.paid`?
* Why is my Storekit webhook signature verification failing, or rejecting Hookdeck retries?

## Verification (core)

Signed content is `{svix-id}.{svix-timestamp}.{raw body}`. Svix signs it with HMAC-SHA256 using
the base64-decoded secret (the part after `whsec_`), base64-encodes the result, and sends one or
more space-separated `v1,<signature>` entries. Use the official `svix` library, which compares in
constant time. Read the `svix-*` headers, and when `svix-id` is absent fall back to the `webhook-*`
headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`), as Hookdeck does.

```javascript
import { Webhook } from 'svix'; // svix 2.x is ESM-only

const prefix = req.headers['svix-id'] ? 'svix' : 'webhook';
const id = req.headers[`${prefix}-id`];
const timestamp = req.headers[`${prefix}-timestamp`];
const signature = req.headers[`${prefix}-signature`];
// rawBody: the raw Buffer/string, never re-serialized JSON
new Webhook(process.env.STOREKIT_WEBHOOK_SECRET).verify(rawBody, {
  'svix-id': id, 'svix-timestamp': timestamp, 'svix-signature': signature,
  'webhook-id': id, 'webhook-timestamp': timestamp, 'webhook-signature': signature,
}); // throws on a bad signature or a timestamp outside the window
const event = JSON.parse(rawBody.toString('utf8')); // parse only after verify passes

```

```python
from svix.webhooks import Webhook, WebhookVerificationError

wh = Webhook(os.environ["STOREKIT_WEBHOOK_SECRET"])  # "whsec_..."
wh.verify(raw_body, {"svix-id": id, "svix-timestamp": ts, "svix-signature": sig,
                     "webhook-id": id, "webhook-timestamp": ts, "webhook-signature": sig})

```

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/storekit-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/storekit-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/storekit-webhooks/examples/fastapi/).

The signing secret is per endpoint, starts with `whsec_`, and comes from the embedded webhooks
portal in the Storekit dashboard (**Settings > Webhooks**). Keep the full `whsec_...` value in
`STOREKIT_WEBHOOK_SECRET`. Storekit's manual sample strips the prefix before base64-decoding, so
the key is the decoded part after `whsec_`. Handlers fail closed: with the variable unset they
return 500 instead of accepting anything. Disable CSRF protection on the webhook route, and
always verify the **raw** body.

### Hookdeck retries and replays hit the 5-minute window

The `svix` library always enforces a fixed 5-minute timestamp window, and it can't be configured.
Hookdeck retries and replays resend the **original** `svix-timestamp`, so the library rejects any
retry or replay that happens more than 5 minutes after the original delivery. If you verify
behind Hookdeck (or replay old events), use the manual verifier in
[references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/storekit-webhooks/references/verification.md), where the window can be dropped or
widened. Deduplicate on `svix-id` instead of relying on the timestamp.

## Payload Shapes

Read the event name from the top-level `event` field and the resource from `data`. Two
documented exceptions:

* **`order.refund.created`, `order.refund.succeeded`, `order.refund.failed`** have no `data`
  wrapper: `order`, `customer`, `venue` and `refund` sit beside `event`.
* **The legacy `order.created`** sends the bare order object as the body, with no `event` at all.
  The examples recognise it by its order fields (`id`, `orderType`, `items`). Subscribe to
  `order.created.v2` for new integrations. Both fire for every new order.

There is no event id in the body. `svix-id` is the message id and stays the same across retries,
so deduplicate on it (keep ids for at least 72 hours).

## Common Event Types

27 events are documented (the list may not be exhaustive). The examples handle all of these:

| Event | Triggered When |
| --- | --- |
| `order.created.v2` | A new order is placed (use this for new integrations) |
| `order.created` | Legacy version of the same event, with the bare order as body |
| `order.accepted` / `order.rejected` | An order is accepted or rejected at a store with Auto-accept off |
| `order.canceled` | An order is canceled |
| `order.preparing` | An order moves to preparing |
| `order.ready_for_pickup` | An order is marked ready for pickup |
| `order.out_for_delivery` | A delivery order is dispatched |
| `order.completed` | An order is marked completed |
| `order.pos.dispatch.failed` | An order could not be sent to the POS |
| `order.rating.updated` | A customer submits or updates a rating |
| `order.refund.created` / `.succeeded` / `.failed` | A refund is issued, then confirmed or rejected by the gateway |
| `store.opened` / `store.closed` | A store opens (or a snooze ends) or closes (or is snoozed) |
| `store.opening_hours.updated` | Opening hours change |
| `item.out_of_stock` | A menu item becomes unavailable |
| `payments.payout.created` | A payout is sent to your bank account |
| `bill.payment.created` | A bill payment is created (no payload shown in the docs) |
| `payment_link.created` / `payment_link.paid` | A payment link is created or paid |
| `payment_link.refund.created` / `.succeeded` / `.failed` | A payment link refund is initiated, then confirmed or rejected |
| `printer.status.offline` / `printer.status.online` | A cloud printer drops off or reconnects |

See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/storekit-webhooks/references/overview.md) for payload fields and delivery rules.

## Environment Variables

```bash
# Signing secret of the webhook endpoint (Svix format, starts with whsec_)
STOREKIT_WEBHOOK_SECRET=whsec_xxxxx

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 storekit --path /webhooks/storekit

```

Add the tunnel URL as an endpoint in **Settings > Webhooks**, copy its signing secret into
`STOREKIT_WEBHOOK_SECRET`, and send a test event from the portal. See
[references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/storekit-webhooks/references/setup.md).

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/storekit-webhooks/references/overview.md) - Storekit webhook concepts, events, payload shapes, delivery rules
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/storekit-webhooks/references/setup.md) - Enable webhooks, add an endpoint, get the secret, retry and replay
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/storekit-webhooks/references/verification.md) - Svix verification details, header fallback, manual verifier, timestamp window