Docs
Dashboard
View as Markdown
On this page
  1. Endpoints and events
  2. Headers
  3. Verify the signature
  4. Answer fast, retry safely
  5. Rotate a secret
  6. Event explorer
  7. Reshape events with a transformer
  8. Security

Webhooks

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

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:

{
  "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: 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.

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);
  });
}
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
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
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;
}

Any Standard Webhooks library 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 uses:

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

Was this page helpful?