# Order webhooks

Shopify's orders/create, orders/paid and orders/updated webhooks, signed with X-Shopify-Hmac-Sha256, sent to one URL you set when an order is written or a scheduled order arrives as the clock moves.

Test the code that receives your app's order webhooks without a public URL. Every order write records its webhooks on the egress log, whether or not delivery is on:

```bash
ADMIN="$RYSTIC_URL/admin/api/2026-10/graphql.json"
curl -s "$ADMIN" -H "X-Shopify-Access-Token: shpat_placeholder" -H "Content-Type: application/json" -d '{"query": "mutation { orderCreate(order: {currency: NOK, email: \"kari@example.com\", financialStatus: PAID, lineItems: [{title: \"Wool sweater\", quantity: 2, priceSet: {shopMoney: {amount: \"450.00\", currencyCode: NOK}}}]}) { order { id name } userErrors { message } } }"}' | jq -c '.data.orderCreate'
# -> {"order":{"id":"gid://shopify/Order/1","name":"#1004"},"userErrors":[]}
curl -s "$RYSTIC_URL/_rystic/egress" | jq -c '.events[] | {type, name: .payload.name, financial_status: .payload.financial_status, total_price: .payload.total_price}'
# -> {"type":"orders/create","name":"#1004","financial_status":"paid","total_price":"900.00"}
# -> {"type":"orders/paid","name":"#1004","financial_status":"paid","total_price":"900.00"}
```

The [Webhooks](https://www.rystic.ai/docs/simulators/shopify.md#webhooks) section lists which mutation sends which topic.

### Receive signed webhooks

To have the simulator POST each webhook, boot it with delivery on and set your receiver's URL. Until delivery is on, setting the URL returns 409:

```bash
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$RYSTIC_URL/_rystic/webhooks" -d '{"url": "http://127.0.0.1:4242/shopify", "secret": "my-client-secret", "settings": {"shop_domain": "nordlys-supply.myshopify.com"}}'
# -> 409
```

This receiver checks `X-Shopify-Hmac-Sha256` as your app does, with the client secret:

```python
# receiver.py: verifies each Shopify webhook's X-Shopify-Hmac-Sha256
import base64, hashlib, hmac, json, os
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["SHOPIFY_API_SECRET"].encode()

class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers["Content-Length"]))
        digest = base64.b64encode(hmac.new(SECRET, body, hashlib.sha256).digest()).decode()
        if not hmac.compare_digest(digest, self.headers.get("X-Shopify-Hmac-Sha256", "")):
            print("rejected", self.headers.get("X-Shopify-Topic"), flush=True)
            self.send_response(401)
        else:
            order = json.loads(body)
            print("verified", self.headers["X-Shopify-Topic"], order["name"], order["total_price"], order["currency"], flush=True)
            self.send_response(200)
        self.end_headers()

HTTPServer(("127.0.0.1", 4242), Receiver).serve_forever()
```

Start the receiver, boot the simulator with delivery on, and set the URL with your client secret and shop domain:

<!-- example: skip network -->
```bash
SHOPIFY_API_SECRET=my-client-secret python3 receiver.py &
rystic run shopify-twin -d --port 0 --env RYSTIC_WEBHOOK_DELIVERY=on
curl -s -X POST "$RYSTIC_URL/_rystic/webhooks" -d '{"url": "http://127.0.0.1:4242/shopify", "secret": "my-client-secret", "settings": {"shop_domain": "nordlys-supply.myshopify.com"}}' | jq -c '{active, delivery}'
# -> {"active":true,"delivery":"on"}
```

### Orders that arrive as the clock moves

The `shopify_store` scenario's `future_orders_per_day` parameter schedules that many orders a day over the 30 days after the clock ([Generated store](https://www.rystic.ai/docs/simulators/shopify/features/generated-store.md)). Each one is created through `orderCreate` when [`POST /_rystic/clock`](https://www.rystic.ai/docs/control-plane-reference.md#rystic-clock) moves past its time, so it sends `orders/create` and `orders/paid`. The URL you set stays through a scenario load and a reset. Load the store and move the clock a day:

<!-- example: skip network -->
```bash
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"shopify_store","params":{"history_days":30,"future_orders_per_day":5,"clock":"2026-11-08T12:00:00Z"}}'
curl -s -X POST "$RYSTIC_URL/_rystic/clock" -d '{"advance_seconds": 86400}'
# the receiver prints:
# verified orders/create #1041 668.00 NOK
# verified orders/paid #1041 668.00 NOK
# ...
```

The store held 40 orders before the clock moved and 47 after. Each arriving order is paid, names one of the store's customers and buys one to four of its active variants.

### Headers and body

Each delivery includes these headers:

| Header | Value |
|---|---|
| `X-Shopify-Topic` | the topic, such as `orders/create` or `products/update` |
| `X-Shopify-Hmac-Sha256` | the base64 HMAC-SHA256 of the raw body, keyed with the `secret` you set |
| `X-Shopify-Shop-Domain` | the `shop_domain` you set |
| `X-Shopify-API-Version` | `2026-10` |
| `X-Shopify-Webhook-Id` | a UUID for each delivery |
| `X-Shopify-Event-Id` | a UUID for each event |
| `X-Shopify-Triggered-At` | the time the webhook was sent, such as `2026-11-08T22:20:23.554032Z` |

The body is the order as the REST Admin API writes it: numeric `id` and `admin_graphql_api_id`, amounts as strings with their `_set` money bags and timestamps in the shop's timezone offset. It includes `line_items`, `shipping_lines` and the `customer`.

Each `discount_codes` entry carries the code's type: `percentage`, `fixed_amount` or `shipping`.

**Differs from Shopify:** `X-Shopify-Triggered-At` is the real time the simulator sent the webhook, not the simulator's clock. The order's `created_at` and `updated_at` follow the clock.

**Differs from Shopify:** `billing_address`, `shipping_address`, `order_status_url` and `token` are `null`.

**Differs from Shopify:** the lists `tax_lines`, `refunds` and `fulfillments` are empty, and a shipping line's `id` is `null`.

**Differs from Shopify:** a completed draft's shipping line has a `null` `code` and `source`.

### Product and inventory level webhooks

Product and stock writes send their webhooks the same way, with the same headers. The [Webhooks](https://www.rystic.ai/docs/simulators/shopify.md#webhooks) section lists which mutation sends which topic.

Each body is the record as the REST Admin API writes it:

| Topic | Body |
|---|---|
| `products/create`, `products/update` | the product, with its `variants` by position, its `options` and its `variant_gids` |
| `inventory_levels/update` | the level's `available` quantity at one location, read after the write |

A variant's `inventory_quantity` is the sum of what is available at each location.

**Differs from Shopify:** a product body has no images or media, and its category is `null`. Its `old_inventory_quantity` equals `inventory_quantity`.

**Differs from Shopify:** a stock change through `inventorySetQuantities`, `inventoryAdjustQuantities` or `inventoryMoveQuantities` sends `inventory_levels/update` only, not `products/update`. Stock that `productSet` writes sends no `inventory_levels/update`.

**Differs from Shopify:** a line item's `grams` is `0`.

**Differs from Shopify:** the first `discount_codes` entry has the order's whole discount as its `amount`.

A response outside 2xx, or none within 5 seconds, is retried 8 times within 4 hours. The exact delays are unconfirmed. `GET /_rystic/webhooks` counts the webhooks sent, and the `delivery` report on `GET /_rystic/egress` lists each result.

---

Source: https://www.rystic.ai/docs/simulators/shopify/features/order-webhooks · Markdown: https://www.rystic.ai/docs/simulators/shopify/features/order-webhooks.md
