deploteka ← All guides

By DeploTeka · Last updated August 11, 2026

Wire the DeploTeka fleet contract into any Shopify app

Short answer: DeploTeka creates a dedicated Shopify app per client store, but your backend still has to answer three things per request: whose store is this, which client_id/secret pair belongs to it, and (if the app is embedded) which client_id App Bridge should load. That's the entire fleet contract — two small HTTP routes plus a per-shop credential lookup. It doesn't care what framework you're on, how old it is, or whether it's embedded. If you can add two routes and read a row from a table keyed by shop, you can capture your app as-is — no rewrite, no upgrade, no codemod required.

This is the manual version of what DeploTeka's npx deploteka onboard codemod does automatically for @shopify/shopify-app-remix v4. Everything else — Express, raw @shopify/shopify-api, Next.js, Rails, Laravel, Python, or anything not listed — implements the same contract by hand, once, in well under a day.

Why your app needs this at all

DeploTeka's model is one codebase, many dedicated apps: each client store gets its own Shopify app (its own client_id/secret, its own install), all running the same backend you already have. That only works if your backend stops assuming there is one Shopify app. Concretely, it needs to:

  1. Accept per-store credentials. Today your app almost certainly reads one SHOPIFY_API_KEY/SHOPIFY_API_SECRET pair from the environment. Once you're running a fleet, every store has its own pair, and your app needs somewhere durable to keep them and a way to receive new ones as DeploTeka provisions stores.
  2. Resolve OAuth, session verification, and webhook HMAC per shop — instead of against one fixed env pair, against whichever store's credentials the current request belongs to.
  3. Serve the right `client_id` to the embed — but only if the app is embedded. An embedded admin UI initializes App Bridge with a client_id; in a fleet, that has to be the requesting store's client_id, not a hardcoded one, or the iframe won't authenticate. Non-embedded apps (plain OAuth apps, webhook-only services) skip this entirely — there's no App Bridge to configure.

None of this requires touching your OAuth library, your session model, or your webhook handlers' logic. It requires one change repeated everywhere you currently read a single global client_id/secret: read it from a per-shop lookup instead.

The contract, precisely

DeploTeka's fleet wire contract is frozen at contractVersion: 1 and is exactly two HTTP routes. This is the reference implementation DeploTeka's own generic runtime package ships against — copy the shapes exactly; they are not open to interpretation.

POST /api/fleet/register

DeploTeka calls this right after it provisions a store's dedicated app, handing your backend that store's credentials to persist. Gate it with a bearer token (an env var you control, e.g. FLEET_REGISTER_TOKEN) — treat it like any other secret capable of overwriting a shop's credentials.

Request:

POST /api/fleet/register
Authorization: Bearer <FLEET_REGISTER_TOKEN>
Content-Type: application/json

{
  "contractVersion": 1,
  "shop": "example.myshopify.com",
  "clientId": "3f9c1b2a...",
  "secret": "shpss_a1b2c3...",
  "appUrl": "https://example-app.yourdomain.com",
  "shopifyAppId": "gid://shopify/App/123456789",
  "scopes": ["read_products", "write_orders"]
}

shopifyAppId and scopes ride along for consumers that want them; if you have no use for them, accept and ignore both — don't reject the payload for carrying extra fields.

Response (200):

{ "contractVersion": 1, "ok": true, "shop": "example.myshopify.com" }

Failure modes:

  • 401 — missing or wrong bearer token.
  • 400 with { "error": "UNSUPPORTED_CONTRACT_VERSION" }contractVersion isn't 1.
  • 400 Invalid credentials payloadshop doesn't match *.myshopify.com, or clientId/secret/appUrl is missing or empty.

Idempotency: calling this again for a shop that's already registered is a normal, expected operation — it's how secret rotation and re-provisioning work. Treat it as an upsert: overwrite the existing row for that shop with the new values, don't reject a re-registration as a duplicate, and don't require the caller to delete anything first.

GET /api/fleet/installed?shop=

DeploTeka polls this to show install state and to attest that your app actually stored the secret it was handed. Same bearer token as register; read-only.

Request:

