Shopify
Connect a Shopify store to manage its blog articles through the Blogs API, manage its catalog, stock and discounts through the Commerce API and install ad platform pixels; a store publishes no social posts.
Create, schedule, update and delete the blog articles of a Shopify store with the Blogs API, manage its products, stock, collections, discounts and more with the Commerce API, and install Meta, TikTok, Google, LinkedIn, Pinterest, X and OpenAI tags on its storefront (Tracking pixels), through a connected account with platform: "shopify". Shopify is connect-only: a store never appears as a target in POST /v1/posts, publishes no social posts and reports no analytics.
Quick reference
| Property | Value |
|---|---|
| Platform value | shopify |
| What it manages | Storefront blogs and blog articles; the store's catalog through the Commerce API |
| Auth | OAuth 2.0 (store domain required) or a custom-app Admin token |
| Scopes | read_content, write_content, read_products, write_products, write_pixels, read_customer_events |
| Tracking pixels | One Zernio web pixel firing one tag per ad platform (Tracking pixels) |
| Social posting | No |
| Analytics | No |
| Media requirements | None (article images are referenced by URL) |
| Scheduling | Yes, native (Shopify publishes at publishDate; no Zernio queue) |
| Drafts | Yes (isPublished: false) |
| Article body | HTML (bodyHtml) |
| SEO fields | Yes (seo.title, seo.description) |
| Pagination | Cursor (limit 1 to 50, default 20, plus nextCursor) |
Before you start
Shopify requires the store's myshopify.com domain before OAuth can start. Shopify has no store picker and no lookup from a merchant to their shops, so an authorization URL can only be built for a domain you already know; collect it from the merchant first. A merchant who installs from the Shopify App Store never types it, because Shopify supplies the domain to Zernio on that path.
Blog, article, product and variant ids are Shopify's own numeric ids, not Zernio object ids. Read them from API responses; never construct them.
Connect
Call GET /v1/connect/shopify with profileId and shop on Get Shopify OAuth connect URL. Send the merchant to the returned authUrl; after they approve the install, Shopify calls Zernio's callback, the account is created on your profile, and the browser lands on your redirect_url. The connecting accounts guide covers the flow and scopes in general.
import Zernio from '@zernio/node';
const zernio = new Zernio();
const { data: connect } = await zernio.connect.getShopifyConnectUrl({
query: {
profileId: '66a1f0c2a4b9d3e8f1a2b3c4',
shop: 'your-store.myshopify.com',
redirect_url: 'https://myapp.com/connected'
}
});
// Send the merchant's browser to connect.authUrlResponse (200):
{
"authUrl": "https://your-store.myshopify.com/admin/oauth/authorize?client_id=...",
"state": "..."
}shop accepts the full domain or the bare your-store prefix. redirect_url must be an absolute http(s) URL or an app scheme such as myapp://callback; a relative path is rejected with 400 INVALID_REDIRECT_URL. Connecting the same profile to the same store again refreshes the stored token in place instead of creating a second account.
Custom-app Admin token
To skip the browser flow, the merchant creates a custom app in their Shopify admin (Settings, then Apps and sales channels, then Develop apps) with the read_content, write_content, read_products and write_products scopes and hands you its Admin API access token, which starts with shpat_. Exchange it with Connect a Shopify store with a custom-app Admin token:
curl -X POST "https://zernio.com/api/v1/connect/shopify/token" \
-H "Authorization: Bearer $ZERNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"profileId": "66a1f0c2a4b9d3e8f1a2b3c4",
"shop": "your-store.myshopify.com",
"accessToken": "shpat_..."
}'Response (200):
{
"account": {
"_id": "66b2e19d8c3f5a7e9d0b1c2d",
"platform": "shopify",
"username": "your-store.myshopify.com",
"displayName": "Your Store",
"profileId": "66a1f0c2a4b9d3e8f1a2b3c4"
}
}shop is required here too: an Admin token does not identify its own store. Zernio validates the token against the store before saving anything, and custom-app tokens do not expire.
OAuth scopes
| Scope | What it enables |
|---|---|
read_content | Read the store's blogs and articles |
write_content | Create, update and delete blogs and articles |
read_products | List and read products, variants and images |
write_products | Edit product fields and variant prices |
write_pixels | Install tracking pixels on the storefront |
read_customer_events | Let those pixels receive the store's customer events |
Zernio requests no access to customers, orders or payments. Commerce features such as stock, discounts or markets use optional scopes that the merchant approves separately (see Commerce).
Stores connected before 16 September 2026 hold a content-only grant. On those, every Products endpoint answers 403 insufficient_permissions until the merchant goes through the connect flow again: the consent screen shows the two product scopes and the existing account keeps its id.
When the merchant uninstalls
Shopify invalidates its own token the moment the app is uninstalled and sends Zernio the app/uninstalled webhook, so there is nothing to revoke and nothing to call. Zernio deactivates that store's account: it stops appearing in GET /v1/accounts as active, and any request naming its accountId fails. Scheduled articles on the account are held through the disconnect grace period rather than deleted at once, so a reinstall inside that window brings them back with the account. Reinstalling runs the same connect flow and issues a fresh token.
Publish
There are no social posts. A connected store publishes blog articles: a store has one or more blogs (Shopify creates a "News" blog by default), and each blog holds articles. All content lives on Shopify; Zernio proxies it and stores nothing.
List blogs
Start by listing the blogs to get the id you will write into (List blogs):
curl "https://zernio.com/api/v1/accounts/66b2e19d8c3f5a7e9d0b1c2d/blogs" \
-H "Authorization: Bearer $ZERNIO_API_KEY"Response (200):
{
"platform": "shopify",
"blogs": [
{ "id": "121793282419", "platform": "shopify", "title": "News", "handle": "news" }
],
"nextCursor": null
}Create an article
Call POST /v1/accounts/{accountId}/blogs/{blogId}/articles with title and bodyHtml (Create an article).
const { data: created } = await zernio.blogs.createBlogArticle({
path: { accountId: '66b2e19d8c3f5a7e9d0b1c2d', blogId: '121793282419' },
body: {
title: 'Autumn collection preview',
bodyHtml: '<p>The first pieces land next month.</p>',
tags: ['autumn', 'new-arrivals'],
author: 'Maria Costa',
excerpt: 'An early look at what is arriving this September.',
isPublished: true
}
});
console.log(created.article.id);Response (201):
{
"platform": "shopify",
"article": {
"id": "589234567890",
"blogId": "121793282419",
"platform": "shopify",
"title": "Autumn collection preview",
"handle": "autumn-collection-preview",
"tags": ["autumn", "new-arrivals"],
"isPublished": true,
"publishedAt": "2026-09-08T09:00:00Z"
}
}Draft
isPublished: false keeps the article as a draft; it reads back with publishedAt: null:
{
"title": "Autumn collection preview",
"bodyHtml": "<p>The first pieces land next month.</p>",
"isPublished": false
}Scheduled article
A future publishDate schedules the article natively on Shopify. Shopify publishes it at that time with no Zernio queue involved, and until then the article reads back as isPublished: false with publishedAt set to the future date:
{
"title": "Autumn collection preview",
"bodyHtml": "<p>The first pieces land next month.</p>",
"publishDate": "2027-01-01T12:00:00+01:00"
}Featured image and SEO
image.url sets the featured image; Shopify downloads it, so the URL must be publicly reachable. seo.title and seo.description map to Shopify's title_tag and description_tag metafields, which themes read for the page title and meta description:
{
"title": "Autumn collection preview",
"bodyHtml": "<p>The first pieces land next month.</p>",
"image": { "url": "https://cdn.example.com/autumn.jpg", "altText": "Wool coats on a rail" },
"seo": { "title": "Autumn collection preview", "description": "An early look at the autumn pieces." }
}Every operation
| Operation | Endpoint |
|---|---|
| List blogs | GET /v1/accounts/{accountId}/blogs |
| Create a blog | POST /v1/accounts/{accountId}/blogs |
| Get a blog | GET /v1/accounts/{accountId}/blogs/{blogId} |
| Update a blog | PATCH /v1/accounts/{accountId}/blogs/{blogId} |
| Delete a blog | DELETE /v1/accounts/{accountId}/blogs/{blogId} |
| List articles | GET /v1/accounts/{accountId}/blogs/{blogId}/articles |
| Create an article | POST /v1/accounts/{accountId}/blogs/{blogId}/articles |
| Get an article | GET /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId} |
| Update an article | PATCH /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId} |
| Delete an article | DELETE /v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId} |
Commerce
A connected store is a Commerce store: the Commerce API at /v1/commerce creates and edits products, variants, images, stock, collections, discounts, sales channel publishing, market prices, metafields, metaobjects, pages, menus, redirects and marketing activities, and can sync the store into a Meta catalog for catalog ads. Pass this account's id as accountId.
Products, collections, metafields and pages work on every install. Stock, sales channels, discounts, navigation, metaobjects, markets, marketing activities and image removal need optional scopes: GET /v1/commerce/store lists them in missingCapabilities and returns grantPermissionsUrl, where the merchant approves them without reinstalling. See Capabilities and extra permissions.
curl "https://zernio.com/api/v1/commerce/store?accountId=66b2e19d8c3f5a7e9d0b1c2d" \
-H "Authorization: Bearer $ZERNIO_API_KEY"Products
The Products API below is deprecated in favor of the Commerce API, which returns the same catalog in the platform-neutral shape. It keeps working for existing integrations: list and read products with their variants, options and images, and edit a product's own fields and variant prices. All data lives on Shopify; Zernio proxies it and stores nothing.
List products
Call GET /v1/accounts/{accountId}/products. Filter with status (active, draft or archived) and query, Shopify's product search syntax, which is passed through verbatim:
const { data: page } = await zernio.products.listProducts({
path: { accountId: '66b2e19d8c3f5a7e9d0b1c2d' },
query: { status: 'active', query: 'vendor:Acme tag:summer', limit: 20 }
});
for (const product of page.products) {
console.log(product.id, product.title, product.variants[0]?.price);
}Response (200):
{
"platform": "shopify",
"products": [
{
"id": "8214569812345",
"platform": "shopify",
"title": "Linen shirt",
"handle": "linen-shirt",
"descriptionHtml": "<p>Breathable linen for warm days.</p>",
"vendor": "Acme",
"productType": "Shirts",
"tags": ["summer", "linen"],
"status": "active",
"featuredImage": { "url": "https://cdn.shopify.com/s/files/1/.../linen-front.jpg", "altText": "Linen shirt, front" },
"images": [{ "url": "https://cdn.shopify.com/s/files/1/.../linen-front.jpg", "altText": "Linen shirt, front" }],
"options": [{ "name": "Size", "values": ["S", "M", "L"] }],
"variants": [
{
"id": "45123456789012",
"title": "S",
"sku": "LINEN-S",
"barcode": null,
"price": "59.00",
"compareAtPrice": "79.00",
"inventoryQuantity": 12,
"availableForSale": true,
"selectedOptions": [{ "name": "Size", "value": "S" }]
}
],
"seo": { "title": "Linen shirt | Acme", "description": null },
"totalInventory": 31,
"onlineStoreUrl": "https://acme.com/products/linen-shirt",
"createdAt": "2026-05-02T10:12:00Z",
"updatedAt": "2026-09-01T08:30:00Z",
"publishedAt": "2026-05-02T10:15:00Z"
}
],
"nextCursor": "eyJsYXN0X2lkIjo4MjE0NTY5ODEyMzQ1fQ=="
}Pagination is the same cursor scheme as the Blogs API: limit 1 to 50 (default 20) and nextCursor, null on the last page. The response carries the first 100 variants and the first 20 images of each product.
Update a product
Call PATCH /v1/accounts/{accountId}/products/{productId} with any subset of title, descriptionHtml, handle, vendor, productType, tags, status, seo and variants. tags replaces the whole list. variants changes the price and compare-at price of the listed variant ids only; a variant that belongs to another product is a 400. The response is the product as it reads after the update.
const { data: updated } = await zernio.products.updateProduct({
path: { accountId: '66b2e19d8c3f5a7e9d0b1c2d', productId: '8214569812345' },
body: {
descriptionHtml: '<p>Breathable linen for warm days. Now in three sizes.</p>',
tags: ['summer', 'linen', 'new'],
seo: { title: 'Linen shirt | Acme', description: 'A breathable linen shirt in S, M and L.' },
variants: [{ id: '45123456789012', price: 54.0, compareAtPrice: 79.0 }]
}
});
console.log(updated.product.variants[0].price); // "54.00"Prices are decimal strings in the store currency; a number is accepted and formatted to two decimals. Send "compareAtPrice": null to take a variant off sale. status: "draft" unpublishes a product without deleting it and status: "archived" hides it everywhere; both are reversible with status: "active".
Every product operation
| Operation | Endpoint |
|---|---|
| List products | GET /v1/accounts/{accountId}/products |
| Get a product | GET /v1/accounts/{accountId}/products/{productId} |
| Update a product | PATCH /v1/accounts/{accountId}/products/{productId} |
Tracking pixels
Put an ad platform's pixel on the store's storefront and checkout with one call and no theme edits: POST /v1/accounts/{accountId}/tracking-tags/{tagId}/install, where accountId is your connected ads account (Meta, TikTok, Google, LinkedIn, Pinterest, X or OpenAI), tagId the tracking tag and the body names this store's storeAccountId. Zernio installs its web pixel app extension on the store, which maps Shopify's customer events to each platform's events.
How the install works
A Shopify app gets one web pixel per store, so the store runs a single Zernio web pixel that holds one tag per ad platform:
- Install is idempotent. Calling it again rewrites the same settings; installing a different tag of the same platform replaces the previous one and names it in
replacedTagId; tags of other platforms are kept. - Status (
GETwith?storeAccountId=) says whether this tag is the one the store fires for its platform (installed), which tag of that platform it fires now (installedTagId), and lists every Zernio tag on the store intags. - Uninstall (
DELETEwith?storeAccountId=) removes only this platform's tag and keeps the others; the web pixel itself is deleted when no tag remains. Removing a tag that is not installed is a no-op, and removing a tag while the store fires a different tag of that platform changes nothing and answers409 invalid_resource_state.
const { data } = await zernio.trackingtags.installTrackingTagOnStore({
path: { accountId: '66b2e19d8c3f5a7e9d0b1c2d', tagId: '7412345678901234567' },
body: { storeAccountId: '66b2e19d8c3f5a7e9d0b1c3e' }
});Response (200), a TikTok Pixel added next to a Meta pixel already on the store:
{
"platform": "tiktokads",
"install": {
"storeAccountId": "66b2e19d8c3f5a7e9d0b1c3e",
"platform": "shopify",
"shopDomain": "acme.myshopify.com",
"tagPlatform": "tiktokads",
"siteTagId": "D00IKHRC77UE0J0RTNHG",
"installed": true,
"installedTagId": "D00IKHRC77UE0J0RTNHG",
"webPixelId": "2031620387",
"tags": [
{ "platform": "metaads", "siteTagId": "1729525464415281" },
{ "platform": "tiktokads", "siteTagId": "D00IKHRC77UE0J0RTNHG" }
],
"replacedTagId": null
}
}siteTagId, installedTagId, replacedTagId and tags carry the on-site id (the TikTok pixel code, the Google AW- id, the X or OpenAI pixel id), not the API tagId. Google, LinkedIn and X also store their per-event ids (conversion labels, rule ids, event ids) at install time, so install again after you add or change conversion events on those platforms.
Stores connected before 28 September 2026 have not granted write_pixels and read_customer_events. On those, all three calls answer 409 reconnect_required with a fresh consent link in details.authUrl and the missing scopes in details.missingScopes; the account keeps its id, so retry after the merchant approves. A store connected with a custom-app Admin token gets the same answer, because a web pixel belongs to the app that installs it. See When the store must re-approve for the full error.
Event mapping
The pixel subscribes to 7 Shopify standard customer events, reduces them to Zernio's neutral site events and sends each one to every installed platform:
| Shopify event | Site event | Meta | TikTok | X | OpenAI | |||
|---|---|---|---|---|---|---|---|---|
page_viewed | page_view | PageView | Pageview | PAGE_VIEW action, if one exists | Page view (base tag hit) | pagevisit | Page view (twq('config')) | page_viewed |
product_viewed | view_content | ViewContent | ViewContent | Not sent | VIEW_CONTENT rule, if one exists | viewcontent | CONTENT_VIEW event, if one exists | contents_viewed |
product_added_to_cart | add_to_cart | AddToCart | AddToCart | ADD_TO_CART action, if one exists | ADD_TO_CART rule, if one exists | addtocart | ADD_TO_CART event, if one exists | items_added |
search_submitted | search | Search | Search | Not sent | SEARCH rule, if one exists | search | SEARCH event, if one exists | Custom search |
checkout_started | initiate_checkout | InitiateCheckout | InitiateCheckout | BEGIN_CHECKOUT action, if one exists | START_CHECKOUT rule, if one exists | initiatecheckout | CHECKOUT_INITIATED event, if one exists | checkout_started |
payment_info_submitted | add_payment_info | AddPaymentInfo | AddPaymentInfo | Not sent | ADD_BILLING_INFO rule, if one exists | addpaymentinfo | ADDED_PAYMENT_INFO event, if one exists | Custom addpaymentinfo |
checkout_completed | purchase | Purchase | Purchase | PURCHASE action, if one exists | PURCHASE rule, if one exists | checkout | PURCHASE event, if one exists | order_created |
- Meta, TikTok, Pinterest and OpenAI receive every event. Product and cart events carry the variant id, value and currency; checkout events carry every line, the total and the currency; the purchase adds the order id.
- Google counts only events that have their own conversion action, so it receives
page_view,add_to_cart,initiate_checkoutandpurchase, each only when the account has an enabledWEBPAGEconversion action of the matching category with a label (the primary-for-goal action wins). Google has no category for search or payment info, and sending product views to the page-view action would count every product page twice. See Google tag. - LinkedIn always receives the page view as a base Insight Tag hit; any other event is sent only when the ad account has an enabled conversion rule of the matching type that a page can fire (an event-specific Insight Tag rule, not a Conversions API or URL-match rule; the most recently fired one wins), with the value, currency and order id. A
KEY_PAGE_VIEWrule is never fired, since it would count every page as a conversion. See Insight Tag. - X always receives the page view; any other event is sent only when the ad account has a conversion event of the matching type (the most recently tracked one wins). See X Pixel.
- OpenAI has no standard search or payment-info event, so those go out as the lowercase custom events
searchandaddpaymentinfo, the names the OpenAI Conversions API stores for a server-side Search or AddPaymentInfo. Search text is never sent to OpenAI.
Deduplication
Every event carries an event id: Shopify's own event id, except the purchase, whose id is shopify_order_<orderId> (the numeric order id). Send a server-side purchase for the same order through POST /v1/ads/conversions with eventId: "shopify_order_<orderId>" and each platform counts it once. The id travels as Meta's eventID, TikTok's event_id, Google's oid (one conversion per order id per action), LinkedIn's eventId, Pinterest's event_id, X's conversion_id and OpenAI's event_id.
How events reach each platform
Shopify runs app pixels in a strict sandbox (a web worker with no DOM), where the platforms' own browser scripts cannot load. The Zernio pixel therefore sends each event straight to the endpoint the platform's script would call, and keeps the platform's click id (fbclid, ttclid, gclid/gbraid, li_fat_id, epik, twclid, oppref) in a first-party cookie so later events stay attributed:
| Platform | Endpoint | Status |
|---|---|---|
| Meta | www.facebook.com/tr with _fbp / _fbc | Documented (the pixel's image endpoint) |
| TikTok | analytics.tiktok.com/api/v2/pixel, reusing _ttp | Undocumented: the endpoint TikTok's events.js calls |
www.googleadservices.com/pagead/conversion/<id>/ with the conversion label | Documented no-JavaScript conversion tag; gclid/gbraid kept in _gcl_aw/_gcl_gb | |
px.ads.linkedin.com/collect with li_fat_id from the landing URL | Documented image pixel (pid, conversionId, eventId), plus the parameter names insight.min.js sends | |
ct.pinterest.com/v3/ (GET, or a form POST past 2048 characters) | The endpoint Pinterest's documented noscript tag and core.js call, with core.js's parameter names; epik kept in _epik | |
| X | analytics.twitter.com/1/i/adsct | Reverse-engineered from uwt.js (X documents only the legacy image pixel) |
| OpenAI | bzr.openai.com/v1/sdk/events | Documented image tag; oppref kept in __oppref for 30 days, amounts in minor units |
The pixel follows the store's customer privacy settings: where consent is required, it fires only for visitors who allow marketing.
Platform fields
There is no platformSpecificData for Shopify, because a store is not a POST /v1/posts target. The article fields:
| Field | Type | Default | Description |
|---|---|---|---|
title | string | Required. | |
bodyHtml | string | Article body as HTML. | |
handle | string | (slug of the title) | URL slug. Shopify sets it once, at creation; renaming the article later leaves the URL unchanged, so set it explicitly when the URL matters. |
tags | Array<string> | Shopify returns them alphabetized, not in the order you sent. | |
author | string | Display name of the author. | |
excerpt | string | Short summary shown in blog listings. | |
image | {url, altText?} | Featured image, downloaded by Shopify from a public URL. | |
seo | {title?, description?} | Maps to Shopify's title_tag and description_tag metafields. | |
isPublished | boolean | false creates a draft. | |
publishDate | datetime | ISO 8601 with offset or Z. A future date schedules publication on Shopify. |
Media requirements
None. Article images are referenced by URL in image.url and downloaded by Shopify, not uploaded to Zernio.
Analytics
Shopify exposes no analytics through Zernio.
Inbox
Shopify has no inbox.
What you cannot do
Shopify's connection does not expose:
- Social posts (
POST /v1/postsrejects a Shopify account) - Analytics
- An inbox
- Media uploads
- Orders and customers
- Restoring a deleted blog or article (deletes are permanent, and deleting a blog deletes every article inside it)
Common errors
| Error | Cause | Fix |
|---|---|---|
400 INVALID_REDIRECT_URL | redirect_url is a relative path | Pass an absolute http(s) URL or an app scheme. |
400 on GET /v1/connect/shopify | shop is not a myshopify.com store domain | Pass your-store.myshopify.com or the bare your-store prefix. |
400 on a Blogs endpoint | blogId is not numeric, or the account is on a platform without blogs | Read the id from List blogs; use a Shopify account. |
409 reconnect_required | The store was connected before pixel access existed (or with a custom-app token) and a pixel install was called | Send the merchant to details.authUrl; the account keeps its id. |
409 invalid_resource_state | Uninstalling a tag while the store fires a different tag of that platform | Read the install status; uninstall the tag in installedTagId. |
403 insufficient_permissions | Shopify rejected the request, or the store was connected before product access existed and a Products endpoint was called | Reconnect the store: the consent screen grants the missing scopes and the account keeps its id. |
404 blog_article_not_found | The article was deleted, or the id belongs to another blog | Deletes are permanent; list the blog's articles to find a current id. |
404 product_not_found | The product was deleted or the id belongs to another store | List the store's products to find a current id. |
405 | The platform lacks this specific Blogs operation | Not available for this account. |
429 | Rate limited by Zernio or by Shopify | Retry later. See rate limits. |
Deleting an article returns 204, and a later read of it returns 404:
{
"error": "Article not found",
"type": "not_found",
"code": "blog_article_not_found"
}Zernio stores nothing to restore it from. Error handling covers the envelope.
Related
- Connecting accounts: the OAuth and Admin-token paths.
- Blogs API: every blog and article endpoint.
- Commerce API: products, stock, collections, discounts and Meta catalog sync.
- Pixels and tracking tags: the tags you can install, per ad platform.
- Get Shopify OAuth connect URL: every parameter of the connect call.
- Connect with an Admin token: the token-paste alternative.
- Platforms overview: the 16 posting platforms.
OpenAI Ads
Run ChatGPT ads on an openaiads account, from connecting an API key to creating chat card campaigns, pixels and server-side conversions.
WordPress
Connect WordPress.com or a self-hosted WordPress site, then create, schedule, update and delete its articles through the Blogs API, manage a WooCommerce store through the Commerce API and install ad platform tracking tags.