Webhooks
A webhook sends a message to your own system when something happens in your shop, for example when an order is paid. Use it to book a courier, update your accounting software or fill a spreadsheet. Webhooks are for people who have a developer or a tool that can receive them. If you do not, you can ignore this page.
Find them under Settings, Webhooks. Only the shop owner can open that page. Webhooks are part of the free Developer tools pack: if you can't see them, add it under Apps, Tool packs.
Add a webhook
- Step 1: Open Settings, Webhooks and press Add webhook.
- Step 2: Paste the address that should receive the messages. It must start with https://.
- Step 3: Tick the events you want and press Add webhook.
- Step 4: Copy the signing secret on the next page. It is shown once. If you lose it, press Make a new secret (the old one stops working straight away).
- Step 5: Press Send test. Your server should answer with a 200. The result shows in the delivery log.
You can have up to 10 webhooks per shop.
Events
- order.created: an order is placed. A card or PayPal checkout counts once the customer has paid, so you never hear about carts that were not paid. Orders on account, by EFT or WhatsApp, orders you enter yourself and orders from the order API count straight away.
- order.paid: the payment is confirmed, or you marked the order paid.
- order.fulfilled: the order is marked fulfilled.
- order.cancelled: an order that was placed or paid is cancelled.
- order.refunded: you record a refund. The message includes the amount of that refund.
- order.updated: you edit an order after it was placed (lines, quantities or delivery), or you mark the extra amount an edit left as received. The message is the order as it is now, with an `edit` object that says what changed.
- product.created, product.updated, product.deleted: a product is added, changed (including its variants and stock) or deleted. Bulk changes send one message per product. Products brought in with the Shopify import do not send messages.
- customer.created: a new customer record is made, usually at checkout.
- automation.run: one of your shop rules ran and its action list includes the webhook. The message names the rule and what it ran on, and includes the order when there is one.
The Send test button sends an event called ping.
What a message looks like
Every message is a JSON body with the same outer shape. Amounts are whole cents, so R149.99 is 14999.
{
"event": "order.paid",
"id": 481,
"created_at": "2026-10-11T08:15:02Z",
"store": { "id": 12, "slug": "koppie-coffee" },
"data": {
"ref": "SF-8F3K2A",
"status": "paid",
"currency": "ZAR",
"payment_method": "card",
"subtotal_cents": 28000,
"discount_cents": 0,
"delivery_cents": 6000,
"total_cents": 34000,
"refunded_cents": 0,
"items": [
{ "name": "Ethiopia 250g", "sku": "ETH-250", "qty": 2, "price_cents": 14000 }
],
"customer": { "name": "Thandi Dlamini", "email": "[email protected]", "phone": "0821234567" },
"delivery": { "kind": "ship", "option": "Courier", "street": "12 Main Rd", "city": "Pretoria" }
}
}
The id is the delivery number. It stays the same when we retry or when you press Redeliver, so you can use it to ignore a message you have already handled. Order messages carry the order's public details. Product messages carry the name, price, SKU, stock, tags, photo and variants. We never send your costs, your keys or a customer's payment details.
Check the signature
Each message carries these headers:
- X-Storeforge-Event: the event name.
- X-Storeforge-Delivery: the delivery number.
- X-Storeforge-Signature: the text sha256= followed by an HMAC-SHA256 of the raw request body, using your signing secret as the key.
Work out the same value on your server and compare. Use the raw body exactly as it arrived, before you parse it, and a constant-time compare.
Python:
import hashlib, hmac
def is_genuine(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
Node:
const crypto = require("crypto");
function isGenuine(rawBody, header, secret) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
What counts as delivered, and what we do when it fails
- Your server must answer within 10 seconds with a status in the 200s. Anything else counts as a failure. We do not follow redirects, so give us the final address.
- A failed message is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. After the sixth try it is marked Gave up.
- Ten messages in a row that gave up switch the webhook off, and we email you once. Fix your server, tick the box on the webhook page and save to switch it back on. Any success resets the count.
- The delivery log keeps the newest 200 messages for each webhook. Press Redeliver on any of them to send it again.
- Messages can arrive more than once, and not always in order. Use the delivery number to skip repeats.
Addresses we will not send to
Webhooks must go to a public address on the internet over https. We refuse addresses that point at private networks, the server itself or cloud metadata services, and we check this every time we send, not only when you save.
Still stuck? [email protected] or WhatsApp 073 429 5517.