deploteka ← All guides

By DeploTeka · Last updated August 13, 2026

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

  1. `POST /api/fleet/register` — DeploTeka pushes a freshly provisioned dedicated app's credentials to your app. You persist them.
  2. `GET /api/fleet/installed?shop=` — DeploTeka polls install state and verifies the secret/client_id binding.
  3. Per-shop credential resolution where you currently read one global pair.
  4. 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. rawBody means 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-Domain is 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.

Frequently asked questions

What counts as a webhook-only app?

An app whose entire Shopify surface is inbound webhooks and outbound Admin API calls, with no embedded admin UI. Agency utility apps are the common shape: order or inventory syncs, tag writers, warehouse and ERP bridges, compliance exporters. If your app never renders inside the Shopify admin, never verifies a session token and never uses App Bridge, it is webhook-only for the purposes of this guide, whatever else it does.

Why is this the smallest capture?

Because 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. There is no App Bridge key to serve, no JWT audience to check, and often no OAuth redirect flow at all if the app is installed with a custom app token. What is left is a per-shop secret lookup in one function, plus the two contract endpoints, which are the same in every stack.

Does the shop domain really arrive on every webhook?

Yes. Shopify sends X-Shopify-Shop-Domain on every webhook delivery, alongside X-Shopify-Hmac-Sha256, X-Shopify-Topic and X-Shopify-Webhook-Id. That is what makes the per-shop lookup possible without a session: you read the header, resolve that shop’s secret from your replica, and verify with it. The header is not signed, but it does not need to be — an attacker who names a different shop only causes the HMAC check to fail against that shop’s secret.

Do we need OAuth at all?

Only if you install through it. Many webhook-only apps are installed as custom apps with an Admin API access token pasted into configuration, in which case there is nothing to rewire and the access token you already store is the only per-shop credential you need. If you do run OAuth, the same per-shop lookup applies to the token request; that is one extra call site, not a different design.

What does installed mean for an app with no OAuth?

Whatever ‘we can call this store’s Admin API right now’ means in your app — usually the presence of a usable access token in your own storage. The contract asks the question because the DeploTeka cabinet shows install state per store and stops chasing an install once it reports true. That is why the one answer to avoid is optimism: reporting installed:true without a usable token makes the cabinet mark the store live and stop.

Can the two endpoints live in a different service from the webhook receiver?

Yes, provided both read the same credential replica. The register endpoint writes rows and the receiver reads them, so a shared database is the only coupling. Splitting them is a reasonable choice when your receiver is a serverless function and you would rather not give it write access, or when the receiver scales differently from everything else.

We do have an embedded UI. Is this guide still useful?

The endpoints and the table are identical, so yes for half of it. What differs is the number of injection sites: an embedded app also verifies session tokens against the per-shop secret and asserts the JWT audience against the per-shop client_id, and it has to serve that same client_id to App Bridge instead of a build-time constant. The section ‘When it is not enough’ lists them, and the universal contract guide covers each in detail.