Fleet contract for extension-only Shopify apps: the one-file shim
Short answer: an app that is only Shopify extensions — a theme app extension, a web pixel, a checkout UI extension, a Function — has no backend to modify, so running it as a fleet is a hosting question rather than an integration one. You deploy one small file that answers two HTTP requests, and nothing about your extensions changes. npx deploteka onboard . --scaffold writes it; it has no dependencies.
The inversion
Every other stack guide describes changing an existing backend: find where the app reads its one client_id and secret, and make that read per-shop. Extension-only apps invert it. There is nothing to change, because there is nothing there. What is missing is a host.
The deploy half already works and always did. The Shopify CLI deploys extensions per app from a shopify.app.toml, and DeploTeka drives exactly that, once per dedicated app it provisions. Your extension source, your build pipeline and your toml stay as they are.
What does not work without a host is the handshake. DeploTeka provisions a dedicated Shopify app for a store, and then has to do two things: hand you the credentials, and later confirm the store installed. Both are HTTP requests. Something has to be on the other end.
What the shim is, and what it must never become
It is a credential mailbox with a status light.
- It receives
POST /api/fleet/registerand stores what it is given. - It answers
GET /api/fleet/installed?shop=with the facts it stored plus a boolean. - It never calls Shopify.
- It holds no merchant data beyond the credentials DeploTeka pushes to it.
- It does no work between requests. There is no queue, no scheduler, no traffic.
That list is the point. A component with those properties can run on the cheapest thing you have and be forgotten about. The temptation, once a server exists, is to give it a second job — receive the webhooks, ingest the pixel events, host a settings page. Resist it here. The moment this file ingests merchant traffic it acquires uptime, capacity and data-handling requirements that the contract does not have, and you have quietly built the backend you did not want. Run that separately; it can read the same replica if it needs a per-shop secret.
What DeploTeka requires
Four things, of which an extension-only app genuinely needs two and a half.
- `POST /api/fleet/register` — DeploTeka pushes a freshly provisioned dedicated app's credentials. You persist them.
- `GET /api/fleet/installed?shop=` — DeploTeka polls install state and verifies the secret/client_id binding.
- Per-shop credential resolution. For an app with no OAuth, no session tokens and no webhook verification of its own, there is nothing to resolve credentials for — this obligation is satisfied vacuously. If any of those three appear later, the universal contract covers them.
- A local replica so reads never depend on DeploTeka being reachable.
The wire shapes are frozen at contractVersion: 1.
Generate it
npx deploteka onboard . --scaffold
The CLI recognises the shape — a shopify.app.toml and an extensions/ directory, with no Shopify server dependency in any manifest — and writes deploteka-fleet/fleet.mjs plus a README into your repository. It writes nowhere else and never overwrites an existing file.
The file is about 250 lines including its comments, imports only node:http, node:crypto, node:fs and node:path, and runs standalone:
FLEET_REGISTER_TOKEN=... FLEET_STORE_PATH=/data/fleet-stores.json PORT=8787 \
node deploteka-fleet/fleet.mjs
It is the same implementation the webhook-only guide describes, and that guide walks through the contract line by line if you want to read it before you run it. Every release, the generated file 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.
Where to run it
The requirements are modest and specific: a stable HTTPS URL, and storage that survives a restart. DeploTeka polls the URL and reads back what it wrote, so a URL that changes or state that evaporates both break the handshake in ways that look like a provisioning failure.
| Option | Fits when | What to change |
|---|---|---|
| Small container or VM | You already run infrastructure | Nothing. Mount a volume for the JSON file |
| Cloudflare Worker | You want zero servers | Swap readStore/writeStore for KV or D1; keep everything above them |
| Vercel / Netlify function | You already deploy there | Same swap, against your KV of choice |
| An existing unrelated service | You have one with a stable URL | Import the file and call handleFleetRequest as the first line of your handler |
The two storage functions are the only platform-specific part of the file. Everything above them is the contract and should not be touched — a serverless port that also "simplifies" the response bodies is the way this breaks.
One note if you go serverless: the file writes its replica atomically via a temporary file and a rename, which a KV store gives you for free but a naive object-store port does not. Make the replacement a single atomic put, not a read-modify-write across two calls.
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.
FLEET_STORE_PATH where the replica lives. Point it at a persistent volume.
PORT what to listen on. Defaults to 8787.
Then give DeploTeka the app URL. It has to be the URL DeploTeka can actually reach, because it is polled rather than pushed to blindly.
Answering the install question honestly
installed is the one field the shim cannot derive on its own, and it is worth a minute of thought rather than a default.
DeploTeka polls it to decide whether a store is live, and it stops chasing an install once the answer is true. So a wrong true is expensive — the cabinet shows a store as done and nobody notices it never installed — while a false costs nothing except continued polling.
For an app that is genuinely install-only, with nothing calling back, two honest signals are available:
- The `app/installed` (or `app_subscriptions`) webhook. Subscribe the dedicated app to it and have the delivery write a flag onto the row. This is the cleanest answer, and it makes the shim's own HMAC verification worth adding — the per-shop secret is already sitting in the row.
- A ping from the extension itself. A web pixel or theme extension that already talks to an endpoint of yours can report the shop domain once; write the flag from there.
If neither is available, return false and let the operator confirm installs by hand. That is a smaller lie than the alternative.
What does not change
- Your extensions. Source, build, and
shopify.app.tomlall stay as they are. - Your deploys. DeploTeka runs the Shopify CLI per dedicated app; that path has never needed a backend.
- Web pixel configuration. A pixel receives its settings at install time rather than from the toml, so if your pixel points at an endpoint of your own, that stays a per-store setting and is unaffected by any of this.
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-shim.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, not an empty 200.
Then restart the process and read again. If the row is gone, your storage is not persistent, and that is the failure this checklist exists to catch.
The future version of this page
A DeploTeka-hosted attestation mode — where the platform holds the replica for apps that have no backend, and this handshake happens without you running anything — is a plausible addition to a future contract version. It would delete this page for extension-only apps. It does not exist today, so the shim is the honest answer, which is why it is deliberately small enough to deploy once and forget.