DeploTeka Set up your first store
Developer docs: Fleet contract

Fleet contract

Your app keeps one codebase and serves many dedicated apps, one per client store. These two routes let DeploTeka hand your app each store’s credentials and check that the store really installed.

How it is used

  1. DeploTeka creates the store’s dedicated app in your Shopify organization.
  2. It calls POST /api/fleet/register with that app’s client ID and secret. Your app stores them, so the store can authenticate from its first request.
  3. It calls GET /api/fleet/installed to read the record back and compare the secret’s fingerprint.
  4. After the merchant installs, the same call reports installed: true. Only then does the store count as live.

Your app also needs a per-shop lookup: when a request arrives for a shop, use that shop’s client ID and secret, not one global pair.

Authentication

Both routes take Authorization: Bearer <token>. The token is a secret shared between your backend, as FLEET_REGISTER_TOKEN, and the app’s card in DeploTeka. A wrong or missing token gets 401. Treat it like a password: whoever holds it can overwrite a shop’s credentials.

POST /api/fleet/register

Request body:

{
  "contractVersion": 1,
  "shop": "client-store-shopify-demo.myshopify.com",
  "clientId": "a1b2c3d4e5f6",
  "secret": "<the dedicated app’s client secret>",
  "appUrl": "https://app.your-product.example",
  "shopifyAppId": "1234567890",
  "scopes": [
    "read_products"
  ]
}
FieldNotes
contractVersionAlways 1. Answer 400 {"error":"UNSUPPORTED_CONTRACT_VERSION"} to anything else.
shopThe client store’s *.myshopify.com domain.
clientId, secretThe dedicated app’s credentials. Store the secret encrypted.
appUrlYour app’s URL as configured on the card.
shopifyAppId, scopesSent for information. You may ignore them.

Answer 200 with:

{ "contractVersion": 1, "ok": true, "shop": "client-store-shopify-demo.myshopify.com" }

GET /api/fleet/installed?shop=

Answer 404 {"error":"Store not registered"} for a shop you never stored. Otherwise answer 200 with cache-control: no-store, even before the install:

{
  "contractVersion": 1,
  "installed": true,
  "clientId": "a1b2c3d4e5f6",
  "secretFingerprint": "9f86d081884c7d65",
  "appUrl": "https://app.your-product.example",
  "grantedScopes": [
    "read_products"
  ],
  "extensionAttestation": {
    "contractVersion": 1,
    "clientId": "a1b2c3d4e5f6",
    "observedAt": "2026-11-07T10:00:00.000Z",
    "handles": [
      {
        "handle": "theme-widget",
        "verdict": "active"
      }
    ]
  }
}
FieldNotes
installedtrue only when you hold an offline token for this shop that Shopify accepts.
secretFingerprintThe first 16 hex characters of the SHA-256 of the stored secret, as UTF-8. DeploTeka computes the same value to confirm you stored the right secret, without either side sending it again.
grantedScopesWhat the merchant actually granted.
extensionAttestationOptional. Each required extension handle with active, absent or unknown.

Express example

A minimal version, without our package. saveStore, findStore and findWorkingOfflineSession are yours.

import express from 'express';
import { createHash } from 'node:crypto';

const app = express();
app.use(express.json());
const TOKEN = process.env.FLEET_REGISTER_TOKEN;
const authorized = (req) => Boolean(TOKEN) && req.get('authorization') === `Bearer ${TOKEN}`;
const SHOP = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/i;

app.post('/api/fleet/register', async (req, res) => {
  if (!authorized(req)) return res.status(401).send('Unauthorized');
  const { contractVersion, shop, clientId, secret, appUrl } = req.body ?? {};
  if (contractVersion !== 1) return res.status(400).json({ error: 'UNSUPPORTED_CONTRACT_VERSION' });
  if (!SHOP.test(shop ?? '') || !clientId || !secret || !appUrl) return res.status(400).send('Invalid credentials payload');
  await saveStore(shop.toLowerCase(), { clientId, secret, appUrl }); // your database
  res.json({ contractVersion: 1, ok: true, shop: shop.toLowerCase() });
});

app.get('/api/fleet/installed', async (req, res) => {
  if (!authorized(req)) return res.status(401).send('Unauthorized');
  const shop = String(req.query.shop ?? '').toLowerCase();
  if (!SHOP.test(shop)) return res.status(400).send('Invalid shop');
  const stored = await findStore(shop);
  if (!stored) return res.status(404).json({ error: 'Store not registered' });
  // Report installed only when you hold an offline token for this shop that Shopify accepts.
  const offline = await findWorkingOfflineSession(shop);
  res.set('cache-control', 'no-store').json({
    contractVersion: 1,
    installed: Boolean(offline),
    clientId: stored.clientId,
    secretFingerprint: createHash('sha256').update(stored.secret, 'utf8').digest('hex').slice(0, 16),
    appUrl: stored.appUrl,
    grantedScopes: offline ? offline.scope.split(',') : [],
  });
});

Done for you

  • Remix v2–v4 and React Router v7: npx deploteka adds both routes and the lookup to your code and shows the diff first.
  • Express: the deploteka-app-fleet-express package. Guide
  • Laravel, Rails, Python, webhook-only, extension-only: a guide each, in the sidebar.