Publish back-in-stock products, with Mechanic.

Mechanic is a development and ecommerce automation platform for Shopify. :)

Publish back-in-stock products

This task monitors inventory updates, and publishes the product to the configured sales channels whenever a product's total inventory meets your "back in stock" threshold. Optionally, it'll send you an email when it does so. You may also choose whether to further refine the products being considered by this task by configuring inclusion or exclusion tags (note: exclusion tags will always take precedence over inclusion tags).

Runs Occurs whenever an inventory level is updated and Occurs when a user manually triggers the task. Configuration includes back in stock inventory quantity, sales channel names, only include products with any of these tags, always exclude products with any of these tags, only include inventory from these location names, and email notification recipient.

15-day free trial – unlimited tasks

Documentation

This task monitors inventory updates, and publishes the product to the configured sales channels whenever a product's total inventory meets your "back in stock" threshold. Optionally, it'll send you an email when it does so. You may also choose whether to further refine the products being considered by this task by configuring inclusion or exclusion tags (note: exclusion tags will always take precedence over inclusion tags).

If you'd like for the task to only count inventory from specific locations, then add the exact location names into the task configuration. This feature can be combined with the tag options for unique publishing scenarios.

This task can also be run manually, to scan all products in the shop.

Developer details

Mechanic is designed to benefit everybody: merchants, customers, developers, agencies, Shopifolks, everybody.

That’s why we make it easy to configure automation without code, why we make it easy to tweak the underlying code once tasks are installed, and why we publish it all here for everyone to learn from.

(By the way, have you seen our documentation? Have you joined the Slack community?)

Open source
View on GitHub to contribute to this task
Subscriptions
shopify/inventory_levels/update
mechanic/user/trigger
Tasks use subscriptions to sign up for specific kinds of events. Learn more
Options
back in stock inventory quantity (number, required), sales channel names (required, array), only include products with any of these tags (array), always exclude products with any of these tags (array), only include inventory from these location names (array), email notification recipient (email)
Code
{% assign back_in_stock_inventory_quantity = options.back_in_stock_inventory_quantity__number_required %}
{% assign sales_channel_names = options.sales_channel_names__required_array %}
{% assign inclusion_tags = options.only_include_products_with_any_of_these_tags__array %}
{% assign exclusion_tags = options.always_exclude_products_with_any_of_these_tags__array %}
{% assign location_names = options.only_include_inventory_from_these_location_names__array %}
{% assign email_notification_recipient = options.email_notification_recipient__email %}

{% comment %}
  -- get all of the sales channels in the shop; no known sales channel limit, so just use query max of 250
  -- NOTE: publication name is deprecated (as of Apil 2023), but the Shopify offered solution of using the catalog title currently returns null
{% endcomment %}

{% capture query %}
  query {
    publications(first: 250) {
      nodes {
        id
        name
      }
    }
  }
{% endcapture %}

{% assign result = query | shopify %}

{% if event.preview %}
  {% capture result_json %}
    {
      "data": {
        "publications": {
          "nodes": [
            {
              "id": "gid://shopify/Publication/1234567890",
              "name": {{ sales_channel_names.first | json }}
            }
          ]
        }
      }
    }
  {% endcapture %}

  {% assign result = result_json | parse_json %}
{% endif %}

{% assign publication_ids = array %}
{% assign publication_names_by_id = hash %}

{% comment %}
  -- add the configured publication names into a hash for use in lookups
{% endcomment %}

{% for publication in result.data.publications.nodes %}
  {% if sales_channel_names contains publication.name %}
    {% assign publication_ids[publication_ids.size] = publication.id %}
    {% assign publication_names_by_id[publication.id] = publication.name %}
  {% endif %}
{% endfor %}

{% comment %}
  -- make sure the configured sales channel names match what is in the shop
{% endcomment %}

