eekaam.docs

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.

CommandWhat it does
eekaam app initScaffold 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 devRun the app locally on a dev store, through a tunnel and a dev session
eekaam app deployPush the local manifest to Eekaam (--message labels the version)
eekaam app infoShow the server-side state of the linked app
eekaam app installInstall the current draft on one of your dev stores
eekaam app webhook triggerSend a test webhook for a topic
eekaam app env show / env pullPrint the app's environment variables, masked / write them to .env
eekaam app config link / use / listKeep one manifest per environment
eekaam app checkCheck the project against the automated review rules
eekaam app submitRun app check, then submit the current draft for admin review
eekaam app versions listList the app's versions and which one is live
eekaam app release --versionRelease 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 store

2. Develop locally

app dev runs your app against a real dev store. It:

  1. opens a public HTTPS tunnel to your local port (cloudflared, installed for you),
  2. creates the app with a first deploy, if this directory is not linked to one yet,
  3. asks which dev store to use, unless you pass --store,
  4. starts a dev session that points the app at the tunnel on that store,
  5. starts your dev server (npm run dev, or your package manager's) with EEKAAM_APP_URL, EEKAAM_CLIENT_ID, EEKAAM_CLIENT_SECRET, EEKAAM_WEBHOOK_SECRET and EEKAAM_SCOPES in its environment,
  6. starts a GraphQL explorer for the store,
  7. 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 dev

Dev 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 dev moves each callback to the tunnel's host and puts it in the dev session.
  • app deploy leaves out webhooks with a local callback, and prints one line saying so. The saved config never carries a local callback.
  • Without a tunnel, app dev prints webhooks │ off — no public URL, and no webhook reaches you. --no-tunnel runs without one on purpose. --require-tunnel stops 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 are urls, scopes, webhooks and extensions.
  • Scopes changed: app dev prints 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]], need eekaam 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
KeyDoes
pOpens your app's page in the store admin
aShows the app's name, URL, scopes and webhooks
sShows the dev store
wLists the last 10 webhook deliveries
kLists the manifest's webhook topics, numbered. Press a number to send a test webhook for that topic
gOpens GraphiQL against the dev store, with autocomplete on the Admin API schema
lShows the last 10 log entries
tShows the tunnel
qQuits. 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.

ScopeGrants
read_settingsShop details
read_productsList and read products
write_productsCreate, update, delete products
read_ordersList and read orders
read_customersList and read customers
read_webhooksList webhook subscriptions
write_webhooksCreate 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):

EndpointPurpose
GET /admin/oauth/authorizeSend the merchant here to approve scopes
POST /admin/oauth/access_tokenExchange the returned code for a token
POST /admin/oauth/session/verifyVerify an embedded session token
POST /admin/oauth/registerRFC 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_challenge on authorize and code_verifier on exchange.
  • The resource parameter 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:

MethodPathScope
GET/shopread_settings
GET/productsread_products
POST/productswrite_products
GET/products/:idread_products
PUT/products/:idwrite_products
DELETE/products/:idwrite_products
GET/orders · /orders/:idread_orders
GET/customers · /customers/:idread_customers
GET/webhooksread_webhooks
POST/webhookswrite_webhooks
DELETE/webhooks/:idwrite_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:

TypeWhere it appears
admin_pageA full page inside the store admin
admin_linkAn entry point into your app from elsewhere in the admin
sales_channelYour app as a sales channel
theme_blockA block a merchant can add to a theme section
theme_embedA 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.

GroupTopics
Appapp/uninstalled · app/scopes_update
Productsproducts/create · products/update · products/delete
Ordersorders/create · orders/updated · orders/paid · orders/fulfilled · orders/cancelled · orders/refunded · orders/payment_failed
Customerscustomers/create · customers/update

Every delivery carries these headers:

HeaderValue
X-Eekaam-TopicThe topic that fired
X-Eekaam-Webhook-IdEvent id, stable across retries — use it to dedupe
X-Eekaam-Hmac-Sha256HMAC-SHA256 of the raw body, hex encoded
User-AgentEekaam-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/uninstalled is the one you cannot skip. Stop billing and delete the store's data when it arrives.
  • app/scopes_update tells 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_at and data, plus "test": true.
  • data is 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/create

9. 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.

FieldRequiredValue
owner_typeYesproduct, variant, collection, customer or order
namespaceYes"$app" — the only namespace an app can declare
keyYes2 to 64 characters of lowercase letters, digits and _
typeYesA supported metafield type, such as single_line_text_field or boolean
nameNoWhat the merchant sees. Defaults to the key. Up to 255 characters
descriptionNoHelp text
accessNoWhat 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:

accessThe merchant can
merchant_read_writeSee the definition and its values, and edit the values
merchant_readSee the definition and its values, but not change or delete them
noneNothing. 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-deletes

10. 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 with npm 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, named NNN_what_it_does.sql. They run in order, once each, each in a transaction, and are recorded in a schema_migrations table. 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_IDThe app's client id
EEKAAM_CLIENT_SECRETThe client secret. eekaam app env pull --rotate issues a new one
EEKAAM_WEBHOOK_SECRETVerifies webhook signatures
EEKAAM_APP_URLThe public URL
EEKAAM_SCOPESThe manifest's scopes, comma-separated
EEKAAM_DB_PATHThe 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-app

12. 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 link fetches 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 writes eekaam.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 default switches back to eekaam.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 config

13. 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 trigger sends one on demand
  • app/uninstalled stops 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 check

15. 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 list
eekaam app release --version 1.3.0

16. 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/uninstalled arrives.

Updated 17 September 2026