Apps
Building an app
Building an app
An Eekaam app is a web service you host. Eekaam sends merchants to it during install, gives it an access token scoped to what the merchant approved, embeds its UI inside the store admin, and posts events to it as the store changes. Nothing runs on Eekaam's servers.
This page is the whole path: create, develop locally, configure, authenticate, call the API, receive webhooks, host, test, submit for review, and release.
Reference: every endpoint, header, and scope named below is what the platform actually enforces. Where a value is a limit or an exact string, it is copied from the implementation rather than from a plan.
1. Create the app
Two ways in, and they produce the same record.
The CLI is the one that leaves you with a project:
eekaam app deploy pushes the manifest to Eekaam, creating or updating the app. You need it once the app has a public home — see Host your app.
Or create it in the partner dashboard under Apps → Create app, which asks only for a name and issues credentials immediately. Use eekaam app link <handle> afterwards to bind a local manifest to it.
| Command | What it does |
|---|---|
eekaam app init | Scaffold an app and its eekaam.app.toml in the current directory |
eekaam app link [handle] | Bind the local manifest to an existing app |
eekaam app dev | Run the app locally on a dev store, through a tunnel and a dev session |
eekaam app deploy | Push the local manifest to Eekaam (--message labels the version) |
eekaam app info | Show the server-side state of the linked app |
eekaam app install | Install the current draft on one of your dev stores |
eekaam app webhook trigger | Send a test webhook for a topic |
eekaam app env show / env pull | Print the app's environment variables, masked / write them to .env |
eekaam app config link / use / list | Keep one manifest per environment |
eekaam app check | Check the project against the automated review rules |
eekaam app submit | Run app check, then submit the current draft for admin review |
eekaam app versions list | List the app's versions and which one is live |
eekaam app release --version | Release an approved version, or roll back to an older one |
eekaam app deploy overwrites server configuration with what the manifest declares. Anything you edited in the dashboard but did not put in the manifest is replaced on the next deploy — keep the manifest as the source of truth, or edit in the dashboard only.
npm i -g @eekaam/cli
eekaam auth login
eekaam app init # scaffolds the app and writes eekaam.app.toml
eekaam app dev # creates the app on first run, then runs it on a dev store2. Develop locally
app dev runs your app against a real dev store. It:
- opens a public HTTPS tunnel to your local port (cloudflared, installed for you),
- creates the app with a first deploy, if this directory is not linked to one yet,
- asks which dev store to use, unless you pass
--store, - starts a dev session that points the app at the tunnel on that store,
- starts your dev server (
npm run dev, or your package manager's) withEEKAAM_APP_URL,EEKAAM_CLIENT_ID,EEKAAM_CLIENT_SECRET,EEKAAM_WEBHOOK_SECRETandEEKAAM_SCOPESin its environment, - starts a GraphQL explorer for the store,
- once your server is listening, opens your app's install route in the browser.
Your eekaam.app.toml is never modified. The tunnel hostname changes every run, so it only ever goes into the session.
Your project needs no .env. Eekaam stores the client secret hashed and cannot return it, so the CLI keeps the copy it was given when the app was created in .eekaam/credentials.json (git-ignored) and passes it in. If there is none, app dev warns you; run eekaam app env pull --rotate to get a new one. Projects created before this keep working: the CLI still reads a secret from an existing .env.
A quick tunnel's hostname takes a few seconds to appear in DNS. app dev waits for it ("waiting for … to appear in DNS…") before it starts the session or opens your browser.
eekaam app devDev sessions
A dev session is an override, not a deploy. Your app's saved config is untouched, and so is every store other than your dev store.
On your dev store only, while the session is active, these come from your manifest, rewritten to the tunnel:
- the app URL and embedded URL,
- redirect URLs — a redirect is allowed if it is in the saved list or the session's,
- allowed origins,
- scopes — an install or authorize on that store asks for the session's scopes,
- webhooks,
- extension target URLs, matched by extension
handle.
sales_channel and theme_embed targets are not overridden.
A session can only be started for your own app on a dev store you own. The app URL, embedded URL and redirect URLs must be https://, or http:// on localhost or 127.0.0.1. Webhook callbacks and allowed origins must be public https://.
A session expires an hour after it was last written. app dev writes it again every 5 minutes, so it stays alive while the command runs. When app dev exits, it ends the session. If the CLI dies instead, the session expires on its own within the hour, and the store goes back to the saved config.
On an Eekaam without dev sessions, app dev prints one yellow line and falls back to the old behaviour: it deploys the tunnel URLs into the app, after a confirmation (skip it with --yes). Editing the manifest then needs a restart.
Webhooks in development
Eekaam calls webhooks from its own servers, so it cannot reach localhost. The tunnel is what makes webhooks work on your machine.
eekaam app init always subscribes to the two topics every app needs, even with a local app URL:
app devmoves each callback to the tunnel's host and puts it in the dev session.app deployleaves out webhooks with a local callback, and prints one line saying so. The saved config never carries a local callback.- Without a tunnel,
app devprintswebhooks │ off — no public URL, and no webhook reaches you.--no-tunnelruns without one on purpose.--require-tunnelstops instead of carrying on when no tunnel can be opened.
While a session is active, events for your app on your dev store go to the session's webhooks, and not to the install's saved subscriptions. A topic that is not in the session is not delivered on that store.
[[webhooks]]
topic = "app/uninstalled"
callback_url = "http://localhost:3000/webhooks"
[[webhooks]]
topic = "app/scopes_update"
callback_url = "http://localhost:3000/webhooks"Editing the manifest while app dev runs
app dev watches eekaam.app.toml. When you save it:
- Valid, and something the session carries changed: the change is pushed into the session, and you see
app │ config pushed (urls, scopes). The parts named areurls,scopes,webhooksandextensions. - Scopes changed:
app devprints the install link again. A new scope is only granted by installing again. - Nothing the session carries changed: you see a dim line saying so. Other fields, such as the name or
[[metafields]], needeekaam app deploy. - Invalid: the error is printed and the file is ignored. The session keeps the last good config. Fix the file and save again.
Keys
app dev shows a row of keys and, under it, two URLs:
│ (p) Preview in browser │ (a) App info │ (s) Store info │ (w) Webhooks │ (k) Send test webhook │ (g) Open GraphiQL (Admin API) │ (l) Logs │ (t) Tunnel │ (q) Quit
Preview URL: https://admin.eekaam.com/apps/open/my-app
GraphiQL URL: http://localhost:3457/graphiql
| Key | Does |
|---|---|
p | Opens your app's page in the store admin |
a | Shows the app's name, URL, scopes and webhooks |
s | Shows the dev store |
w | Lists the last 10 webhook deliveries |
k | Lists the manifest's webhook topics, numbered. Press a number to send a test webhook for that topic |
g | Opens GraphiQL against the dev store, with autocomplete on the Admin API schema |
l | Shows the last 10 log entries |
t | Shows the tunnel |
q | Quits. Ctrl-C does the same |
GraphiQL is served at http://localhost:3457/graphiql. If that port is taken, it uses the next free one up to 3467, then any free port. The access token stays in the CLI; the page only talks to the local server. eekaam app explorer starts the same explorer on its own.
Stopping
Press q or Ctrl-C. app dev ends the dev session first, so Eekaam stops sending the store to your machine. Then it stops the file watcher, your dev server, the explorer and the tunnel.
Your dev server is stopped with its whole process tree: SIGTERM, up to five seconds to exit, then SIGKILL. On Windows it uses taskkill /T /F. Nothing is left holding your port.
3. Access scopes
An app requests scopes; a merchant approves them at install. Every Admin API route checks the scope for that route, so requesting less than you need fails at call time, and requesting more than you need costs you approvals.
| Scope | Grants |
|---|---|
read_settings | Shop details |
read_products | List and read products |
write_products | Create, update, delete products |
read_orders | List and read orders |
read_customers | List and read customers |
read_webhooks | List webhook subscriptions |
write_webhooks | Create and delete webhook subscriptions |
Changing scopes
Stores keep the scopes they granted. When you add a scope and deploy, existing stores keep the old set until the merchant approves the new one on your app's page in their admin, /apps/<app id>. On your dev store, app dev prints the install link again instead.
Whenever an install's granted scopes change, Eekaam sends app/scopes_update to your app. The event's data holds the set before and after, sorted:
It is only sent when the set really changed. Re-granting the same scopes sends nothing.
app dev and app env pull give your app EEKAAM_SCOPES: the manifest's scopes, comma-separated. Compare it with what the store granted to know when to ask for more. Set it in production too.
Apps made with eekaam app init do this already. app/routes/webhooks.tsx stores the new set when app/scopes_update arrives, and the home page shows a warning banner, with a link to the approval page, when EEKAAM_SCOPES lists a scope the store has not granted. With EEKAAM_SCOPES unset, the check is skipped.
{ "previous": ["read_products"], "current": ["read_orders", "read_products"] }4. The install flow
Standard OAuth 2.0 authorization code. The endpoints live at /admin/oauth (also reachable at /api/admin/oauth):
| Endpoint | Purpose |
|---|---|
GET /admin/oauth/authorize | Send the merchant here to approve scopes |
POST /admin/oauth/access_token | Exchange the returned code for a token |
POST /admin/oauth/session/verify | Verify an embedded session token |
POST /admin/oauth/register | RFC 7591 dynamic client registration |
Four things the exchange enforces, each of which is a real rejection rather than a guideline:
- The code is single use. A second exchange with the same code fails, as does one past its expiry.
- PKCE is mandatory for public clients. An app with no usable client secret must send
code_challengeon authorize andcode_verifieron exchange. - The
resourceparameter must match the one sent to authorize, when it was sent. - Suspended apps cannot exchange. Credentials for a suspended app are rejected before the code is even read.
The response is a bearer token and the scopes actually granted, which can be narrower than the scopes requested:
Register the redirect target under Configuration → URLs & permissions. A redirect URI not on that list is refused.
curl -X POST https://api.eekaam.com/admin/oauth/access_token \
-H 'Content-Type: application/json' \
-d '{
"client_id": "$EEKAAM_CLIENT_ID",
"client_secret": "$EEKAAM_CLIENT_SECRET",
"code": "<code from the redirect>",
"code_verifier": "<PKCE verifier>"
}'{ "access_token": "…", "token_type": "Bearer", "scope": "read_products,read_orders" }5. Calling the Admin API
Every resource route sits under a dated prefix and takes the merchant's access token:
| Method | Path | Scope |
|---|---|---|
GET | /shop | read_settings |
GET | /products | read_products |
POST | /products | write_products |
GET | /products/:id | read_products |
PUT | /products/:id | write_products |
DELETE | /products/:id | write_products |
GET | /orders · /orders/:id | read_orders |
GET | /customers · /customers/:id | read_customers |
GET | /webhooks | read_webhooks |
POST | /webhooks | write_webhooks |
DELETE | /webhooks/:id | write_webhooks |
The token is scoped to one store. An app installed on forty stores holds forty tokens; never reuse one across stores.
curl https://api.eekaam.com/admin/api/2026-05/products \
-H "Authorization: Bearer $EEKAAM_ACCESS_TOKEN"6. Embedded apps
An embedded app renders inside the store admin in an iframe. Two settings make that work, both under Configuration → URLs & permissions:
- Embedded app URL — what Eekaam loads in the frame
- Allowed origins — the origins permitted to frame it; an origin missing here is refused by the browser, not by us, so the failure looks like a blank frame
Eekaam appends shop, host, tenant and session_token to the frame URL. The session token identifies the merchant for that page load and must be verified server-side before you trust it:
Never treat the shop or tenant query parameters as authentication. They are hints; the session token is the proof.
curl -X POST https://api.eekaam.com/admin/oauth/session/verify \
-H 'Content-Type: application/json' \
-d '{
"client_id": "$EEKAAM_CLIENT_ID",
"client_secret": "$EEKAAM_CLIENT_SECRET",
"session_token": "<token from the query string>"
}'Look native: the UI kit
The frame is yours to draw, but merchants should not be able to tell where the admin ends and your app begins. Load the Eekaam UI kit in the <head> of every embedded page:
It gives you <e-page>, <e-card>, <e-text-field>, <e-data-table>, <e-modal> and the rest of the admin's components, in its light or dark scheme. It is served from Eekaam's CDN, so when the admin's design changes your app changes with it, without a redeploy. React apps add @eekaam/ui-react for typed components, and keep the script tag. Apps scaffolded with eekaam app init are set up this way already.
See App UI kit for loading, conventions and every component.
<script type="module" src="https://themes-cdn.eekaam.com/ui/v1/ui.js"></script>Talk to the admin: App Bridge
Your frame cannot touch the admin around it, so it asks, over postMessage. With the kit loaded, window.eekaam (or the same functions from @eekaam/ui-react) lets you:
- show a toast in the admin's style —
toast("Saved", "success") - move the merchant to an admin page —
navigate("/orders/123") - set the admin's title bar —
setTitle("Reviews") - get a session token for your backend —
await sessionToken() - list your app's pages in the admin's sidebar —
<e-app-nav> - keep the admin's address bar on your current page —
setAppLocation("/settings") - open the admin's own editor for a record —
openAdmin("edit:product", id)
<e-modal> with a plain-text body is drawn by the admin over the whole window, and <e-save-bar> puts your unsaved changes into the admin's own bar, so the merchant cannot leave without deciding.
See App Bridge for how each works, what happens outside the admin, and the raw protocol.
Opening admin pages
openAdmin(intent, id?) opens one of the admin's own pages: a product's editor, or the form for a new order.
The intents are edit: or create: with product, order, customer or collection. There is no create:customer. The admin goes through its unsaved-changes guard first, as it does for its own links.
See openAdmin for the admin page each intent opens.
openAdmin("edit:product", "123");
openAdmin("edit:product", "gid://eekaam/Product/123"); // a GraphQL gid works too
openAdmin("create:collection");7. Extensions
An extension is a surface your app adds to the merchant's store. Declare them in the manifest or under Configuration. Five types exist:
| Type | Where it appears |
|---|---|
admin_page | A full page inside the store admin |
admin_link | An entry point into your app from elsewhere in the admin |
sales_channel | Your app as a sales channel |
theme_block | A block a merchant can add to a theme section |
theme_embed | A site-wide embed injected into the storefront |
At least one extension is required before an app can be submitted — an app with no surface has nothing for a merchant to open.
8. Webhooks
Subscribe to topics and Eekaam posts JSON to your endpoint as things change.
| Group | Topics |
|---|---|
| App | app/uninstalled · app/scopes_update |
| Products | products/create · products/update · products/delete |
| Orders | orders/create · orders/updated · orders/paid · orders/fulfilled · orders/cancelled · orders/refunded · orders/payment_failed |
| Customers | customers/create · customers/update |
Every delivery carries these headers:
| Header | Value |
|---|---|
X-Eekaam-Topic | The topic that fired |
X-Eekaam-Webhook-Id | Event id, stable across retries — use it to dedupe |
X-Eekaam-Hmac-Sha256 | HMAC-SHA256 of the raw body, hex encoded |
User-Agent | Eekaam-Webhooks/1.0 |
Verify the signature before parsing the body. It is computed over the exact bytes received with your subscription secret, so compute it before any JSON round-trip:
Operational rules worth designing around:
- Deliveries time out after 10 seconds. Acknowledge with a 2xx immediately and do the work asynchronously; slow handlers are recorded as failures.
- Any non-2xx is a failure. Only 200–299 counts as delivered.
- Five recent failures disable the subscription. It stops firing until you re-enable it, so a broken deploy silently costs you events. Watch delivery history after every release.
app/uninstalledis the one you cannot skip. Stop billing and delete the store's data when it arrives.app/scopes_updatetells you what a store granted. See Changing scopes.
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}Testing webhooks
You do not have to create an order to see how your app handles orders/create. Send a test webhook:
It goes where a real event would: the running app dev session's callback for that topic, else the install's saved subscription. Pass --address to send it somewhere else; it must be a public https:// URL. --store picks the dev store. eekaam app webhooks trigger is the same command.
✓ orders/create delivered to https://calm-river.trycloudflare.com/webhooks
status 200 · 184 ms
The command exits 1 when your app did not accept it, so you can use it in a script. In app dev, press k to do the same for any topic in your manifest.
A test webhook is a real delivery in every way your handler can check:
- The body is the production envelope,
id,topic,app_id,tenant_id,created_atanddata, plus"test": true. datais built from your dev store's most recent real record of that type — its latest order, product or customer. When the store has none, it is a fixed sample whose ids are all zeros.- The headers are the production headers, signed with the same secret, plus
X-Eekaam-Test: true. - It is sent once and waits up to 10 seconds for your answer. It shows up in the delivery history and the app's logs, marked as a test.
A handler that verifies signatures verifies a test webhook the same way. Use X-Eekaam-Test or test if your handler should skip side effects, such as emails, for tests.
eekaam app webhook trigger --topic orders/create9. Metafields
An app can declare the metafield definitions it needs in the manifest. Eekaam creates them in every store the app is installed on, so your app never has to create them at runtime.
| Field | Required | Value |
|---|---|---|
owner_type | Yes | product, variant, collection, customer or order |
namespace | Yes | "$app" — the only namespace an app can declare |
key | Yes | 2 to 64 characters of lowercase letters, digits and _ |
type | Yes | A supported metafield type, such as single_line_text_field or boolean |
name | No | What the merchant sees. Defaults to the key. Up to 255 characters |
description | No | Help text |
access | No | What the merchant can do. Defaults to merchant_read_write |
An app can declare up to 50 definitions. Two entries with the same owner_type and key are refused. The CLI checks the fields before it sends anything, and Eekaam checks them again, along with the type and the limit.
[[metafields]]
owner_type = "product"
namespace = "$app"
key = "demo_info"
name = "Demo info"
type = "single_line_text_field"
description = "Shown on the product page"
access = "merchant_read_write"The $app namespace
$app stands for your app's own namespace, app--<handle>. An app with the handle reviews gets app--reviews. Use $app in the Admin GraphQL API too: it resolves to the same namespace, and no app can write to another app's app--* namespace.
Access
access decides what the merchant can do with the values in their admin:
access | The merchant can |
|---|---|
merchant_read_write | See the definition and its values, and edit the values |
merchant_read | See the definition and its values, but not change or delete them |
none | Nothing. The definition and its values are hidden from the merchant |
Whatever the access, the merchant cannot rename, retype or delete a definition your app declared. The manifest manages it.
When definitions are applied
app deploy sends the definitions, and shows added (+), changed (~) and removed (-) ones in its diff. They are then written to a store when your app is installed there, and when the store moves to a new version of your app.
On your dev store, app dev also writes them each time it starts or refreshes its dev session, using the definitions you last deployed. So after editing [[metafields]], run eekaam app deploy; saving the file while app dev runs is not enough.
Removing a definition
Removing a definition from the manifest deletes it from every store your app is on. So a deploy that drops a [[metafields]] entry is refused unless you confirm it:
Without the flag, the CLI warns and Eekaam refuses the deploy. If you remove every [[metafields]] entry, the deploy goes through and the existing definitions stay. Only definitions the manifest declared are deleted. Ones your app created at runtime through the API are left alone.
eekaam app deploy --allow-deletes10. Pricing
An app can ship several plans. A free plan is activated at install with no billing step. A paid plan must be connected to a billing provider variant before the app can be submitted — a paid plan with no variant is the most common reason a submission is blocked.
If your app has both a free and a paid plan, merchants choose at install. Ship the free plan first if you want installs before revenue.
11. Host your app
Your app is a web service, and you run it. Merchants need a public HTTPS URL for it before you submit.
Apps made with eekaam app init are one Node server with a SQLite database file. They come ready to host:
- A
Dockerfile. It builds a production image, keeps the database at/data/installs.db, and starts withnpm run docker-start: migrations, then the server. npm run setup, which applies database migrations without starting anything. Run it before each deploy.- Versioned migrations in
app/migrations, namedNNN_what_it_does.sql. They run in order, once each, each in a transaction, and are recorded in aschema_migrationstable. The server also applies new ones when it opens the database. To change the schema, add the next file. Never edit one that has already run somewhere.
Mount a volume at /data. Without it, every deploy starts with an empty database, and every store has to reinstall.
The project's README has step-by-step setups for Fly.io, Render and a plain VM, and notes on moving to Postgres. Wherever you host, keep one server while the database is SQLite, and set these environment variables:
| Variable | |
|---|---|
EEKAAM_CLIENT_ID | The app's client id |
EEKAAM_CLIENT_SECRET | The client secret. eekaam app env pull --rotate issues a new one |
EEKAAM_WEBHOOK_SECRET | Verifies webhook signatures |
EEKAAM_APP_URL | The public URL |
EEKAAM_SCOPES | The manifest's scopes, comma-separated |
EEKAAM_DB_PATH | The SQLite file, on a persistent volume |
eekaam app env show prints the current values with the secrets masked (--reveal prints them in full). Copy them into your host's secret store rather than shipping a .env, and set EEKAAM_APP_URL to your host.
Then point app_url, embedded_url, redirect_urls, allowed_origins and the webhook callback_urls in eekaam.app.toml at the public host, and run eekaam app deploy.
docker build -t my-app .
docker run -p 3000:3000 -v my-app-data:/data --env-file .env my-app12. Environments
Keep one manifest per environment, each linked to its own app on Eekaam — a staging app and a production app, for example. Beside eekaam.app.toml, add eekaam.app.<name>.toml files:
config linkfetches an app you own, writes its settings to the file and links the file to it. Without--app, it asks which app. Without--name, it writeseekaam.app.toml. It will not overwrite an existing file unless you pass--force.config use <name>makes every app command use that file.config use defaultswitches back toeekaam.app.toml.--config <name>works on every app command, and wins over the default.
A name is up to 40 lowercase letters, digits, - and _, and starts with a letter or digit. default is reserved. The links are kept in .eekaam/app.json.
eekaam app config link --name staging --app my-app-staging # writes eekaam.app.staging.toml
eekaam app deploy --config staging # use it for one command
eekaam app config use staging # make it the default
eekaam app config list # show every config13. Test before you submit
Install the app on one of your own development stores and walk the real flow — the partner dashboard's Testing screen does this without needing admin approval, and eekaam app install does the same from the terminal. eekaam app dev installs it for you while you develop.
Confirm on that store:
- The authorize screen lists the scopes you expect, and no more
- The redirect lands on a URL in your allowlist and the code exchanges once
- The embedded page loads in the admin frame, and the session token verifies server-side
- Every Admin API call your app makes succeeds with only the scopes you requested
- Each webhook you subscribe to arrives, verifies, and is acknowledged inside 10 seconds —
eekaam app webhook triggersends one on demand app/uninstalledstops billing and clears the store's data
14. Submit for review
Submission is blocked until all of the following are true. The partner dashboard shows them as a checklist on the Release screen:
- Listing details are complete — name and short description
- OAuth URLs and iframe origins are configured
- Embedded app URL is set
- Permissions are selected
- At least one extension is declared
- Paid plans are connected to billing
- Support and privacy links are ready
Then run the automated review rules against your project:
It checks that the app loads the UI kit, uses no browser dialogs, keeps its client secret on the server, verifies webhook signatures, serves everything over HTTPS, and more. eekaam app submit runs it first and refuses to submit while it reports errors.
Reviewers then check every rule — the automated ones and those only a person can judge — and cite rule ids such as ui.save-bar when they reject a submission. Read the App review guidelines before you submit.
A release is a snapshot of the configuration at the moment you submit. Merchants keep running the currently released version until a new one is approved, so editing configuration after submitting does not change what they have — it creates the next draft.
eekaam app check15. Versions and releases
Once your app is approved, each change goes out as a new version. Deploy it with a message, so you can tell versions apart later:
The message is up to 500 characters. On an app that has never been released, it is kept for the first version you submit.
List the versions, newest first. * marks the one stores run:
VERSION STATUS MESSAGE CREATED RELEASED
1.3.0 approved Adds the reviews widget 2026-09-17 14:03 2026-09-17 16:20
* 1.2.0 approved Fixes the settings page 2026-09-02 10:11 2026-09-02 12:40
Release an approved version with eekaam app release:
It asks before releasing; --yes skips the question. Stores then move to that version. A store that has to approve new scopes gets it as a pending update, as after any release.
To roll back, release an older approved version. The command says "Roll back to" when the version is older than the live one, and prints the command to undo it. Only approved versions can be released, and not the one already live. A suspended app cannot release at all.
eekaam app deploy --message "Adds the reviews widget"eekaam app versions listeekaam app release --version 1.3.016. Credentials and security
- The client secret is shown once, when the app is created or the secret is rotated. Store it as
EEKAAM_CLIENT_SECRET; there is no way to read it back. - Rotating invalidates the old secret immediately. Deploy the new one to every server that uses it before rotating, not after.
- Never ship the client secret to a browser. An app that cannot keep a secret is a public client and must use PKCE.
- Access tokens are per store and per install. Treat them as credentials belonging to that merchant, and delete them when
app/uninstalledarrives.
Updated 17 September 2026