deploteka ← All guides

By DeploTeka · Last updated August 13, 2026

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/register and 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.

  1. `POST /api/fleet/register` — DeploTeka pushes a freshly provisioned dedicated app's credentials. 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. 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.
  4. 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.

OptionFits whenWhat to change
Small container or VMYou already run infrastructureNothing. Mount a volume for the JSON file
Cloudflare WorkerYou want zero serversSwap readStore/writeStore for KV or D1; keep everything above them
Vercel / Netlify functionYou already deploy thereSame swap, against your KV of choice
An existing unrelated serviceYou have one with a stable URLImport 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.toml all 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.

Frequently asked questions

Why does an app with no backend need a backend at all?

Only because DeploTeka is a distributed system and the two halves have to agree. When it provisions a dedicated Shopify app for a store, it has to hand the credentials somewhere and later confirm that the store actually installed. Both are HTTP requests, and something has to answer them. Nothing about your extensions changes: the Shopify CLI already deploys them per app from your shopify.app.toml with no server involved. The shim exists to be the endpoint of a handshake, not to serve your app.

How small is it really?

One file, about 250 lines including comments, with no dependencies beyond the Node standard library. It uses node:http, node:crypto and node:fs. It never calls Shopify, holds no merchant data beyond the credentials DeploTeka pushes to it, and does no work between requests. Generate it with npx deploteka onboard . --scaffold and the only thing you have to decide is where to run it.

Where should we host it?

Anywhere with a stable HTTPS URL and somewhere to persist a small amount of state. A $5 container, a free-tier VM, or a Cloudflare Worker or Vercel function after you swap the JSON file store for KV — the two storage functions are the only platform-specific part. The important properties are the URL not changing and the storage surviving a restart: DeploTeka polls the URL and reads back what it wrote.

Do our extensions need to change?

No. Extensions are deployed per app by the Shopify CLI against a shopify.app.toml, and DeploTeka drives exactly that, once per dedicated app. Your extension source, your build and your toml stay as they are. The one thing to know is that a web pixel receives its configuration through the pixel settings set at install time, not from the toml, so if your pixel points at a backend of your own, that endpoint is configured per store there.

Should the shim also receive our webhooks or pixel events?

Not this file. It is small, auditable and boring precisely because it does one thing, and the moment it also ingests merchant traffic it becomes a service with uptime and capacity requirements that the contract does not have. If you need an events endpoint, run it separately — it can read the same credential replica if it needs a per-shop secret to verify signatures.

What does installed mean when there is no OAuth and no token?

Whatever installation means for your app. If your app is genuinely install-only — the merchant installs it and the extensions activate, with nothing calling back — then the truthful answer needs some signal that the install happened, and the honest options are the app-installed webhook writing a flag, or your pixel or extension pinging the shim once. If you have nothing, report false rather than true: false only means DeploTeka keeps checking, while a wrong true makes the cabinet mark the store live and stop.

Is there a hosted version, so we do not run anything?

Not today. A DeploTeka-hosted attestation mode — where the platform holds the replica for apps that have no backend — is a plausible future addition to the contract, and it would remove this page entirely for extension-only apps. Until it exists, the shim is the honest answer, which is why it is deliberately small enough to deploy and forget.