Shopify for omnisense/omnitrade: a sale paid on the
shop's invoice page (a draft order with the shop's prices or the application's), the shop's
orders, its catalogue (products, variants, inventory) and its webhooks - over the Admin GraphQL
API, its cost bucket respected. Every call is one POST of a GraphQL document to
https://<shop_domain>/admin/api/<api_version>/graphql.json
with the X-Shopify-Access-Token header (api_version 2026-07 by default). Shopify has no
sandbox address: a development store is the test shop.
| Operation | GraphQL |
|---|---|
| purchase | draftOrderCreate mutation |
| fetch | draftOrder(id:), with the order it became |
| fetch order | order(id:) |
| products | products(first:, after:, query:, sortKey: UPDATED_AT) with shop { currencyCode taxesIncluded } |
| product | product(id:), or products(query: "handle:...") |
| inventory | nodes(ids:) on the variants, or productVariants page by page |
| notify | none (the HMAC is checked locally); shop { currencyCode taxesIncluded } once for a product webhook's prices |
Installation
composer require omnitrade/shopify
Requires PHP 8.2, omnisense/omnitrade (1.x), symfony/http-client (6.4, 7 or 8) and psr/log.
In a Symfony application with Omnitrade\Bridge\Symfony\OmnitradeBundle, the factory is
registered as soon as the package is installed.
$gateway = (new ShopifyGatewayFactory($http))->create(['shop_domain' => 'example.myshopify.com', 'admin_token' => '...']); // $http: the application's HTTP client; none given, the factory makes its own
Credentials
- A custom app in the shop's admin (Settings → Apps → Develop apps) with the Admin API scopes
write_draft_ordersandread_ordersfor the sales,read_productsandread_inventoryfor the catalogue; its Admin API access token (shpat_...) isadmin_token. - The app's API secret is
webhook_secret: Shopify signs the app's webhooks with it. Subscribe the app toorders/paid,orders/cancelled,refunds/create, and toproducts/create,products/update,products/delete,inventory_levels/updatefor the catalogue.
Options
| Option | Type | Default | Required | |
|---|---|---|---|---|
shop_domain |
string | - | yes | example.myshopify.com |
admin_token |
string | - | yes | the Admin API access token |
webhook_secret |
?string | null |
for notify() |
the app's API secret |
api_version |
string | 2026-07 |
no | the Admin API version |
timeout |
int | 15 |
no | seconds |
draft_order_tags |
list of strings | [] |
no | tags put on the draft orders purchase() creates |
currency |
?string | null |
no | the currency the catalogue's prices are read in; null: the shop's, read with them. A label: nothing is converted |
retry_delay |
float | 1 |
no | seconds before retrying a 429 or a 5xx, doubled at each attempt (3 attempts); a 429's Retry-After prevails, capped at 60 seconds |
omnitrade:
gateways:
shopify:
factory: shopify
options:
shop_domain: '%env(SHOPIFY_SHOP_DOMAIN)%' # example.myshopify.com
admin_token: '%env(SHOPIFY_ADMIN_TOKEN)%' # shpat_..., a custom app's Admin API token
webhook_secret: '%env(SHOPIFY_WEBHOOK_SECRET)%' # the app's API secret, for notify()
draft_order_tags: [omnitrade]
currency: null # the catalogue's currency; null: the shop's
retry_delay: 1 # seconds before retrying a 429 / 5xx, doubled each time (3 attempts); a 429's Retry-After prevails
Capabilities
| Request | Supported |
|---|---|
Purchase |
yes (a draft order, paid on its invoice page) |
Authorize, Capture, VoidAuthorization, Refund |
no: Shopify's admin does those on its orders |
FetchTransaction |
yes (the draft order and what it became) |
GetPaymentMethods |
no |
Notify |
yes |
FetchOrder |
yes |
FetchProducts, FetchProduct, FetchInventory |
yes |
AffiliateLink, connected accounts, subscriptions |
no |
Behaviour
Calls, limits and retries. A 429 or a 5xx is retried, 3 attempts in all: after Retry-After
(capped at 60 seconds) for a 429 that has one, else after retry_delay seconds doubled at each
attempt; then a ProviderException (Shopify returned HTTP 503 after 3 attempts.). An answer whose
GraphQL errors say THROTTLED is retried the same way. After each answer, when the cost bucket
(extensions.cost.throttleStatus) has fewer than 200 points left, the client waits until it has
refilled to 200 (ceil((200 - currentlyAvailable) / restoreRate) seconds). 401, 403 and 404 are
never retried. The access token never reaches a message.
Purchase. A draft order, answered PENDING with its invoiceUrl as redirectUrl, its gid as
reference (gid://shopify/DraftOrder/...), its totalPrice as the amount and its name and the
shop in metadata. Shopify's checkout never redirects back: returnUrl and cancelUrl are not
used; the sale is known by fetch() or the orders/paid webhook. The DraftOrderInput:
| Payment | Draft order |
|---|---|
lines, when they add up |
lineItems: a line whose reference is a variant gid (gid://shopify/ProductVariant/...) names the variant (variantId, so Shopify counts its stock), any other is a custom line (title, requiresShipping from physical, sku); each at the line's unit price (originalUnitPriceWithCurrency) |
| no lines adding up | one custom line of the amount, titled by the description (else the reference), not shipped |
shipping, discount (lines adding up) |
shippingLine (Shipping), appliedDiscount (Discount, fixed amount) |
amount->currency |
presentmentCurrencyCode |
description |
note (else Order <reference>) |
reference |
the custom attribute omnitrade_reference |
metadata |
more custom attributes |
customer->email |
email |
the draft_order_tags option |
tags |
Not sent: idempotencyKey, method, notice, locale, the customer's other fields. A mutation's
userErrors are a ProviderException listing them (draftOrderCreate: lineItems.0.quantity: ...).
Fetch. The draft order and the order it became: completed and the order PAID (or no status)
is PAID; completed and REFUNDED, PARTIALLY_REFUNDED, VOIDED are REFUNDED,
PARTIALLY_REFUNDED, CANCELLED; a draft order Shopify no longer has (deleted) is CANCELLED with
message: deleted; anything else PENDING with the invoice URL. metadata holds the draft's
name, and the order's gid (order) and name (order_name) once there is one.
Fetch order. A shop order by its gid or bare id: number is its name (#1042), status
from displayFinancialStatus (PAID; PARTIALLY_REFUNDED; REFUNDED; VOIDED CANCELLED;
AUTHORIZED; else PENDING), total its totalPriceSet.shopMoney, the customer from its email,
customer.displayName and shipping address, its first 100 line items (title, unit price,
quantity, SKU, variant gid as reference), financialStatus and fulfillmentStatus as Shopify
names them, createdAt. An unknown order is a ProviderException (Shopify knows no order ...).
Notify. X-Shopify-Hmac-Sha256 is checked first (base64 of the HMAC-SHA256 of the raw body
with webhook_secret; there is no timestamp in the signature), then X-Shopify-Shop-Domain, when
sent, must be the configured shop (The notification is another shop's (...)). The topic is
X-Shopify-Topic, the event's id X-Shopify-Webhook-Id (keep it: Shopify resends a delivery
with the same id).
| Topic | status |
reference |
Carries |
|---|---|---|---|
orders/paid |
PAID | the draft order the sale began with (draft_order_id), else the order's gid |
|
orders/cancelled |
CANCELLED | the same | |
other orders/* (orders/fulfilled...) |
none | the same | |
refunds/create |
REFUNDED (a partial refund too) | the refund's gid | |
products/create, products/update |
none | the product's gid | product |
products/delete |
none | the product's gid | no product |
inventory_levels/update |
none | the inventory item's gid | stocks: one level at one location |
raw holds the topic, the shop, the merchant's reference (the note attribute
omnitrade_reference), the order's gid and the payload. The catalogue's webhooks:
Shopify's catalogue.
Example
<?php
require __DIR__.'/vendor/autoload.php';
use Omnitrade\Model\Customer;
use Omnitrade\Model\Line;
use Omnitrade\Model\Money;
use Omnitrade\Model\Payment;
use Omnitrade\Shopify\ShopifyGatewayFactory;
use Symfony\Component\HttpClient\HttpClient;
$shopify = (new ShopifyGatewayFactory(HttpClient::create()))->create([
'shop_domain' => getenv('SHOPIFY_SHOP_DOMAIN'),
'admin_token' => getenv('SHOPIFY_ADMIN_TOKEN'),
'webhook_secret' => getenv('SHOPIFY_WEBHOOK_SECRET'),
'draft_order_tags' => ['site'],
]);
$transaction = $shopify->purchase(new Payment(
amount: Money::of(2750, 'EUR'),
reference: 'ORDER-1042',
customer: new Customer(email: 'camille@example.org'),
lines: [new Line('Margaux 2019', Money::of(1200, 'EUR'), 2, reference: 'gid://shopify/ProductVariant/7')],
shipping: Money::of(350, 'EUR'),
));
header('Location: '.$transaction->redirectUrl); // Shopify's invoice page
// later: the webhook, or
$shopify->fetch($transaction->reference)->isPaid();
$order = $shopify->fetchOrder($shopify->fetch($transaction->reference)->metadata['order']);
In the core's Docker harness: docker compose run --rm omnitrade purchase shopify --line "Margaux=1200x2@gid://shopify/ProductVariant/7", catalogue shopify --since='-1 day'.
Errors
Every failure is an Omnitrade\Shopify\Api\ShopifyApiException, a ProviderException whose
provider is shopify and whose errors holds what Shopify said; providerCode is never set:
Shopify returned HTTP <status>.(401, 403, 404...),Shopify returned HTTP <status> after 3 attempts.(429, 5xx),Shopify returned a body that is not JSON.,Shopify request failed: ...(a network failure, a copy of the HTTP client's exception asprevious);- GraphQL
errors:Shopify rejected the query: <message>; ...; a mutation'suserErrors:draftOrderCreate: <field>: <message>; Shopify created no draft order with an invoice URL.,Shopify knows no order ...;- a webhook:
InvalidNotificationExceptionwithoutwebhook_secret, for a signature that does not match or another shop's; a body that is not JSON is aProviderException.
A missing shop_domain or admin_token is an InvalidConfigException when the gateway is built;
Authorize, Capture, VoidAuthorization, Refund, GetPaymentMethods, AffiliateLink,
connected accounts and subscriptions raise RequestNotSupportedException before any call.
fetchProduct() of a product the shop does not have is null.
No failure carries the admin token: a network failure's message has them masked as *** (the key
parameters of a URL too), its previous is a copy of the HTTP client's exception (the same class and
code, without its request), and no trace of the chain keeps its arguments, where the request's
options stood (No secret in a
failure).
Verified, and not
Tested offline, 24 tests on answers shaped as the Admin GraphQL API documents them:
Tests/ShopifyGatewayTest.php (a draft order paid on its invoice page, fetched as what became of
it, a shop order, a webhook's HMAC and shop), Tests/ShopifyCatalogueTest.php (the catalogue page by page, a product mapped whole, the currency
option winning over the shop's, a product found by id, bare id and page, the inventory by variant
or whole, the REST and GraphQL shapes giving the same product, untracked inventory as a null
quantity, the product and inventory webhooks) and Tests/ShopifyContractTest.php,
the family's conformance suite. The suite found a 429 retried after a fixed backoff whatever
Retry-After said; Retry-After now prevails, and retry_delay sets the backoff (0 in tests).
Not verified: no call was made to a Shopify shop, development store or live; the scopes listed are those the queries need, not checked against a real app.
Responsibility
An independent, unofficial client, not affiliated with, endorsed by or sponsored by Shopify Inc. Whoever runs it complies with Shopify's terms (Shopify API License and Terms of Use) and obtains what they require: see the README's Responsibility section.
Proposer une modification docs/index.md
Catalogue
The shop's products, variants and inventory, read through the omnitrade catalogue contract over the Admin GraphQL API (the cost bucket is respected, a THROTTLED answer retried).
Setting it up
composer require omnitrade/shopify symfony/http-client
omnitrade:
gateways:
shopify:
factory: shopify
options:
shop_domain: '%env(SHOPIFY_SHOP_DOMAIN)%' # example.myshopify.com
admin_token: '%env(SHOPIFY_ADMIN_TOKEN)%' # shpat_...
webhook_secret: '%env(SHOPIFY_WEBHOOK_SECRET)%' # for the product and inventory webhooks
currency: null # the prices' currency; null: the shop's
The custom app needs the read_products and read_inventory scopes (on top of
write_draft_orders, read_orders for the sales).
| Option | Default | |
|---|---|---|
currency |
null |
The currency of the prices. Shopify's prices are bare decimals in the shop's currency: by default it is read with them (shop { currencyCode }, in the same query for fetchProducts()/fetchProduct(), once per gateway for the webhooks). |
Requests
| Request | What is sent |
|---|---|
fetchProducts(?cursor, ?updatedSince, ?query, limit) |
products(first: limit (≤ 250), after: cursor, query: …, sortKey: UPDATED_AT) with shop { currencyCode taxesIncluded }. updatedSince becomes updated_at:>'2026-10-01T08:00:00Z' (UTC), query is appended as Shopify's own search text (updated_at:>'…' margaux). The next cursor is pageInfo.endCursor while hasNextPage. Pass the same updatedSince and query with the cursor. |
fetchProduct($reference) |
gid://shopify/Product/42 or 42 → product(id:); a handle, or a page's URL (https://shop.example/products/<handle>, also under /collections/…/products/<handle>) → products(query: "handle:\"<handle>\""), the exact handle kept; an admin URL /admin/products/42 → by id. null when there is no such product. |
fetchInventory($variants) |
The variants (gids, or bare ids) through nodes(ids:), 250 at a time; none: every variant of the shop through productVariants, page by page. Unknown variants are left out. |
Each product carries at most 100 variants, 20 collections, 20 metafields and 20 media.
What is mapped how
| Shopify | omnitrade |
|---|---|
id (gid) |
Product::$reference |
title, descriptionHtml, handle |
title, description, handle |
vendor |
brand |
productType + collections.title |
categories (once each, the type first) |
tags |
tags |
metafields (first 20) |
attributes, keyed namespace.key |
options (optionValues) |
options (Option); Shopify's "Title: Default Title" is dropped |
media |
media: MediaImage → image, Video (first source) and ExternalVideo → video; 3D models skipped |
status ACTIVE / DRAFT / ARCHIVED |
Product::ACTIVE / DRAFT / ARCHIVED (UNLISTED → draft) |
onlineStoreUrl |
url (null when the product is not published to the online store) |
updatedAt |
updatedAt |
variant id, sku, barcode |
ProductVariant::$reference, sku, barcode (blank → null) |
variant title |
title, "Default Title" → null |
variant selectedOptions |
ProductVariant::$options (['Millésime' => '2018']) |
variant price, compareAtPrice |
Offer price and compareAt (compareAt kept only when higher), in the shop's currency; available = availableForSale; taxIncluded = the shop's taxesIncluded; url = the product page with ?variant=<id> when there are several |
variant inventoryQuantity, inventoryItem { id tracked } |
Stock: the quantity when tracked, else null with tracked: false; item is the InventoryItem gid |
variant image |
ProductVariant::$image |
inventoryItem.measurement.weight |
weight in grams |
Webhooks
Subscribe the app to products/create, products/update, products/delete and
inventory_levels/update; notify() checks the HMAC and the shop as for the orders.
| Topic | Notification |
|---|---|
products/create, products/update |
$product: the REST-shaped payload mapped to the same Product as a GraphQL node (bare ids made gids, the comma-separated tags split, option1..3 named after the product's options, "Default Title" dropped; no collections, metafields nor onlineStoreUrl, which the payload does not carry). $reference is the product's gid. |
products/delete |
$product null, $reference the product's gid. |
inventory_levels/update |
$stocks: one Stock at one location (location = gid://shopify/Location/…), quantity = available. The payload names the inventory item, not the variant: Stock::$reference and $item are the gid://shopify/InventoryItem/… - match it on the variants' Stock::$item. |
$notification = $gateway->notify($request->getContent(), $request->headers->all());
if ($notification->isCatalogue()) {
match (true) {
null !== $notification->product => $catalogue->save($notification->product),
[] !== $notification->stocks => $catalogue->restock($notification->stocks), // by Stock::$item
default => $catalogue->remove($notification->reference),
};
}
Examples
$page = $shopify->fetchProducts(updatedSince: $lastSync);
while (true) {
foreach ($page->products as $product) {
// $product->brand, $product->variants[0]->price(), $product->variants[0]->stock->quantity
}
if (!$page->hasMore()) {
break;
}
$page = $shopify->fetchProducts($page->next, updatedSince: $lastSync);
}
$product = $shopify->fetchProduct('https://cave.example/products/margaux-2019');
$stocks = $shopify->fetchInventory(['gid://shopify/ProductVariant/7', '8']);
With the harness of omnisense/omnitrade (core/docker):
docker compose run --rm omnitrade catalogue shopify --since=-1day
docker compose run --rm omnitrade catalogue shopify --product=https://cave.example/products/margaux-2019 --inventory