The 40-line fleet capture: webhook-only Shopify apps
Short answer: a Shopify app with no embedded UI is the cheapest thing in the ecosystem to run as a fleet. The client_id and secret of a Shopify app are read in four places — OAuth, token exchange, session-token verification, and webhook HMAC — and a webhook-only app uses one of them. Replace the secret in that one check with a per-shop lookup, add the two contract endpoints, and you are done. npx deploteka onboard . --scaffold writes the whole thing as a single dependency-free file.
Why an app would want a dedicated app per store
A fleet means one Shopify app per merchant store rather than one app installed on many stores. Merchants get an app that is theirs, with its own credentials, its own scopes, and its own uninstall; you keep one codebase and one deployment. The multi-store distribution guide covers the why. This guide is only about the how, for the smallest possible app.
What "webhook-only" means here
An app whose entire Shopify surface is inbound webhooks and outbound Admin API calls, with no embedded admin UI. The common shapes are agency utility apps: order and inventory syncs, tag writers, ERP and warehouse bridges, compliance exporters. If your app never renders inside the Shopify admin, never verifies a session token and never touches App Bridge, this is you.
The CLI also sends any Node app that depends on @shopify/shopify-api directly — Fastify, Koa, Next.js route handlers, a bare node:http server — to this page, because the endpoints and the table are identical. If yours does have an embedded UI, read When it is not enough at the bottom for the extra injection sites.
The four things DeploTeka needs, and the one that is actually work
- `POST /api/fleet/register` — DeploTeka pushes a freshly provisioned dedicated app's credentials to your app. You persist them.
- `GET /api/fleet/installed?shop=` — DeploTeka polls install state and verifies the secret/client_id binding.
- Per-shop credential resolution where you currently read one global pair.
- A local replica so reads never depend on DeploTeka being reachable.
One and two are the same in every stack and are pure boilerplate — the code below is complete. Four is one table. Three is the only part that touches your app, and for a webhook-only app it is one function.
The wire shapes are frozen at contractVersion: 1 and documented language-neutrally in the universal fleet contract guide.
The one injection: per-shop webhook HMAC
Shopify signs every webhook delivery with the client secret of the app that owns the subscription, and sends the digest as base64 in X-Shopify-Hmac-Sha256. It also sends X-Shopify-Shop-Domain on the same request. That second header is the whole trick: you know which shop this is before you have verified anything, so you can look up that shop's secret and verify with it.
import { createHmac, timingSafeEqual } from 'node:crypto';
export async function webhookHmacValid(rawBody, req) {
const provided = req.headers['x-shopify-hmac-sha256'];
const shop = String(req.headers['x-shopify-shop-domain'] || '').trim().toLowerCase();
if (!provided || !shop) return false;
const creds = (await credentialsForShop(shop)) ?? {
secret: process.env.SHOPIFY_API_SECRET, // fallback: shops with no dedicated app yet
};
if (!creds.secret) return false;
const expected = createHmac('sha256', creds.secret).update(rawBody).digest('base64');
const a = Buffer.from(expected);
const b = Buffer.from(String(provided));
return a.length === b.length && timingSafeEqual(a, b);
}
That is the diff. Everything else in this guide is the plumbing that makes credentialsForShop have something to return.
Three notes:
- Raw body.
rawBodymeans the exact bytes Shopify sent. If a JSON body parser runs before your verification, re-serialising the parsed object will not reproduce them and the check will fail intermittently — on whitespace, on key order, on Unicode escapes. Capture the raw buffer first. - The fallback matters. Keeping the env secret as the fallback for shops with no row is what makes this a non-event for your existing installs. They keep working; stores move to a dedicated app as DeploTeka provisions them; there is no cutover moment.
- The unsigned header is fine.
X-Shopify-Shop-Domainis not covered by the signature, but an attacker who names a different shop only causes the HMAC to be checked against the wrong secret, which fails. It selects a key; it does not grant anything.
The two endpoints, in full
Here is the complete implementation, node:http and nothing else. Mount it into whatever server you already run, or execute it standalone.
import { createHash, timingSafeEqual } from 'node:crypto';
const SHOP_DOMAIN = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i;
const TOKEN = process.env.FLEET_REGISTER_TOKEN || '';
export async function handleFleetRequest(req, res) {
const url = new URL(req.url || '/', 'http://fleet.invalid');
if (url.pathname === '/api/fleet/register') { await handleRegister(req, res); return true; }
if (url.pathname === '/api/fleet/installed') { await handleInstalled(req, res, url); return true; }
return false; // not ours — fall through to your own routing
}
async function handleRegister(req, res) {
if (req.method !== 'POST') return text(res, 405, 'Method Not Allowed');
if (!bearerOk(req)) return text(res, 401, 'Unauthorized');
let parsed;
try { parsed = JSON.parse(await readBody(req)); }
catch { return text(res, 400, 'Bad Request'); }
const body = parsed !== null && typeof parsed === 'object' ? parsed : {};
if (body.contractVersion !== 1) return json(res, 400, { error: 'UNSUPPORTED_CONTRACT_VERSION' });
const shop = String(body.shop ?? '').trim().toLowerCase();
const clientId = String(body.clientId ?? '').trim();
const secret = String(body.secret ?? '').trim();
const appUrl = String(body.appUrl ?? '').trim();
if (!SHOP_DOMAIN.test(shop) || !clientId || !secret || !appUrl) {
return text(res, 400, 'Invalid credentials payload');
}
await putCredentials(shop, { clientId, secret, appUrl });
return json(res, 200, { contractVersion: 1, ok: true, shop });
}
async function handleInstalled(req, res, url) {
if (req.method !== 'GET' && req.method !== 'HEAD') return text(res, 405, 'Method Not Allowed');
if (!bearerOk(req)) return text(res, 401, 'Unauthorized');
const shop = (url.searchParams.get('shop') || '').trim().toLowerCase();
if (!SHOP_DOMAIN.test(shop)) return text(res, 400, 'Invalid shop');
const row = await credentialsForShop(shop);
if (!row) return json(res, 404, { error: 'Store not registered' });
const installed = await isInstalled(shop, row);
return json(res, 200, {
contractVersion: 1,
installed,
clientId: row.clientId,
secretFingerprint: secretFingerprint(row.secret),
appUrl: row.appUrl,
grantedScopes: installed ? splitScopes(row.scope) : [],
}, { 'cache-control': 'no-store' });
}
export function secretFingerprint(secret) {
return createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16);
}
function bearerOk(req) {
const match = /^Bearer\s+(.+)$/i.exec(String(req.headers.authorization || '').trim());
const got = Buffer.from((match?.[1] ?? '').trim(), 'utf8');
const want = Buffer.from(TOKEN, 'utf8');
return want.length > 0 && got.length === want.length && timingSafeEqual(got, want);
}
Five things in there are contract rather than taste, and each one has broken a real integration somewhere:
- The fingerprint is pinned: sha256 over the raw secret's UTF-8 bytes, lowercase hex, first 16 characters. DeploTeka computes the same value to confirm the secret it pushed is the secret you stored, without either side transmitting it again. Truncate to a different length and provisioning fails an attestation that cannot explain itself.
- Order: method, then bearer, then JSON, then contract version, then payload. A GET to the register endpoint with a perfectly valid token is still a 405.
- 404 is not `installed: false`. 404 means no row — never registered. Once a row exists, its facts come back on every read even before an install, because DeploTeka reads them back to confirm its own write landed.
- The 200 on the read carries `cache-control: no-store`, and nothing else carries a cache-control header at all.
- Register is an idempotent upsert. A re-registration is how a rotated secret arrives, so overwrite the credential fields unconditionally — and leave install state alone, because rotating a secret does not uninstall the app.
The store
For a webhook-only service, a JSON file on a persistent volume is a real answer, not a placeholder — the write rate is "occasionally, when a store is provisioned" and the read rate is one small file per request. Use your database if you have one; only two functions change.
async function putCredentials(shop, creds) {
const store = await readStore();
store[shop] = { ...(store[shop] ?? {}), ...creds }; // preserves accessToken / scope
await writeStore(store);
}
export async function credentialsForShop(shop) {
const store = await readStore();
return store[String(shop).trim().toLowerCase()] ?? null;
}
Write it atomically — to a temporary file, then rename — so a crash mid-write cannot truncate the replica, and serialise concurrent writes so two registrations in flight cannot lose one another. The generated file does both.
And define isInstalled honestly:
async function isInstalled(shop, row) {
return Boolean(row.accessToken); // whatever "we can call this store's API" means to you
}
Reporting true without a usable token is the one lie that costs you something: the cabinet marks the store live and stops chasing the install.
Generate it instead of typing it
npx deploteka onboard . --scaffold
The CLI reads your package.json, sees @shopify/shopify-api with no app framework on top, and writes deploteka-fleet/fleet.mjs — the code above, complete, with the atomic store and the standalone server mode. It writes nowhere else, never overwrites an existing file, and marks the four TODO(deploteka) points inline. Mount it with one line:
import { handleFleetRequest } from './deploteka-fleet/fleet.mjs';
// first line of your request listener:
if (await handleFleetRequest(req, res)) return;
Ahead of your own routing, and ahead of any body parser.
That generated file is not a sketch. Every release, it is executed against the same 29 recorded request/response vectors that the reference TypeScript runtime and the PHP adapter are checked against — status, content type, cache-control, response body byte for byte, and the post-state of the credential replica.
Environment
FLEET_REGISTER_TOKEN required. Bearer for both routes. Strong, private, rotatable.
Record the same value on the DeploTeka app card. Unset means
both routes reject everything — it fails closed, never open.
SHOPIFY_API_SECRET leave exactly as it is. It stays the fallback for every shop
without a dedicated app, so existing installs keep working.
Verification checklist
curl -i -X POST "$APP_URL/api/fleet/register" \
-H "authorization: Bearer $FLEET_REGISTER_TOKEN" -H "content-type: application/json" \
-d '{"contractVersion":1,"shop":"test.myshopify.com","clientId":"x","secret":"y","appUrl":"https://your-app.example.com"}'
Expect 200 with {"contractVersion":1,"ok":true,"shop":"test.myshopify.com"}, and 401 when you drop the bearer.
curl -s "$APP_URL/api/fleet/installed?shop=test.myshopify.com" \
-H "authorization: Bearer $FLEET_REGISTER_TOKEN"
Expect 200 carrying your clientId, a 16-character secretFingerprint, the appUrl, and installed:false. Ask for a shop you never registered and expect 404.
Then the real one: send a webhook body signed with a dedicated app's secret, carrying that shop's X-Shopify-Shop-Domain header, and confirm it verifies — while the same body signed with the base app's secret does not. That is the feature.
When it is not enough
Webhook-only is the floor, and being honest about where the floor ends matters more than the floor being low.
- An embedded admin UI adds two injections. Session tokens must be verified against the per-shop secret with the JWT audience checked against the per-shop client_id; and App Bridge has to be handed that same client_id at render time instead of a build-time constant.
- OAuth as your install path adds a third: the token request needs the per-shop pair. It is one form POST to
https://{shop}/admin/oauth/access_token, so the change is small, but it is a change. - App proxies are signed like webhooks but with a query-parameter HMAC rather than a body one, and they need the same per-shop secret lookup in a different function.
- A framework — Remix, React Router, Express, Laravel — means you should not be on this page at all. Those are captured by a codemod or a first-party adapter; run
npx deploteka onboard .and it will route you.
Each of those is covered in the universal fleet contract guide, which is the same contract with every injection site spelled out.