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.
| 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.*}}`).
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.
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_ordersSteps:
| 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` = `<store>.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.
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 |
| Direction | Scheme | Header |
| Caller -> `/v1/` endpoints | Bearer token, compared with `ms_server_token` | `Authorization: Bearer <ms_server_token>` |
| OL -> `/v1/webhooks/shopify` | Bearer token, the secret of the company's OL webhook, compared with `hash_equals` | `Authorization: Bearer <webhook secret>` |
| 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 <device uuid | master token>` |
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.
| 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 |
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://<host>/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`.
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 |
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 |
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 |
URL (mapped to `shopify.php` by `v1/webhooks/.htaccess`):
`https://<host>/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.
`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 | Resolves the customer: by e-mail (`toUpsert`), else by phone (search, else `customerCreate`) |
| 8 | `orderCreate` with `DECREMENT_OBEYING_POLICY`, `sendReceipt: false`, `sendFulfillmentReceipt: false` |
| 9 | Refused only for optional parts (phone, pending cash transaction, billing address) -> those parts are dropped together and the order sent again, with a warning |
| 10 | Still refused -> OL order marked lost (status 9, which tells the customer over WhatsApp), Telegram alert, `rejected` |
| 11 | Transport failure -> searched again after 5 s; found -> `sent`, else `failed` (500, retried). Never marked lost |
orderCreate input:
| Area | Behavior |
| Currency | `display_currency_iso3`, else EUR. A display rate other than 1 is ignored with a warning: amounts are sent unconverted |
| Product lines | `variantId` from `metadata.shopify.variant_gid` of the OL product; else a custom line (`title`, `requiresShipping`, `taxable`) |
| Variable products | The parent row (price 0) is skipped; its chosen variant is the line |
| Modifiers | Rows with `parent_id` get a `Part of` property naming the parent, except variants of a Shopify product |
| Discounts | Negative rows (or campaign rows without product) are not lines. All promotions become one `itemFixedDiscountCode`, labelled with the largest promotion |
| Free campaign items | A campaign row with price 0 and a product is sent as a custom line: listed, but never blocks the order on stock |
| Delivery fee | Reference `delivery_fee` becomes a shipping line `Delivery`; a delivery order always gets one, even when free |
| VAT | OL prices are gross: `taxesIncluded: true`, one tax line `VAT` per line, lowered in proportion to the discount |
| Payment | Paid -> `PAID` + SALE SUCCESS, gateway `OrderLemon`, `authorizationCode` = `payment_uid`. Cash -> `PENDING` + SALE PENDING, gateway `Cash on Delivery (COD)` (or `PAID` when `shopify.payment.cash_as = paid`) |
| Addresses | Shipping address for delivery only. Billing address from the `billing_*` fields |
| processedAt | `ordered_time` read as Europe/Brussels |
| Tags | `OrderLemon`, `OL-{shop_id}-{order_id}` |
Custom attributes, in this order (empty ones left out):
OrderLemon order
Logistics (delivery | pickup)
Pick-up moment / Delivery moment
Payment reference
Business name / Business VAT / Business TIN
Promotion (one per promotion)
Pick-up and delivery moment labels:
| OL data | Label |
| Midnight, the calendar row of that day has no slots | Day only: `2026-10-23` |
| A time, with `pick_up_time_end` | `2026-10-23 12:00 to 12:30` |
| A time, no end, the calendar row has slots | Start plus `time_limit_slots` minutes: `2026-10-23 00:00 to 00:30` |
A row for the date wins over the weekday row (`day_of_week` 0 = Monday). A slot at midnight looks like a day without slots; only the calendar tells them apart.
| Flow | Behavior | Statuses |
| Cancel | `orderCancel` with reason OTHER, restock, no customer notice, no refund (the money was taken by OL, not Shopify) | `cancelled`, `already_cancelled`, `not_in_shopify`, `cancel_refused`, `busy`, `failed` |
| Fulfil | One `fulfillmentCreate` per open fulfillment order, no customer notice. Orders handled by a fulfillment service are skipped with a warning | `fulfilled`, `already_fulfilled`, `not_in_shopify`, `cancelled_in_shopify`, `fulfill_refused`, `busy`, `failed` |
Both take the same order lock as the send. A refusal sends a Telegram alert.
`runShopifyCatalogSync()` runs collections first, then products, because `products.category_id` is NOT NULL.
| Shopify | OrderLemon |
| Collection | Category. Name uppercased, max 24 bytes. Found by `metadata.shopify.external_id`, then by name, else created |
| Simple product | One product per mapped category ("copy"), price, SKU as `reference`, enabled when ACTIVE |
| Product with options | A parent product at 0.00 per category, plus one modifier per variant in a shared modifier category per option set (min 1, max 1) |
| Inventory | `isstock = 1` and `stock` only when tracked with policy DENY; otherwise unlimited |
| Images | From `cdn.shopify.com` only, jpeg or png, max 5 MB. Without an image: `nopicture.jpg` for listed rows, none for modifiers |
| No collection | Fallback category `UNCATEGORIZED` |
Retirement rules:
| Rule | Details |
| Categories | Never deleted, only disabled when gone from Shopify |
| Products | Deleted when gone from Shopify |
| Empty or partial list | Nothing is retired |
| Failed writes | Rows of a product with any failed write this run are spared |
| Hand-made categories | A category matched by name is only linked: never renamed, disabled or retired |
| Relations | Only missing ones are added; never removed |
| Over 50 variants / 10 collections | Product stays offline / its leftover copies are kept |
OL catalogue limits: error 43 = 10 enabled categories per shop, error 42 = 30 enabled products per category. OL skips both for web catalogue shops, but the API cannot read that setting. They need the merchant or support to act.
| Rule | Details |
| Request | POST to `servers.orderlemon.api`, header `Module: <module>`, body `{"a": action, "data": {...}}` |
| Caller | A device of an enabled user of the company (`devices_{company_id}`), admins first. A refused device or user is skipped and the next one tried |
| Master token fallback | When no device is left, or OL answers "Shop is not enabled." / "Company is not enabled." (cached for the run) |
| Types | OL checks PHP types strictly, so fields are cast (bool, int, float) |
| Timeouts | 5 s connect / 60 s total; module `webhook` 3 s / 10 s |
| Circuit breaker | After a transport failure of a module, its later calls in the process are skipped. Per module, reset before each shop's sync. A sync cut by it is a failure (infra 3) that still returns its counts |
Modules used: categories (new, update), products (new, update, info, delete, relations_add), shops (info, update), orders (update), webhook (list, new, update). Reads go straight to the OL database through DBAL.
`shop_settings` (reads take the newest row: the unique key is missing on some live databases)
| Reference | Meaning |
| `shopify.shop.domain` | `xxx.myshopify.com`, written outside; anything else is never dialled |
| `shopify.access_token` / `.expires_at` | Store token and its expiry |
| `shopify.connection.status` / `.reason` / `.changed_at` | Connection state |
| `shopify.pause.active` / `shopify.pause.calendar` | Pause flag and the calendar ids it switched off |
| `shopify.hours.initialized` | `0` = 24/7 opening due, `1` = done, absent = never touched |
| `shopify.ol_webhook.secret` / `.state` / `.checked_at` | OL webhook (company-wide) |
| `shopify.alert.<key>` | Last alerted failure code, `''` when cleared |
| `shopify.category.fallback_id` | Id of the `UNCATEGORIZED` category |
| `shopify.payment.cash_as` | `paid` sends cash orders as paid (set by hand) |
| `shopify.product.vat_percentage` | VAT of new products (set by hand), else 0 with a warning |
`metadata` JSON column, `shopify` namespace:
- `categories_{shop_id}` → external_id, gid, handle, title, updated_at, linked, merged_external_ids, image_url, modifier_key, fallback
- `products_{shop_id}` → role (simple | modifier | parent), external_id, variant_id, variant_gid, product_id, product_gid, handle, sku, title, updated_at, category_scope, image_url, image_updated_at
Orders keep no Shopify id in OL: the OL API cannot store one. The order is found in Shopify by its tag `OL-{shop_id}-{order_id}`.
MariaDB named locks, name `shopify_<kind>_` + md5 of database and id (a long database name must not cut the id off).
| Lock | Scope | Wait | Purpose |
| `sync` | shop | 0 s | One catalogue sync per shop (endpoint and cron) |
| `catalog_cron` | database | 0 s | One cron run at a time |
| `pause` | shop | 10 s | Pause, resume, 24/7 opening |
| `olwh` | company | 10 s | OL webhook enable / disable |
| `order` | shop + order | 10 s | Send, cancel and fulfil of one order |
Telegram, through the `telegram` microservice. Production only. State alerts fire once when a failure starts or changes, and once when it clears.
| Key | Raised | Cleared |
| `connection` | Connection closed for a pausing reason | Connection active again |
| `catalog_sync` | Hourly sync of a shop failed | It works again |
| `catalogue_limit` | Items hit OL error 42 / 43 | A run without errors |
| `ol_webhook` | Enabling or disabling the OL webhook failed (company) | Enabling worked |
One-off alerts: order refused by Shopify (and marked lost), cancel refused, fulfil refused.
Not one of the framework's cron files (those are overwritten on update), so `install.sh` does not register it. Add on every server:
0 * * * * cd <ms_root>/v1/cron/;php shopify_catalog_sync.phpOnly shops with status `active` are synced. Output goes to stdout, one line per shop; exit code 1 when any shop failed.
| File | Responsibility |
| `v1/general/shopify_functions.php` | GraphQL client, tokens, connection state, shop_settings access, locks |
| `v1/general/shopify_order_functions.php` | Order send, cancel, fulfil, orderCreate input |
| `v1/general/shopify_catalog_functions.php` | Collections -> categories |
| `v1/general/shopify_product_functions.php` | Products, variants, modifiers, images, stock |
| `v1/general/shopify_sync_functions.php` | Full catalogue sync shared by the endpoint and the cron |
| `v1/general/shopify_pause_functions.php` | Pause, resume, 24/7 opening of OL ordering |
| `v1/general/shopify_ol_webhook_functions.php` | OL webhook registration and secret |
| `v1/general/shopify_alert_functions.php` | Telegram alerts |
| `v1/general/orderlemon_api_functions.php` | OL module API client, device auth, circuit breaker |
| `v1/general/shopify_oauth_functions.php` | OAuth: state, nonce, HMAC, code exchange, conflicts, storing the connection |
| `v1/webhooks/shopify_oauth.php` | OAuth callback |
| `v1/general/ms_functions.php` | Includes everything above |
| `v1/webhooks/shopify.php` | OL webhook receiver |
| `v1/cron/shopify_catalog_sync.php` | Hourly catalogue sync |
| Environment | URL |
| DEV | `https://shopify.orderlemon.dev/v1/` |
| PROD | `https://shopify.orderlemon.com/v1/` (to confirm) |
| Webhook | `https://shopify.orderlemon.{dev|com}/v1/webhooks/shopify` |
| Constraint | Status |
| Order idempotency | `orderCreate` has no idempotency key and OL cannot store a Shopify id, so it relies on the tag. Shopify's search lags a few seconds: the lock covers concurrent deliveries, a 5 s settle covers failed creates |
| Display currency | The rate is ignored; amounts are sent unconverted |
| One discount code | All promotions are summed into one code |
| Midnight slot | When OL sends no `pick_up_time_end`, a slot at midnight is told from a day only through the calendar |
| Resume | Reopens every entry the pause closed, even one the merchant wanted closed meanwhile |
| Client credentials | Only for stores in the app's organization |
| Merchant stores (OAuth) | Backend done (see Connect a Merchant Store). MyShop needs the "Connect to Shopify" button. Expiring offline tokens (refresh token) are not supported |
| Registered and closed shops | Not synced by the cron; they come back through the endpoint or the webhook |