{% unless event.preview %}
  {% assign available_channels = result.data.publications.nodes | map: "name" %}

  {% if publication_ids.size != sales_channel_names.size %}
    {% error
      message: "Each sales channel configured in this task must exist in the shop. Check the list of available channels and verify each configured channel exists.",
      available_sales_channel_names: available_channels,
      configured_sales_channel_names: sales_channel_names
    %}

    {% break %}
  {% endif %}
{% endunless %}

{% if location_names != blank %}
  {% comment %}
    -- get all of the locations in the shop; Shopify supports 1000 as of July 2023
  {% endcomment %}

  {% assign cursor = nil %}
  {% assign locations = array %}

  {% for n in (1..10) %}
    {% capture query %}
      query {
        locations(
          first: 250
          after: {{ cursor | json }}
          sortKey: NAME
        ) {
          pageInfo {
            hasNextPage
            endCursor
          }
          nodes {
            id
            name
          }
        }
      }
    {% endcapture %}

    {% assign result = query | shopify %}

    {% if event.preview %}
      {% capture result_json %}
        {
          "data": {
            "locations": {
              "nodes": [
                {
                  "id": "gid://shopify/Location/1234567890",
                  "name": {{ location_names.first | json }}
                }
              ]
            }
          }
        }
      {% endcapture %}

      {% assign result = result_json | parse_json %}
    {% endif %}

    {% assign locations = locations | concat: result.data.locations.nodes %}

    {% if result.data.locations.pageInfo.hasNextPage %}
      {% assign cursor = result.data.locations.pageInfo.endCursor %}
    {% else %}
      {% break %}
    {% endif %}
  {% endfor %}

  {% assign location_ids = array %}

  {% comment %}
    -- save the location IDs that map to the configured location names
  {% endcomment %}

  {% for location in locations %}
    {% if location_names contains location.name %}
      {% assign location_ids[location_ids.size] = location.id %}
    {% endif %}
  {% endfor %}

  {% comment %}
    -- make sure the configured location names match what is in the shop
  {% endcomment %}

  {% unless event.preview %}
    {% if location_ids.size != location_names.size %}
      {% assign available_location_names = locations | map: "name" %}

      {% error
        message: "Each location name configured in this task must exactly match a location name in the shop. Check the list of available locations and verify each configured location exists.",
        available_location_names: available_location_names,
        configured_location_names: location_names
      %}

      {% break %}
    {% endif %}
  {% endunless %}
{% endif %}

