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.

ParameterTypeDescription
POST /api/webhooks/shopify/orders-createorders/createRecords an attributed sale.
POST /api/webhooks/shopify/refunds-createrefunds/createAppends 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.

Note.a shop we have no connection for gets 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#

The subset of the payload that is read
{
  "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_codes and discount_applications, de-duplicated and upper-cased — is matched against the campaigns belonging to that business.
  • Click id. Failing that, a ref is recovered from note_attributes (accepting the names ref, click_id or proof_ref, case-insensitively), and otherwise from the ?ref= parameter on landing_site. It is then looked up against recorded clicks.
Warning.discount code wins over click id, and the code match takes the first campaign it finds. If two campaigns share a discount code, orders land on whichever the database returns first — effectively arbitrary. Keep discount codes unique per campaign.

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#

The subset of the payload that is read
{
  "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#

Warning.a refund for an order Yonto never attributed is silently dropped — { 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#

ParameterTypeDescription
401bothHMAC missing, malformed, or wrong.
400 Missing shop domainbothThe shop domain header was absent.
400 Bad payloadbothThe verified body did not parse as JSON.
200 No connectionbothValid signature, but no business is connected to that shop.
200 { attributed: false }bothOrder matched no campaign, or the refund's sale was never recorded. Nothing written.
200 { duplicate: true }bothAlready recorded. This is the normal response to a Shopify retry.
200 { attributed: true }ordersA conversion was recorded.
200 { reversed: <amount> }refundsA reversal was recorded, with the positive amount that was reversed.
500 DB errorbothThe insert failed for a reason other than a duplicate. Shopify will retry, which is the intended behaviour here.
Note.neither handler is rate limited, and both bypass the limiter entirely. They are HMAC-authenticated, so an unauthenticated flood is rejected at verification, and throttling Shopify's own retries would only delay legitimate orders.