sallaapp/salla-partners-agent-kit

salla-app-auth

Salla OAuth 2.0 and merchant token management — the Salla delta on standard OAuth: Easy Mode (tokens via the app.store.authorize webhook, no callback — published apps) vs Custom Mode (authorization-code, dev/testing only), the offline_access scope, single-use refresh tokens needing a per-merchant refresh lock, and the User Info endpoint. Use before writing any Salla token-handling code. Lifecycle events → salla-app-lifecycle; webhook verification → salla-webhooks; API usage → salla-api-core.

First seen Jun 30, 2026

Installation

$ npx skills add sallaapp/salla-partners-agent-kit --skill salla-app-auth

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from sallaapp/salla-partners-agent-kit · top by installs.

npx skills add sallaapp/salla-partners-agent-kit

Browse all from sallaapp/salla-partners-agent-kit

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 2
License LICENSE
Default branch master
Open issues 0
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 23,229 B
  • docs SUMMARY.md 528 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 70 installs

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 /callback or state handling. Custom Mode (a
/callback code 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.authorize is an app event — auto-delivered, no subscribe call. The app is
subscribed to its own app events by default, so once a webhook_url is set the token event
(app.store.authorize) and every other app.* lifecycle event arrive automatically. You do
not call salla_events action=subscribe for 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

  1. Is this app going on the App Store (→ Easy Mode) or are you testing locally/Postman

(→ Custom Mode)?

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

  1. 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:

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

  1. Connectsallaapps action=connect, appid, with scopes

({ "<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"}).

  1. No subscribe call for the token event. app.store.authorize is 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 Moderedirecturls = 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)

  1. Merchant installs the app → Salla fires app.store.authorize to your webhook.
  2. Your handler reads accesstoken + refreshtoken from payload.data and saves both,

keyed by merchant.

  1. When the merchant updates the app, Salla fires app.updated then app.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 — /tmp and in-memory are NOT storage. Write the
access/refresh tokens to a durable DB (Postgres, MySQL, DynamoDB, Redis with persistence,
…) keyed by merchant. 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