{% if event.topic == "mechanic/user/trigger" %}
  {% comment %}
    -- use an inventory filter in the products search query only if no location names have been configured in the task
  {% endcomment %}

  {% if location_names == blank %}
    {% assign inventory_filter = back_in_stock_inventory_quantity | prepend: "inventory_total:>=" %}
  {% endif %}

  {% comment %}
    -- if inclusion or exclusion tags are configured, use them to refine the search query in order to reduce the number of products to be processed
  {% endcomment %}

  {% if inclusion_tags != blank %}
    {% assign inclusion_tag_filters = array %}

    {% for inclusion_tag in inclusion_tags %}
      {% assign inclusion_tag_filter
        = inclusion_tag
        | json
        | prepend: "tag:"
      %}
      {% assign inclusion_tag_filters = inclusion_tag_filters | push: inclusion_tag_filter %}
    {% endfor %}

    {% if inclusion_tag_filters.size > 1 %}
      {% capture inclusion_tag_filters -%}
        ({{ inclusion_tag_filters | join: " OR " }})
      {%- endcapture %}
    {% endif %}
  {% endif %}

  {% if exclusion_tags != blank %}
    {% assign exclusion_tag_filters = array %}

    {% for exclusion_tag in exclusion_tags %}
      {% assign exclusion_tag_filter
        = exclusion_tag
        | json
        | prepend: "tag_not:"
      %}
      {% assign exclusion_tag_filters = exclusion_tag_filters | push: exclusion_tag_filter %}
    {% endfor %}

    {% if exclusion_tag_filters.size > 1 %}
      {% capture exclusion_tag_filters -%}
        {{ exclusion_tag_filters | join: " AND " }}
      {%- endcapture %}
    {% endif %}
  {% endif %}

  {% comment %}
    -- recombine any search filters for use in the products query
  {% endcomment %}

  {% assign search_query
    = array
    | push: inventory_filter, inclusion_tag_filters, exclusion_tag_filters
    | compact
    | join: " AND "
  %}

  {% log products_search_query: search_query %}

  {% comment %}
    -- use paginated query to get all products in the shop that match the optional search filters
  {% endcomment %}

  {% assign products = array %}
  {% assign cursor = nil %}

  {% for n in (0..100) %}
    {% capture query %}
      query {
        products(
          first: 250
          after: {{ cursor | json }}
          query: {{ search_query | json }}
        ) {
          pageInfo {
            hasNextPage
            endCursor
          }
          nodes {
            id
            legacyResourceId
            title
            totalInventory
            tags
            {% for publication_id in publication_ids %}
              published{{ forloop.index }}: publishedOnPublication(publicationId: {{ publication_id | json }})
            {% endfor %}
          }
        }
      }
    {% endcapture %}

    {% assign result = query | shopify %}

    {% if event.preview %}
      {% capture result_json %}
        {
          "data": {
            "products": {
              "nodes": [
                {
                  "id": "gid://shopify/Product/1234567890",
                  "legacyResourceId": "1234567890",
                  "title": "Widget",
                  "totalInventory": {{ back_in_stock_inventory_quantity }},
                  "published1": false
                }
              ]
            }
          }
        }
      {% endcapture %}

      {% assign result = result_json | parse_json %}
    {% endif %}

    {% assign products = products | concat: result.data.products.nodes %}

    {% if result.data.products.pageInfo.hasNextPage %}
      {% assign cursor = result.data.products.pageInfo.endCursor %}
    {% else %}
      {% break %}
    {% endif %}
  {% endfor %}

{% elsif event.topic contains "shopify/inventory_levels/" %}
  {% comment %}
    -- query the inventory level to get the product data needed for this task
  {% endcomment %}

  {% capture query %}
    query {
      inventoryLevel(
        id: {{ inventory_level.admin_graphql_api_id | json }}
      ) {
        item {
          variant {
            product {
              id
              legacyResourceId
              title
              totalInventory
              tags
              {% for publication_id in publication_ids %}
                published{{ forloop.index }}: publishedOnPublication(publicationId: {{ publication_id | json }})
              {% endfor %}
            }
          }
        }
      }
    }
  {% endcapture %}

  {% assign result = query | shopify %}

  {% if event.preview %}
    {% capture result_json %}
      {
        "data": {
          "inventoryLevel": {
            "item": {
              "variant": {
                "product": {
                  "id": "gid://shopify/Product/1234567890",
                  "legacyResourceId": "1234567890",
                  "title": "Widget",
                  "totalInventory": {{ back_in_stock_inventory_quantity }},
                  "tags": {{ inclusion_tags.first | json }},
                  "published1": false
                }
              }
            }
          }
        }
      }
    {% endcapture %}

    {% assign result = result_json | parse_json %}
  {% endif %}

  {% assign product = result.data.inventoryLevel.item.variant.product %}

  {% comment %}
    -- check to see if product is included or excluded (i.e. whether to process) by any configured tags
  {% endcomment %}

  {% assign product_included_by_tag = nil %}
  {% assign product_excluded_by_tag = nil %}

  {% if inclusion_tags != blank %}
    {% for inclusion_tag in inclusion_tags %}
      {% if product.tags contains inclusion_tag %}
        {% assign product_included_by_tag = true %}
      {% endif %}
    {% endfor %}
  {% endif %}

  {% if exclusion_tags != blank %}
    {% for exclusion_tag in exclusion_tags %}
      {% if product.tags contains exclusion_tag %}
        {% assign product_excluded_by_tag = true %}
      {% endif %}
    {% endfor %}
  {% endif %}

  {% comment %}
    -- check if a product has been excluded first, then only if there are inclusion tags configured check if it has been included
  {% endcomment %}

  {% if product_excluded_by_tag %}
    {% log
      message: "Product was excluded by a configured tag and will not be processed by this task.",
      exclusion_tags: exclusion_tags,
      product: product
    %}
    {% break %}

  {% elsif inclusion_tags != blank %}
    {% unless product_included_by_tag %}
      {% log
        message: "Product was not included by any configured tag and will not be processed by this task.",
        inclusion_tags: inclusion_tags,
        product: product
      %}
      {% break %}
    {% endunless %}
  {% endif %}

  {% comment %}
    -- product qualifies to be processed in main product loop
  {% endcomment %}

  {% assign products = array | push: product %}
{% endif %}

