Appearance
Shopify Integration
Overview
Shopify is a location-specific e-commerce integration that connects an Upvendo location to a Shopify online store. The integration authenticates via Shopify's OAuth 2.0 flow, registers webhooks on the Shopify store, and maps Shopify collections/products/variants to Upvendo display groups, items, and variant groups. It is implemented in app/Services/BackOffice/ShopifyIntegrationService.php and exposes a Shopify menu visibility channel (App\Enums\ChannelOptions::Shopify).
This integration is partially implemented in production. The OAuth connect flow and the Shopify→Upvendo menu import are functional. Several other pieces are stubs or are wired incorrectly, and are flagged explicitly below. Treat the export, transaction-fetch, and status-update behaviours as not production-complete.
Purpose
Connect a Shopify store to an Upvendo location so collections and products can be imported into the Upvendo menu (display groups, items, variant groups), and so Shopify orders can be received as Upvendo transactions via webhooks.
Key Concepts
- OAuth Authentication: Connecting starts a Shopify OAuth 2.0 authorization. The merchant enters their
*.myshopify.comstore URL; Upvendo returns a Shopify authorization URL, the merchant authorizes, and Shopify redirects to the/shopify-callbackroute, which exchanges the code for an access token. Client ID/secret come fromconfig('services.shopify.client_id'|'client_secret'). (A legacy API key/secret path also exists in the orchestrator but is marked@deprecatedand is not used by the connect UI.) - Shopify Collections = Upvendo Display Groups: Both Shopify
custom_collectionandsmart_collectiontypes are imported as Upvendo display groups. The collection type is stored underexternal_ids.shopify.type. - Shopify Products/Variants = Upvendo Items / Variant Groups: A single-variant product imports as one item. A multi-variant product (
count($product['variants']) > 1) imports as a variant group plus one item per variant. Each item storesexternal_ids.shopify.id(product ID) andexternal_ids.shopify.variant_id. - Webhook-Driven Order Intake: After the OAuth callback, Upvendo registers a list of webhook topics on the Shopify store (see below). Inbound webhooks hit
POST /shopify-webhookand are HMAC-verified synchronously by theverify.shopify-webhookmiddleware; the actual work is then queued asProcessShopifyWebhookJob(queuepayments-medium, 3 tries, 30s backoff, 120s timeout), which rebinds the tenant database before processing the order into an Upvendo transaction. In practice only order topics are handled (see Data Sync Details).
Prerequisites
- A Shopify store with admin access, on the
*.myshopify.comdomain (the store URL is validated to end with.myshopify.com). - Shopify app credentials configured server-side (
services.shopify.client_id/client_secret); without these the OAuth flow throws "Shopify API credentials not configured". - A location already created in Upvendo. The OAuth flow is initiated against a specific
locationId. - Payment providers (e.g. Stripe / Viva) are configured separately. Shopify does NOT process Upvendo payments.
Setup Steps
Step 1: Open the Add Channel dialog and choose Shopify
Connecting is done from the back-office Add Channel dialog (AddChannelDialog.vue), not from the /shopify page. The /shopify page (src/pages/shopify/index.vue) is currently an empty stub.
Availability. The Shopify tile is pushed into the Add Channel dialog whenever the online-channels endpoint (
GET /api/back-office/online-channels/{locationId},OrderingChannelService::onlineChannels()) returnsshopifyunderapplicable— which it always does: onlydeliverooandtrivecare region-restricted (US), and onlydeliveroois additionally config-gated. There is no build-environment gate (AddChannelDialog.vue:72-78). The tile can still render disabled with reasonpos_integration_requiredwhen the merchant/location has no active POS integration, and it is filtered out entirely once Shopify is already connected for that location. Uber Eats is likewise unconditionally applicable; Deliveroo is hidden only for US merchants or whenservices.deliveroo.enabledis false.
- Select the location you want to connect.
- Open the channel/add-channel dialog and choose Shopify.
Step 2: Enter your store URL and authorize
- Enter your Shopify store URL (must contain/end with
myshopify.com). - The back-office calls
POST /api/back-office/shopify/{locationId}/oauthwith{ "store_url": "..." }. - The server validates the domain ends with
.myshopify.com, creates a placeholder integration record (is_active: false) for the location, and returns{ success: true, auth_url: "https://{shop}/admin/oauth/authorize?..." }. - You are redirected to Shopify to authorize the requested scopes.
- Shopify redirects back to
GET /shopify-callback, which exchanges the code for an access token, registers webhooks, marks the integration active, and (via the OAuth orchestrator path, matched byshop_name) storesaccess_token,scopes, andauth_method: oauth. On success the merchant lands on/shopify/success.
Step 3: Import the menu from Shopify
- Import from Shopify:
POST /api/back-office/shopify/{locationId}/import-menudispatchesImportShopifyMenuJob, which runsimportJobAsync(). It fetches custom collections and smart collections, then for each collection paginates through its products (following the ShopifyLink: rel="next"header) and creates display groups, variant groups, items, and a Shopify-channel menu in Upvendo. - "Sync Menu" (export) — NOT working as labelled:
POST /api/back-office/shopify/{locationId}/sync-menudispatchesExportUpvendoMenuToShopifyJob. NOTE: in the current code this job'shandle()callsimportJobAsync()(the same import routine), not the export routinesyncJobAsync(). So triggering "Sync Menu" runs an import, not an export. The export code (syncJobAsyncand thesaveIntoThirdParty*push methods) exists in the service but is not actually invoked by either job.
Step 4: Verify the import
- Review imported items under Menus > Items (
/menus/items). - The integration's
sync_statusandlast_sync_atare updated as the job runs (in_progress→success/error).
Data Sync Details
What Imports (Shopify → Upvendo)
| Shopify Entity | Upvendo Entity | Notes |
|---|---|---|
| Custom collection + Smart collection | Display Group | Type stored in external_ids.shopify.type |
| Product (single variant) | Item | One item; price via PriceConverter::forStorage((float) variant price) |
| Product (multiple variants) | Variant Group + Items | One variant group, one item per variant, item name = title - variant |
| Product variant image / product image | Item content (image) | Saved via getContentRepository()->saveExternalImage(...) |
| (import target) | Menu | A "Shopify Default Menu" is created with the Shopify visibility channel if the integration has no menu yet |
Sync Direction (what actually runs in prod)
- Import (Shopify → Upvendo) — functional. Uses the Shopify Admin REST API version
2023-10. Fetchescustom_collections.jsonandsmart_collections.json, thenproducts.json?collection_id={id}with full pagination. Inactive products (status !== 'active') are soft-deleted in Upvendo during import; if a matching item/variant group was previously trashed and the product is active, it is restored. - Export (Upvendo → Shopify) — present in source but not actually triggered because
ExportUpvendoMenuToShopifyJobruns the import routine instead. The export code, if it ran, would create/update Shopify collections, products, and product-variant products, then add products to collections via the Collects API. (Treat the export path as not-verified-in-prod.) - Order intake (webhooks) — order topics are processed into Upvendo transactions; product/collection/inventory/customer topics are registered on Shopify but their handlers are commented out (ignored).
Webhook Topics
On the OAuth callback, registerShopifyWebhooks() subscribes the Shopify store to these topics (all posting to route('shopify.webhook') as JSON):
products/create, products/update, products/delete, collections/create, collections/update, collections/delete, inventory_items/update, orders/create, orders/updated, orders/cancelled, orders/fulfilled, orders/paid, order_transactions/create, customers/create, customers/update, customers/delete.
Inbound handling (processWebhookPayload) only has live case branches for orders/create, orders/updated, orders/cancelled — these transform the Shopify order into an Upvendo transaction (transformOrderToTransaction) and run processTransactions. All other topics (products, collections, inventory, customers, orders/fulfilled, orders/paid, order_transactions/create) are commented out and fall through to the default branch, which logs "Unknown Shopify webhook topic" and returns ignored. The controller reads the topic from the X-Shopify-Topic header and merges it into the payload as topic before routing, so header-only Shopify deliveries route correctly (WebhookController.php:161; ProcessShopifyWebhookJob.php:88-92).
Conflict Resolution (import)
- Items are matched by
external_ids.shopify.id(product) ANDexternal_ids.shopify.variant_id(variant); matched items are updated, others created. - Display groups are matched by
external_ids.shopify.id(with a fallback for an older flat-stringexternal_ids.shopifystructure). - Variant groups are matched by their Shopify product ID.
- Products with
status !== 'active'are soft-deleted (deleted_at) in Upvendo on import.
Actions
All endpoints are under the back-office API: /api/back-office/shopify/{locationId} (routes in routes/api/backoffice/shopify.php, mounted inside the type:backoffice + /back-office group with the admin-vendor-override middleware).
Connect
- Initiate OAuth:
POST /api/back-office/shopify/{locationId}/oauth— body{ store_url }. Returns{ success, auth_url }or a 400 "Invalid Shopify store URL. The URL must end with .myshopify.com". - OAuth callback:
GET /shopify-callback(public guest route, nameshopify.callback) — exchanges the code, registers webhooks, activates the integration.
Sync
- Import Menu from Shopify:
POST /api/back-office/shopify/{locationId}/import-menu— dispatchesImportShopifyMenuJob. Returns success message "Shopify synchronization started!". - "Sync Menu":
POST /api/back-office/shopify/{locationId}/sync-menu— dispatchesExportUpvendoMenuToShopifyJob, which currently runs an import (see Step 3 note). Returns "Shopify synchronization started!". - Get Status:
GET /api/back-office/shopify/{locationId}/— returns integration status (see Fields). - Update Settings:
PUT /api/back-office/shopify/{locationId}/— updatesshop_name/api_key/api_secret/is_sandbox/settings. (Note: the update path expects legacy API-key fields and does not update OAuth credentials.)
Disconnect
- Disable:
DELETE /api/back-office/shopify/{locationId}/— currently hard-disabled. It unconditionally returns HTTP 400{ success: false, message: "Disconnecting Shopify integration is disabled. Please contact administrator." }. The disable logic in the orchestrator/service is not reachable through this endpoint.
NOTE: The back-office front-end currently only wires up the OAuth initiation (
initiateShopifyOAuth→ the/oauthendpoint). The import-menu, sync-menu, status, update, and disable endpoints exist on the backend but are not driven by a Shopify settings UI in the back-office (the/shopifypage is an empty stub).
Fields
Credentials (stored in ThirdPartyIntegration.credentials)
| Field | Type | Description |
|---|---|---|
shop_name | string | The Shopify store domain (e.g. example.myshopify.com) |
access_token | string | OAuth access token for the Shopify Admin API |
scopes | string | Granted OAuth scopes (comma-separated) |
auth_method | string | oauth when connected via the OAuth flow |
api_key | string | (Legacy) Shopify API key — legacy/deprecated path only |
api_secret | string | (Legacy) Shopify API/app secret — also used as the webhook HMAC secret |
Settings (stored in ThirdPartyIntegration.settings)
Settings differ by enable path:
- OAuth enable seeds
{ sync_menus: true, sync_orders: true }. - Legacy (deprecated) enable seeds
{ sync_inventory: true, sync_prices: true, sync_orders: true }.
The PUT update request validates settings.sync_inventory, settings.sync_prices, settings.sync_orders (booleans). None of these flags currently gate behaviour in the sync/import code — they are stored but not enforced.
Status response (GET /.../)
{ enabled, provider: "shopify", location_id, is_sandbox, settings, sync_status, last_sync_at, external_id }. external_id is set to sp_{locationId} only on the legacy setupIntegration path. sync_status carries status (in_progress / success / error), message, and a details object for the last run.
OAuth Scopes Requested
read_products, write_products, read_product_listings, read_inventory, write_inventory, read_orders, write_orders, read_draft_orders, write_draft_orders, read_order_edits, write_order_edits, read_customers, write_customers, read_locations, read_price_rules, write_price_rules, read_merchant_managed_fulfillment_orders, write_merchant_managed_fulfillment_orders.
Business Rules
- A location/shop has a single Shopify integration record. Initiating OAuth for a location that already has an active integration aborts with 400 "Shopify integration has been enabled for this location" (verified via
checkIntegration, which calls Shopify'sshop.json). - Import/sync require valid
access_tokenandshop_name; otherwise the job throws "Missing required Shopify credentials (access_token or shop)." Import/sync also abort with 403 "Unauthorized" if the location's merchant does not match the current merchant. - On import, a Shopify menu is created with the
Shopifyvisibility channel; if the integration has no menu yet it is named "Shopify Default Menu" and the menu ID is stored on the integration. - Modifier groups are NOT synced — the code has explicit TODO comments noting Shopify has no modifier-group concept. Only display groups, items, and variant groups are handled.
- Prices to/from Shopify use the decimal money value (
getPriceMoney()->decimal()outbound;PriceConverter::forStorage((float) price)inbound). - The
DELETEdisable endpoint is intentionally blocked (returns 400; "contact administrator").
Stubbed / simulated behaviour (do NOT describe as live)
fetchTransactionsFromProvider()returns randomly generated dummy transactions — there is no real Shopify orders fetch on a date range.updateTransactionStatusInProvider()andupdateItemStatusInProvider()return simulated success — they do not call Shopify; the real fulfillment/status API calls are commented out.- These stubs use the legacy
api_key/api_secretcredentials and would not work with an OAuth-only integration.
FAQs
How do I connect my Shopify store?
From the back-office Add Channel dialog, choose Shopify, enter your *.myshopify.com store URL, and complete the Shopify OAuth authorization. After authorizing you are redirected to a success page. The tile is present on production builds. If it is missing, the location either has no active POS integration (the tile renders disabled with reason pos_integration_required) or Shopify is already connected there (the tile is filtered out).
Can I sync in both directions?
Import (Shopify → Upvendo) works. The "Sync Menu" (export) action currently runs an import, not an export, because the export job is wired to the import routine. Treat full export-to-Shopify as not working in production.
Does Shopify handle payments for kiosk or online ordering?
No. The Shopify integration handles menu import and order intake only. Payment processing still uses your configured Upvendo payment provider (e.g. Stripe / Viva).
What happens to inactive Shopify products during import?
Products whose status is not active are soft-deleted in Upvendo. If the product becomes active again and is re-imported, the matching trashed item/variant group is restored.
Are modifier groups supported?
No. Shopify has no equivalent concept; the sync code explicitly skips modifier groups. Only items, variant groups, and display groups are handled.
Can I disconnect Shopify from the back-office?
Not currently. The disconnect endpoint is disabled and returns "Disconnecting Shopify integration is disabled. Please contact administrator."
Troubleshooting
"Invalid Shopify store URL. The URL must end with .myshopify.com"
The store URL you entered does not resolve to a *.myshopify.com domain. Use your store's myshopify.com domain (custom domains are not accepted here).
"Shopify integration has been enabled for this location"
The location already has an active Shopify integration (the server reached Shopify's shop.json successfully during the check). You cannot re-initiate OAuth for an already-connected location.
"Missing required Shopify credentials (access_token or shop)"
The integration is missing its OAuth access_token or shop_name. This usually means the OAuth callback did not complete. Re-run the OAuth connect flow.
"Shopify API credentials not configured"
The server-side Shopify app credentials (services.shopify.client_id / client_secret) are not set. This is an environment/configuration issue, not a merchant setting.
Products not appearing after import
The import runs asynchronously via a queued job. Check the integration sync_status (it moves in_progress → success/error). Only products that belong to a collection are imported (the importer iterates collections and pulls each collection's products).
Webhook events not updating Upvendo
Inbound webhooks hit POST /shopify-webhook and are HMAC-verified using the integration's api_secret credential (header X-Shopify-Hmac-Sha256; the shop is resolved from X-Shopify-Shop-Domain). Only orders/create, orders/updated, and orders/cancelled are processed; other topics are logged and ignored. The topic itself arrives in the X-Shopify-Topic header and is merged into the payload before routing, so header-only deliveries are matched correctly.
Verification is synchronous (VerifyShopifyWebhook middleware: 401 on missing/invalid X-Shopify-Hmac-Sha256, 400 on missing X-Shopify-Shop-Domain, 404 when no is_enabled integration matches the shop). Processing is then deferred to ProcessShopifyWebhookJob on the payments-medium queue (3 tries, 30s backoff, 120s timeout), which rebinds the tenant database before touching any repository — so Shopify gets its 200 well inside the 5s delivery window.
"Disconnecting Shopify integration is disabled"
The disable endpoint is intentionally blocked. Contact an administrator to remove a Shopify integration.
Assistant Guidance
When a user asks about Shopify integration:
- The reliable, working path is: connect via OAuth (Add Channel dialog) → Import Menu from Shopify. Frame import as the supported direction.
- The Add Channel dialog does offer Shopify on production. If a merchant cannot pick it, check the two real preconditions before escalating: the tile renders disabled with reason
pos_integration_requiredunless the merchant/location has an active POS integration, and it is filtered out of the list entirely when Shopify is already connected for that location. - If they ask to "export" or "sync menu to Shopify", warn that the export action currently runs an import (the export job is misconfigured) and full Upvendo→Shopify export is not working in production.
- Remind them Shopify does not process Upvendo payments.
- If they cannot find connect/settings controls on the
/shopifypage, explain that connecting is done from the Add Channel dialog; the/shopifypage is a stub. - If they want to disconnect, tell them the disconnect endpoint is disabled and they must contact an administrator.
- Do not claim live order-fetch or order-status-update with Shopify — those code paths are stubs returning simulated data.
Relations
Depends On
- Locations (a location must exist before connecting Shopify; OAuth is per-location).
- ThirdPartyIntegration model (stores credentials, settings, sync status, menu ID).
- Menu system (display groups, items, variant groups) — populated by import.
- Server-side Shopify app credentials (
services.shopify.*).
Affects
- Menu Items, Display Groups, Variant Groups (created/updated/soft-deleted during import).
- Menus (a "Shopify Default Menu" with the
Shopifyvisibility channel is created on import). - Transactions (Shopify order webhooks for create/updated/cancelled are turned into Upvendo transactions).