Publish a Shopify Product to a Sales Channel via API

To publish a Shopify product to a sales channel with the API, call the Admin GraphQL publishablePublish mutation with the product’s GID and that channel’s publication ID, from an app holding read_publications and write_publications. Setting status: ACTIVE does not do this. A buyer only sees a product that is both active and published.

Everything below is written against Admin GraphQL 2026-10, as of November 2026. Shopify’s product publishing guide shows the same operations against 2026-07.

Why is my ACTIVE product missing from the channel?

Status and publication are two separate switches. The ProductStatus reference says it directly: “Products with an active status aren’t automatically published to sales channels, such as the online store, or apps.”

The productCreate docs say it creates products in an unpublished state by default and that publishing is a separate operation. ProductSetInput has no publication field at all, so an import built on our productSet upsert pattern still needs the extra step, or at least a check.

The CSV importer behaves differently, so moving an import from a spreadsheet to a script can change the outcome. Its Published on online store column defaults to true, which puts imported products on the storefront without anyone asking. That column names the Online Store and no other channel.

We could not settle one thing from the docs. Publication.autoPublish is described as whether new products are automatically published to that publication, which sits awkwardly beside “unpublished state by default”. We have not tested which wins for API-created products on a live store, so read the state back instead of assuming.

On a headless build the symptom looks like a bad handle. The Storefront API product reference says “Unpublished products will behave just like they were archived or deleted”: missing from connections, not found by handle or ID. You get null and no error. A Shopify staff reply on the developer forum gives the same first diagnosis for collections coming back with a count of zero when the token’s scopes are correct. Sales channel apps have a stricter rule again, because the product must be published to the channel and to the market being queried.

How do I find the publication ID?

Ask the shop. Every sales channel has a catalog, and each catalog can have a publication:

query {
  publications(first: 100, catalogType: APP) {
    nodes { id catalog { title } }
  }
}

catalogType: APP leaves out market and B2B company location catalogs, which have publications of their own. The name field you will see in older forum answers is deprecated on Publication, so match on catalog.title. That field is nullable, so handle a publication with no catalog instead of crashing on it.

Look the ID up per shop rather than copying one from a development store into production config.

How do I publish a product to a sales channel with the API?

mutation Publish($id: ID!, $publicationId: ID!) {
  publishablePublish(id: $id, input: [{ publicationId: $publicationId }]) {
    publishable { publishedOnPublication(publicationId: $publicationId) }
    userErrors { field message }
  }
}

Selecting publishedOnPublication in the payload gives you the read-back in the same round trip. The guide says that publishing to a publication the product is already on succeeds with no change, so a retry is safe.

The mutation reference adds a few limits. On top of write_publications, the acting user needs permission to create and edit products or catalogs, which matters if you use online access tokens. Products with requiresSellingPlan: true can only go to online stores. input takes an array, so one call can cover several channels, and PublicationInput has a publishDate for scheduling, which only online store channels support.

The same mutation takes a Collection or ProductVariant GID, so an import that creates collections has the same step to do for them.

If you think in the other direction, one channel and many products, publicationUpdate takes publishablesToAdd, capped at 50 per call.

How do I backfill products that were never published?

A scripted migration can leave a whole catalogue active and off the channel. The script below walks every active product, reports the ones missing from a named channel, and publishes them only when you pass --apply.

// Usage: SHOP=x.myshopify.com TOKEN=shpat_x node publish-missing.mjs "Online Store" [--apply]
const { SHOP, TOKEN } = process.env;
const [title, mode] = process.argv.slice(2);
if (!SHOP || !TOKEN || !title) {
  console.error('Set SHOP and TOKEN, and pass a channel title.');
  process.exit(1);
}
const apply = mode === '--apply';