{% comment %}
  -- process products, publishing as needed and saving links for optional email output
{% endcomment %}

{% assign published_product_links = array %}

{% for product in products %}
  {% assign mutations = array %}
  {% assign publication_names = array %}

  {% if location_names == blank %}
    {% comment %}
      -- any products returned in the query will have met the back in stock quantity threshold due to the search filter, so can just output the total inventory
    {% endcomment %}

    {% assign summed_inventory = product.totalInventory %}

  {% else %}
    {% assign summed_inventory = 0 %}

    {% comment %}
      -- query and sum available inventory across the configured locations to determine stock status
      -- to avoid exceeding api query cost, first get the inventory item IDs
    {% endcomment %}

    {% capture query %}
      query {
        product(id: {{ product.id | json }}) {
          id
          variants(first: 100) {
            nodes {
              inventoryItem {
                id
              }
            }
          }
        }
      }
    {% endcapture %}

    {% assign result = query | shopify %}

    {% if event.preview %}
      {% capture result_json %}
        {
          "data": {
            "product": {
              "id": "gid://shopify/Product/1234567890",
              "variants": {
                "nodes": [
                  {
                    "inventoryItem": {
                      "id": "gid://shopify/InventoryItem/1234567890"
                    }
                  }
                ]
              }
            }
          }
        }
      {% endcapture %}

      {% assign result = result_json | parse_json %}
    {% endif %}

    {% assign inventory_item_ids
      = result.data.product.variants.nodes
      | map: "inventoryItem"
      | map: "id"
    %}

    {% comment %}
      -- loop through inventory items and get available quantity for each configured location
    {% endcomment %}

    {% for inventory_item_id in inventory_item_ids %}
      {% capture query %}
        query {
          inventoryItem(id: {{ inventory_item_id | json }}) {
            {% for location_id in location_ids %}
              inventory_level_{{ forloop.index }}: inventoryLevel(locationId: {{ location_id | json }}) {
                quantities(names: "available") {
                  quantity
                }
              }
            {% endfor %}
          }
        }
      {% endcapture %}

      {% assign result = query | shopify %}

      {% if event.preview %}
        {% capture result_json %}
          {
            "data": {
              "inventoryItem": {
                "inventory_level_1": {
                  "quantities": [
                    {
                      "quantity": {{ back_in_stock_inventory_quantity }}
                    }
                  ]
                },
                "inventory_level_2": null
              }
            }
          }
        {% endcapture %}

        {% assign result = result_json | parse_json %}
      {% endif %}

      {% for item in result.data.inventoryItem %}
        {% assign summed_inventory = summed_inventory | plus: item[1].quantities.first.quantity %}
      {% endfor %}
    {% endfor %}
  {% endif %}

  {% if summed_inventory < back_in_stock_inventory_quantity %}
    {% comment %}
      -- the summed inventory across the configured locations is less than the back in stock threshold; skip this product
    {% endcomment %}

    {% continue %}
  {% endif %}

  {% comment %}
    -- capture publish mutation for each sales channel a product should be published on
  {% endcomment %}

  {% for publication_id in publication_ids %}
    {% assign key = "published" | append: forloop.index %}

    {% if product[key] == true %}
      {% continue %}
    {% endif %}

    {% assign publication_names[publication_names.size] = publication_names_by_id[publication_id] %}

    {% capture mutation %}
      publishablePublish{{ forloop.index}}: publishablePublish(
        id: {{ product.id | json }}
        input: {
          publicationId: {{ publication_id | json }}
        }
      ) {
        userErrors {
          field
          message
        }
      }
    {% endcapture %}

    {% assign mutations[mutations.size] = mutation %}
  {% endfor %}

  {% comment %}
    -- if there are any publishing actions to take, then do so and generate a notification email if a recipient is configured
  {% endcomment %}

  {% if mutations != empty %}
    {% action "shopify" %}
      mutation {
        {{ mutations | join: newline }}
      }
    {% endaction %}

    {% comment %}
      -- if this task was triggered by an inventory level event, then send email right away; otherwise save admin links for each product that was published
    {% endcomment %}

    {% if event.topic contains "shopify/inventory_levels/" %}
      {% if email_notification_recipient != blank %}
        {% capture email_subject %}
          Back in stock: {{ product.title }}
        {% endcapture %}

        {% capture email_body %}
          Hi there,

          Your product is back in stock{% if location_names != blank %} at the configured locations{% else %} across all locations{% endif %}! This product has been published to: {{ publication_names | join: ", " }}.

          <a href="https://{{ shop.domain }}/admin/products/{{ product.legacyResourceId }}">Manage this product</a>

          Thanks,
          - Mechanic, for {{ shop.name }}
        {% endcapture %}

        {% action "email" %}
          {
            "to": {{ email_notification_recipient | json }},
            "subject": {{ email_subject | unindent | strip | json }},
            "body": {{ email_body | unindent | strip | newline_to_br | json }}
          }
        {% endaction %}
      {% endif %}

    {% else %}
      {% capture link -%}
        <li><a href="https://{{ shop.domain }}/admin/products/{{ product.legacyResourceId }}">{{ product.title }}</a> ({{ summed_inventory }} - published to {{ publication_names | join: ", " }})</li>
      {%- endcapture %}

      {% assign published_product_links[published_product_links.size] = link %}
    {% endif %}
  {% endif %}
{% endfor %}