GET /api/fleet/installed?shop=example.myshopify.com
Authorization: Bearer <FLEET_REGISTER_TOKEN>

Response (200), before the merchant has installed the app:

{
  "contractVersion": 1,
  "installed": false,
  "clientId": "3f9c1b2a...",
  "secretFingerprint": "9f86d081884c7d65",
  "appUrl": "https://example-app.yourdomain.com",
  "grantedScopes": []
}

Response (200), after OAuth completes:

{
  "contractVersion": 1,
  "installed": true,
  "clientId": "3f9c1b2a...",
  "secretFingerprint": "9f86d081884c7d65",
  "appUrl": "https://example-app.yourdomain.com",
  "grantedScopes": ["read_products", "write_orders"]
}

Failure modes:

  • 401 — missing or wrong bearer token.
  • 400 Invalid shop — the shop query param doesn't match *.myshopify.com.
  • 404 { "error": "Store not registered" } — this shop was never registered. This is the only 404 case; once a shop is registered, always return 200 with the row facts, even while installed is still false — DeploTeka's pre-install checks read clientId/secretFingerprint/appUrl before any install has happened, and a 404 there would look like registration failed.

Always send Cache-Control: no-store on the response — this is a live status check, not something a CDN or browser should cache.

`secretFingerprint` — the attestation, exactly: sha256(secret, "utf8"), hex-encoded, first 16 characters. This is pinned; don't substitute a different hash, encoding, or length. It exists so DeploTeka can verify the secret it handed over during register actually landed and matches what your app is using — without either side transmitting the raw secret again. DeploTeka computes the same fingerprint independently and compares. If they don't match, either your app stored the wrong value or is using a stale one — that mismatch is a debugging signal worth surfacing in your own logs too.

What `installed` means: an offline (non-online) access token exists for that shop — i.e., OAuth has completed at least once and you're holding a durable token, not just an online session from an admin page load. grantedScopes is the scope list attached to that offline grant. If you have no concept of "offline" sessions (e.g. a webhook-only service with no OAuth), use whatever your closest equivalent is — a stored, durable credential for the shop — and say so plainly if you write your own docs for it.

The local replica: one table, read before anything else

Everything above implies one piece of state: a table keyed by shop, storing at minimum clientId, secret, and appUrl. This is your table, in your database — not a live call back to DeploTeka.

fleet_stores
  shop        text primary key   -- "example.myshopify.com", lowercase
  client_id   text not null
  secret      text not null
  app_url     text not null

The local-first rule: every credential lookup — OAuth begin, OAuth callback, session-token verification, webhook HMAC, App Bridge client_id — reads this table directly. It never makes a network call to DeploTeka on the request path. DeploTeka pushes new rows via register; your app just reads what's already there. This matters operationally: a DeploTeka outage, deploy, or slow response must never be able to take your data plane down. If you want a pull-through fallback for a store your table doesn't have yet, that's a reasonable addition (fetch-and-cache once, then it's local from then on) — but it's optional, not part of the contract, and the read path should still check the local table first every time.

Where to inject per-shop credentials

The exact wiring differs by whether your app is embedded, but the underlying move is the same everywhere: stop reading one global client_id/secret, and instead resolve the request's shop first, then look up that shop's row.

Resolving the shop itself is worth doing consistently, in this priority order, because different request types carry the signal differently:

  1. `X-Shopify-Shop-Domain` header — the only signal a webhook POST carries (no query params, no cookies, no Referer).
  2. Session-token JWT — decode the Authorization: Bearer token (App Bridge fetches) or the id_token query param (document loads) without verifying the signature yet, and read the dest or iss claim. You need the shop before you know which secret to verify with, so this first decode is deliberately unverified — verification happens next, once you have the right secret.
  3. `shop` / `host` query params — first embedded load, before any session token exists.
  4. Cookies / Referer — last resort only; never load-bearing, since third-party cookies inside the admin iframe are being phased out.

