Hi. Here is my solution fabricated with the aid of Claude and it worked!
How to Set Up a Properly Scoped Shopify API Token for CLI Scripts
Background
I run a series of Python CLI scripts to manage my Shopify store — bulk product updates, pricing, metafields, draft orders etc. I needed a robust way to get a properly scoped API token that could be refreshed automatically every 24 hours.
This guide covers the full setup including the gotchas I ran into along the way. Hopefully it saves someone else the same pain.
The Problem
Shopify custom app tokens expire every 24 hours. There are two ways to get a new one:
- Client credentials flow — simple POST request, no browser needed, easy to automate
- OAuth authorization code flow — requires browser interaction, but gives a properly scoped token
The catch: the client credentials flow only works correctly if your app is not using the legacy install flow. If you’re getting a token back with empty scopes, that’s why.
Step 1 — Create a Custom App in the Partners Dashboard
- Go to https://partners.shopify.com
- Apps → Create app → Start from Dev Dashboard
- Enter an app name and click Create
Configure the app
In the Configuration page:
App URL:
http://localhost:3456
Scopes — paste into the Scopes field (adjust to your needs):
read_all_orders,write_checkouts,read_checkouts,read_customers,write_customers,
read_price_rules,write_price_rules,write_draft_orders,read_draft_orders,
read_fulfillments,write_fulfillments,write_inventory,read_inventory,
write_locations,read_locations,read_metaobjects,write_metaobjects,
read_orders,write_orders,read_product_listings,write_product_listings,
read_products,write_products,read_shipping,write_shipping,read_themes
Redirect URLs:
http://localhost:3456/callback
Important settings:
Use legacy install flow — leave unchecked
Embed app in Shopify admin — leave unchecked
Click Release.
Get your credentials
Go to the Settings tab and note down:
- Client ID
- Client Secret (click the eye icon to reveal)
Step 2 — Run the OAuth Catch Server
Since the redirect goes to localhost, you need a small local server running to catch the OAuth callback and exchange it for a token.
Save this as shopify_oauth_catch.py and update the credentials:
#!/usr/bin/env python3
import http.server
import urllib.parse
import requests
import os
import secrets
env = {}
with open(os.path.expanduser("~/.env")) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
key, _, value = line.partition("=")
env[key.strip()] = value.strip()
SHOP = env["SHOPIFY_SHOP"]
CLIENT_ID = "YOUR_CLIENT_ID"
CLIENT_SECRET = "YOUR_CLIENT_SECRET"
PORT = 3456
class OAuthHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
params = urllib.parse.parse_qs(parsed.query)
if "code" in params:
code = params["code"][0]
print(f"\n✓ Got authorization code: {code[:20]}...")
url = f"https://{SHOP}/admin/oauth/access_token"
payload = {
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"code": code,
}
r = requests.post(url, json=payload)
data = r.json()
if "access_token" in data:
token = data["access_token"]
print(f"✓ Token: {token[:20]}...")
print(f" Scopes: {data.get('scope', '')}")
env_path = os.path.expanduser("~/.env")
with open(env_path, "r") as f:
lines = f.readlines()
with open(env_path, "w") as f:
for line in lines:
if line.startswith("SHOPIFY_TOKEN="):
f.write(f"SHOPIFY_TOKEN={token}\n")
else:
f.write(line)
print(f"✓ ~/.env updated!")
self.send_response(200)
self.end_headers()
self.wfile.write(b"<h1>Token captured! You can close this window.</h1>")
import threading
threading.Thread(target=self.server.shutdown).start()
else:
print(f"✗ Failed: {data}")
self.send_response(400)
self.end_headers()
self.wfile.write(b"Failed to get token")
elif "shop" in params:
shop = params["shop"][0]
state = secrets.token_hex(16)
scopes = "read_products,write_products,read_orders,write_draft_orders,read_draft_orders,read_customers,write_customers,read_inventory,write_inventory,read_locations"
redirect_uri = f"http://localhost:{PORT}/callback"
auth_url = (f"https://{shop}/admin/oauth/authorize"
f"?client_id={CLIENT_ID}"
f"&scope={scopes}"
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
f"&state={state}")
print(f"\n→ Redirecting to OAuth authorization...")
self.send_response(302)
self.send_header("Location", auth_url)
self.end_headers()
else:
self.send_response(200)
self.end_headers()
self.wfile.write(b"Waiting for OAuth callback...")
def log_message(self, format, *args):
pass
print(f"Starting OAuth server on http://localhost:{PORT} — waiting for callback...")
server = http.server.HTTPServer(("localhost", PORT), OAuthHandler)
server.serve_forever()
Run it:
python3 shopify_oauth_catch.py
Then go to your Partners dashboard → your app → Overview → click Install app.
You’ll see a permissions screen — click Install. The browser will redirect to localhost, the script catches the callback, exchanges the code for a token, and saves it to ~/.env automatically.
Step 3 — Daily Token Refresh
Once the app is installed, you can refresh the token automatically using the client credentials flow — no browser needed:
#!/usr/bin/env python3
# shopify_get_token.py
import requests, os, sys
env = {}
with open(os.path.expanduser("~/.env")) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
key, _, value = line.partition("=")
env[key.strip()] = value.strip()
SHOP = env["SHOPIFY_SHOP"]
args = sys.argv[1:]
client_id = args[args.index("--client-id") + 1] if "--client-id" in args else None
client_secret = args[args.index("--client-secret") + 1] if "--client-secret" in args else None
url = f"https://{SHOP}/admin/oauth/access_token"
payload = {
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
}
r = requests.post(url, data=payload)
data = r.json()
if "access_token" in data:
token = data["access_token"]
print(f"✓ Token: {token[:15]}... Scopes: {data.get('scope','')}")
env_path = os.path.expanduser("~/.env")
with open(env_path, "r") as f:
lines = f.readlines()
with open(env_path, "w") as f:
for line in lines:
f.write(f"SHOPIFY_TOKEN={token}\n" if line.startswith("SHOPIFY_TOKEN=") else line)
print("✓ ~/.env updated")
else:
print(f"✗ Failed: {data}")
Run manually:
python3 shopify_get_token.py --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET
Automate with cron (refreshes daily at 6am):
0 6 * * * /path/to/refresh_shopify_token.sh
Where refresh_shopify_token.sh contains:
#!/bin/bash
python3 /path/to/shopify_get_token.py \
--client-id YOUR_CLIENT_ID \
--client-secret YOUR_CLIENT_SECRET \
>> /path/to/results/token_refresh.log 2>&1
Gotchas
Empty scopes from client credentials flow
If you get a token back but scope is empty, your app is using the legacy install flow. Create a new app with legacy install flow unchecked.
status=any returns 0 products
On some stores status=any doesn’t work. Fetch active and draft separately and merge.
metafield_definitions require GraphQL
The REST endpoint for metafield definitions returns HTTP 406. Use the GraphQL Admin API instead.
Blank metafield values rejected
Shopify rejects empty string "" as a metafield value. Use "-" as a placeholder.
“Embed app in Shopify admin” causes redirect issues
If this is checked, the OAuth redirect goes to the Shopify admin instead of your localhost callback. Uncheck it for CLI-only apps.
“localhost sent an invalid response”
The browser blocked HTTP localhost. The OAuth catch script handles this by intercepting the initial app launch request and redirecting to the proper OAuth URL itself.
~/.env Structure
SHOPIFY_SHOP=yourstore.myshopify.com
SHOPIFY_TOKEN=shpat_xxxxxxxxxxxxxxxxxxxx
The token is updated in place by both scripts so all your CLI scripts that read from ~/.env automatically get the new token.