{% comment %}
  -- for manually triggered task runs, send notification email if any publishing actions were taken AND if there is an email notification recipient configured
{% endcomment %}

{% if published_product_links == blank %}
  {% unless event.topic contains "shopify/inventory_levels" %}
    {% log "No products qualified to be published during this task run." %}
  {% endunless %}

  {% break %}
{% endif %}

{% if email_notification_recipient != blank %}
  {% capture email_subject %}
    Found {{ published_product_links.size }} {{ published_product_links.size | pluralize: "product", "products" }} back in stock
  {% endcapture %}

  {% capture email_body %}
    Hi there,
    <br><br>
    These products were found to be at or above your back in stock minimum quantity ({{ back_in_stock_inventory_quantity }}), when adding up the inventory for each product{% if location_names != blank %} at the configured locations{% else %} across all locations{% endif %}.
    <br>
    <ul>{{ published_product_links | join: "" }}</ul>
    <br>
    Thanks,
    <br>
    - Mechanic, for {{ shop.name }}
  {% endcapture %}

  {% action "email" %}
    {
      "to": {{ email_notification_recipient | json }},
      "subject": {{ email_subject | unindent | strip | json }},
      "body": {{ email_body | unindent | strip | json }}
    }
  {% endaction %}
{% endif %}
Task code is written in Mechanic Liquid, an extension of open-source Liquid enhanced for automation. Learn more
Defaults
Back in stock inventory quantity
1
Sales channel names
["Online Store"]