Run your code inside a merchant's store, without running a server.
A ready-to-ship TypeScript project for building a Salla App Function: typed event handlers that Salla executes for you when something happens in a store.
What are App Functions? · Quick Start · Supported Events · Testing · Partner Portal
Note
This is a starter kit, not a finished app. Clone or scaffold from it with the Salla CLI, replace the example handlers with your own, and ship. The two handlers in src/functions/ are reference material — delete them once you have your own.
- What is an App Function?
- Quick start
- The contract
- Project structure
- Event reference
- Custom events
- Response envelope
- Calling Salla APIs
- Testing
- Deploying and watching logs
- Scripts
- Troubleshooting
- Support
An App Function lets your Partner app react to activity inside a merchant's store — an order is created, a customer signs in, a shipment is about to be made. You write a small typed handler; Salla runs it on its own infrastructure when the event fires. There is no server to provision, no endpoint to expose, and no webhook signature to verify.
Compared with classic webhooks, you skip the hosting, the retry handling, and the request authentication — Salla API calls from inside a function are already authenticated.
Which one you get is decided by the event, not by your code.
| Asynchronous events | Synchronous actions | |
|---|---|---|
| Example | order.created |
shipment.creating |
| When it runs | In the background, after the operation completes | Inline, before the operation completes |
| Is the user waiting? | No | Yes — the storefront/dashboard is blocked |
| Time budget | Up to 30s | Respond in milliseconds (< 500ms recommended) |
| Use it for | Syncing, notifications, analytics, enrichment | Validation, or modifying the operation |
Warning
In a synchronous action a real person is staring at a spinner. Avoid slow external
API calls, heavy computation, and sequential round-trips. Everything storefront
customers trigger is asynchronous; today shipment.creating is the only synchronous
event (shipment schemas).
App Functions are free while in beta. Future pricing will be based on call count, execution time, and resources — see the overview.
- Node.js ≥ 22.12 — the Salla CLI requires it (this project's own floor is 20.19).
- A Salla Partner account.
- A Partner app — App Functions always run on behalf of an app, so you need one before
anything else here works. Create it either way:
- Partner Portal — salla.partners → My Apps → Create App.
- CLI —
salla app createwalks you through the same thing from the terminal (install the CLI and runsalla loginfirst — see Install below).
- A demo store with your app installed.
- The app scopes your events need (e.g.
orders.read), enabled on the app.
pnpm install # or npm install / yarn install
npm install -g @salla.sa/cli
salla loginThe CLI is a global binary — installing it globally is what puts salla on your PATH.
Copy the example env file and fill in your app ID, so you don't have to pass it on every command:
cp .env.example .env# .env
SALLA_APP_ID=1234567890 # from the portal, or `salla app list`.env is gitignored and configures the CLI on your machine — it is not shipped to the
deployed function. Per-merchant secrets belong in your app's settings form, which reaches
your handler as context.settings.
salla app functions build # bundle src/index.ts -> dist/index.js
salla app functions deploy # build + ship it (prints a preview URL)
salla app functions serve # stream live logs from your functiondeploy builds for you, so it works standalone — but running build and
npm run typecheck first surfaces type and compile errors locally instead of mid-deploy.
Your project exports one events map from src/index.ts. That's the whole
interface between your code and the platform — the CLI will refuse to build without it:
An App Function project must export an "events" map from src/index.ts.
// src/index.ts
import type { DefineEvents } from '@salla.sa/app-functions-types';
import { orderCreated } from './functions/order-created';
// The types package ships no runtime code, so provide the identity implementation
// and borrow its `DefineEvents` signature for compile-time key validation.
const defineEvents: DefineEvents = (events) => events;
const events = defineEvents({
'order.created': orderCreated
});
export default events;defineEvents is a type-level guard: an event name that Salla doesn't recognise
collapses the map's type to never, so npm run typecheck fails on a typo before you ever
deploy it.
Each handler takes a typed context and returns a FunctionResponse. Handlers may be
async and return Promise<FunctionResponse>.
// src/functions/order-created.ts
import type { FunctionResponse, Order } from '@salla.sa/app-functions-types';
export const orderCreated = (context: Order): FunctionResponse => {
const order = context.payload.data;
// Validate first, and return an error response the platform can log.
if (!order.id) {
const message = 'Order ID is missing from the payload';
return { success: false, status: 400, message, error: { message } };
}
// ...your logic here (call an API, enqueue a job, enrich the order, …).
return {
success: true,
status: 200,
message: `Order ${order.reference_id} received`,
data: { orderId: order.id, itemCount: order.items.length }
};
};Every handler receives the same three-part shape:
const { payload, merchant, settings } = context;| Field | What's in it |
|---|---|
payload.event |
The event name that fired |
payload.data |
The event-specific record — the order, the customer, … |
payload.merchant |
Merchant (store) ID |
payload.created_at |
ISO timestamp of the event |
merchant |
Merchant details ({ id, … }) |
settings |
Your app's settings for this merchant — put API keys and URLs here, never in code |
Types come from @salla.sa/app-functions-types,
a types-only package (zero runtime code) kept as a devDependency. You author against
its shapes; Salla injects the real SDK at deploy time.
src/
index.ts # the events map — your default export
functions/ # one file per handler
order-created.ts
customer-login.ts
test/
index.spec.ts # example tests for the handlers
dist/
index.js # build output (generated; deployed artifact)
.env.example # template for CLI config — copy to .env
Event names are exact string literals in dot.case trigger format — order.created,
customer.login, product.viewed, signed.in. This is true for every event, whatever its
origin; the platform normalises all triggers to this form, so there is no Title Case
variant to remember:
- Merchant / backend events fire from the dashboard and the API —
order.created - Storefront (customer) events fire from the shopper's browser —
product.viewed
The lists below are the platform's trigger names, grouped by the category the platform assigns them. Each row links to that category's payload schema.
Merchant events — dot.case, all async unless flagged
| Category | Event names | Schema |
|---|---|---|
| Orders | order.created · order.updated · order.cancelled · order.refunded · order.deleted · order.status.updated · order.products.updated · order.payment.updated · order.coupon.updated · order.total.price.updated · order.shipping.address.updated · order.customer.updated |
Order Events |
| Products | product.created · product.updated · product.deleted · product.available · product.quantity.low |
Events index |
| Customers | customer.created · customer.updated · customer.login |
Customer Events |
| Shipments | ⚡ shipment.creating (sync) · shipment.created · shipment.updated · shipment.cancelled |
Shipment Events |
| Shipping zones | shipping.zone.created · shipping.zone.updated |
Shipping Zone Events |
| Categories | category.created · category.updated · category.deleted |
Category Events |
| Brands | brand.created · brand.updated · brand.deleted |
Brand Events |
| Store | store.branch.created · store.branch.updated · store.branch.setDefault · store.branch.activated · store.branch.deleted · storetax.created |
Store Branch Events |
| Cart | abandoned.cart · abandoned.cart.update |
Cart Events |
| Invoices | invoice.created |
Invoice Events |
| Special offers | specialoffer.created · specialoffer.updated |
Special Offer Events |
| Communication | communication.sms.send · communication.email.send · communication.whatsapp.send |
Events index |
Storefront events — platform category ecommerce_events, always async
| Category | Event names | Schema |
|---|---|---|
| Products | product.viewed · product.clicked · product.shared · product.reviewed · products.searched |
Product Events |
| Product lists | product.list.viewed · product.list.filtered · product.list.sorted |
Product Events |
| Product details | product.price.updated · product.status.updated · product.brand.updated · product.category.updated · product.image.updated · product.tags.updated · product.channels.changed |
Product Events |
| Cart | cart.viewed · cart.updated · cart.shared · product.added · product.removed |
Cart & Checkout |
| Checkout | checkout.started · checkout.step.viewed · checkout.step.completed |
Cart & Checkout |
| Payment | payment.info.entered · payment.submitted · payment.succeeded · payment.failed · payment.pending |
Cart & Checkout |
| Orders | order.completed · ecommerce.order.updated · ecommerce.order.cancelled · ecommerce.order.refunded |
Cart & Checkout |
| Coupons & promotions | coupon.entered · coupon.applied · coupon.removed · coupon.denied · promotion.viewed · promotion.clicked |
Promotion & Coupon Events |
| Wishlist | product.added.to.wishlist · product.removed.from.wishlist · wishlist.product.added.to.cart |
Wishlist Events |
| Account | signed.in · signed.up · signed.out · user.profile.updated |
Account Events |
| Address | address.added · address.updated · map.clicked |
Cart & Checkout |
Tip
The storefront order events are prefixed — ecommerce.order.updated, ecommerce.order.cancelled
and ecommerce.order.refunded — precisely because a merchant event of the same shape
(order.updated, order.cancelled, order.refunded) already exists. They are different
events with different payloads; the prefix is what keeps them apart.
Important
If the docs and the types package disagree, the types
package decides what compiles. A few documented events aren't in the type union yet
(for example shipping.company.* and review.added), so they won't typecheck as map keys.
Beyond Salla's events you can define your own, namespaced under custom.event.:
const events = defineEvents({
'custom.event.nightly-inventory-sync': nightlyInventorySync
});The suffix is validated at compile time:
| Rule | ✅ | ❌ |
|---|---|---|
Lowercase a–z, 0–9, - and . only |
custom.event.order-sync |
custom.event.orderSync |
| No leading or trailing hyphen | custom.event.sync-v2 |
custom.event.-sync |
| No consecutive hyphens | custom.event.sync-v2 |
custom.event.sync--v2 |
| Max 60 characters | — | a 61-char suffix |
Custom events have no prebuilt context type, so describe your own payload using the shared building blocks the package exports:
import type {
CustomEventPayload,
EventSettings,
FunctionResponse,
Merchant
} from '@salla.sa/app-functions-types';
interface CustomEventContext<T extends CustomEventPayload> {
payload: { event: string; created_at: string; merchant: number; data: T };
merchant: Merchant;
settings?: EventSettings;
}
type SyncPayload = { sku: string; quantity: number };
export const nightlyInventorySync = (
context: CustomEventContext<SyncPayload>
): FunctionResponse => {
const { sku, quantity } = context.payload.data;
return { success: true, status: 200, data: { sku, quantity } };
};Every handler returns a FunctionResponse<T> — a discriminated union on success:
// Success
{ success: true, data: T, status?: number, message?: string }
// Error
{ success: false, message: string, error: { message: string, fields?: Record<string, string[]> } }data is required on success (pass {} if there's nothing to report) and status defaults
to 200. Because it's a discriminated union, TypeScript only lets you read data after
you've narrowed with if (response.success).
Note
Functions authored in the Partner Portal editor use a Resp builder
(Resp.success().setData({})) as shown in the docs.
In a CLI project like this one you return the plain typed object above. Same envelope on
the wire, two authoring styles.
Requests to https://api.salla.dev/admin/v2/ from inside a function are authenticated
automatically — don't add an Authorization header. Just make sure the app has the right
scope enabled in the portal.
const res = await fetch('https://api.salla.dev/admin/v2/orders/12345');Full endpoint list: Merchant API reference.
Run the local suite (Vitest):
npm test # once
npm run test:watch # watch mode
npm run typecheck # tsc --noEmittest/index.spec.ts exercises handlers the way the platform does — it
looks each one up in the exported events map and calls it with a context object. Real
payloads have dozens of fields your handler never reads, so it uses a small fixture
helper to keep test data minimal while still type-checking the fields you do set. Copy a
block, swap in your event, assert on the response.
Beyond unit tests, validate against a real store with the preview panel in the Partner Portal — pick your demo store, supply an order/product/customer ID, and inspect the response, console logs, and execution time. See the testing guide.
Good practice: cover the happy path and malformed payloads, log flow without ever logging secrets or payment data, and keep responses small.
salla app functions build # bundle to dist/index.js
salla app functions deploy [app_id] # build + upload, waits for status, prints preview URL
salla app functions serve [app_id] # stream live logs (console.log, errors, …)| Flag / variable | Effect |
|---|---|
--app-id <id> |
Target app, instead of the positional app_id |
SALLA_APP_ID in .env |
Default app ID, so you can omit it entirely |
deploy --skip-build |
Upload the existing dist/index.js without rebuilding |
serve --raw |
Print full websocket envelopes instead of formatted lines |
Omit the app ID entirely and the CLI will ask you to pick from your apps. Changes stay in the sandbox until you publish from the portal; once published, merchants with your app installed get them automatically.
Command surface may evolve — run
salla app functions --helpfor the current list.
| Command | What it does |
|---|---|
npm test |
Run the test suite once (Vitest) |
npm run test:watch |
Run tests in watch mode |
npm run typecheck |
Type-check without emitting (tsc --noEmit) |
npm run lint |
Lint with ESLint |
npm run format |
Format with Prettier |
Build and deploy go through the CLI, not npm scripts.
| Symptom | Likely cause |
|---|---|
Argument of type '{ … }' is not assignable to parameter of type 'never' |
An event key isn't recognised — check exact spelling; keys are lowercase dot.case triggers (product.viewed, not "Product Viewed") |
No src/index.ts found |
Running the CLI outside the project root, or the events map isn't the default export |
No app_id found |
Pass app_id / --app-id, or set SALLA_APP_ID in .env |
No dist/index.js found |
You used --skip-build before ever building — run salla app functions build |
| Function times out | Async budget is 30s, sync is sub-second — add fetch timeouts and drop sequential calls |
context fields are undefined |
Wrong event type selected, or the test record doesn't exist in the demo store |
| Salla API call returns 401/403 | The app is missing the scope that endpoint needs |
- Add or rename handlers under src/functions/.
- Register them in the
eventsmap in src/index.ts. - Cover them in test/index.spec.ts.
npm run typecheck && npm test, thensalla app functions build.salla app functions deployand open the preview URL.salla app functions serveto watch it run for real.- Publish from the Partner Portal when you're happy.
MIT © Salla