Shopify

Shopify Custom Actions

Give your bot direct access to the Shopify Admin API — cancel orders, edit addresses, issue refunds, look up customers — by writing Python actions that borrow your store's own credentials.

Anything you want your bot to do in Shopify — look up a customer, read an order, cancel it, edit a shipping address, issue a partial refund, apply a discount, read metafields, tag an order — you build as a Python custom action.

There is no catalogue of pre-built Shopify operations to pick from, and that's deliberate. A wrapper per operation is always a step behind what you actually need. Instead, Octocom hands your action the store's own credentials and gets out of the way.

So the rule of thumb is simple: if the Shopify Admin API can do it, your bot can do it. No allowlist, no feature gate. The only ceiling is the scopes your Shopify integration was granted.

This page is the playbook.


Start here: have Copilot or MCP build it

You don't have to write any of this by hand. Both Octocom Copilot and an agent connected to the Octocom MCP can create, edit, and test custom actions for you — this is the fastest way to go from "the bot should be able to do X" to a working action.

Describe the outcome in plain language:

"Create a Python action called updateShopifyOrderAddress that takes an order ID and a new shipping address, refuses if the order is already fulfilled, updates the shipping address via the Shopify Admin API, and tags the conversation address-changed. Fetch credentials with get_integration_credentials."

The agent writes the code, creates the action, and can run a test against a real order. Because it can also read your workflows, bot rules, and past conversations, the most productive loop is to point it at a real conversation that went wrong and ask it to build the action that would have fixed it.

Every change is scoped to your organisation, and in Copilot every change requires your approval before it is applied. See Octocom Copilot and Octocom MCP for setup.

The rest of this page is what the agent is writing on your behalf — worth understanding so you can review its work, and necessary if you'd rather write it yourself.


Getting at the Admin API

Fetch the store's credentials at runtime with get_integration_credentials(context), then call Shopify directly.

The helper returns a dict keyed by business slug. Each business that has Shopify installed exposes its domain and accessToken:

def _get_shopify_creds(context):
    """Return (domain, access_token) for the business this conversation belongs to."""
    slug = (context.get("business") or {}).get("slug")
    if not slug:
        return None, None
    creds = get_integration_credentials(context) or {}
    shopify = (creds.get(slug) or {}).get("shopify") or {}
    return (shopify.get("domain") or None), (shopify.get("accessToken") or None)

Never hardcode a Shopify token in an action. Fetching at runtime means the action keeps working when the integration is reinstalled or the token rotates, and the same code can be shared across several businesses without edits.

Once you have the pair, both the REST and GraphQL Admin APIs are open to you:

import requests

SHOPIFY_API_VERSION = "2025-01"

def _headers(token):
    return {
        "X-Shopify-Access-Token": token,
        "Accept": "application/json",
        "Content-Type": "application/json",
    }

def execute_action(context):
    domain, token = _get_shopify_creds(context)
    if not domain or not token:
        return {"success": False, "error": "Shopify is not connected for this business."}

    order_id = context["args"]["orderId"]

    # REST
    resp = requests.get(
        f"https://{domain}/admin/api/{SHOPIFY_API_VERSION}/orders/{order_id}.json",
        headers=_headers(token),
        timeout=15,
    )

    # ...or GraphQL
    gql = requests.post(
        f"https://{domain}/admin/api/{SHOPIFY_API_VERSION}/graphql.json",
        headers=_headers(token),
        json={"query": "...", "variables": {}},
        timeout=20,
    )

Multi-store businesses

If a business has more than one Shopify store connected, the credentials payload also carries a stores list with every connected store (oldest first). The top-level domain / accessToken mirror the first store, so single-store code keeps working unchanged — but if you run multiple storefronts under one business, iterate stores instead of assuming the top-level pair:

shopify = (creds.get(slug) or {}).get("shopify") or {}
for store in shopify.get("stores") or []:
    domain, token = store["domain"], store["accessToken"]
    # try each store until you find the order

A worked example: cancelling an unfulfilled order

This is the shape most write actions end up taking — look up state, check guards, act, report back in terms the bot can use.

import requests

SHOPIFY_API_VERSION = "2025-01"

CANCEL_MUTATION = """
mutation OrderCancel($orderId: ID!, $refundMethod: OrderCancelRefundMethodInput!,
                     $restock: Boolean!, $reason: OrderCancelReason!, $notifyCustomer: Boolean) {
  orderCancel(orderId: $orderId, refundMethod: $refundMethod, restock: $restock,
              reason: $reason, notifyCustomer: $notifyCustomer) {
    job { id done }
    orderCancelUserErrors { field message code }
  }
}
""".strip()

