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
- DeploTeka creates the store’s dedicated app in your Shopify organization.
- It calls
POST /api/fleet/registerwith that app’s client ID and secret. Your app stores them, so the store can authenticate from its first request. - It calls
GET /api/fleet/installedto read the record back and compare the secret’s fingerprint. - 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"
]
}
| Field | Notes |
|---|---|
contractVersion | Always 1. Answer 400 {"error":"UNSUPPORTED_CONTRACT_VERSION"} to anything else. |
shop | The client store’s *.myshopify.com domain. |
clientId, secret | The dedicated app’s credentials. Store the secret encrypted. |
appUrl | Your app’s URL as configured on the card. |
shopifyAppId, scopes | Sent 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"
}
]
}
}
| Field | Notes |
|---|---|
installed | true only when you hold an offline token for this shop that Shopify accepts. |
secretFingerprint | The 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. |
grantedScopes | What the merchant actually granted. |
extensionAttestation | Optional. 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 deplotekaadds both routes and the lookup to your code and shows the diff first. - Express: the
deploteka-app-fleet-expresspackage. Guide - Laravel, Rails, Python, webhook-only, extension-only: a guide each, in the sidebar.