mcpField
S
salla-webhooks

salla-webhooks

github.com/sallaappDevelopment292 installsUpdated 2w ago
Repository

About salla-webhooks

salla-webhooks is a development skill for building and operating Salla webhook integrations. It guides developers through choosing events, setting up a receiver, configuring subscriptions, verifying deliveries, and handling retries and idempotency, with Partners MCP actions for app configuration and event subscriptions.

Key features

  1. 01Guides event and payload discovery
  2. 02Covers Node.js/Express and Laravel/PHP setup
  3. 03Configures webhook receivers and subscriptions
  4. 04Explains signature and token verification
  5. 05Addresses retries and idempotent handling

Use cases

  1. 01Creating a Salla app webhook receiver
  2. 02Subscribing an app to store events
  3. 03Verifying incoming webhook deliveries
  4. 04Building webhook handling into a Salla integration

MCP support

Uses the Salla Partners MCP to configure an app's webhook URL and security strategy, and to list or subscribe to store events. Runtime server code receives and verifies deliveries.

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/sallaapp/salla-partners-agent-kit --skill salla-webhooks

After installing, type/salla-webhooksin Claude Code to confirm it loaded.

Bundled filesreferences/operations.mdreferences/server-setup.md

Details

From the project README

Salla Webhooks Flow

Stand up a reliable webhook integration — build the server, subscribe to events, verify every delivery, respond fast, and stay idempotent. Work through the steps in order; complete each gate before moving on. Step 2 performs actions with the Salla Partners MCP; the server and verification are runtime code.

Prefer an App Function when the event has one. If a trigger exists in salla-app-functions, use it instead of a webhook — it runs in Salla's sandbox (no signature to verify), gets context.settings, calls the Salla API with built-in auth, and can run synchronously on action events (e.g. shipment.creating) where the return value shapes the operation. Use a webhook only when no App Function trigger covers the event.

Tools & MCPs

