Shopify webhooks
The orders/create and refunds/create handlers — HMAC verification, the attribution order, and every status code they return.
Two server-to-server handlers. No front-end script is involved on Shopify — the thank-you page hooks Shopify once offered are deprecated, so everything here happens between Shopify's servers and ours.
| Parameter | Type | Description |
|---|---|---|
| POST /api/webhooks/shopify/orders-create | orders/create | Records an attributed sale. |
| POST /api/webhooks/shopify/refunds-create | refunds/create | Appends a negative reversal against a sale already recorded. |
Both are registered for you during the Shopify OAuth connection; neither is something you call yourself.
Verification#
Every request is authenticated by HMAC-SHA256 over the raw request body, keyed with the app secret, base64-encoded, and compared against the X-Shopify-Hmac-Sha256 header in constant time. A mismatch, a missing header, or an unconfigured secret is a flat 401 with no body.
Because the digest is over the raw bytes, the body is read as text and only then parsed — any middleware that re-serialised the JSON first would break verification.
X-Shopify-Shop-Domain is also required, and identifies which business the order belongs to. Missing it is a 400.
200 "No connection", not a 404. Shopify retries non-2xx responses with backoff for days; acknowledging a shop we cannot place is how that retry storm is avoided. The same reasoning explains most of the 200s below.orders/create#
{
"id": 5123456789012,
"current_total_price": "49.90",
"total_price": "52.40",
"currency": "EUR",
"landing_site": "/products/kit?ref=abc123",
"discount_codes": [{ "code": "MAYA10" }],
"discount_applications": [{ "code": "MAYA10" }],
"note_attributes": [{ "name": "ref", "value": "abc123" }]
}Attribution order#
Two methods, tried in a fixed order. The first that resolves wins:
- Discount code. Every code on the order — from both
discount_codesanddiscount_applications, de-duplicated and upper-cased — is matched against the campaigns belonging to that business. - Click id. Failing that, a
refis recovered fromnote_attributes(accepting the namesref,click_idorproof_ref, case-insensitively), and otherwise from the?ref=parameter onlanding_site. It is then looked up against recorded clicks.
If neither method resolves, the handler returns 200 { ok: true, attributed: false } and records nothing. The order is not stored as unattributed; it simply never enters Yonto. This is the largest single reason a store's Shopify revenue exceeds the total Yonto reports.
Value and currency#
The amount is current_total_price if present, falling back to total_price — so an order edited after placement is counted at its current total, not its original one. An unparseable value becomes 0 rather than rejecting the order.
Currency comes from the order. Only if it is missing or not a valid ISO-4217 code does it fall back to the campaign's currency, and then to GBP as a last resort.
Idempotency#
The Shopify order id is stored as external_order_id, unprefixed — unlike the pixel endpoint, which prefixes with pixel:. A uniqueness constraint on (business_id, external_order_id) means a Shopify retry can never double-count; the repeat returns { ok: true, duplicate: true }.
refunds/create#
{
"id": 9876543210,
"order_id": 5123456789012,
"transactions": [
{ "kind": "refund", "status": "success", "amount": "20.00", "currency": "EUR" }
]
}Refunds are append-only. The original sale is never edited or deleted; a separate row is written with a negative value and kind: "reversal", pointing at the sale it reverses. Both facts survive — that a sale happened, and that money went back — which is why a report can say “four sales, one refunded” rather than silently showing three.
The amount#
Summed from transactions, counting only entries where kind === "refund" and the status is success (or absent). Each is taken as an absolute value, then the total is stored negative.
A refund whose transactions total zero — a restock with no money moved, or a failed refund transaction — is acknowledged with { ok: true, ignored: "no successful refund transaction" } and nothing is written.
Partial refunds#
Idempotency is keyed on the refund id, not the order id, stored as shopify_refund:<id>. Two partial refunds against one order are therefore two reversal rows, and a redelivery of either is a no-op.
When the sale was never recorded#
{ ok: true, attributed: false }, nothing written. If the order predates the Shopify connection, or was never matched to a campaign, there is no sale to reverse, and inventing one would subtract revenue that was never counted. Correct, but it means a refund you can see in Shopify may have no trace in Yonto.Responses#
| Parameter | Type | Description |
|---|---|---|
| 401 | both | HMAC missing, malformed, or wrong. |
| 400 Missing shop domain | both | The shop domain header was absent. |
| 400 Bad payload | both | The verified body did not parse as JSON. |
| 200 No connection | both | Valid signature, but no business is connected to that shop. |
| 200 { attributed: false } | both | Order matched no campaign, or the refund's sale was never recorded. Nothing written. |
| 200 { duplicate: true } | both | Already recorded. This is the normal response to a Shopify retry. |
| 200 { attributed: true } | orders | A conversion was recorded. |
| 200 { reversed: <amount> } | refunds | A reversal was recorded, with the positive amount that was reversed. |
| 500 DB error | both | The insert failed for a reason other than a duplicate. Shopify will retry, which is the intended behaviour here. |