# Shopify Microservice - Developer > Overview This microservice connects OrderLemon with Shopify. It syncs the Shopify catalogue (collections, products, variants, images, inventory) into OrderLemon, sends paid OrderLemon orders to Shopi… - Page: https://cloud.orderlemon.com/knowledge_base/view/343-shopify-microservice-developer - Part of: [Knowledge base](https://cloud.orderlemon.com/knowledge_base/llms.txt) on OrderLemon Cloud — https://cloud.orderlemon.com/llms.txt - Updated: 2026-10-26T11:01:20Z ### Overview This microservice connects OrderLemon with Shopify. It syncs the Shopify catalogue (collections, products, variants, images, inventory) into OrderLemon, sends paid OrderLemon orders to Shopify, and cancels or fulfils them there when their OrderLemon status changes. It also follows the Shopify connection of each shop: when the connection is lost, ordering in OrderLemon is paused. | Direction | Purpose | | --- | --- | | Shopify -> OrderLemon | Collections, products, variants, images and stock (pulled: hourly cron or manual endpoint) | | OrderLemon -> Shopify | Paid orders, cancellations, fulfilment (pushed by the OL webhook) | **Shopify never calls this microservice.** There are no Shopify webhooks: the catalogue is pulled, and the connection state is learned from the answers of our own GraphQL calls. #### Main Entry Points | Entry point | File | Purpose | | --- | --- | --- | | `/v1/webhooks/shopify` | `v1/webhooks/shopify.php` | Receiver of the OL webhook (`payments.completed`, `order.updated`) | | `send_order_to_shopify` (POST) | `v1/POST/send_order_to_shopify.php` | Send an OL order to Shopify by hand, for retries and support | | `shopify_oauth_start` (POST) | `v1/POST/shopify_oauth_start.php` | Start connecting a merchant store: answers the Shopify consent URL | | `/v1/webhooks/shopify_oauth` | `v1/webhooks/shopify_oauth.php` | OAuth callback: stores the connection, redirects to MyShop | | `shopify_connection_status` (GET) | `v1/GET/shopify_connection_status.php` | Connection status of a shop, for MyShop | | `sync_shopify_products` (PUT) | `v1/PUT/sync_shopify_products.php` | Full catalogue sync of a shop: collections, then products | | `sync_shopify_categories` (PUT) | `v1/PUT/sync_shopify_categories.php` | Collections only | | `shopify_catalog_sync.php` (cron) | `v1/cron/shopify_catalog_sync.php` | Hourly full sync of every shop whose connection is `active` | **The endpoints must be listed in `allowed_functions` of the config, or they answer 405:** ``` "allowed_functions": {  "GET": ["shopify_connection_status"],  "POST": ["send_order_to_shopify", "shopify_oauth_start"],  "PUT": ["sync_shopify_products", "sync_shopify_categories"]} ``` **Each endpoint takes `shop_id` (and `order_id` for orders) in `parameters`:** ``` {  "function": "send_order_to_shopify",  "parameters": { "shop_id": 2, "order_id": 71 }} ``` A Postman collection with every request is in `shopify.postman_collection.json` (variables `{{shopify.*}}`). #### Requirements **Before a shop is connected, all of these must be in place:** | Requirement | Where | Why | | --- | --- | --- | | A bot user of the company, enabled, with a device | OrderLemon: a user of the company (`users`, `company_id`) with a row in `devices_{company_id}` | Every OL module call goes out as a user of the company, with the device UUID as Bearer. The OL webhook module has no master token fallback (the master token cannot write `created_by`), so without a device the webhook is never registered and no order reaches Shopify. Catalogue calls fall back to the master token, but lose what needs a user behind the caller, such as the web catalogue exemption | | The Shopify app with the scopes below | Shopify Partner / Dev Dashboard (see Shopify App Setup) | Each call fails with HTTP 403 when its scope is missing | | `orderlemon_config.tokens.shopify.api_version` | Secrets JSON | Every GraphQL call fails without it: "Shopify API version missing from ms_secrets" | | `orderlemon_config.servers.shopify.webhook_url` | Secrets JSON | The OL webhook cannot be registered or enabled without it | | The endpoints in `allowed_functions` | Secrets JSON | They answer 405 otherwise | | The hourly cron line | Crontab of the server | `install.sh` does not register it | **Shopify access scopes:** | Scope | Used for | | --- | --- | | `read_products` | Collections, products, variants and images (catalogue sync) | | `read_inventory` | Tracked stock and inventory policy of variants | | `read_orders` | Finding an order by its tag `OL-{shop_id}-{order_id}` (idempotency, cancel, fulfil) | | `write_orders` | `orderCreate`, `orderCancel` | | `read_customers` | Finding the customer by phone | | `write_customers` | `customerCreate`, and `toUpsert` of the customer on the order | | `read_merchant_managed_fulfillment_orders` | Reading the open fulfillment orders of an order | | `write_merchant_managed_fulfillment_orders` | `fulfillmentCreate` when the OL order is done or delivered | **API version:** set `orderlemon_config.tokens.shopify.api_version` to a supported Admin API version (format `YYYY-MM`, e.g. `2026-07`). It is never hardcoded: Shopify supports each version for 12 months only, so it must be raised before the pinned version is retired. #### Shopify App Setup (bot app) The app the microservice calls Shopify with. Created in the Shopify Partner / Dev Dashboard of the OrderLemon organization, and installed on our dev store. The microservice gets its token through the client credentials grant, which never opens the app URL or a redirect URL: Shopify only requires them as form fields. **App configuration:** | Field | Value | | --- | --- | | App name | `OrderLemon` | | App URL | `https://shopify.orderlemon.dev/` | | Redirect URLs | `https://shopify.orderlemon.dev/` | | Embedded app | Off: the integration has no UI inside Shopify | | Access scopes | see below | Never use the webhook path (`/v1/webhooks/shopify`) as the app URL: it only accepts OL's POST with the OL secret. **Access scopes (paste as one line):** ``` read_products,read_inventory,read_orders,write_orders,read_customers,write_customers,read_merchant_managed_fulfillment_orders,write_merchant_managed_fulfillment_orders ``` **Steps:** | Step | Action | | --- | --- | | 1 | Create the app with the configuration above and release a version | | 2 | Install it on the dev store. The store must belong to the same organization, or the client credentials grant is refused | | 3 | Copy Client ID and Client secret into the secrets JSON: `orderlemon_config.tokens.shopify.client_id` and `orderlemon_config.tokens.shopify.api_secret` | | 4 | Set `orderlemon_config.tokens.shopify.api_version`, e.g. `2026-07` | | 5 | In `shop_settings` of the OL shop: `shopify.shop.domain` = `.myshopify.com`, `shopify.connection.status` = `registered`. The token can stay empty: the microservice fetches it | | 6 | Run `sync_shopify_products` for the shop (Postman: Sync products) | **Merchant stores** cannot use the client credentials grant: they connect through OAuth. Then App URL and Redirect URLs point at the OAuth start and callback endpoints of the microservice; the scopes stay the same. #### Connect a Merchant Store (OAuth) Merchant stores cannot use the client credentials grant, so they connect through OAuth (authorization code grant), started from MyShop. | Step | Who | What happens | | --- | --- | --- | | 1 | MyShop backend | `POST shopify_oauth_start` with `shop_id` and `domain` (server token; never from the browser). Answers `authorize_url` | | 2 | Browser | Opens `authorize_url`: the Shopify consent page. The merchant approves the scopes | | 3 | Shopify | Redirects the browser to `/v1/webhooks/shopify_oauth` with `code`, `shop`, `state`, `hmac` | | 4 | Microservice | Checks the HMAC, the state and its nonce, conflicts, exchanges `code` for an offline token, checks the scopes, stores the connection | | 5 | Microservice | Redirects the browser to `oauth_return_url` with the outcome | | 6 | MyShop | On `status=registered`, calls `sync_shopify_products` (first sync: 24/7 opening, OL webhook). Shows `shopify_connection_status` | **Endpoints:** | Endpoint | File | Purpose | | --- | --- | --- | | `shopify_oauth_start` (POST) | `v1/POST/shopify_oauth_start.php` | `shop_id`, `domain` -> `authorize_url`, `expires_at` | | `/v1/webhooks/shopify_oauth` (GET) | `v1/webhooks/shopify_oauth.php` | OAuth callback, called by Shopify through the browser | | `shopify_connection_status` (GET) | `v1/GET/shopify_connection_status.php` | `shop_id` -> `values.row`: domain, status, connected, reason, changed_at, ordering_paused | **Configuration (`orderlemon_config`):** | Key | Value | | --- | --- | | `servers.shopify.oauth_redirect_url` | `https://shopify.orderlemon.dev/v1/webhooks/shopify_oauth`. Must equal a Redirect URL of the app in Shopify | | `servers.shopify.oauth_return_url` | MyShop page to come back to, e.g. `https://myshop.orderlemon.dev/`. https only; without it the callback answers JSON | | `tokens.shopify.client_id` / `api_secret` | App credentials; the secret also signs the state and checks Shopify's HMAC | And `shopify_oauth_start` (POST) and `shopify_connection_status` (GET) in `allowed_functions`. **Outcome on the return URL:** | Query | Meaning | | --- | --- | | `shopify=connected&shop_id=..&status=registered` | First connection stored. Run `sync_shopify_products` | | `shopify=connected&shop_id=..&status=active` | Reconnected and working: ordering resumed | | `shopify=error&reason=invalid_request` | Shopify's signature or the code missing or wrong | | `shopify=error&reason=invalid_state` | State forged, expired (15 min), already used, or replaced by a later start | | `shopify=error&reason=shop_mismatch` | The merchant approved in another store than the one entered | | `shopify=error&reason=already_connected` | The shop is linked to another store, or the store to another shop | | `shopify=error&reason=exchange_failed` | Shopify refused the code exchange | | `shopify=error&reason=missing_scopes` | The app configuration grants fewer scopes than needed; token not stored | | `shopify=error&reason=storage_failed` | Database error | | `shopify=error&reason=check_failed` | Reconnected, token stored, but the check call failed: `sync_shopify_products` tries again | | `shopify=error&reason=not_configured` | OAuth settings missing in config | **Rules:** | Rule | Details | | --- | --- | | State | `shop_id`, `domain`, nonce and expiry, signed with `api_secret` (HMAC-SHA256). Binds the callback to the store and shop of the start | | Single use | The nonce is stored at the start (`shopify.oauth.nonce`) and consumed by the callback with a guarded UPDATE. A new start invalidates earlier URLs | | Shopify HMAC | Over the decoded query values sorted by name, `hmac` and `signature` left out | | Scopes | Shopify leaves `read_x` out of the granted list when it grants `write_x`; both count as granted | | Conflicts | A shop already linked to another store, or a store linked to another shop, is refused at the start and again in the callback. Switching stores is done by support | | Storage | Token, `expires_at` (''), domain and status in one transaction. First connection -> `registered`; a reconnect is checked at once with one call (`active`, or `check_failed`) | | Tokens | Offline, without `grant_options[]=per-user`. The app must issue non-expiring offline tokens: a refresh token is not kept, so an expiring one closes the connection (`token_revoked`) once it runs out | #### Authentication | Direction | Scheme | Header | | --- | --- | --- | | Caller -> `/v1/` endpoints | Bearer token, compared with `ms_server_token` | `Authorization: Bearer ` | | OL -> `/v1/webhooks/shopify` | Bearer token, the secret of the company's OL webhook, compared with `hash_equals` | `Authorization: Bearer ` | | This microservice -> Shopify | Admin API access token of the store | `X-Shopify-Access-Token` | | This microservice -> OL module API | Device UUID of a user of the company, master token as fallback | `Authorization: Bearer ` | ###### Webhook secret The secret is minted here (64 hex characters) and stored in `shop_settings` as `shopify.ol_webhook.secret`, because OL only ever shows it masked. OL keeps webhooks per company, so one secret serves every shop of a company: the newest row of any shop of the company is the one OL signs with. An unknown shop and a wrong secret get the same answer (401), so the caller cannot learn which shops exist. ###### Shopify access token | Rule | Details | | --- | --- | | Source | `shopify.access_token` in `shop_settings`, written by the connection flow outside this microservice | | Refresh | Client credentials grant (`client_id` + `api_secret`), `POST https://{domain}/admin/oauth/access_token`. The token lives 24 h; refreshed when missing or within 300 s of `shopify.access_token.expires_at` | | Legacy token | A token without `expires_at` (`shpat_...`, pasted by hand) is used until Shopify answers 401, then refreshed once | | Limit of the grant | Client credentials only work for stores in the app's own organization (our dev stores). Merchant stores connect through OAuth | #### Configuration **Secrets JSON, path in `config_path` of `v1/config.php`. Keys under `orderlemon_config` unless noted.** | Key | Required | Meaning | | --- | --- | --- | | `tokens.shopify.api_version` | yes | Admin API version, e.g. `2026-07`. Never hardcoded: Shopify supports each version for 12 months only | | `tokens.shopify.client_id` / `api_secret` | for refresh | App credentials for the client credentials grant. Without them tokens are never refreshed | | `servers.shopify.api` | no | GraphQL URL template, default `https://{shop}.myshopify.com/admin/api/{version}/graphql.json` | | `servers.shopify.webhook_url` | yes | URL OL posts to, e.g. `https:///v1/webhooks/shopify`. Never built from the request host: a call through localhost would register a URL OL cannot reach | | `servers.orderlemon.api` | yes | OL module API URL | | `master_token` | yes | OL master token: fallback caller, and a precondition of every OL call | | `use_dbal` + `db` | yes | OL database (Doctrine DBAL), exposed as `OL_DB_CONN` / `OL_DB_NAME` | | `alerts.telegram.chat` / `teams` | no | Telegram chat and teams, default `dev` / `dev` | | `environment` / `imagesurl` | no | Pick the `nopicture.jpg` URL (`imagesurl` wins) | | `universe` node `telegram` (top level) | for alerts | Telegram microservice, called with function `send_telegram_log` | | `env` (top level) | — | Alerts go out only when it is `prod` or `production` | Two different "environment" values exist: alerts read the top-level `env`, the picture URL reads `orderlemon_config.environment`. #### Connection Lifecycle The state of each shop lives in `shop_settings` as `shopify.connection.status`, `.reason` and `.changed_at`. `markShopConnection()` is its only writer; the microservice never uninstalls anything, it records what it observed. | Status | Set by | Meaning | | --- | --- | --- | | `registered` | the connection flow, outside this microservice | Store linked, no working call yet. Not synced by the cron | | `active` | the first successful GraphQL call of a process | Mapping and token proven to work | | `closed` | a closing answer from Shopify | Every call to the store fails until the cause is fixed | **Closing reasons:** | Answer | Reason | Pauses OL ordering | | --- | --- | --- | | HTTP 401 after one failed refresh | `token_revoked` | yes | | HTTP 402 | `plan_limit` | yes | | HTTP 423 | `shop_locked` | yes | | Refresh refused with `app_not_installed`, shop was active | `uninstalled` (also clears the access token) | yes | | HTTP 403 | does not close; only that call fails | no | | Refresh refused with `invalid_client` | does not close: it is our own credentials, and would close every shop at once | no | **What follows a state change (checked on every call, so a failed step is made up for by the next call):** | Event | Action | | --- | --- | | Closed for a pausing reason | Pause ordering in OL, disable the OL webhook (when no other shop of the company is active), Telegram alert | | Active again | Resume ordering, enable the OL webhook, "connection is back" alert | | First connection ever | Open the shop around the clock in OL (once, see below) | | Active, OL webhook checked more than 24 h ago | Recheck the webhook against OL, so one disabled or deleted in the OL panel comes back | #### Pause, Resume and 24/7 Opening Ported from Deliverect busy mode. All three run under the per-shop lock `pause` (10 s wait). | Action | Behavior | | --- | --- | | Pause | Reads the `business_hours`, `pickup` and `delivery` calendars through `shops/info`, stores the enabled entry ids in `shopify.pause.calendar`, then switches them off through `shops/update`. `shopify.pause.active = 1` is set only after success. Refuses to start when the id list exceeds 2000 bytes | | Resume | Switches back on the recorded ids that are still disabled, then clears the list and the flag. Entries edited meanwhile are reopened with the edit | | 24/7 opening | On the first connection only (`shopify.hours.initialized = 0`): for each calendar type and weekday the first entry becomes 00:00-23:59, enabled; extra entries that day are disabled, missing days created. Then the flag becomes `1`. Shops connected before this feature have no flag and are never touched | #### OL Webhook **Registered by this microservice in OL, per company:** | Field | Value | | --- | --- | | name | `Shopify` | | webhook_url | `servers.shopify.webhook_url` | | enabled_events | `payments.completed`, `order.updated` | | async | `false` | | Rule | Details | | --- | --- | | Caller | OL module `webhook` as a user of the company (a device). No master token fallback: the master token cannot write `created_by` | | Lookup | Ours is the one named exactly "Shopify" or pointing at our URL (host and path compared, `.php` and trailing slash ignored). Deleted rows are skipped. A failed lookup never adds a webhook, to avoid duplicates | | Secret | OL's secret when shown unmasked > our stored secret > a new one. Stored before OL hears of it | | Disable | Only when no other shop of the company is active (newest status row per shop). A read failure keeps it on. Works without `webhook_url` configured: the URL OL holds is kept | | State | `shopify.ol_webhook.state` (`active` / `disabled`), `shopify.ol_webhook.checked_at` | | Locking | Per-company lock `olwh` (10 s wait): a shop closing while its sibling connects could otherwise disable the webhook the sibling just enabled | #### Webhook Receiver URL (mapped to `shopify.php` by `v1/webhooks/.htaccess`): > `https:///v1/webhooks/shopify` **Event routing:** | Event | Condition | Action | | --- | --- | --- | | `payments.completed` | — | Send the order to Shopify | | `order.updated` | `status_id` 8 (cancelled) | Cancel the Shopify order | | `order.updated` | `status_id` 3 (done) or 7 (delivered) | Fulfil the Shopify order | | `order.updated` | any other status | `no_action`, not logged | | any | shop not `active`, no order id, or another event | 200 `ignored`. OL sends events of every shop of the company, also of shops never linked to Shopify | The event type is read from `type`, then `event.type`, then `event`, then `?event=`. **Response codes:** | HTTP | When | | --- | --- | | 200 | Done, ignored, or refused for good (a refused order included) | | 500 | Worth retrying: Shopify unreachable, order busy in another request, any exception. OL delivers the event again | | 400 / 401 | Not POST, missing token, invalid JSON / wrong secret or unknown shop | Only error types declared in `v1/assets/errors.json` may be used: an unknown one degrades to 204, which OL would read as delivered. #### Order Flow: OrderLemon -> Shopify **`sendOrderToShopify()` in `v1/general/shopify_order_functions.php`:** | Step | Behavior | | --- | --- | | 1 | Empty cart -> `empty_cart` | | 2 | Invalid UTF-8 in the OL order is replaced, with a warning | | 3 | Takes the order lock (10 s wait); not taken -> `busy` (500, retried) | | 4 | Reads the OL status: cancelled (8), or lost (9) when not sent by hand -> `cancelled_in_ol` | | 5 | Idempotency: searches Shopify for the tag `OL-{shop_id}-{order_id}`; found -> `already_sent` | | 6 | Reads the slot lengths of the pick-up / delivery moment from `calendar_{shop_id}` | | 7 (…cut; the full text is on the page.)