Embedded apps

  • OAuth begin / callback: construct (or fetch from a small cache keyed by client_id) a Shopify API client instance using the shop's stored clientId/secret, instead of one shared instance built from env vars. Everything downstream — the redirect URL, the code exchange, HMAC verification of the callback — flows from that instance.
  • Session-token JWT verification: resolve the shop from the unverified dest/iss claim first, look up that shop's secret, then verify the token's signature (HS256) with it. Reject if verification fails — don't fall back to a default secret.
  • Webhook HMAC: resolve the shop from X-Shopify-Shop-Domain, look up that shop's secret, verify X-Shopify-Hmac-Sha256 against it.
  • App Bridge `client_id`: the embedded page (or its AppProvider/meta tag, however your stack initializes App Bridge) must render the requesting shop's client_id, not a fixed one. Get this wrong and the iframe initializes against the wrong app identity — it won't authenticate, even though your backend's OAuth is otherwise correct.

Non-embedded apps

Same OAuth-begin/callback and webhook-HMAC injection as above. Skip session-token JWT verification and the App Bridge client_id step entirely — there's no iframe and no App Bridge, so there's nothing to configure. Non-embedded apps are, if anything, the easier case: two injection points instead of four.

Webhook-only services

No OAuth at all — just a receiver validating X-Shopify-Hmac-Sha256 against a per-shop secret, keyed off X-Shopify-Shop-Domain. This is the smallest possible capture: a register route, an installed route reporting install state from whatever durable credential you do store, and a per-shop secret swap in the HMAC check. Realistically well under 100 lines total.

Reference snippets

Each of these is complete enough to run, minus your own database calls (store.get/store.put, spelled out as an interface). None reach past what the framework already gives you.

Express

import { Router, json } from 'express';
import { createHash, timingSafeEqual } from 'node:crypto';

const SHOP_RE = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i;
const TOKEN = process.env.FLEET_REGISTER_TOKEN;

function bearerOk(req) {
  const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization || '');
  const got = Buffer.from(m?.[1]?.trim() || '');
  const want = Buffer.from(TOKEN || '');
  return got.length === want.length && want.length > 0 && timingSafeEqual(got, want);
}

function fingerprint(secret) {
  return createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16);
}

// `store` is your own table access: get(shop) -> row|null, put(shop, creds) -> void,
// hasOfflineToken(shop) -> boolean, grantedScopes(shop) -> string[].
export function fleetRouter(store) {
  const router = Router();
  router.use(json());

  router.post('/api/fleet/register', async (req, res) => {
    if (!bearerOk(req)) return res.status(401).send('Unauthorized');
    const b = req.body || {};
    if (b.contractVersion !== 1) return res.status(400).json({ error: 'UNSUPPORTED_CONTRACT_VERSION' });
    const shop = String(b.shop || '').trim().toLowerCase();
    if (!SHOP_RE.test(shop) || !b.clientId || !b.secret || !b.appUrl) {
      return res.status(400).send('Invalid credentials payload');
    }
    await store.put(shop, { clientId: String(b.clientId), secret: String(b.secret), appUrl: String(b.appUrl) });
    res.json({ contractVersion: 1, ok: true, shop });
  });

  router.get('/api/fleet/installed', async (req, res) => {
    if (!bearerOk(req)) return res.status(401).send('Unauthorized');
    const shop = String(req.query.shop || '').trim().toLowerCase();
    if (!SHOP_RE.test(shop)) return res.status(400).send('Invalid shop');
    const row = await store.get(shop);
    if (!row) return res.status(404).json({ error: 'Store not registered' });
    const installed = await store.hasOfflineToken(shop);
    res.set('Cache-Control', 'no-store');
    res.json({
      contractVersion: 1,
      installed,
      clientId: row.clientId,
      secretFingerprint: fingerprint(row.secret),
      appUrl: row.appUrl,
      grantedScopes: installed ? await store.grantedScopes(shop) : [],
    });
  });

  return router;
}

// Use this wherever your OAuth/webhook code currently reads one global pair:
export const credsForShop = (store, shop) => store.get(shop);

Raw Node http + @shopify/shopify-api

import { createHash, timingSafeEqual } from 'node:crypto';
import { shopifyApi } from '@shopify/shopify-api';

const SHOP_RE = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i;
const TOKEN = process.env.FLEET_REGISTER_TOKEN;
const clients = new Map(); // clientId -> shopifyApi() instance, built lazily per shop

