Shopify product push
One-click write of PowersportOS catalog data into a merchant's Shopify store. PowersportOS is the source of truth; Shopify is the storefront consumer. Re-pushing the same SKU updates the existing Shopify product rather than creating a duplicate, so the data layer stays clean even if you push 200 times a day.
Why this exists
Powersport retailers who run on Shopify maintain their catalog by hand or via spreadsheet-style PIM tools, categories, descriptions, dimensions, images, fitments per product, often re-keyed for every new brand they onboard. Onboarding a new manufacturer can take 4-7 full days of catalog data entry per brand. With Shopify push, PowersportOS becomes the structured data layer and a single click (or bulk action) materialises hundreds of complete product pages on the merchant's store.
The same mechanism is the wedge for the distributor go-to-market: a distributor recommending PowersportOS to its reseller network sells "save 4 days per brand onboarding" as a concrete, demonstrable benefit.
Credential model
Outbound writes use a customer-managed Dev Dashboard app via Shopify's Dev Dashboard (developers.shopify.com). The merchant creates the app there, grants the scopes PowersportOS publishes, and provides the Client ID + Client Secret. PowersportOS exchanges those for short-lived OAuth access tokens on each call via the client_credentials grant. No long-lived shpat_ token to leak, no PowersportOS-side Partner Dashboard app to distribute. (Shopify retired the in-admin Custom App pattern in 2026; the Dev Dashboard app is the modern replacement and works on every Shopify plan tier.)
- Scopes required
- write_products, read_products, read_inventory (write_inventory if pushing stock; read_files if pushing images)
- Token lifetime
- Short-lived access tokens fetched per-batch via client_credentials. PowersportOS caches in-memory until expiry.
- Revocation
- Merchant revokes the Dev Dashboard app credentials via the Shopify Dev Dashboard. No PowersportOS-side change needed; subsequent push attempts return an auth error.
- Works on every Shopify plan
- Not Plus-restricted. Dev Dashboard apps are available to merchants on every plan tier.
What gets pushed
The push reads from PowersportOS's PIM-Light data (see the PIM-Light doc) and writes via Shopify Admin GraphQL's productSet mutation. Mapping:
- PowersportOS field
- Shopify field
- Part.name (or PartVariantGroup.name)
- product.title
- Part.description (sanitised HTML)
- product.descriptionHtml
- Part.brand
- product.vendor
- Part.category
- product.productType
- TenantCatalogItem.seoTitle / seoDescription
- product.seo
- TenantCatalogItem.tags
- product.tags
- Part.partNumber
- variant.sku
- Part.ean
- variant.barcode
- TenantCatalogItem.price
- variant.price
- TenantCatalogItem.compareAtPrice
- variant.compareAtPrice
- TenantCatalogItem.costPrice
- inventoryItem.cost
- TenantCatalogItem.stock
- inventoryItem.inventoryLevel (first active location)
- Part.weightKg
- inventoryItem.measurement.weight
- Part.hsCode
- inventoryItem.harmonizedSystemCode
- Part.countryOfOrigin (ISO 3166-1 alpha-2)
- inventoryItem.countryCodeOfOrigin
- Part.imageUrl (per variant for groups)
- ProductVariantSetInput.file → product gallery + variant association
Push profile (per-tenant policy)
Each tenant configures a push profile in Settings → Shopify push profile that controls, per field, whether PowersportOS writes it. The profile distinguishes create (first push for a SKU, every field can be included) from update (re-push, every field is either overwrite or leave alone).
- Default content policy (leave-alone on update)
- title, descriptionHtml, vendor, productType, tags, status, seoTitle, seoDescription, image
- Default operational policy (overwrite on update)
- price, compareAtPrice, costPrice, weight, ean, hsCode, countryOfOrigin, stock
The defaults reflect the typical division of labour: PowersportOS owns operational data (stock, price, dimensions) and is allowed to clobber Shopify on re-push. Merchants own content (copy, SEO, manual product photography); routine re-pushes don't disturb their work. Both defaults are tweakable per tenant.
Variant groups
When PowersportOS parts are grouped via PartVariantGroup (or TenantPartVariantGroup for tenant-owned parts), the push assembles all activated members into one Shopify product with N variants. Each variant carries its own SKU, price, stock, weight, EAN, HS code, country of origin, and image, included in the same atomic productSet call rather than a separate media upload.
- Membership. For central-catalog pushes only members the tenant has activated as
TenantCatalogItemrows become Shopify variants. For own-parts pushes every group member becomes a variant. - Re-push. Members added since the last push become new Shopify variants. Members removed are deleted from Shopify by
productSet(which has set-semantics on the variants list) and PowersportOS cleans up the per-part mapping row. - Master fields. Title, description, vendor, and product-type come from the position-0 member; option dimension comes from
PartVariantGroup.option1Name(e.g. "Color", "Length", "Pack size").
Bulk push
The customer portal exposes bulk push on the My Catalog and My Parts pages. Operator selects N rows via the grid checkboxes, clicks Push to Shopify…, and watches live progress (succeeded / failed / remaining + a failure list). Items are processed serially with a small inter-call delay to stay under Shopify's cost-based GraphQL rate limit; a failure on one SKU never aborts the batch. Closing the dialog mid-batch keeps the push running server-side; reopening re-attaches.
One bulk push per tenant runs at a time. Job state is in-memory (1 hour retention after completion); restarts drop in-flight jobs but the persistent ShopifyProductMapping rows mean already-pushed items don't need re-pushing.
Stock-feed loop guard
When PowersportOS writes stock to Shopify, Shopify fires its inventory_levels/update webhook. If the merchant's own inventory worker is subscribed to that webhook and forwards changes to POST /api/integrations/stock-feed, the same stock value would round-trip back to us. To stop this from polluting the merchant's sync logs:
- Every outbound push records
lastPushHash = "stock:<n>"+lastPushedAton the per-partShopifyProductMappingrow. - The stock-feed endpoint checks each inbound row against the mapping. If the incoming stock value matches a push that happened in the last 5 minutes, the row is skipped and counted as
echoSkippedin the response. - Outside the 5-minute window the loop guard doesn't apply: a daily ERP cron re-pushing the same value still writes through.
What's deliberately not in v1
- Auto-push on PowersportOS data changes. Push is operator-triggered. Continuous outbound sync (e.g. "any time stock changes in PowersportOS, push to Shopify") is a follow-up.
- Multi-location stock distribution. Stock writes to the first active Shopify location. Multi-location strategy (write per-location based on PowersportOS DealerStock) is on the roadmap.
- Fitment metafields. Fitment data stays inside PowersportOS and is consumed by the storefront theme via the public API. We deliberately don't sync fitment as Shopify tags or metafields, that approach scales badly and lock-ins to Shopify-specific structures.
- Reverse sync. Edits made in Shopify (manual title changes, image uploads) are not pulled back into PowersportOS. The push profile's leave-alone policy is the closest thing, it lets the merchant edit in Shopify without re-push wiping their work.