About salla-app-auth
A guided skill for configuring Salla app OAuth and handling merchant tokens, aimed at app developers. It distinguishes webhook-based Easy Mode from callback-based Custom Mode and emphasizes durable storage and safe refresh-token rotation.
Key features
- 01Choose an OAuth mode
- 02Configure scopes and redirect URLs
- 03Receive or exchange merchant tokens
- 04Persist tokens by merchant
- 05Track access-token expiry
- 06Serialize refreshes to prevent races
Use cases
- 01Set up token delivery for a published app
- 02Test OAuth locally or with Postman
- 03Store merchant tokens for later API calls
- 04Refresh tokens without concurrent reuse
MCP support
The skill uses Salla Partners MCP tools to read and set OAuth scopes and connect app configuration. It guides developers through receiving or exchanging tokens; token handling itself is runtime code.
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ sallaapp/ salla-partners-agent-kit --skill salla-app-authAfter installing, type/salla-app-authin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/app-events.mdcustom-mode.mdtoken-refresh.mdDetails
From the project READMESalla 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": "[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/{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 |