function apiFor(creds) {
  let client = clients.get(creds.clientId);
  if (!client) {
    client = shopifyApi({
      apiKey: creds.clientId,
      apiSecretKey: creds.secret,
      hostName: new URL(creds.appUrl).host,
      isEmbeddedApp: true,
    });
    clients.set(creds.clientId, client);
  }
  return client;
}

function bearerOk(req) {
  const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization || '');
  const got = Buffer.from(m?.[1]?.trim() || '');
  const want = Buffer.from(TOKEN || '');
  return got.length === want.length && want.length > 0 && timingSafeEqual(got, want);
}

function fingerprint(secret) {
  return createHash('sha256').update(secret, 'utf8').digest('hex').slice(0, 16);
}

async function readJson(req) {
  const chunks = [];
  for await (const c of req) chunks.push(c);
  return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
}

// `store` as in the Express example. Wire this handler into your existing
// http.createServer request listener, ahead of your OAuth/webhook routes.
export async function handleFleetRoutes(req, res, url, store) {
  if (req.method === 'POST' && url.pathname === '/api/fleet/register') {
    if (!bearerOk(req)) return res.writeHead(401).end('Unauthorized');
    const b = await readJson(req);
    if (b.contractVersion !== 1) {
      return res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'UNSUPPORTED_CONTRACT_VERSION' }));
    }
    const shop = String(b.shop || '').trim().toLowerCase();
    if (!SHOP_RE.test(shop) || !b.clientId || !b.secret || !b.appUrl) return res.writeHead(400).end('Invalid credentials payload');
    await store.put(shop, { clientId: b.clientId, secret: b.secret, appUrl: b.appUrl });
    return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ contractVersion: 1, ok: true, shop }));
  }
  if (req.method === 'GET' && url.pathname === '/api/fleet/installed') {
    if (!bearerOk(req)) return res.writeHead(401).end('Unauthorized');
    const shop = (url.searchParams.get('shop') || '').trim().toLowerCase();
    if (!SHOP_RE.test(shop)) return res.writeHead(400).end('Invalid shop');
    const row = await store.get(shop);
    if (!row) return res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Store not registered' }));
    const installed = await store.hasOfflineToken(shop);
    return res
      .writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
      .end(JSON.stringify({
        contractVersion: 1,
        installed,
        clientId: row.clientId,
        secretFingerprint: fingerprint(row.secret),
        appUrl: row.appUrl,
        grantedScopes: installed ? await store.grantedScopes(shop) : [],
      }));
  }
  return false; // not a fleet route — fall through to your app's own routing
}

// Elsewhere, wherever you currently build ONE global shopifyApi() instance:
// const creds = await store.get(shop); const api = apiFor(creds);

Anything else (Rails, Laravel, Python — framework-agnostic)

The shapes above translate directly; this is the same logic with no language-specific syntax, for whatever isn't Node.

FUNCTION handle_fleet_register(request):
  IF NOT constant_time_equal(bearer_token(request), FLEET_REGISTER_TOKEN):
    RETURN 401 "Unauthorized"
  body = parse_json(request.body)
  IF body.contractVersion != 1:
    RETURN 400 { error: "UNSUPPORTED_CONTRACT_VERSION" }
  shop = lowercase(trim(body.shop))
  IF NOT matches(shop, /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i):
    RETURN 400 "Invalid credentials payload"
  IF is_blank(body.clientId) OR is_blank(body.secret) OR is_blank(body.appUrl):
    RETURN 400 "Invalid credentials payload"
  UPSERT fleet_stores WHERE shop = shop
    SET client_id = body.clientId, secret = body.secret, app_url = body.appUrl
  RETURN 200 { contractVersion: 1, ok: true, shop: shop }

