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.authorizewebhook, 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
merchantid, with expiry./tmpand 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,app_id; update them withsalla_scopes action=set(a flatslug → "read" | "read_write" | ""map) or as part of Connect below. Request least privilege: grant only the resource slugs the app actually uses, and prefer"read"over"read_write"unless the app writes that resource — don't apply a broadread_writedefault across slugs.Connect —
salla_apps action=connect,app_id, withscopes({ "<slug>": "read" | "read_write" }— slug and access level are separate keys, e.g.{"orders": "read_write"}).redirect_urlsis the auth-mode selector, not just a URL registration:- Easy Mode — set
redirect_urlsto the app'seasy_redirect_url(the Portal computes it as the Salla-owned callback; read it fromsalla_apps action=get). In production that's["https://accounts.salla.sa/callback/{app_id}"]. Pointing the OAuthredirect_uriat Salla's own callback is what makes Salla own the exchange. Pair it withwebhook_url+webhook_security_strategy: "signature". - Custom Mode — set
redirect_urlsto 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 inredirect_urls" are the same decision: in Easy Mode, pointredirect_urlsat 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.
connectdoes not touch the webhook signing secret. Create or rotate it manually in the Partner Portal athttps://portal.salla.partners/apps/{app_id}— give the user a ready link with the app's real id (e.g.https://portal.salla.partners/apps/1234567), not the{app_id}placeholder. Rotating there invalidates the old value. The MCP is read-only for it: before any deploy, read the current secret withsalla_apps action=get(thewebhook_secretfield) 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, everyX-Salla-Signaturecheck fails, and tokens never land. (Signature verification itself → salla-webhooks.)offline_accessdoes NOT go in theconnectscopes map. It is an OAuth2 token scope that enables refresh tokens and belongs only in the authorize URL (space-delimited, e.g.scope=offline_access orders.read_write). Theconnectmap takes resource slugs only (e.g.{"orders": "read_write"}).- Easy Mode — set
No subscribe call for the token event.
app.store.authorizeis an app event — with thewebhook_urlset in step 2 it auto-delivers, along with every otherapp.*lifecycle event. (Usesalla_events action=subscribeonly for store events your app reacts to → salla-webhooks.)
Manual fallback: Partners Portal → App Keys / Webhooks / App Scope.
Gate: "Resource scopes applied, and redirect_urls matches the intended mode — Easy
Mode → redirect_urls = the app's easy_redirect_url (prod:
["https://accounts.salla.sa/callback/{app_id}"]) AND webhook_url +
webhook_security_strategy 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 webhook_secret via salla_apps 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
access_token+refresh_tokenfrompayload.dataand saves both, keyed bymerchant. - When the merchant updates the app, Salla fires
app.updatedthenapp.store.authorizeagain — the same handler receives fresh tokens.
Handler shape: verify the signature first (salla-webhooks),
then on app.store.authorize upsert access_token / refresh_token /
expires * 1000 keyed by merchant, and return 200 immediately. Full handler code:
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.authorizefires 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
offline_access (so refresh tokens are
issued) · DB stores access_token / refresh_token / token_expires_at per merchant ·
handler upserts (not inserts).
Custom Mode (testing / local dev)
The authorization-code flow — authorize request (offline_access required, redirect_uri
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.
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 offline_access is in scope. Always include
offline_access — 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.
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": "testuser@email.partners",
"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/{app_id}); before deploy read the current webhook_secret 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 grant_type=refresh_token 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 trusted_ips in salla_apps 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 |