async function admin(query, variables = {}, attempt = 0) {
  const res = await fetch(`https://${SHOP}/admin/api/2026-10/graphql.json`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  const body = await res.json();
  const throttled = body.errors?.some((e) => e.extensions?.code === 'THROTTLED');
  if (throttled && attempt < 5) {
    await new Promise((done) => setTimeout(done, 2000 * (attempt + 1)));
    return admin(query, variables, attempt + 1);
  }
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const PUBLICATIONS = `query {
  publications(first: 100, catalogType: APP) {
    nodes { id catalog { title } }
  }
}`;

const PRODUCTS = `query Products($publicationId: ID!, $after: String) {
  products(first: 100, after: $after, query: "status:active") {
    pageInfo { hasNextPage endCursor }
    nodes { id handle publishedOnPublication(publicationId: $publicationId) }
  }
}`;

const PUBLISH = `mutation Publish($id: ID!, $publicationId: ID!) {
  publishablePublish(id: $id, input: [{ publicationId: $publicationId }]) {
    publishable { publishedOnPublication(publicationId: $publicationId) }
    userErrors { field message }
  }
}`;

const { publications } = await admin(PUBLICATIONS);
const match = publications.nodes.find((p) => p.catalog?.title === title);
if (!match) {
  const found = publications.nodes.map((p) => `${p.id} ${p.catalog?.title ?? '(no catalog)'}`);
  throw new Error(`No publication titled "${title}". Found:\n${found.join('\n')}`);
}

let after = null;
let missing = 0;
let failed = 0;
do {
  const { products } = await admin(PRODUCTS, { publicationId: match.id, after });
  for (const product of products.nodes) {
    if (product.publishedOnPublication) continue;
    missing += 1;
    if (!apply) {
      console.log(`would publish ${product.handle}`);
      continue;
    }
    const { publishablePublish: result } = await admin(PUBLISH, {
      id: product.id,
      publicationId: match.id,
    });
    const live = result.publishable?.publishedOnPublication;
    if (result.userErrors.length || !live) {
      failed += 1;
      console.error(product.handle, JSON.stringify(result.userErrors));
    }
  }
  after = products.pageInfo.hasNextPage ? products.pageInfo.endCursor : null;
} while (after);

console.log(`${missing} missing from ${title}, ${failed} failed, apply=${apply}`);
process.exitCode = failed ? 1 : 0;

Run it without --apply and read the list first. It cannot tell a forgotten product from one a merchant kept off that channel on purpose, or from one scheduled for a future launch, and we found nothing in the docs on what publishing over a pending schedule does. The script has no exclude list, so if the dry run shows any of those, narrow the query string in PRODUCTS (a tag or vendor filter, say) before you apply. The retry is deliberately crude, a growing wait on THROTTLED and nothing else. If your catalogue is large, our GraphQL rate limit notes cover budgeting by query cost.

What we actually ran: the script under Node 24.12 with fetch stubbed, covering pagination, a throttled retry, a user error, a false read-back, an unknown channel title and a publication with no catalog. The GraphQL operations in this post passed schema validation for 2026-10. Neither the script nor the queries have been run against a live store, so the stub proves the control flow and says nothing about how Shopify responds. Treat your first run on a development store as the real test.

How do I check why one product is not showing?

query WhyHidden($id: ID!) {
  product(id: $id) {
    status
    onlineStoreUrl
    resourcePublicationsV2(first: 20, onlyPublished: false) {
      nodes {
        isPublished
        publishDate
        publication { id catalog { title } }
      }
    }
    unpublishedPublications(first: 20) {
      nodes { id catalog { title } }
    }
  }
}

Read it in order. A status other than ACTIVE is your answer (the guide also allows Unlisted on supported channels). A null onlineStoreUrl means the product is not on the Online Store. onlyPublished: false includes scheduled entries, so a node with isPublished: false and a future publishDate means the product is scheduled.

If all of that looks right, go down a level. Variants now implement Publishable as well, and the guide’s visibility rules need the product active, the product published and the variant published. A variant created with published: false stays hidden on a published product, and publishing the product will not change it, because the two states are independent.

For a store-wide audit, use the published_status filter on the products query. status:active AND published_status:unpublished finds active products off the Online Store. Swap in published_status:pos-hidden for Point of Sale, or published_status:unavailable for products on no channel at all.

What does “Your app doesn’t have a publication for this shop” mean?

We have not reproduced this one, so read this as a likely cause. The report we found is a community thread, unresolved, where the poster says the product did publish and the error came back anyway. Their mutation passed an Online Store publication ID but selected publishedOnCurrentPublication in the payload. Going by its name, that field asks about the requesting app’s own publication. An app that is not a sales channel would have none, which would explain a successful publish alongside the error, though the thread never confirms it.

The change costs nothing either way. publishedOnCurrentPublication is deprecated, as is publishablePublishToCurrentChannel. Pass an explicit publication ID and select publishedOnPublication(publicationId:). The guide tells sales channel apps to do the same for their own channel.

What we would do

Make publishing a named step in any product pipeline, straight after the write and before anything reports success, and fail the job when the read-back is false. On a catalogue migration, run the backfill as a dry run before cutover and compare its count with what the merchant expects to sell on each channel.

For a one-off batch of a few dozen products, do not write any of this. Select them in the admin and bulk-edit their availability. And if a CSV import already meets the need and only the Online Store matters, keep the CSV.

We build import pipelines and cutover checks like these as part of our Shopify development work.

Need this built properly?

Get in touch