What this doc is: A first-person reference log of everything I went through while building a Shopify product image optimization tool. The actual goal of the script doesn't matter — what matters is the journey to get the script talking to Shopify at all. These steps, challenges, and fixes will apply to almost any Shopify automation I build in the future.
🎯 What I Was Trying to Do
My client had a Shopify store with 250+ products. Many product images were over 1MB in size, which was tanking their site performance. The goal was simple: automatically compress all images over 200KB down to under 200KB and re-upload them to Shopify — no manual work, no mistakes, fully automated.
🗂️ The Stack I Chose
- Runtime: Node.js (v20+)
-
Language: TypeScript (with
ts-nodefor running directly) -
Libraries:
axiosfor HTTP,sharpfor image compression - Shopify interface: Shopify Admin REST API
📋 Step-by-Step: What I Did, What Broke, and How I Fixed It
Step 1 — Writing the Script
I started with a TypeScript script that would:
- Fetch all products via the Shopify API (with pagination for 250+ products)
- Download each product image
- Compress images over 200KB using
sharp - Re-upload the compressed image back to Shopify, replacing the original
The script was fully typed with interfaces for all Shopify API shapes (ShopifyProduct, ShopifyImage, SummaryRow, etc.) and had a final before/after summary report.
✅ No issues here — the code was solid from the start.
Step 2 — First Run: getaddrinfo ENOTFOUND https
Error:
Fatal error: getaddrinfo ENOTFOUND https
What happened: I had set my SHOPIFY_STORE env var to https://coderom.myshopify.com — with the https:// prefix. But the script was already building the URL as https://${STORE}/..., which resulted in https://https://coderom.myshopify.com. DNS couldn't resolve https as a hostname.
Fix: Added a one-liner at the top of the config to strip any protocol prefix automatically:
const STORE = (process.env.SHOPIFY_STORE ?? "")
.replace(/^https?:\/\//i, "") // strips https:// or http://
.replace(/\/$/, ""); // strips trailing slash
Lesson learned: Always sanitize env var inputs. Users (including myself) will paste full URLs. Strip the protocol defensively so the script never breaks regardless of how the var is set.
Step 3 — Second Run: connect ETIMEDOUT
Error:
Fatal error: connect ETIMEDOUT 218.248.112.60:443
What happened: The script connected to an IP but timed out. I ran a curl test to isolate whether this was a code issue or a network issue:
curl -v --connect-timeout 10 https://solar-for-nature.myshopify.com
Result: same timeout, same IP — 218.248.112.60.
Root cause: That IP is not a Shopify IP. Shopify runs on Fastly CDN (151.101.x.x, 23.227.x.x). My ISP (Indian ISP) was DNS hijacking the request — intercepting the DNS resolution and pointing the domain to a local IP instead of Shopify's actual servers.
Fix: Changed DNS resolver to Cloudflare (1.1.1.1) in system network settings, which bypassed the ISP's DNS hijacking. Could also be fixed by connecting to a VPN or using mobile hotspot.
Lesson learned: ETIMEDOUT doesn't always mean the store is down or the code is wrong. Always run a curl test to check the resolved IP. If it doesn't look like a CDN IP, the problem is DNS, not the script. This is especially common in India with ISPs like Jio, Airtel, and BSNL.
Step 4 — The Shopify App Setup Problem
Wrong Turn 1 — Creating a Public App first
My first attempt was creating an app through the Shopify Partners dashboard. I built it there, but then I noticed a Distribution field on the app's homepage inside the partner dashboard. I clicked it, which took me to the distribution section where Shopify presented two options:
- Public distribution — Publish on the Shopify App Store (listed or unlisted). Reaches a global merchant audience with unlimited installs. Requires Shopify review.
- Custom distribution — Generate custom install links for one store or one organization. Installs are limited to that store/org. No App Store review.
I selected Custom distribution since this was for one specific client store. Shopify showed a warning: "This can't be undone" — once you select custom distribution, you can't switch it back to public. I confirmed, and it then asked me for the store domain (e.g. shopone.myshopify.com). It generated a custom install link which I opened, and that installed the app on the store.
The problem: Even after going through all this — Partners dashboard → custom distribution → install link → app installed on store — the token situation was still unclear and this whole flow added unnecessary complexity for what should have been a simple script setup.
The lesson: The Shopify Partners dashboard is for building apps meant to be distributed to multiple stores — even if you pick custom distribution and limit it to one store, it's still the wrong tool for a private client script. It creates overhead (distribution settings, install links, partner account dependency) that you don't need.
For any client-specific automation script, always use a Custom App created directly inside the store's own admin, not through the Partners dashboard. It lives inside one store, is invisible to the App Store, and gives an instant access token with zero distribution complexity.
What I learned: There are two types of Shopify apps:
| Type | Use case | Review needed? | Time to token |
|---|---|---|---|
| Public App | Listed on App Store, used by many stores | ✅ Yes — Shopify reviews it | Days/weeks |
| Custom App | Built inside one specific store | ❌ No review | Instant |
For any client-specific automation script, always use a Custom App. It lives inside one store, is invisible to the App Store, and gives an instant access token.
How to create a Custom App (the right way):
- Go to client's Shopify Admin → Settings → Apps
- Click Develop apps → Allow custom app development (one-time)(if sees this)
- Click Build apps in Dev Dashboard it will open dev dashboard
- Click Create app → and you will see 2 ways to move forward:
- i) Start with Shopify CLI and ii) Start from Dev Dashboard
- I went with Start from Dev Dashboard
- Name it (e.g.
Image Optimizer) - In the version settings under the Access Scopes → select scopes:
-
read_products— for reading/auditing -
write_products— for modifying/uploading
-
- Click Release → So this is a 2nd release of the app which means with updates
- Click Settings → Here you can get the Credentials i.e.
Client IDandSecretand rest of the settings related to the app.
⚠️ Common gotcha: After this you might think you can now move forward to your app scripts because you are seeing here Credentials i.e. Client ID and Secret but no you need to get the the Access Token. I missed this at first and thought I can use the this secret and it will work but it didn’t actually.
Step 4b — The Access Token Wasn't There: OAuth Endpoint to the Rescue
After completing all the Custom App steps above, I ran into a problem: the "Access Token is not correct". The API credentials section showed a Client ID and Client Secret, but no access token.
I initially tried using the Client Secret directly as the API token — that didn't work and caused auth failures.
After going through Shopify's documentation, I found the actual solution: for this app setup, the access token isn't pre-generated in the UI — I had to request it programmatically by hitting Shopify's OAuth endpoint using the Client ID and Client Secret.
The request:
POST https://{shop}.myshopify.com/admin/oauth/access_token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id={your_client_id}
&client_secret={your_client_secret}
The response:
{
"access_token": "shpat_xxxxx",
"scope": "read_products,write_products",
"expires_in": 86399
}
The access_token value (shpat_xxxxx) is what goes into SHOPIFY_TOKEN. This is the token used in the X-Shopify-Access-Token header for all API calls.
⚠️ Important: Tokens expire after 24 hours. This means for long-running automations or scheduled tasks, I need to re-request a new token before each run using this same OAuth call.
⚠️ Client Secret ≠ Access Token. This was my exact mistake — I copied the Client Secret from the app credentials page thinking that was the token. It's not. The Client Secret is only used to obtain an access token via the OAuth endpoint. The actual token you use in API calls is the access_token returned from that POST request.
Step 5 — Building a Debug Script Before Going to Prod
Before running the full optimizer (which modifies images), I built two safer scripts:
shopify-debug.ts — A pure connectivity tester that runs 5 progressive checks:
- DNS resolution
- TCP port 443 reachability
- HTTPS handshake
- API version detection (tries
2025-01→2024-10→ ... →2024-01) - Reports exactly which version works and what error each one returns
This was invaluable — it told me immediately where in the chain things were breaking rather than just showing a generic fatal error.
shopify-image-audit.ts — A 100% read-only script that:
- Fetches all products
- Checks each image size (using
HEADrequests first, falling back to full download) - Lists every image URL over 200KB with its size
- Makes zero writes to the store
Why I built the audit script first: Running the optimizer directly on a production store is risky. The audit script let me verify that connectivity, auth, and pagination all worked correctly, and showed me exactly what would be changed — before any changes were made.
Lesson learned: For any Shopify automation, always build in this order:
- Debug/connectivity script first
- Read-only audit script second
- Write/modify script last — only after both above confirm everything works
Step 6 — The Optimizer: How Image Compression Works
The core compression logic uses sharp and works in two stages:
Stage 1 — Quality reduction:
Start at quality 85 and step down by 5 until the image is under 200KB:
let quality = 85;
while (quality >= 20) {
compressed = await sharp(buffer).jpeg({ quality, progressive: true }).toBuffer();
if (compressed.length <= TARGET_SIZE_BYTES) return compressed;
quality -= 5;
}
Stage 2 — Dimension resize (if Stage 1 isn't enough):
If even quality 20 doesn't get it under 200KB, proportionally scale down the image dimensions:
const scaleFactor = Math.sqrt(TARGET_SIZE_BYTES / compressed.length);
const newWidth = Math.max(Math.floor((metadata.width ?? 1200) * scaleFactor), 400);
compressed = await sharp(buffer)
.resize({ width: newWidth, withoutEnlargement: true })
.jpeg({ quality: 75, progressive: true })
.toBuffer();
Why this order? Quality reduction is invisible to most viewers. Resizing dimensions is noticeable. So quality is always tried first, and dimension reduction is only the last resort.
Shopify rate limiting: The Shopify API allows ~2 requests/second. The script adds a 600ms delay between image uploads to stay within this limit and avoid 429 Too Many Requests errors.
🧱 Final Project Structure
image-optimizer/
├── src/
│ ├── shopify-debug.ts # Step 1: Run this first — tests connectivity
│ ├── shopify-image-audit.ts # Step 2: Read-only image size report
│ └── shopify-image-optimizer.ts # Step 3: Compresses & re-uploads images
├── package.json
└── tsconfig.json
Always run in order: debug → audit → optimizer.
⚡ Quick Reference: Run Commands
# Install dependencies (one time)
npm install
# 1. Test connectivity and API access
SHOPIFY_STORE=store.myshopify.com SHOPIFY_TOKEN=shpat_xxx npx ts-node src/shopify-debug.ts
# 2. Audit images (read-only, safe on prod)
SHOPIFY_STORE=store.myshopify.com SHOPIFY_TOKEN=shpat_xxx npx ts-node src/shopify-image-audit.ts
# 3. Run optimizer (modifies images — run after audit confirms everything)
SHOPIFY_STORE=store.myshopify.com SHOPIFY_TOKEN=shpat_xxx npx ts-node src/shopify-image-optimizer.ts
📌 Master Checklist for Any Future Shopify Automation
- [ ] Use a Custom App, not a Public App — avoids review, instant token
- [ ] Enable correct API scopes (at minimum:
read_products; addwrite_productsif modifying) - [ ] Hit the OAuth endpoint programmatically with Client ID + Secret to get the
access_token - [ ] Never use Client Secret directly as the API token — it's only used to request the real token
- [ ] Tokens obtained via OAuth expire after 24 hours — re-request before each run
- [ ] Set
SHOPIFY_STOREwithouthttps://— or strip it in code - [ ] If
ETIMEDOUT, runcurland check the resolved IP — may be DNS hijacking - [ ] Switch to
1.1.1.1DNS or use a VPN if ISP is hijacking DNS - [ ] Always run the debug script before the real script
- [ ] Always build a read-only audit script before a write script
- [ ] Respect the
~2 req/srate limit — add 500–600ms delays between writes - [ ] Back up prod data before any destructive operation
💡 Key Lessons (TL;DR)
-
ETIMEDOUT≠ code bug. Check the IP withcurl -v. ISPs in India (and elsewhere) hijack DNS. - Never use a Public App for client scripts. Custom App is always the right choice — instant, no review, scoped to one store.
-
Access Token never appears in the UI, don't use the Client Secret directly — hit the OAuth endpoint (
POST /admin/oauth/access_token) with the Client ID and Secret to programmatically get the realaccess_token. - Client Secret ≠ Access Token. The secret is a credential used to request a token. The token is what you actually use in API calls. Confusing these two wastes a lot of time.
- Build read-only first. Debug → Audit → Optimizer. Never jump straight to writes on prod.
- Auto-detect API version. Hardcoding it is fragile. Try newest-first.
- Quality before resize. When compressing images, always try quality reduction before touching dimensions.
Top comments (0)