With a custom app, you give your own integration targeted access to your Shopify shop, for example a tool from your agency or your own script. You create the app in the Shopify Dev Dashboard, define its permissions, and then generate an access token for the integration.
You do not need a custom app for the connection between Xentral and Shopify. You set up this connection via the public app, see the article Adding Shopify.
Note
You no longer create new custom apps in the Shopify admin under Apps and sales channels > Develop apps, but in the Shopify Dev Dashboard. Existing custom apps from the Shopify admin continue to work.
- You have access to your Shopify shop with administrator rights and are allowed to develop apps.
- You know the permissions (scopes) your integration needs. If in doubt, ask the provider of the integration.
- For the permanent access token only: You are working on a computer with macOS or Linux on which Python 3.8 or later is installed. Python is usually already available there.
A custom app from the Dev Dashboard does not display a ready-made token for copying. You generate it in one of two ways:
| Token | Use case |
|---|---|
| Permanent access token | Your integration expects a fixed token that you enter once, for example a tool that asks for a Shopify access token. The token does not expire. It remains valid until you uninstall the app or rotate the client secret. |
| Access token valid for 24 hours | Your integration fetches a new token on its own at regular intervals. For this, it receives the client ID and client secret and requests a new token every day. This is the more secure option, but the integration has to support it. It only works if the app and your shop belong to the same Shopify organization. |
- Open dev.shopify.com and log in with the Shopify account your shop belongs to.
- Click on Create app and select Create app manually. Shopify opens the form Create an app.
-
Fill in the form:
Field name Description App name Enter a name that helps you recognize the app later, for example Integration Agency Example. Version name (optional) Leave the field empty. App URL Keep https://example.com. The integration has no user interface of its own in Shopify.Embed app in Shopify admin Deactivate the option. Allowed redirection URL(s) Enter http://localhost:3456/callback. You need this address for the permanent access token. It does no harm for the token valid for 24 hours.Use legacy install flow Leave the option deactivated. Scopes Enter the permissions your integration needs, separated by commas, for example read_orders,write_products. Alternatively, select them via Select from list.Webhooks API version Keep the preselected value. - Click on Create app in the top right corner.
- Open the Settings of the app. There you can find the Client ID and Client secret. Treat the client secret like a password.
For the permanent token, continue with the section Generating a permanent access token. For the token valid for 24 hours, go to Retrieving an access token valid for 24 hours.
For the permanent access token, we provide a small Python script. It opens the approval page of your shop in the browser, installs the app there, and retrieves the token. You do not need any additional packages.
-
Copy the script and save it as the file
shopify_offline_token.py.#!/usr/bin/env python3 """ Gets an offline access token for your own Shopify app (Dev Dashboard) using the OAuth authorization code grant. No extra packages needed, Python 3.8+ only. How it works: 1. Starts a small web server on http://localhost:<port>/callback 2. Opens the Shopify approval page in your browser 3. Verifies the redirect (state, shop, HMAC) and exchanges the code for the token Required app settings (Dev Dashboard, "Create app manually"): - Allowed redirection URL(s): http://localhost:3456/callback (same port as --port) - Use legacy install flow: leave unchecked - Scopes: the ones you need Usage: python3 shopify_offline_token.py --shop my-shop.myshopify.com --client-id <ID> --scopes read_orders,write_products (The client secret is prompted for without echo or read from SHOPIFY_CLIENT_SECRET.) By default you get a non-expiring offline token. With --expiring you get a token valid for 1 hour plus a refresh token instead. """ import argparse import getpass import hashlib import hmac import html import http.server import json import os import re import secrets import sys import threading import urllib.error import urllib.parse import urllib.request import webbrowser SHOP_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$") # Shopify Admin API version for the test request (--check). If needed, enter the # current version, see https://shopify.dev/docs/api/usage/versioning API_VERSION = "2026-10" TIMEOUT_SECONDS = 300 def normalize_shop(value: str) -> str: shop = value.strip().lower() shop = re.sub(r"^https?://", "", shop).split("/")[0] if "." not in shop: shop += ".myshopify.com" if not SHOP_RE.match(shop): raise ValueError(f"Invalid shop domain: {value!r} (expected: name.myshopify.com)") return shop def valid_hmac(params: dict, secret: str) -> bool: """Shopify signs the redirect: all parameters except hmac, sorted, key=value joined with &.""" received = params.get("hmac", "") message = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "hmac") expected = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(expected, received) def post_form(url: str, data: dict) -> dict: body = urllib.parse.urlencode(data).encode() req = urllib.request.Request(url, data=body, method="POST", headers={ "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", "User-Agent": "shopify-offline-token-script"}) with urllib.request.urlopen(req, timeout=30) as r: return json.load(r) def check_token(shop: str, token: str) -> str: req = urllib.request.Request( f"https://{shop}/admin/api/{API_VERSION}/graphql.json", method="POST", data=json.dumps({"query": "{ shop { name myshopifyDomain } }"}).encode(), headers={"Content-Type": "application/json", "X-Shopify-Access-Token": token, "User-Agent": "shopify-offline-token-script"}) with urllib.request.urlopen(req, timeout=30) as r: data = json.load(r) s = data.get("data", {}).get("shop") or {} return f'{s.get("name")} ({s.get("myshopifyDomain")})' def run(shop, client_id, client_secret, scopes, port, expiring, out, open_browser=True, token_url=None): redirect_uri = f"http://localhost:{port}/callback" state = secrets.token_urlsafe(24) result = {} done = threading.Event() class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, *a): # no access logs in the terminal pass def reply(self, status, title, text): page = (f"<!doctype html><meta charset='utf-8'><title>{html.escape(title)}</title>" f"<body style='font:16px/1.5 system-ui;margin:48px;max-width:40em'>" f"<h1 style='font-size:24px'>{html.escape(title)}</h1><p>{html.escape(text)}</p>") self.send_response(status) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() self.wfile.write(page.encode()) def do_GET(self): url = urllib.parse.urlsplit(self.path) if url.path != "/callback": return self.reply(404, "Not found", "This address is not used.") params = dict(urllib.parse.parse_qsl(url.query, keep_blank_values=True)) if "error" in params: result["error"] = f'Shopify reports: {params.get("error")} {params.get("error_description", "")}'.strip() elif params.get("state") != state: result["error"] = "state does not match. Please restart the script." elif params.get("shop", "").lower() != shop: result["error"] = f'Redirect came from a different shop ({params.get("shop")}).' elif not valid_hmac(params, client_secret): result["error"] = "HMAC check failed. Is the client secret correct?" elif not params.get("code"): result["error"] = "No authorization code in the redirect." else: result["code"] = params["code"] if "error" in result: self.reply(400, "Failed", result["error"] + " See the terminal for details.") else: self.reply(200, "Approval received", "You can close this window and return to the terminal.") done.set() try: server = http.server.HTTPServer(("127.0.0.1", port), Handler) except OSError as e: raise SystemExit(f"Port {port} is in use ({e}). Choose another port with --port and add it to the app.") threading.Thread(target=server.serve_forever, daemon=True).start() auth_url = f"https://{shop}/admin/oauth/authorize?" + urllib.parse.urlencode({ "client_id": client_id, "scope": scopes, "redirect_uri": redirect_uri, "state": state}) print("\n1. Confirm the approval in your browser. If no browser opens, open this address:\n") print(" " + auth_url + "\n") if open_browser: webbrowser.open(auth_url) if not done.wait(TIMEOUT_SECONDS): server.shutdown() raise SystemExit(f"No redirect within {TIMEOUT_SECONDS // 60} minutes. Aborting.") server.shutdown() if "error" in result: raise SystemExit("Error: " + result["error"]) print("2. Approval received, exchanging code for token …") data = {"client_id": client_id, "client_secret": client_secret, "code": result["code"], "expiring": "1" if expiring else "0"} try: tok = post_form(token_url or f"https://{shop}/admin/oauth/access_token", data) except urllib.error.HTTPError as e: raise SystemExit(f"Token request failed: HTTP {e.code} {e.read().decode(errors='replace')[:300]}") granted = set((tok.get("scope") or "").split(",")) missing = [s for s in scopes.split(",") if s and s not in granted and not (s.startswith("read_") and "write_" + s[5:] in granted)] with open(os.open(out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f: json.dump({"shop": shop, **tok}, f, indent=2) print("\n3. Done.\n") print(f" Shop: {shop}") print(f" Scopes: {tok.get('scope')}") print(f" Validity: {'%s seconds (refresh token saved)' % tok.get('expires_in') if tok.get('expires_in') else 'does not expire (until the app is uninstalled or the secret is rotated)'}") print(f" Saved: {os.path.abspath(out)} (readable by your user only)") if missing: print(f" WARNING: not granted: {', '.join(missing)}") return tok def main(): p = argparse.ArgumentParser(description="Get an offline access token for your own Shopify app.") p.add_argument("--shop", required=True, help="e.g. my-shop.myshopify.com") p.add_argument("--client-id", required=True, help="Client ID from the app settings in the Dev Dashboard") p.add_argument("--scopes", required=True, help="Comma-separated, e.g. read_orders,write_products") p.add_argument("--port", type=int, default=3456, help="Port for the redirect (default 3456)") p.add_argument("--expiring", action="store_true", help="Get an expiring token (1h) plus refresh token") p.add_argument("--out", default="shopify-token.json", help="File for the token (default shopify-token.json)") p.add_argument("--no-browser", action="store_true", help="Do not open the browser automatically") p.add_argument("--check", action="store_true", help="Verify the token with a test request afterwards") a = p.parse_args() try: shop = normalize_shop(a.shop) except ValueError as e: raise SystemExit(str(e)) scopes = ",".join(s.strip() for s in a.scopes.split(",") if s.strip()) secret = os.environ.get("SHOPIFY_CLIENT_SECRET") or getpass.getpass("Client secret (input stays hidden): ").strip() if not secret: raise SystemExit("No client secret provided.") print(f"\nRedirect URL that must be set in the app: http://localhost:{a.port}/callback") tok = run(shop, a.client_id.strip(), secret, scopes, a.port, a.expiring, a.out, not a.no_browser) if a.check: try: print(f" Test request: OK, {check_token(shop, tok['access_token'])}") except Exception as e: # noqa: BLE001 print(f" Test request failed: {e}") if __name__ == "__main__": main() -
Open a terminal in the folder of the file. In the command, replace
HANDLE,CLIENT_ID, and the scopes with your values. You can find the handle of your shop in the address of the Shopify admin:admin.shopify.com/store/HANDLE. The scopes must be the same as in the app.python3 shopify_offline_token.py --shop HANDLE.myshopify.com --client-id CLIENT_ID --scopes read_orders,write_products --check - Enter the Client secret when the script asks for it. Your input remains hidden.
- Your browser opens the approval page of your shop. Check the permissions and confirm them. Shopify installs the app in your shop. You can then close the browser window.
-
Check the result in the terminal:
Output Meaning Validity: does not expire You have received a permanent access token. Test request: OK The token works. The name of your shop follows. Saved The token is stored in the file shopify-token.jsonin the same folder. Only your user can read the file. - Enter the token in your integration. Then delete the file
shopify-token.json.
Caution
The permanent access token grants access to your shop until you uninstall the app or rotate the client secret. Only pass it on via a secure channel, for example a password safe, and never by email or chat.
Use this option if your integration retrieves the token itself and renews it regularly. It only works if the app and your shop belong to the same Shopify organization.
- In the Dev Dashboard, open Apps. Click on ⋯ in the row of your app and select Install app. Shopify opens a new tab.
- Select your shop. Shopify installs the app in your shop.
- Pass the Client ID and Client secret on to the provider of your integration via a secure channel.
Your integration requests the token with this request and repeats it before the token expires:
curl -X POST "https://HANDLE.myshopify.com/admin/oauth/access_token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=CLIENT_ID" \
-d "client_secret=CLIENT_SECRET" \
-d "grant_type=client_credentials"The response contains the token in the field access_token and its lifetime in expires_in (86399 seconds). Your integration sends the token in the header X-Shopify-Access-Token with every request to Shopify.
If Shopify responds with the error shop_not_permitted, the app and the shop do not belong to the same organization. In this case, use the permanent access token.
- Only give an app the scopes the integration really needs. If permissions are missing later, add them in a new version of the app and confirm them in the shop.
- Create a separate app for each integration. This way, you can revoke access for a single integration without affecting the others.
- If a service provider creates the app in its own Shopify organization, it has to release the app for your shop via Custom distribution. Only then can you install the app in your shop.
- To invalidate a permanent access token, uninstall the app in your shop or rotate the client secret in the Dev Dashboard. Then generate a new token if needed.
- If the script reports that the port is in use, start it with
--port 3457and enterhttp://localhost:3457/callbackas Allowed redirection URL(s) in the app. - If the browser displays "This store will be right back", the handle in the command is wrong. Check it in the address of the Shopify admin.