def execute_action(context):
    order_id = str(context["args"]["orderId"]).strip()
    domain, token = _get_shopify_creds(context)
    if not domain or not token:
        return {"success": False, "error": "Shopify is not connected for this business."}

    headers = _headers(token)
    base = f"https://{domain}/admin/api/{SHOPIFY_API_VERSION}"

    # 1. Read the order first — never act blind.
    resp = requests.get(f"{base}/orders/{order_id}.json", headers=headers, timeout=15)
    if resp.status_code == 404:
        return {"success": False, "error": f"Order {order_id} not found."}
    order = resp.json().get("order") or {}

    # 2. Guards. Refuse anything ambiguous rather than guessing.
    if order.get("cancelled_at"):
        return {"success": False, "error": "This order is already cancelled."}

    if order.get("fulfillment_status") not in (None, "", "unfulfilled"):
        return {
            "success": False,
            "error": "This order has already shipped and cannot be cancelled.",
        }

    # 3. Act.
    result = requests.post(
        f"{base}/graphql.json",
        headers=headers,
        json={
            "query": CANCEL_MUTATION,
            "variables": {
                "orderId": f"gid://shopify/Order/{order['id']}",
                "refundMethod": {"originalPaymentMethodsRefund": True},
                "restock": True,
                "reason": "CUSTOMER",
                "notifyCustomer": True,
            },
        },
        timeout=20,
    ).json()

    errors = (((result.get("data") or {}).get("orderCancel") or {})
              .get("orderCancelUserErrors")) or []
    if errors:
        return {"success": False, "error": "Shopify refused the cancellation."}

    # 4. Report structured, customer-safe output.
    add_conversation_tag(context, "order-cancelled")
    return {
        "success": True,
        "order_name": order.get("name"),
        "refunded_to_original_payment": True,
    }

Notice the pattern: the action decides, not the bot. The workflow instruction is just "call cancelShopifyOrder with the order ID" — every rule about what is and isn't cancellable lives in Python, where it can't be argued out of by a persistent customer.


Test with real test orders

This is the part most people skip, and it's the part that matters. Shopify write actions are irreversible — a cancelled order stays cancelled, a refund stays refunded. Test against orders you created on purpose.

The loop:

  1. Create a test order in Shopify. Use a draft order, a 100%-off discount code, or Shopify's Bogus Gateway so no real money moves. Make it look like the real thing: right product, right variant, right fulfilment state.
  2. Run the action from the dashboard. Open the action, fill in the test arguments (the order ID), and click Run Test. You get the raw return value back, plus any error.
  3. Check Shopify. Confirm the order actually changed the way you expected — not just that the action returned success: True.
  4. Test the guards, not just the happy path. Run it against an already-fulfilled order, an already-cancelled order, and a nonexistent order ID. Each should return a clean, readable error rather than a stack trace.
  5. Then test it through the bot. Enable the action in a workflow and drive a real conversation at it. Passing a direct test only proves the code works; it doesn't prove the bot calls it at the right moment with the right arguments.

If your action reads conversation data — metadata, tags, message history — pass a Conversation ID in the test panel so context["conversation"] is populated with real data. Keep a test conversation open in another tab, send messages to set up the state you want, then re-run.

See Testing for the full test-panel reference.


Guardrails worth building in

Hard-won patterns from stores running Shopify write actions in production:

  • Read before you write. Always GET the order and check its state before mutating it. Fulfilment status, cancellation status, and financial status all change what's safe to do.
  • Refuse ambiguity. If the customer has three active subscriptions and you can't tell which one they mean, don't guess — return an error and let the bot hand off. A wrong write costs more than a handoff.
  • Carve out the cases that must never be automated. Prepaid orders, wholesale orders, orders under manual review. Detect them explicitly and return a "hand off to a human" result rather than proceeding.
  • Make actions idempotent where you can. If the change has already been applied, return success with an "already done" flag instead of applying it twice.
  • Never fail a completed write. If step 1 succeeded and step 2 failed, return success: True with a flag telling the bot a human needs to finish the job. The bot must not tell the customer the cancellation failed when the money has already moved.
  • Return customer-safe output. Whatever you return may end up in the reply. Don't leak internal SKUs, error strings, or reasons — return a clean summary and, if a handoff is needed, an explicit instruction for what the bot should say.
  • Never log or return credentials. The access token stays inside the action.
  • Always set a timeout on every request.

Next steps

On this page