Salla App Lifecycle Flow
Keep your backend in sync with each merchant's relationship to your app — from install through trial, subscription, and uninstall. Follow the steps in order; complete each gate before moving on. Step 1 sets the webhook receiver with the Salla Partners MCP; Steps 2–6 are the runtime handlers you write.
Tools & MCPs
The App Events doc (https://docs.salla.dev/421413m0.md) is the authoritative source for every lifecycle payload shape. The Salla Partners MCP performs actions:
| Tool | Action | What it does |
|---|---|---|
salla_apps |
connect / get |
Set the webhook_url (lifecycle app events auto-deliver to it) / inspect app state |
Lifecycle events are
app.*events — auto-delivered, no subscribe call. The app is subscribed to its own app events by default, soapp.installed,app.store.authorize,app.updated,app.uninstalled,app.trial.*,app.subscription.*, andapp.settings.updatedall arrive at yourwebhook_urlautomatically the moment they fire. You do not callsalla_events action=subscribefor any of them — that action is only for non-app (store) events (order.*,product.*, …) → salla-webhooks.
All events arrive on your single webhook endpoint inside the standard envelope (
event,merchant,created_at,data). Verify the signature, acknowledge fast with200, then process asynchronously. Always upsert keyed bymerchant. Prerequisites: signature + idempotency + fast 200 → salla-webhooks; token persistence fromapp.store.authorize→ salla-app-auth.
Event Catalog (reference)
| Event | When | What to do |
|---|---|---|
app.installed |
First install | Provision merchant resources, set defaults |
app.store.authorize |
Install or token refresh | Save/update access_token + refresh_token + expiry per merchant |
app.updated |
Merchant updates the app | Salla fires app.store.authorize right after — wait for it for new tokens |
app.settings.updated |
Merchant changes app settings | Apply data.settings — schema/validation owned by salla-app-settings |
app.trial.started |
Trial begins | Enable trial features |
app.trial.expired |
Trial ended without upgrade | Restrict access |
app.trial.canceled |
Trial cancelled | Restrict access |
app.subscription.started |
Paid plan or addon activated | Unlock features; branch on data.item_type |
app.subscription.renewed |
Plan/addon renewed | Confirm active; store new data.end_date / renew_date |
app.subscription.expired |
Plan/addon lapsed | Restrict access, notify merchant |
app.subscription.canceled |
Plan/addon cancelled | Restrict access |
app.feedback.created |
Merchant leaves a review | Log rating/comment |
app.subscription.*fires for both plans and addons —data.item_type("plan"|"addon") tells them apart.app.trial.*carries a smaller payload than subscription (nosubscription_id,item_type, pricing, etc.) — see the reference. Full subscription payload + plan-state handling → salla-app-billing. Full per-event JSON (authoritative shapes from https://docs.salla.dev/421413m0.md) → references/lifecycle-payloads.md.
Step 0 — Discover
Ask before starting:
- Which lifecycle states does your app care about? (install only, or trial + subscription too?)
- What do you provision on install, and what must be cleaned up on uninstall (retention / GDPR policy)?
- Do you gate features on plan/trial state? (if so, pair with salla-app-billing)
Step 1 — Set the Webhook URL (lifecycle events auto-deliver)
Lifecycle events are app events — the app is subscribed to them by default, so they
arrive the moment a webhook_url is set. There is no subscribe call; the one action is
to configure the receiver with the Partners MCP:
- Configure the receiver:
salla_apps action=connect,app_id,webhook_url,webhook_security_strategy: "signature".connectdoesn't mint the signing secret — create/rotate it in the Partner Portal (https://portal.salla.partners/apps/{app_id}) and read the current value viasalla_apps action=get(thewebhook_secretfield) before deploy. Store it in a secret manager/KMS (never in source or logs) — secure storage and HMAC verification are owned by salla-webhooks. - That's it for delivery:
app.installed,app.store.authorize,app.updated,app.uninstalled,app.trial.*,app.subscription.*, andapp.settings.updatednow flow to that URL automatically. (salla_events action=subscribeis for store events —order.*,product.*, … — which lifecycle handling doesn't need → salla-webhooks.)
Inspect the app's current configuration any time with salla_apps action=get, app_id.
Gate: "webhook_url set via salla_apps action=connect (app events auto-deliver to it —
no subscribe call), and your endpoint returns 200?"
Step 2 — Handle the Install Flow
On first install Salla fires app.installed and app.store.authorize. Treat
each handler as independent and idempotent; arrival order is not guaranteed.
Upsert the per-
merchantrecord (create-or-update) in every handler. Any lifecycle event — e.g.app.trial.started— can land beforeapp.store.authorize, so the row may not exist yet. Each handler creates the merchant row if missing and updates it if present, keyed bymerchant. This keeps every handler safe in any arrival order and on re-install.
if (payload.event === "app.installed") {
// Provision: create the merchant row, seed defaults, queue a welcome step.
await db.merchants.upsert({
where: { id: payload.merchant },
create: {
id: payload.merchant,
status: "installed",
installedAt: new Date(),
},
update: { status: "installed" },
});
}
if (payload.event === "app.store.authorize") {
// Persist tokens — see salla-app-auth for the full upsert + refresh rules.
// Encrypt access_token/refresh_token at rest and keep them out of logs.
const { access_token, refresh_token, expires, scope } = payload.data;
await db.merchants.upsert({
where: { id: payload.merchant },
create: {
id: payload.merchant,
accessToken: access_token,
refreshToken: refresh_token,
tokenExpiresAt: new Date(expires * 1000),
scope,
},
update: {
accessToken: access_token,
refreshToken: refresh_token,
tokenExpiresAt: new Date(expires * 1000),
scope,
},
});
}
Manage the merchant/token tables with a versioned migration tool (versioned
ALTER TABLEs). This keeps the schema in lockstep with the code as you add or change columns, where a create-on-boot step would skip an already-existing table.
Gate: "Install of a demo store creates the merchant row and persists tokens, in any arrival order?"
Step 3 — Handle the Update Flow
When the merchant updates the app, Salla fires app.updated then immediately
app.store.authorize with fresh tokens.
- Treat
app.updatedas a signal only — do not expect tokens in its payload. - Let your existing
app.store.authorizehandler persist the new tokens.
Gate: "An app update refreshes stored tokens via the authorize handler, not the update handler?"
Step 4 — Handle Uninstall Cleanup
app.uninstalled means the merchant removed your app. Cleanup is an App Store
requirement — handle it per your retention/GDPR policy.
if (payload.event === "app.uninstalled") {
await jobs.enqueue("cleanupMerchant", { merchantId: payload.merchant });
// In the job: revoke cached tokens, delete or anonymize PII, cancel scheduled work,
// mark merchant status = "uninstalled". Keep only what your policy/law requires.
}
Document what you store and how it's deleted on uninstall — it's reviewed at submission.
Gate: "Uninstall enqueues cleanup and the job revokes tokens + removes/anonymizes PII?"
Step 5 — Handle Trial & Subscription Events
| Event | Effect |
|---|---|
app.trial.started |
Enable trial-tier features; record trial end |
app.trial.expired |
Downgrade/restrict until they subscribe |
app.trial.canceled |
Restrict immediately |
A merchant typically flows trial.started → (subscription.started if they convert |
trial.expired if they don't). Minimum subscription handling here — full logic in
salla-app-billing:
if (payload.event === "app.subscription.started") {
const { item_type, item_slug, end_date } = payload.data; // item_type: "plan" | "addon"
if (item_type === "addon") {
await entitlements.activateAddon(payload.merchant, item_slug, end_date);
} else {
await merchants.activatePlan(payload.merchant, payload.data);
}
}
Watch data.store_type (development | demo | live) to avoid treating test-store
subscriptions as real revenue.
Gate: "Trial and subscription events transition the merchant's stored status, branching
on item_type?"
Step 6 — Harden: Reliability & State Machine
Non-negotiable for every handler above:
- Verify the signature first (HMAC-SHA256, timing-safe) — reject otherwise.
- Acknowledge fast with
200, then process async — Salla retries failed deliveries; the timeout + retry policy is owned by salla-webhooks. - Be idempotent — key on a stable discriminator from the payload (e.g.
subscription_id) or a hash of the raw body. (created_atis second-resolution, so${merchant}:${event}:${created_at}alone can collide.) - Upsert so the same merchant can re-install and re-authorize.
- Handle async-job failure — the
200is already sent, so a throw in the queued worker is invisible to Salla. Route failed jobs to a dead-letter queue, retry with backoff (idempotent), and alert on exhausted retries so a dropped install/uninstall doesn't go unnoticed.
Recommended merchant state machine — persist status per merchant and transition from the
handlers above:
app.installed
│
▼
┌──────► installed ──────┐
│ │ │ app.trial.started
│ │ ▼
│ │ trial ──── app.trial.expired/canceled ──► restricted
│ │ │ app.subscription.started
│ ▼ ▼
│ active ◄── app.subscription.renewed
│ │
│ │ app.subscription.expired/canceled
│ ▼
│ restricted
│ │ app.subscription.started (merchant re-purchases)
└───────────┘
│ app.uninstalled (from any state)
▼
uninstalled (run cleanup)
Gate features on status (and on addon entitlements — see salla-app-billing).
Gate: "Every handler verifies signature, returns 200 fast, is idempotent, and updates
status along the state machine?"
Red Flags
Assumptions about lifecycle delivery that hold in a quick test and fail with a real merchant. If one of these is your plan, re-read the named step.
| Tempting thought | Why it's wrong |
|---|---|
"app.installed arrives before app.store.authorize." |
Order isn't guaranteed — any event can land first. Make each handler independent and idempotent, upserting by merchant (Step 2). |
"app.updated carries the new tokens." |
It's a signal only — tokens come in the app.store.authorize that follows. Don't read tokens from the update payload (Step 3). |
"insert is fine — installs only happen once." |
Merchants re-install and re-authorize. Insert-only throws or duplicates; always upsert keyed by merchant (Steps 2, 6). |
| "The async job is queued, so the work is safe." | The 200 is already sent — a throw in the worker is invisible to Salla. Dead-letter, retry idempotently, and alert (Step 6). |
"${merchant}:${event}:${created_at} is a unique key." |
created_at is second-resolution; two same-type events in one second collide. Add subscription_id or a raw-body hash (Step 6). |
| "I'll bill test-store subscription events as revenue." | Watch data.store_type — only live is real; development/demo installs would corrupt revenue and entitlements (Step 5). |
| "Uninstall just flips a status flag." | Cleanup (revoke tokens, delete/anonymize PII) is an App Store requirement reviewed at submission — enqueue the full job (Step 4). |
Key Resources
| Resource | URL |
|---|---|
| App Events docs | https://docs.salla.dev/421413m0.md |
| Webhooks (security) | salla-webhooks skill |
| Token handling | salla-app-auth skill |
| Plan/trial state | salla-app-billing skill |
| Partners Portal | https://salla.partners |
| Telegram community | https://t.me/salladev |