Confirm live event payload schemas in the webhooks reference (https://docs.salla.dev/421119m0.md) before coding; never assume a shape. The Salla Partners MCP performs actions:

ToolActionWhat it does
salla_appsconnectSet the app's webhook_url, security strategy, custom headers (secret: Portal)
salla_eventslist / subscribeSubscribe the app to store events (order.*, …); app events auto-deliver — no subscribe

New webhooks default to version 2. Always set webhook_security_strategy explicitly (signature | token | none) — the Partners MCP now requires it on salla_apps action=connect. Picking none disables verification: Salla then sends deliveries with no Authorization header and no signature, so there is nothing to verify (Step 3). Because none runs no verification, a wrong or missing verify (e.g. HMAC-ing a token webhook) stays invisible under it — confirm the active strategy is token/signature, not none, before trusting that "verification works." Store-level management base URL (merchant token): https://api.salla.dev/admin/v2; register is POST /webhooks/subscribe. Docs: https://docs.salla.dev/421119m0.md · Conditional: https://docs.salla.dev/421120m0.md · Node/Express repo: https://github.com/SallaApp/webhook-actions-js


Step 0 — Discover

  1. Which events do you need? (lifecycle, orders, products, shipments…) — confirm exact names/payloads in the webhooks reference (https://docs.salla.dev/421119m0.md).
  2. App event or store event? App events (app.* — install, authorize, update, uninstall, trial, subscription, settings) are auto-delivered to the app's webhook_url: the app is subscribed to its own app events by default, so you don't subscribe — just set the webhook_url (salla_apps action=connect) and handle them. Store events (order.*, product.*, customer.*, cart.*, store-side shipment.*) need a subscribe: via the Partners MCP salla_events action=subscribe (app-installed-on-store events) or the Admin API POST /webhooks/subscribe with a merchant token (webhooks.read_write scope).
  3. Stack? Node/Express (@salla.sa/webhooks-actions) or Laravel/PHP (Salla CLI)?

salla_events action=subscribe REPLACES the whole subscription list — include every event you want to keep on each call (a shipping app's 4 default shipment webhooks too). To remove ALL subscriptions, pass clear_all: true; an empty events: [] alone is rejected, so you can't wipe subscriptions by forgetting to pass events.


Step 1 — Build the Webhook Server

Two supported paths, both with a single POST endpoint and the secret loaded from env:

  • Node.js / Express with @salla.sa/webhooks-actions — the official server-side package. It dispatches events (Actions.setSecret(secret), Actions.on(event, cb), Actions.checkActions(body, token)) and does token verification internally — its check is if (secret !== this._secret) return;, i.e. plain equality of the Authorization value against the secret (Step 3, token strategy). Routes events to on(<event>, …) listeners or file-based Actions/<domain>/<event>.js handlers (salla app create-webhook).
    • Caveat — Express only. The package is CommonJS and dispatches handlers via dynamic require() over the file system, which does not work on Next.js (or other) serverless runtimes. The listener API and the token-equality model are portable; the file-dispatch is not. On serverless, verify and dispatch by hand (Step 3) instead of relying on the package.
  • Laravel / PHP via Salla CLI (salla app create) — scaffolds the server; verify manually with hash_hmac('sha256', $rawBody, $secret) + hash_equals for the signature strategy.

The decision that matters: if you verify the signature manually (Step 3), the HMAC must run on the raw request body — capture the unparsed bytes before any JSON middleware. Don't mix a parsed body with a manual HMAC check on the same route.

Check for an official @salla.sa package before hand-writing server-side code. Run npm search "@salla.sa" — alongside @salla.sa/webhooks-actions you may use @salla.sa/passport-strategy (OAuth), @salla.sa/event, and @salla.sa/embedded-sdk. Confirm the package's runtime fit (the serverless caveat above) before adopting it.

Full scaffolding (env vars, Express Pattern A/B, the file-handler tree, PHP verify): load references/server-setup.md.

Gate: "Server up, single POST endpoint receives events, secret loaded from env?"


Step 2 — Register / Subscribe to Events

App events + store-event subscriptions (Partners MCP) ✅ for partner apps

App events auto-deliver — setting the webhook_url is the whole job. Every app.* event (install, app.store.authorize, update, uninstall, app.trial.*, app.subscription.*, app.settings.updated) lands on your webhook_url automatically because the app is subscribed to its own app events by default. You do not subscribe to them — just point them at your handler (step 1 below). The salla_events action=subscribe call (step 2 below) is only for store events the app reacts to (order.*, product.*, …).

  1. Set the receiver: salla_apps action=connect, app_id, webhook_url, webhook_security_strategy (required — signature | token | none), optional webhook_headers. (The signing secret is created/rotated in the Portal, not here.)

    • Set the strategy explicitly every time. Omitting it is treated as none — Salla then delivers with no Authorization header and no signature, so there is nothing to verify and your guard silently passes everything (Red Flags).
    • signature → HMAC; token → plain header equality; none → no verification (Step 3).

    Secret-sync gate: the signing secret is created/rotated in the Partner Portal (https://portal.salla.partners/apps/{app_id}, real id substituted), not by the MCP — a rotation invalidates the old value. Immediately before deploying, read the live value with salla_apps action=get (the webhook_secret field) — never reuse a secret from an earlier session or assume your local/env value still matches Salla's. Set that live value as SALLA_WEBHOOK_SECRET in every deployment environment (prod, staging, preview) and confirm deployed env == Portal secret before testing; a mismatch fails verification and returns 401 on every delivery.

    Published public app: if salla_apps action=get → app.webhook_config.approved has a webhook_secret, live stores still sign with it until the next approval. Set SALLA_WEBHOOK_SECRET_PREVIOUS to that value (read it live each time — it stays the approved value across further rotations) so verification accepts both (Step 5 example). Remove _PREVIOUS once the new publication is approved and approved.webhook_secret is gone (step-app-config).

  2. Subscribe to store events only (skip this if the app handles app events alone): list valid slugs with salla_events action=list, app_id, then salla_events action=subscribe, app_id, events: ["order.created", …] (store-side slugs — not app.*).

Store-level webhooks (Admin API, merchant token)

Base URL https://api.salla.dev/admin/v2; all five operations need the webhooks.read_write scope (reads accept webhooks.read). Register body (5394134):

{
  "name": "Order Created Handler",
  "event": "order.created",
  "url": "https://your-app.com/webhooks",
  "version": 2,
  "rule": "total > 100",
  "headers": [{ "key": "Authorization", "value": "your-secret-value" }]
}
FieldTypeNotes
eventstringRequired. From GET /webhooks/events — verify the name
urlstringRequired. Must accept POST; no localhost/test. URLs
namestringOptional human-readable label
versionnumber1 or 2; defaults to 2 — pass 1 only for legacy
rulestringOptional conditional filter — see Step 4
headersarrayOptional {key, value}[] sent with every delivery

security_strategy (signature|token) and secret (required when security_strategy=signature) are not register-body fields — set them via salla_apps action=connect / the Portal, or on Update (which accepts both).

ActionMethod + PathDoc
RegisterPOST /webhooks/subscribehttps://docs.salla.dev/5394134e0.md
UpdatePUT /webhooks/{id}https://docs.salla.dev/10312606e0.md
List activeGET /webhookshttps://docs.salla.dev/5394135e0.md
List all eventsGET /webhooks/eventshttps://docs.salla.dev/5394136e0.md
DeactivateDELETE /webhooks/unsubscribehttps://docs.salla.dev/5394137e0.md
  • Register returns 200 with the created { id, name, event, type, url, version, rule, headers }. Re-subscribing with the same URL updates events / restores the existing webhook (no duplicate).
  • Update takes the id as a path segment; body may carry name, url, version, rule, headers, security_strategy, secret.
  • List active returns the store's webhooks plus a pagination block (webhooks.read).
  • List events returns { id, label, event }[] — the subscribable event names (webhooks.read).
  • Deactivate selects the target by query param id and/or url (one is required); passing url deletes every webhook registered to that URL. Returns 202.

Gate: "Subscribed to the right events (salla_events action=list / GET /webhooks confirms), webhook URL registered (salla_apps action=get → app.webhook_config.development.webhook_url is what demo stores use; never judge it from a publication draft's webhook_url)? For a published public app, relay webhook_config.note: a non-empty approved.differs_from_development means merchants' live stores still get the approved config until a new publication is approved."


Step 3 — Secure Every Delivery

Verify against the strategy you set on connect (Step 2) — the two strategies use different verification, and using the wrong one rejects (or admits) everything. Read the live secret with salla_apps action=get first (Step 2 secret-sync gate). Keep SALLA_WEBHOOK_SECRET in env only, out of logs. While a rotated secret awaits approval on a published app, a delivery is valid if it matches either SALLA_WEBHOOK_SECRET or SALLA_WEBHOOK_SECRET_PREVIOUS — for both strategies. Docs: https://docs.salla.dev/421119m0.md

Strategy changed on a published app? If webhook_config.approved.differs_from_development includes webhook_security_strategy, live stores still use the approved strategy until the next approval while demo stores use the new one. Every signed delivery names its strategy in X-Salla-Security-Strategy (Token | Signature): verify each delivery by that header, but accept only the current and the approved strategies (never let the header downgrade you to "no check"). A switch to or from none can't be bridged — those deliveries carry no auth — so get the new publication approved promptly instead of accepting unverified requests.

webhook_security_strategyWhat Salla sendsHow you verify
tokenAuthorization: <webhook_secret> headerPlain equality: header value === the secret
signaturex-salla-signature = HMAC of the raw bodyHMAC-SHA256(rawBody, secret) compared timing-safe
noneNo Authorization, no signatureNothing to verify — verification is disabled
Strategy token — plain equality (NOT HMAC)

Salla sends the secret verbatim in the Authorization header; verification is a direct equality check of that header value against your webhook_secret. This is exactly what @salla.sa/webhooks-actions does internally: if (secret !== this._secret) return; — no hashing, no HMAC. A parsed JSON body is fine, since the body is not part of the check.

import { timingSafeEqual } from "crypto";

// `token` strategy: the Authorization header value IS the secret (no "Bearer" prefix,
// no HMAC). Compare it directly to webhook_secret. timingSafeEqual avoids a timing leak;
// the plain-equality model is `received === secret`.
function verifyToken(authHeader: string, secret: string): boolean {
  const a = Buffer.from(authHeader ?? "");
  const b = Buffer.from(secret);
  return a.length === b.length && timingSafeEqual(a, b);
}
Strategy signature — HMAC of the raw body

Salla appends the request body's 64-character SHA256 HMAC hash to the x-salla-signature header (alongside X-Salla-Security-Strategy: Signature), computed as HMAC-SHA256(rawRequestBody, secret). Verify it with a timing-safe comparison before processing or persisting any payload. The HMAC must run on the raw body — capture the unparsed bytes before any JSON middleware.

// TypeScript — Web Crypto API (Node 16+, Deno, Cloudflare Workers).
// Pass the RAW request body (the exact bytes Salla sent). HMAC must run on the
// unparsed body, so capture it BEFORE any JSON middleware parses it.
async function verifySignature(
  rawBody: string,
  signatureHex: string,
  secret: string,
): Promise<boolean> {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["verify"],
  );

  return crypto.subtle.verify(
    "HMAC",
    key,
    hexToBytes(signatureHex),
    new TextEncoder().encode(rawBody),
  );
}

function hexToBytes(hex: string): Uint8Array {
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < hex.length; i += 2)
    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
  return bytes;
}
Custom headers

Both strategies support additional headers (internal routing, gateway auth, multi-tenant):

"headers": [
  { "key": "X-App-Source", "value": "salla-prod" },
  { "key": "X-Tenant-ID", "value": "merchant-123" }
]

Set via the Portal UI, the register/update API, or salla_apps action=connect webhook_headers. Use standard header names (e.g. X-App-Source, X-Tenant-ID) and let the Partners MCP validate what's accepted.

Gate: "Verification matches the strategy I set on connect (token → header equality, signature → HMAC of the raw body), runs timing-safe, and unverified deliveries get 401? (If none, is leaving it unverified a deliberate choice, not an accident?)"


Step 4 — (Optional) Conditional Webhook Rules

A conditional webhook carries a rule string ("Salla Rules"): Salla evaluates it against the event payload and only delivers when the rule is true, so your endpoint sees less noise and less load. Pass rule in the register/update body — it is filtering on the sender side, not in your handler. Rules require version 2 (421120).

A rule is one or more attribute operator value conditions, combined with AND / OR:

field = value      equality        field != value   inequality
field > value      greater than    field < value    less than
condition1 AND condition2          condition1 OR condition2

Quote string/Arabic literals in backticks; numbers are bare. Use only the attributes listed for that event's category (below) — an unsupported attribute won't match.

Examples (from 421120): "total > 100" · "payment_method = mada OR price < 50" · "status = `active` OR applied_to = `first_order`" · "city = `الرياض` AND location != `حي اليرموك`".

Each category supports a fixed set of events and attributes — a rule may only use its category's attributes:

CategoryFilterable attributes (subset — full list in 421120)
Orderid, reference_id, total, sub_total, payment_method, status_id, branch_id, coupon_code, shipment_company_id
Productid, name, sku, type, price, quantity, status, is_available, brand_id
Customerid, first_name, last_name, email, mobile, gender, city, country, location
Special Offerid, name, offer_type, status, expiry_date
Categoryid, name, parent_id, status, sort_order
Brandid, name, status, custom_url
Cartid, subtotal, total, currency, coupon_code, customer_id
Miscellaneousparent_id, store_id, customer_id, product_id, order_id, rating, status (event review.added)

Full event + attribute reference per category: https://docs.salla.dev/421120m0.md


Step 5 — Respond Fast & Stay Idempotent

Every webhook wraps its data in the standard envelope (values below are illustrative — confirm the exact envelope and per-event data shape via the Partners MCP (salla_events action=list) or the webhooks docs before coding):

{
  "event": "order.created",
  "merchant": 123456789,
  "created_at": "2026-05-28T13:34:45+03:00",
  "data": {
    /* event-specific — verify shape via MCP */
  }
}

Read the store id from payload.merchant (top-level), including on app.store.authorize. It is not nested under data, so payload.data.merchant.id is undefined. merchant is your key to look up the right access/refresh tokens.

Response & retry rules — non-negotiable (421119):

RuleDetail
Acknowledge fastSalla waits ~30s for the connection + HTTP response, then treats the delivery as failed — ack well before that
Never block on slow workQueue DB writes, emails, external calls — respond first, process after
Retry behaviorOn a non-success response/timeout Salla resends the event 3 times, ~5 minutes apart; a success stops further tries (docs)
Idempotency requiredWebhooks can be delivered more than once — always deduplicate

Retry interval: follow the doc (421119) — 3 retries ~5 minutes apart, ~30s timeout. An earlier observation recorded the intervals as 30s / 15s / 10s; treat that as possibly-stale and verify on a live store before relying on it. Either way, ack fast and dedupe rather than depend on exact timing.

// Fast response + async processing (Express). This route uses the `signature` strategy.
// Mount a RAW body parser on this route — NOT a global express.json(), which would
// consume the body and break HMAC. Parse JSON only AFTER the signature checks out.
// (For the `token` strategy, verify req.get("Authorization") === secret instead — a
// parsed body is then fine. See Step 3 for both.)
app.post("/webhooks/salla", express.raw({ type: "*/*" }), async (req, res) => {
  const raw = req.body.toString("utf8"); // Buffer from express.raw
  const signature = req.get("X-Salla-Signature") ?? "";
  // _PREVIOUS is set only while a rotated secret awaits publication approval (Step 2 gate).
  const secrets = [
    process.env.SALLA_WEBHOOK_SECRET!,
    process.env.SALLA_WEBHOOK_SECRET_PREVIOUS,
  ].filter((s): s is string => !!s);
  const checks = await Promise.all(
    secrets.map((s) => verifySignature(raw, signature, s)),
  );
  if (!checks.some(Boolean)) return res.status(401).end();
  res.status(200).end(); // respond immediately (well under Salla's ~30s wait)
  processWebhookAsync(JSON.parse(raw)).catch(console.error); // parse AFTER verifying
});
// Idempotency — prefer the stable discriminator subscription_id (unique per lifecycle
// event); otherwise hash the RAW request body (the exact bytes Salla sent), NOT a
// re-stringified object. JSON.stringify(payload) is brittle — key order and number/date
// re-serialization can change the hash for the same delivery. Pass `rawBody` from the same
// express.raw() buffer you verified the signature against.
async function handleWebhook(
  payload: WebhookPayload,
  rawBody: string,
): Promise<void> {
  const discriminator =
    (payload.data as any)?.subscription_id ??
    crypto.createHash("sha256").update(rawBody).digest("hex").slice(0, 16);
  const key = `${payload.merchant}:${payload.event}:${discriminator}`;
  const seen = await db.webhookEvents.exists({ key });
  if (seen) return; // already processed
  await db.webhookEvents.insert({ key, received_at: new Date() }); // insert BEFORE processing
  await processEvent(payload);
}

Gate: "Endpoint verifies → 200s fast (well under ~30s) → processes async → dedupes with subscription_id or a hash of the raw body (not resource id, created_at, or a re-stringified object)?"


Step 6 — Handle App Lifecycle Events

Subscribe to all of these — they keep your app in sync with merchant state:

EventWhenWhat to do
app.installedFirst installProvision resources, set defaults
app.store.authorizeInstall OR token refreshSave/update tokens in DB per merchant
app.updatedMerchant updates appSalla fires app.store.authorize right after — wait for that
app.trial.startedTrial beginsEnable trial features
app.subscription.startedPaid plan activatedMark active, unlock paid features
app.trial.expiredTrial ended, no upgradeRestrict access
app.subscription.expiredPaid plan lapsedRestrict access, notify merchant
app.uninstalledMerchant removes appClean up merchant data per retention policy

app.store.authorize payload (illustrative shape — confirm exact fields via the MCP/docs):

{
  "event": "app.store.authorize",
  "merchant": 123456789,
  "data": {
    "access_token": "...",
    "refresh_token": "...",
    "expires": 1234567890,
    "scope": "orders.read_write products.read_write"
  }
}

Full lifecycle handling → salla-app-lifecycle; token storage → salla-app-auth.


Red Flags

Shortcuts that look harmless on a webhook server and cause silent data loss or security holes in production. If one of these is your plan, re-read the named step.

Tempting thoughtWhy it's wrong
"I'll just leave webhook_security_strategy unset on connect."Unset is treated as none — Salla then sends no Authorization and no signature, so your guard verifies nothing and admits every request. Always set it explicitly (Step 2). This footgun caused a ~1h production outage.
"I'll HMAC the body to verify a token webhook."token is plain equality — Authorization header === the secret, not a hash. HMAC'ing it rejects every valid delivery. Match the verification to the strategy you set (Step 3).
"I'll verify the signature after I parse the JSON."HMAC must run on the raw bytes Salla sent. express.json() first and the body is already mutated — verification breaks. Capture raw first (Step 3).
"=== is fine for comparing the signature."A naive string compare is timing-attackable. Use a timing-safe comparison for both the signature HMAC and the token equality check (Step 3).
"An App Function trigger exists, but a webhook is what I know."App Functions run in Salla's sandbox with built-in auth, settings, and synchronous control — prefer them when a trigger exists (top of skill).
"I'll do the DB write / email, then return 200."Salla waits ~30s then marks the delivery failed and retries 3×. Ack first, process async (Step 5).
"Each event is delivered once, so I can skip idempotency."Webhooks can arrive more than once. Dedupe on subscription_id or a raw-body hash — not the resource id or created_at (Step 5).
"I'll read the store id from payload.data.merchant."merchant is top-level in the envelope. data.merchant.id is undefined, so you persist the wrong/empty store (Step 5).
"Verification returns 401 — must be a code bug."First suspect is secret parity: deployed SALLA_WEBHOOK_SECRET ≠ Portal secret after a Portal secret rotation (or reconnect) rejects every delivery (Step 8).

Step 7 — Event Reference

The events catalogue and the exact payload schema for each event are maintained by Salla — treat the docs as the source of truth, not a list baked into this skill (event names and shapes change). Resolve names two ways:

Always confirm the exact name (case-sensitive) and data shape from one of those before writing a handler. Where an event's payload (or a register/update body) is documented (the OpenAPI block in its docs.salla.dev/<id>.md page — find it via salla-docs), validate your handler's parse and any registration body against that schema and fix before relying on it, via the read-schema → build → validate → fix → retry loop in salla-api-core.


Step 8 — Troubleshoot

When webhooks aren't arriving:

  • Every delivery returns 401 → check secret parity FIRST. Deployed SALLA_WEBHOOK_SECRET must equal the Portal secret; a Portal rotation (or reconnect) mints a new one. This single mismatch rejects every webhook. On a published public app, live stores use the last approved secret until the next approval — see the secret-sync gate.
  • Demo stores receive, live stores don't (or get the old URL/events)? Live stores use the approved publication's config until a new publish request is approved — salla_apps action=get → app.webhook_config.approved.differs_from_development lists what differs (and not_compared what couldn't be checked); webhook_config.note says what to do.
  • Webhook URL set and webhooks.read_write scope enabled
  • App installed on demo store (reinstall if needed — uninstall first from "Installed Apps")
  • Subscribed to the correct event name (case-sensitive)
  • Endpoint returns 200/201 well under Salla's ~30s wait and accepts POST (not just GET)
  • No TLS/SSL issues on your server
  • Check the Salla Webhooks Log in the Portal for delivery attempts and response codes

Test with webhook.site: copy a unique URL → set it as the webhook URL → trigger an event from your demo store → check webhook.site. Payload appears → your server is the problem; nothing appears → subscription or scope is the problem. Add custom headers on the same page to test header delivery.

Webhook Activity Log: Partners Portal → Your App → Webhooks Log shows every delivery attempt, HTTP code, and full payload — check here before debugging your server.


Key Resources

ResourceURL
Webhooks docshttps://docs.salla.dev/421119m0.md
Conditional webhookshttps://docs.salla.dev/421120m0.md
Register webhook (POST /webhooks/subscribe)https://docs.salla.dev/5394134e0.md
Update webhook (PUT /webhooks/{id})https://docs.salla.dev/10312606e0.md
List active webhooks (GET /webhooks)https://docs.salla.dev/5394135e0.md
List events (GET /webhooks/events)https://docs.salla.dev/5394136e0.md
Deactivate (DELETE /webhooks/unsubscribe)https://docs.salla.dev/5394137e0.md
Node.js/Express repohttps://github.com/SallaApp/webhook-actions-js
Operations (retries, custom headers, CLI local dev)references/operations.md
Partner Portalhttps://salla.partners
Telegram communityhttps://t.me/salladev

Comments

Markdown supported