Skip to content

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.com store URL; Upvendo returns a Shopify authorization URL, the merchant authorizes, and Shopify redirects to the /shopify-callback route, which exchanges the code for an access token. Client ID/secret come from config('services.shopify.client_id'|'client_secret'). (A legacy API key/secret path also exists in the orchestrator but is marked @deprecated and is not used by the connect UI.)
  • Shopify Collections = Upvendo Display Groups: Both Shopify custom_collection and smart_collection types are imported as Upvendo display groups. The collection type is stored under external_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 stores external_ids.shopify.id (product ID) and external_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-webhook and are HMAC-verified synchronously by the verify.shopify-webhook middleware; the actual work is then queued as ProcessShopifyWebhookJob (queue payments-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.com domain (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()) returns shopify under applicable — which it always does: only deliveroo and trivec are region-restricted (US), and only deliveroo is additionally config-gated. There is no build-environment gate (AddChannelDialog.vue:72-78). The tile can still render disabled with reason pos_integration_required when 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 when services.deliveroo.enabled is false.

  1. Select the location you want to connect.
  2. Open the channel/add-channel dialog and choose Shopify.

Step 2: Enter your store URL and authorize

  1. Enter your Shopify store URL (must contain/end with myshopify.com).
  2. The back-office calls POST /api/back-office/shopify/{locationId}/oauth with { "store_url": "..." }.
  3. 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?..." }.
  4. You are redirected to Shopify to authorize the requested scopes.
  5. 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 by shop_name) stores access_token, scopes, and auth_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-menu dispatches ImportShopifyMenuJob, which runs importJobAsync(). It fetches custom collections and smart collections, then for each collection paginates through its products (following the Shopify Link: 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-menu dispatches ExportUpvendoMenuToShopifyJob. NOTE: in the current code this job's handle() calls importJobAsync() (the same import routine), not the export routine syncJobAsync(). So triggering "Sync Menu" runs an import, not an export. The export code (syncJobAsync and the saveIntoThirdParty* push methods) exists in the service but is not actually invoked by either job.

Step 4: Verify the import

  1. Review imported items under Menus > Items (/menus/items).
  2. The integration's sync_status and last_sync_at are updated as the job runs (in_progresssuccess/error).

Data Sync Details

What Imports (Shopify → Upvendo)

Shopify EntityUpvendo EntityNotes
Custom collection + Smart collectionDisplay GroupType stored in external_ids.shopify.type
Product (single variant)ItemOne item; price via PriceConverter::forStorage((float) variant price)
Product (multiple variants)Variant Group + ItemsOne variant group, one item per variant, item name = title - variant
Product variant image / product imageItem content (image)Saved via getContentRepository()->saveExternalImage(...)
(import target)MenuA "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. Fetches custom_collections.json and smart_collections.json, then products.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 ExportUpvendoMenuToShopifyJob runs 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) AND external_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-string external_ids.shopify structure).
  • 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, name shopify.callback) — exchanges the code, registers webhooks, activates the integration.

Sync

  • Import Menu from Shopify: POST /api/back-office/shopify/{locationId}/import-menu — dispatches ImportShopifyMenuJob. Returns success message "Shopify synchronization started!".
  • "Sync Menu": POST /api/back-office/shopify/{locationId}/sync-menu — dispatches ExportUpvendoMenuToShopifyJob, 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}/ — updates shop_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 /oauth endpoint). 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 /shopify page is an empty stub).

Fields

Credentials (stored in ThirdPartyIntegration.credentials)

FieldTypeDescription
shop_namestringThe Shopify store domain (e.g. example.myshopify.com)
access_tokenstringOAuth access token for the Shopify Admin API
scopesstringGranted OAuth scopes (comma-separated)
auth_methodstringoauth when connected via the OAuth flow
api_keystring(Legacy) Shopify API key — legacy/deprecated path only
api_secretstring(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's shop.json).
  • Import/sync require valid access_token and shop_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 Shopify visibility 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 DELETE disable 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() and updateItemStatusInProvider() 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_secret credentials 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_progresssuccess/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_required unless 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 /shopify page, explain that connecting is done from the Add Channel dialog; the /shopify page 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 Shopify visibility channel is created on import).
  • Transactions (Shopify order webhooks for create/updated/cancelled are turned into Upvendo transactions).