Duskel Start a project
Blog/Product Engineering

Shopify moved custom apps to the Dev Dashboard, and your permanent shpat_ token habit won't survive it

New Shopify custom apps are created in the Dev Dashboard, not the store admin. Tokens now expire, scope changes now ship as releases, and a forgotten API version gets swapped out without an error. Here is what breaks and how to migrate without downtime.

Duskel·17 Sept 2026·6 min read·Product Engineering

In half the Shopify integrations we inherit, the riskiest line is SHOPIFY_TOKEN=shpat_… in an .env file. Someone who left in 2022 pasted it there. It carries write_orders that nobody remembers granting, and it has never expired. It came from the old flow: open the store admin, tick scopes, click Install, copy the string. Since January 1, 2026, you can't create new apps that way. New custom apps are created in the Dev Dashboard. Apps made in the admin still run, but they are legacy and you can't create new ones.

The change is right, and it will still hurt most teams. The Dev Dashboard handles a one-store integration like any other software: the config lives in git, installs are explicit and scope changes show up in a diff. The damage comes from keeping the old assumptions. The app installs cleanly and the first nightly sync works. The next night, every job returns 401 because the token it read from the environment has expired.

What actually changed

In the admin flow, the app and the credential were the same thing. The app lived inside one store, the store issued one permanent Admin API token and that was the whole lifecycle. A Dev Dashboard app belongs to your developer organization and reaches a store through a custom distribution install link. Your code requests the credential at runtime. Nobody pastes it anywhere.

That split fixes a real problem. One app definition now installs on both staging and production, so you no longer keep two hand-built copies whose scopes drift apart within a quarter. Scopes, webhook subscriptions and the app URL live in shopify.app.toml, and every shopify app deploy creates a numbered app version. If someone changes read_orders to write_orders, the change shows up as a one-line diff in a pull request, not a checkbox no one checks. For a B2B merchant whose ERP sync writes to order data, that audit trail matters more than the ten minutes the old flow saved.

The token is no longer a constant

The failure we fix most often is code that treats the access token as fixed configuration. There are two ways to get a token. Apps distributed to other stores run the OAuth authorization code flow and store a token for each shop. Apps installed on stores your own organization owns can use the client credentials grant, which returns a token with expires_in: 86399, which is 24 hours. Teams run that exchange once and paste the result into AWS Secrets Manager. Everything works until the next day.

Treat the token as a cached value with an expiry. Put token acquisition behind one function, refresh it about five minutes before expires_in runs out, and on a 401, refresh and retry exactly once before paging anyone. Store only the client secret, and let only the one service that exchanges it read it. If five cron jobs each read SHOPIFY_TOKEN directly, you have five call sites to rewrite. Consolidate them before you migrate. If you wait until after the switch, you'll be doing it during an outage.

Scope changes are now a release

In the admin flow, ticking a new scope took effect instantly. Now a new scope ships in a new app version, and the store has to approve the added access before any request that needs it will work. We saw a team ship a feature that read customer metafields in the same deploy as the read_customers scope. They then spent an afternoon on ACCESS_DENIED errors from a query that was correct, because the store hadn't approved the new scope yet.

Sequence it: deploy the scope, confirm the grant with the currentAppInstallation { accessScopes } query, then ship the code that depends on it. Request only the scopes you can justify line by line. Security reviewers at larger merchants now read those lists. An app that asks for write_products just to adjust inventory will be sent back, because write_inventory covers that. In our experience, the narrowest scope list gets approved fastest.

GraphQL, webhooks and the version clock

If you are rebuilding, stop writing REST. Shopify labeled the REST Admin API legacy in October 2024, and new capabilities ship only on GraphQL. GraphQL rate limits are based on calculated query cost, not request count. No single query can cost more than 1,000 points, and every response reports your remaining budget in extensions.cost.throttleStatus. A query for 250 products with nested variants and metafields hits that ceiling on the first page. For anything over a few thousand records, use bulkOperationRunQuery. Wait for the bulk_operations/finish webhook, then download a JSONL file in which child rows point to their parent through __parentId. Our catalog syncs dropped from forty minutes of throttled pagination to under five.

Declare webhook subscriptions in the toml so they are versioned with the app. Don't create them in a one-off setup script. Verify X-Shopify-Hmac-Sha256 against the raw request body before parsing it, return 200 within Shopify's five-second timeout, and push the real work onto a queue. Shopify retries failed deliveries, so make every handler idempotent. Track the API version on a calendar. Shopify ships a version every quarter and supports each one for at least twelve months. When a pinned version like 2025-01 drops out of support, Shopify doesn't return an error. It serves the oldest supported version instead, so field behavior changes with no deploy on your side. Log the X-Shopify-API-Version response header and alert when it stops matching your pinned version.

Migrating a legacy custom app without downtime

Legacy admin apps still run, so plan the migration like a normal project. You don't need to rush it. Build the Dev Dashboard app alongside the old one with identical scopes, and install it on a development store first. Then move one job at a time onto the new token function while the legacy token still serves everything else. Move webhooks last. While both apps are subscribed, each event arrives twice, once per app. Deduplicate on resource ID plus updated_at, not on the delivery ID, which differs between the two apps.

When your logs show no requests using the old token for a full week, uninstall the legacy app. That revokes the shpat_ token for good. Most teams skip this step. The forgotten token then sits in an old repo or a CI log, where GitHub secret scanning and attackers both look for Shopify token patterns. The migration is finished only when a request made with the old token returns 401.

Written by Duskel

A software studio that ships and maintains its own products — KeepChats, Gwora and Cairn — and builds the same way for clients. Founded and led by codewithumar.

Talk to the studio →
RELATED READING
Product Engineering · 7 min read

Pick a WordPress development company by how they handle update day

Product Engineering · 7 min read

What Strapi actually costs once you get past the free tier

Product Engineering · 8 min read

A Strapi tutorial for the people who have to maintain it

PRODUCT ENGINEERING

If you have a legacy Shopify custom app to migrate or a new one to build properly in the Dev Dashboard, talk to Duskel.

Send the problem. You get one fixed number and a plan back within a business day.

Duskel
Duskel
AI AUTOMATIONSOFTWARE

We build software worth keeping — for clients, and for ourselves.

Founded & led by codewithumar

© 2026 Duskel. All rights reserved.DUSKEL SMC-Private Limited · Incorporated 2021 · Lahore, PakistanBuilt to last, not to demo.