FUNCTION handle_fleet_installed(request):
  IF NOT constant_time_equal(bearer_token(request), FLEET_REGISTER_TOKEN):
    RETURN 401 "Unauthorized"
  shop = lowercase(trim(request.query.shop))
  IF NOT matches(shop, /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i):
    RETURN 400 "Invalid shop"
  row = SELECT * FROM fleet_stores WHERE shop = shop
  IF row IS NULL:
    RETURN 404 { error: "Store not registered" }
  installed = EXISTS(a durable/offline access token stored for shop)
  RETURN 200 with header "Cache-Control: no-store", body:
    {
      contractVersion: 1,
      installed: installed,
      clientId: row.client_id,
      secretFingerprint: hex(sha256(row.secret))[0:16],
      appUrl: row.app_url,
      grantedScopes: installed ? stored_scopes(shop) : []
    }

FUNCTION credentials_for_shop(shop):
  RETURN SELECT client_id, secret, app_url FROM fleet_stores WHERE shop = shop
  // Call this everywhere your app currently reads ONE global client_id/secret:
  // OAuth begin, OAuth callback, session-token verification, webhook HMAC check.

Verification checklist

Run these against your own app once the two routes exist, before telling DeploTeka the integration is done.

1. Register rejects an unauthenticated call:

curl -i -X POST https://yourapp.example.com/api/fleet/register \
  -H 'content-type: application/json' \
  -d '{"contractVersion":1,"shop":"test-shop.myshopify.com","clientId":"x","secret":"y","appUrl":"https://yourapp.example.com"}'

Expect 401 Unauthorized, no bearer header sent.

2. Register succeeds with the correct bearer:

curl -i -X POST https://yourapp.example.com/api/fleet/register \
  -H 'authorization: Bearer YOUR_FLEET_REGISTER_TOKEN' \
  -H 'content-type: application/json' \
  -d '{"contractVersion":1,"shop":"test-shop.myshopify.com","clientId":"x","secret":"y","appUrl":"https://yourapp.example.com"}'

Expect 200 and {"contractVersion":1,"ok":true,"shop":"test-shop.myshopify.com"}.

3. Installed reports the row before any install:

curl -i "https://yourapp.example.com/api/fleet/installed?shop=test-shop.myshopify.com" \
  -H 'authorization: Bearer YOUR_FLEET_REGISTER_TOKEN'

Expect 200, "installed":false, and a populated clientId/secretFingerprint/appUrl.

4. Installed flips to true after OAuth: install the app on the test store (via the install link DeploTeka gives you for that store), then repeat the same curl from step 3. Expect "installed":true and a non-empty grantedScopes.

What DeploTeka's cabinet shows when this is working: a store's row moves from Registered to Installed once /installed reports installed:true, and the secretFingerprint your app returns should match the one DeploTeka computed at hand-off. A fingerprint mismatch is DeploTeka's earliest signal that the wrong secret got persisted — check that before anything else if a store gets stuck on Registered.

Frequently asked questions

Does this work with an old or unsupported Shopify library version?

Yes. The fleet contract sits below your OAuth/webhook library entirely — it only needs two HTTP routes and a per-shop credential lookup. A 2023 shopify-app-remix v2 app and a brand-new raw @shopify/shopify-api app implement the exact same contract, because neither the framework nor its version changes what client_id/secret a given request should use.

Does this apply to non-embedded apps?

Yes, with less to do. Non-embedded apps skip session-token JWT verification and the App Bridge client_id step entirely, since there's no iframe. You still need per-shop OAuth (begin/callback) and per-shop webhook HMAC verification — those two injection points are the whole job.

What happens when a secret gets rotated?

DeploTeka calls POST /api/fleet/register again with the new secret; treat it as an upsert and overwrite the stored row. Rotating a secret doesn't invalidate already-issued access tokens, so nothing needs to be re-installed — new OAuth exchanges and new webhook/session-token verifications simply start using the new value from your local table.

What if we can't touch the app's codebase at all?

The two routes have to exist somewhere reachable, but not necessarily inside the app itself. A small sidecar service in front of the same database the app already reads its Shopify credentials from is an acceptable place to implement POST /api/fleet/register and GET /api/fleet/installed — as long as the app's own auth path reads credentials from that same table.

Is there an automated option instead of doing this by hand?

For @shopify/shopify-app-remix v4, yes — npx deploteka onboard runs a codemod automatically. For every other stack, this manual contract is the current path; adapter packages for a few more frameworks are planned, but the two routes plus a credential-lookup swap described here work today for any of them.