SKILL.md
Salla App Auth Flow
Get and keep valid merchant tokens for your app — choose the OAuth mode, configure the app, receive/exchange tokens, and refresh them safely. Work through the steps in order; complete each gate before moving on. Step 2 performs actions with the Salla Partners MCP; the token handling is runtime code.
Publishing the app? → default to Easy Mode. Tokens arrive in the
app.store.authorize
webhook, so you don't need an OAuth/callbackorstatehandling. Custom Mode (a/callbackcode exchange) is mainly for local dev / Postman; shipping it in a published app
without a real, justifiable use case can be rejected at Salla's admin review — so reach for
Easy Mode rather than a familiar OAuth2 callback out of habit.
Tools & MCPs
| Tool | Action | What it does |
|---|---|---|
salla_scopes |
get / set |
Read or update the app's OAuth scopes (slugs, disabled flags, selected) |
salla_apps |
connect |
Set scopes, redirect URLs, and the webhook_url (app.store.authorize auto-delivers to it) in one call |
app.store.authorizeis an app event — auto-delivered, no subscribe call. The app is
subscribed to its own app events by default, so once awebhook_urlis set the token event
(app.store.authorize) and every otherapp.*lifecycle event arrive automatically. You do
not callsalla_events action=subscribefor it — that action is only for store events
(order.,product., …) → salla-webhooks.
Docs: https://docs.salla.dev/421118m0.md · App Events: https://docs.salla.dev/421413m0.md
· API header:Authorization: Bearer <access_token>.
Step 0 — Discover
- Is this app going on the App Store (→ Easy Mode) or are you testing locally/Postman
(→ Custom Mode)?
- Where will tokens be stored? Persist them in a real datastore (a DB) keyed by
merchant id, with expiry. /tmp and in-memory are NOT storage — Step 3 covers why.
- Do you have a token-refresh concurrency story? (you will need one — Step 5)
Step 1 — Choose Your OAuth Mode
| Easy Mode ✅ | Custom Mode | |
|---|---|---|
| How tokens arrive | Via app.store.authorize webhook payload |
Via /oauth/callback code exchange |
| Callback URL needed? | No | Yes |
| Published apps? | Recommended (default) | Allowed with a justified use case — may be rejected at review |
| Allowed for testing? | Yes | Yes (Postman, local dev) |
| Recommendation | Default — recommended for every app | Dev only; needs a real use case |
| Token handling | Salla handles everything; you just save | You implement the full exchange |
Decision rule — default to Easy Mode. Easy Mode is the recommended default for every app — it's the more reliable and straightforward path and the easiest to implement for most use cases: Salla delivers the tokens via the app.store.authorize webhook, so there's no callback or state flow to build, secure, and maintain. Use it unless you have a concrete technical reason it cannot work. Custom Mode is for local dev / Postman during development; if you genuinely need it in production, be ready to justify the use case — a published app that ships Custom Mode without a real one can be rejected at review.
Gate: "Defaulted to Easy Mode (or have a real, reviewable reason for Custom Mode)?"
Step 2 — Configure the App (Partners MCP)
Set up the OAuth + webhook config that makes tokens flow. Do this with the Partners MCP:
- Scopes — read the slugs (+ per-app disabled flags) with
salla_scopes action=get,
appid; update them with sallascopes action=set (a flat slug → "read" | "readwrite" | "" map) or as part of Connect below. Request least privilege: grant only the resource slugs the app actually uses, and prefer "read" over "readwrite" unless the app writes that resource — don't apply a broad read_write default across slugs.
- Connect —
sallaapps action=connect,appid, withscopes
({ "<slug>": "read" | "readwrite" } — slug and access level are separate keys, e.g. {"orders": "readwrite"}). redirecturls is the auth-mode selector, not just a URL registration: - Easy Mode — set redirecturls to the app's easyredirecturl (the Portal computes it as the Salla-owned callback; read it from sallaapps action=get). In production that's ["https://accounts.salla.sa/callback/{appid}"]. Pointing the OAuth redirecturi at Salla's own callback is what makes Salla own the exchange. Pair it with webhookurl + webhooksecuritystrategy: "signature". - Custom Mode — set redirecturls to your own callback URL. Any non-Salla URL here activates Custom Mode — Salla redirects the merchant to your callback expecting a code exchange. So "don't build a callback" (Easy Mode) and "what you put in redirecturls" are the same decision: in Easy Mode, point redirect_urls at the Salla callback, not your app.
(Set trusted IPs here too — Part: IP whitelisting below.)
> The MCP never mints or rotates the signing secret — read it live before deploying. > connect does not touch the webhook signing secret. Create or rotate it manually in > the Partner Portal at https://portal.salla.partners/apps/{appid} — give the user a > ready link with the app's real id (e.g. https://portal.salla.partners/apps/1234567), > not the {appid} placeholder. Rotating there invalidates the old value. The MCP is read-only for it: before any deploy, read the > current secret with sallaapps action=get (the webhooksecret field) and set that > exact value as the verification env var. Never trust a secret remembered from an earlier > session or before a context compaction — if it was rotated in the Portal it's stale, every > X-Salla-Signature check fails, and tokens never land. > (Signature verification itself → [salla-webhooks](../salla-webhooks/SKILL.md).)
> offlineaccess does NOT go in the connect scopes map. It is an OAuth2 > token scope that enables refresh tokens and belongs only in the authorize URL > (space-delimited, e.g. scope=offlineaccess orders.readwrite). The connect > map takes resource slugs only (e.g. {"orders": "readwrite"}).
- No subscribe call for the token event.
app.store.authorizeis an app event — with
the webhookurl set in step 2 it auto-delivers, along with every other app.* lifecycle event. (Use sallaevents action=subscribe only for store events your app reacts to → salla-webhooks.)
Manual fallback: Partners Portal → App Keys / Webhooks / App Scope.
Gate: "Resource scopes applied, and redirecturls matches the intended mode — Easy Mode → redirecturls = the app's easyredirecturl (prod: ["https://accounts.salla.sa/callback/{appid}"]) AND webhookurl + webhooksecuritystrategy set (app.store.authorize auto-delivers to it — no subscribe call; without a webhook_url the event has nowhere to land and tokens never arrive); Custom Mode → your own callback URL?"
Gate: "Before deploy: read the current webhooksecret via sallaapps action=get and set that exact value in the verification env — not a secret carried from an earlier session (it may have rotated)?"
Step 3 — Receive or Exchange Tokens
Easy Mode (production)
- Merchant installs the app → Salla fires
app.store.authorizeto your webhook. - Your handler reads
accesstoken+refreshtokenfrompayload.dataand saves both,
keyed by merchant.
- When the merchant updates the app, Salla fires
app.updatedthenapp.store.authorize
again — the same handler receives fresh tokens.
Handler shape: verify the signature first ([salla-webhooks](../salla-webhooks/SKILL.md)), then on app.store.authorize upsert accesstoken / refreshtoken / expires * 1000 keyed by merchant, and return 200 immediately. Full handler code: [references/app-events.md](references/app-events.md).
Persist tokens in a real datastore —
/tmpand in-memory are NOT storage. Write the
access/refresh tokens to a durable DB (Postgres, MySQL, DynamoDB, Redis with persistence,
…) keyed bymerchant. On serverless/Vercel, the filesystem (/tmp) and any module-level
variable are ephemeral — wiped on every cold start: the token vanishes, the next API
call 403s, and the merchant looks uninstalled even though they aren't.app.store.authorize
fires once per install/update, so a lost token is not re-delivered — only a reinstall
recovers it. Use real storage from the first commit.
Secret hygiene (both modes): access/refresh tokens and the client secret are
secrets — store them encrypted at rest and never write them to logs, errors, or
diagnostics. Redirect and webhook URLs are HTTPS-only. Restrict your app to known server
IPs (IP whitelisting, below).
Easy Mode checklist: webhook URL set (Step 2) · app.store.authorize auto-delivers to it (app event — no subscribe call) · the granted data.scope in the payload contains offlineaccess (so refresh tokens are issued) · DB stores accesstoken / refreshtoken / tokenexpires_at per merchant · handler upserts (not inserts).
Custom Mode (testing / local dev)
The authorization-code flow — authorize request (offlineaccess required, redirecturi must match the Portal exactly) → callback (code + state) → POST /oauth2/token exchange → persist both tokens. It carries several Salla-specific callback traps (deploy the callback before registering it, Salla-initiated installs send their own state, hyphens stripped from state, single-use codes, the Next.js cookie-in-redirect trap).
Full flow with the authorize URL, token exchange, callback rules, and PHP/Laravel code: load [references/custom-mode.md](references/custom-mode.md).
Gate: "Tokens are persisted per merchant with tokenExpiresAt derived from expires?"
Step 4 — Understand the Token Lifecycle
| Token | Lifetime | Notes |
|---|---|---|
| Access token | Per the expires field (no fixed number) |
expires in the app.store.authorize payload is the source of truth — a Unix timestamp. Don't assume a fixed duration |
| Refresh token | Always valid (no expiry) | Single-use per refresh — each refresh returns a new refresh token; save it. The token chain itself does not expire |
expires is an absolute Unix timestamp (seconds), not a duration — drive expiry off it, never a hard-coded number. Convert before storing (Source: https://docs.salla.dev/421413m0.md):
// ✅ expires is an absolute Unix timestamp (seconds)
const expiresAt = new Date(payload.data.expires * 1000); // ms
// ❌ it is NOT a duration — never do: new Date(Date.now() + expires * 1000)
Refresh tokens are only issued when offlineaccess is in scope. Always include offlineaccess — without it, no refresh token is issued, so the access token cannot be renewed once expires passes and the merchant must reinstall.
Gate: "Both tokens + a converted expiresAt are stored, and scope includes offline_access?"
Step 5 — Refresh Tokens Safely (the danger zone)
Each refresh is single-use: it returns a fresh refresh token and kills the previous one. Using the same refresh token twice (a parallel-refresh race) makes Salla's OAuth server treat the chain as compromised — it revokes the chain and the merchant must reinstall, which is unrecoverable. Serialize refreshes with a per-merchant lock so a refresh token never leaves its lock without the new one being persisted.
Required: distributed mutex per merchant. Acquire a per-merchant lock before calling the token endpoint. If another process already holds it, wait briefly then re-read the now-refreshed token from the DB rather than retrying the refresh. Use a proven distributed-lock library (e.g. redlock for Redis, or a DB advisory lock) so owner-token and atomic release are handled for you. Refresh proactively (e.g. ~1 day before expiresAt), not on a 401, and always persist BOTH the new access and refresh tokens before releasing the lock — the old refresh token is dead the moment the call returns.
Full runnable code (TS refreshTokenSafe + getValidToken, and the PHP equivalent): load [references/token-refresh.md](references/token-refresh.md).
Gate: "Refresh is guarded by a distributed lock, saves BOTH new tokens, and runs proactively before expiry?"
Step 6 — Fetch & Store Merchant Info
After obtaining a token (and after every app.store.authorize), refresh merchant details:
GET https://accounts.salla.sa/oauth2/user/info
Authorization: Bearer <access_token>
{
"id": 1771165749,
"name": "Test User",
"email": "[email protected]",
"merchant": {
"id": 1803665367,
"username": "dev-store-name",
"name": "My Store",
"plan": "special",
"status": "active",
"domain": "https://salla.sa/my-store"
}
}
The store id is merchant.id — top level of the response, NOT under data. user/info has no data envelope (unlike webhooks/API responses). Extract defensively:
// ✅ user/info shape: { id, name, email, merchant: { id, ... } }
const merchantId = info?.merchant?.id; // number, top level — NOT info.data.merchant.id
if (!merchantId) throw new Error("user/info: missing merchant.id"); // guard BEFORE stringify
const storeId = String(merchantId);
// ❌ String(info?.merchant?.id ?? "") — turns a missing id into "" / a 0 id into "0", both
// truthy after String(), so the guard silently passes and a bad value reaches the DB
Gate: "Merchant id + store details are stored alongside the tokens?"
Red Flags
Thoughts that feel reasonable in isolation but break a production Salla app. If you catch yourself thinking one of these, stop and re-read the named step.
| Tempting thought | Why it's wrong |
|---|---|
"I know OAuth — I'll just build the /callback flow." |
That's Custom Mode. Shipping it in a published app without a justified use case can be rejected at review. Default to Easy Mode (Step 1). |
| "The per-merchant refresh mutex is overkill." | Single-use refresh tokens: a parallel double-use invalidates the whole token chain and the merchant must reinstall. Non-negotiable (Step 5). |
| "I'll add the distributed lock later / skip it in dev." | Dev habits ship to prod, and the race only shows up under real concurrency — i.e. in production, on a real merchant. Add it once, now (Step 5). |
| "Refresh succeeded — I'll save the new access token." | You must save both new tokens. The old refresh token is already dead; drop the new one and the next refresh fails (Step 5). |
"offline_access is just another resource scope." |
It's an OAuth token scope and goes only in the authorize URL, never the connect map. Omit it and no refresh token is issued (Step 4). |
"expires is how many seconds the token lasts." |
It's an absolute Unix timestamp. Treating it as a duration sets expiry decades out and the token silently dies (Step 4). |
"I'll stash the token in /tmp (or a module variable) for now." |
On serverless the filesystem and memory are wiped on every cold start — the token vanishes and the next call 403s, so the merchant looks uninstalled. Persist to a real DB keyed by merchant from commit one (Step 3). |
"I'll re-run connect to (re)mint the webhook secret before deploying." |
connect never mints or rotates the signing secret — the MCP is read-only for it. Create/rotate it in the Portal (https://portal.salla.partners/apps/{appid}); before deploy read the current webhooksecret via salla_apps action=get and use that exact value (Step 2). |
| "I'll reuse the webhook secret I noted earlier." | Rotating the secret in the Portal invalidates the old value, so a secret remembered from a past session or before a compaction may be stale → all verification fails. Read it live via salla_apps action=get before deploy (Step 2). |
| "Tokens in logs are fine for debugging." | Access/refresh tokens and the client secret are secrets — encrypt at rest, never log them (Step 3). |
Reference
OAuth scopes
There are two distinct scope contexts — do not mix them:
1. salla_apps action=connect scopes map — resource scopes only, slug + level as separate fields:
{ "orders": "read_write", "products": "read", "customers": "read_write" }
2. OAuth authorize URL — space-delimited dotted strings. Include offline_access here (it is an OAuth token scope that enables refresh tokens, not a resource scope):
scope=offline_access orders.read_write products.read customers.read_write
Confirm the app's valid resource slugs (and per-app disabled flags) via salla_scopes action=get:
orders products customers branches
settings webhooks payments taxes
specialoffers categories brands metadata
App events
| Event | When | Action |
|---|---|---|
app.store.authorize |
App installed or updated (never on token refresh — that's your granttype=refreshtoken call) |
Save/update both tokens + expiry |
app.installed |
First install | Provision resources |
app.uninstalled |
Merchant removes app | Clean up merchant data + revoke stored tokens |
Full payload shapes: references/app-events.md. Lifecycle handling → salla-app-lifecycle.
IP whitelisting
Restrict your app to known server IPs: Partners Portal → My Apps → Your App → App Trusted IPs (or pass trustedips in sallaapps action=connect). Reduces attack surface for production apps.
Key Endpoints & Libraries
| Purpose | URL |
|---|---|
| Authorization | https://accounts.salla.sa/oauth2/auth |
| Token exchange + refresh | https://accounts.salla.sa/oauth2/token |
| User info | https://accounts.salla.sa/oauth2/user/info |
| Direct install | https://s.salla.sa/apps/install/{app-id} |
| Salla API base | https://api.salla.dev/admin/v2/ |
| Library | Language | Repo |
|---|---|---|
salla/oauth2-merchant |
PHP | https://github.com/SallaApp/oauth2-merchant |
@salla.sa/passport-strategy |
JavaScript | https://github.com/SallaApp/passport-strategy |
| Laravel starter kit | PHP/Laravel | https://github.com/SallaApp/laravel-starter-kit |