# Webhooks

> Signed HTTPS events for every change in your store, with retries, replays, an event explorer and transformers.

Source: https://shopfa.st/docs/guides/webhooks

## Endpoints and events

Add an endpoint in **Settings → Developers → Webhooks**: an HTTPS URL and the events it wants. Each endpoint gets its
own signing secret (`whsec_…`). Events cover orders (`order.created`, `order.paid`, `order.fulfilled`,
`order.refunded`, `order.needs_attention`), disputes, subscriptions, products, inventory, customers, discounts, gift
cards, payouts, theme publishes, reviews, domains and team changes. The dashboard lists every type with a description.

Every event has the same envelope. Payloads carry ids and a summary, never license keys or gift card codes:

```json
{
  "id": "msg_9c2k4m7q1x0v8b3n6h5d",
  "type": "order.paid",
  "createdAt": "2026-10-21T12:00:00.000Z",
  "storeId": "st_…",
  "data": { "order": { "id": "ord_…", "number": "SF-10482", "totalMinor": 2400, "currency": "USD", "items": [ … ] } }
}
```

## Headers

| Header | Value |
| --- | --- |
| `webhook-id` | The event id. The same on every retry and replay: use it to skip duplicates |
| `webhook-timestamp` | Unix seconds when this attempt was signed |
| `webhook-signature` | `v1,<base64 HMAC-SHA256>`, space separated when a rotated secret still signs |
| `x-shopfast-event` | The event type |
| `x-shopfast-delivery`, `x-shopfast-attempt` | Delivery id and attempt number, for your logs |

## Verify the signature

Deliveries follow [Standard Webhooks](https://www.standardwebhooks.com): the key is the base64 part of your secret,
decoded; the signed content is `${webhook-id}.${webhook-timestamp}.${raw body}`. Always verify on the **raw body**,
before parsing JSON, in constant time, and reject timestamps more than five minutes away.

<div class="tabs">

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyShopFast(secret: string, headers: Headers, rawBody: string): boolean {
  const id = headers.get('webhook-id') ?? '';
  const timestamp = headers.get('webhook-timestamp') ?? '';
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
  return (headers.get('webhook-signature') ?? '').split(' ').some((part) => {
    const [version, value] = part.split(',');
    if (version !== 'v1' || value === undefined) return false;
    const given = Buffer.from(value, 'base64');
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

```python
import base64, hashlib, hmac, time

def verify_shopfast(secret: str, headers: dict, raw_body: bytes) -> bool:
    msg_id, timestamp = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for part in headers["webhook-signature"].split(" "):
        version, _, value = part.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return True
    return False
```

```go
package shopfast

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"math"
	"net/http"
	"strconv"
	"strings"
	"time"
)

func Verify(secret string, h http.Header, rawBody []byte) bool {
	id, ts := h.Get("webhook-id"), h.Get("webhook-timestamp")
	sec, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
		return false
	}
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return false
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)
	for _, part := range strings.Split(h.Get("webhook-signature"), " ") {
		version, value, ok := strings.Cut(part, ",")
		given, err := base64.StdEncoding.DecodeString(value)
		if ok && err == nil && version == "v1" && hmac.Equal(given, expected) {
			return true
		}
	}
	return false
}
```

```php
<?php
function verify_shopfast(string $secret, array $headers, string $rawBody): bool {
    $id = $headers['webhook-id'] ?? '';
    $timestamp = $headers['webhook-timestamp'] ?? '';
    if (abs(time() - (int) $timestamp) > 300) return false;
    $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
    if ($key === false) return false;
    $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true));
    foreach (explode(' ', $headers['webhook-signature'] ?? '') as $part) {
        [$version, $value] = array_pad(explode(',', $part, 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $value)) return true;
    }
    return false;
}
```

</div>

Any [Standard Webhooks library](https://github.com/standard-webhooks/standard-webhooks) works too.

## Answer fast, retry safely

Answer any `2xx` within 10 seconds; do slow work after answering. Anything else, or no answer, is retried after
5 s, 5 min, 30 min, 2 h, 5 h and then every 10 h, 13 attempts over about three days. An endpoint that fails for five
days straight (10 failures or more) is switched off and the store owner gets an email; replay what it missed from the
delivery log once it is fixed. Redirects are not followed: use the final URL.

## Rotate a secret

**Rotate** issues a new secret. For the overlap you choose (none, 1, 24 or 72 hours) deliveries carry two signatures,
one per secret, so you can deploy the new secret without dropping events.

## Event explorer

**Settings → Developers → Events** lists the last 30 days of events, the exact signed body, and where each copy went.
**Send again** queues a fresh delivery to one endpoint or to every endpoint subscribed to the type. The same data is
in the API (scope `webhooks:read`), which is what [`shopfast listen`](https://shopfa.st/docs/cli) uses:

```sh
curl "https://api.shopfa.st/v1/store/webhooks/events?type=order.paid&limit=20" -H "Authorization: Bearer $KEY"
```

Events are recorded when at least one endpoint subscribes to their type.

## Reshape events with a transformer

A service that expects its own format (Slack, Discord, an ERP) does not need a relay server: attach a
[webhook transformer plugin](https://shopfa.st/docs/plugins/transformers) to the endpoint. It rewrites each event in the sandbox before
ShopFast signs and sends it.

## Security

ShopFast resolves your hostname, refuses private, loopback, link-local and metadata addresses, and connects to the
exact address it checked, so DNS rebinding cannot point a webhook inside a network. Only `https` is allowed in
production.
