Skip to Content
SimulatorsShopifyOrder webhooks

Simulators

Order webhooks

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:

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

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:

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

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). Each one is created through orderCreate when POST /_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:

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:

HeaderValue
X-Shopify-Topicthe topic, such as orders/create or products/update
X-Shopify-Hmac-Sha256the base64 HMAC-SHA256 of the raw body, keyed with the secret you set
X-Shopify-Shop-Domainthe shop_domain you set
X-Shopify-API-Version2026-10
X-Shopify-Webhook-Ida UUID for each delivery
X-Shopify-Event-Ida UUID for each event
X-Shopify-Triggered-Atthe 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 section lists which mutation sends which topic.

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

TopicBody
products/create, products/updatethe product, with its variants by position, its options and its variant_gids
inventory_levels/updatethe 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.

Last updated on