Aller au contenu

omnitrade/shopify

Omnitrade Shopify: a sale paid on the shop's invoice page (draft orders), the shop's orders, its catalogue (products, variants, inventory) and its webhooks, over the Admin GraphQL API.

Étage
Fournisseur · Omnitrade
Version
1.x
Suite de conformité
branchée, CI non lue
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnitrade/shopify:1.x-dev

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_orders and read_orders for the sales, read_products and read_inventory for the catalogue; its Admin API access token (shpat_...) is admin_token.
  • The app's API secret is webhook_secret: Shopify signs the app's webhooks with it. Subscribe the app to orders/paid, orders/cancelled, refunds/create, and to products/create, products/update, products/delete, inventory_levels/update for 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 as previous);
  • GraphQL errors: Shopify rejected the query: <message>; ...; a mutation's userErrors: draftOrderCreate: <field>: <message>;
  • Shopify created no draft order with an invoice URL., Shopify knows no order ...;
  • a webhook: InvalidNotificationException without webhook_secret, for a signature that does not match or another shop's; a body that is not JSON is a ProviderException.

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

Proposer une modification docs/catalogue.md