SalesOS

Apps

Package custom fields, objects, actions, hooks, webhooks and UI extensions in one versioned manifest that any SalesOS organization can install, upgrade and cleanly uninstall.

An app bundles everything an integration needs in SalesOS into one file, salesos.app.json: the OAuth app it uses and the scopes it asks for, the custom fields and objects it adds, its custom actions, before-save hooks, webhook subscriptions, UI extensions and the settings an admin fills in.

  • Publishers register a manifest as an immutable, semver-numbered version. The publisher is the organization that owns the manifest's OAuth app.
  • Installing organizations review exactly what an app asks for and install it. Every component is created through the same service that creates it by hand, so all their rules apply. Uninstalling removes exactly what the app created, and nothing else.

Installing apps and page layouts are part of the Professional plan (app_platform); publishing also needs an OAuth app, which is part of API access (Enterprise, or any developer org).

Publish an app

  1. Register an OAuth app under Settings → OAuth apps and allow every scope your manifest will request. Turn on multi-tenant if other organizations should be able to install it; a private app can only be installed by your own organization.
  2. Write salesos.app.json (reference below) and publish it under Settings → Apps → Publish, or:
curl -X POST https://www.salesos.org/v1/apps/published/versions \
  -H "Authorization: Bearer $SESSION_JWT" -H "Content-Type: application/json" \
  -d "{\"manifest\": $(cat salesos.app.json)}"

The first version returns the app's signing secret once (sas_…). Store it with your service; SalesOS keeps only an encrypted copy. Publishing is session-only (never an API key or OAuth token), like OAuth app management.

Later versions must be newer (semver) than every published one; a version can't be changed once published. The slug and OAuth app are fixed by the first version.

Manifest reference

The JSON Schema is at /schemas/salesos.app.schema.json — point your editor at it with "$schema". SalesOS checks more than the schema can express: scopes and event types must exist, every URL must be a public https:// address (no private, loopback or internal hosts), and schemas must use the custom-action subset.

{
  "$schema": "https://docs.salesos.org/schemas/salesos.app.schema.json",
  "manifestVersion": 1,
  "slug": "acme-enrich",
  "name": "Acme Enrich",
  "version": "1.2.0",
  "description": "Scores and enriches leads from Acme's data.",
  "publisher": { "name": "Acme Inc", "website": "https://acme.example.com", "supportEmail": "[email protected]" },
  "icons": { "64": "https://acme.example.com/icon-64.png" },
  "urls": { "homepage": "https://acme.example.com", "privacyPolicy": "https://acme.example.com/privacy" },
  "oauth": { "clientId": "sos_ci_…", "scopes": ["READ_LEADS", "WRITE_LEADS"] },
  "customObjects": [
    { "key": "acme_signal", "label": "Acme signal", "pluralLabel": "Acme signals",
      "fields": [{ "name": "kind", "label": "Kind", "type": "select", "options": ["hiring", "funding"] }] }
  ],
  "customFields": [
    { "key": "acme_score", "object": "LEAD", "label": "Acme score", "type": "NUMBER" }
  ],
  "customActions": [
    { "key": "enrich_lead", "label": "Enrich with Acme", "url": "https://api.acme.example.com/salesos/enrich",
      "kind": "WRITE", "objectTypes": ["LEAD"], "allowManual": true,
      "inputSchema": { "type": "object", "properties": { "email": { "type": "string", "format": "email" } }, "required": ["email"] },
      "outputSchema": { "type": "object", "properties": { "score": { "type": "number" } } },
      "inputMapping": { "email": "email" }, "outputMapping": { "score": "leadScore" } }
  ],
  "beforeSaveHooks": [
    { "key": "check_domain", "name": "Check domain", "objectType": "LEAD", "operations": ["CREATE"],
      "url": "https://api.acme.example.com/salesos/hooks/lead", "failureMode": "FAIL_OPEN" }
  ],
  "webhooks": [
    { "key": "events", "url": "https://api.acme.example.com/salesos/events",
      "events": ["lead.created", "app.installed", "app.uninstalled"] }
  ],
  "uiExtensions": [
    { "key": "lead_insights", "slot": "record.tab", "label": "Acme insights",
      "url": "https://app.acme.example.com/ext/lead", "objects": ["LEAD"] }
  ],
  "settings": {
    "type": "object",
    "properties": { "region": { "type": "string", "enum": ["us", "eu"] } },
    "required": ["region"]
  }
}
FieldMeaning
manifestVersionAlways 1.
slugLowercase letters, digits and -, 3–40 characters. Globally unique; claimed by the first publisher.
versionSemantic version (1.2.0, 2.0.0-beta.1).
publisher, icons, urlsShown to installers. URLs must be https.
oauth.clientIdYour OAuth app. oauth.scopes are the resource scopes (READ_<RESOURCE> / WRITE_<RESOURCE>) installations grant; identity scopes (openid, offline_access…) are not used by installations.
customObjects[]key becomes the object's API name; fields use text, textarea, number, currency, date, boolean, select, email, phone, url, lookup or master_detail. Relationship fields name a target (LEAD, CONTACT, ACCOUNT, OPPORTUNITY, TASK, CASE, or one of the app's own objects as custom:<key>) and an onDelete rule, as in custom objects.
customFields[]A field on LEAD, CONTACT, ACCOUNT, OPPORTUNITY, PRODUCT or QUOTE, of type TEXT, TEXTAREA, NUMBER, CURRENCY, DATE, DATETIME, CHECKBOX, PICKLIST, MULTI_PICKLIST, EMAIL, PHONE or URL. key becomes its API name. Picklists need options. A field's type can't change in a later version — publish a new key.
customActions[]As in Custom actions; key is the apiName. objectTypes may name the app's own objects as custom:<key>.
beforeSaveHooks[]As in Before-save hooks.
webhooks[]A subscription to event types from the catalog, delivered with the versioned envelope.
uiExtensions[]slot is record.tab, record.sidePanel, record.headerAction, list.action, dashboard.widget, app.page or global.modal; record and list slots list objects (LEAD, CONTACT, ACCOUNT, OPPORTUNITY, or custom:<apiName>). Optional formFactors (desktop, mobile) and modalSize. See UI extensions.
settingsThe custom-action schema subset. The installing admin fills it in; your app reads it. Settings are not secret — never ask for passwords or keys here.

Limits: 25 custom fields, 5 custom objects (30 fields each), 20 actions, 10 hooks, 10 webhooks, 20 UI extensions, 30 settings, 256 KB.

Metadata components

An app can also ship automation, pages and content. Each of these sections is an array of components in the metadata source format — the same JSON a salesos metadata pull file holds, so you can build them in a developer org, pull them, and paste them in:

SectionComponent (source file)Limit
functions[]functions/<apiName>/function.json plus source (and language)10
validationRules[]validation-rules/*.rule.json50
workflows[]workflows/*.workflow.json25
flows[]flows/*.flow.json25
approvalProcesses[]approval-processes/*.approval.json10
customLabels[]labels/*.label.json500
translations[]translations/*.translation.json, one per locale10
pageLayouts[]layouts/*.layout.json20
pages[], appPages[]pages/*.page.json, app-pages/*.apppage.json20, 10
reports[], dashboards[]reports/*.report.json, dashboards/*.dashboard.json50, 20
emailTemplates[]email-templates/*.email.json50
customEventTypes[]events/*.event.json (custom events)20
namedCredentials[]credentials/*.credential.json, configuration only — secret and clientSecret are refused10
{
  "functions": [
    { "apiName": "acme_score", "name": "Acme score", "allowedHosts": ["api.acme.example.com"],
      "scopes": ["READ_LEADS"], "source": "export default async function (input) { … }" }
  ],
  "workflows": [
    { "name": "Acme: score new leads", "triggerType": "RECORD_CREATED", "triggerEntity": "LEAD",
      "actions": [{ "type": "FUNCTION", "config": { "functionApiName": "acme_score" } }] }
  ],
  "customLabels": [{ "name": "AcmeScoreHelp", "value": "Scores come from Acme." }],
  "translations": [{ "locale": "fr", "translations": { "label.AcmeScoreHelp": "Les scores viennent d’Acme." } }]
}

They install through the metadata engine, exactly like a deploy: validated against the installing org (references, compile, page and layout checks), written in dependency order, recorded against the installation one by one, and rolled back in reverse if one fails. Rules that follow:

  • Functions run as the installing admin with the user role (never runAsAdmin), may only use scopes that are also in oauth.scopes (the admin consents to them), and cannot declare secrets. Their allowedHosts are listed on the consent screen.
  • Automation is on: workflows, flows and approval processes arrive active, because the admin consented to the app. A marketplace suspension turns the app's functions, workflows and flows off with its actions, hooks and webhooks.
  • A component with the same key that the org already has stops the install (APP_INSTALL_FAILED, 409 from the component) — the app never takes over something the org built.
  • Layouts and pages refer to the app's own extensions as app/<your slug>:<key>.
  • Translations add keys to the org's locale; uninstall removes only the keys the app added.
  • Reports, dashboards and email templates belong to the installing admin.
  • Named credentials install inactive; the installing admin sets the secret under Settings → Developer → Connections, where an app's credential accepts only its secrets and on/off. The app's webhooks may subscribe to the custom event types it declares.

Signing: one secret per organization

Everything SalesOS signs for your app in an organization — custom action and hook calls, webhook deliveries and UI-extension context tokens — uses that organization's installation secret, which you derive from your app's signing secret:

installation_secret = "sai_" + hex(HMAC-SHA256(app_signing_secret, "salesos-app:v1:" + organizationId))

Every signed payload carries organizationId, so your endpoint derives the secret and then verifies X-SalesOS-Signature exactly as described in Events → Verifying signatures:

import { createHmac, timingSafeEqual } from 'node:crypto';

const installationSecret = (appSecret: string, orgId: string) =>
  'sai_' + createHmac('sha256', appSecret).update(`salesos-app:v1:${orgId}`).digest('hex');

export function verify(rawBody: string, header: string, appSecret: string) {
  const { organizationId } = JSON.parse(rawBody);
  const [t, v1] = header.split(',').map((p) => p.split('=')[1]);
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const mac = createHmac('sha256', installationSecret(appSecret, organizationId)).update(`${t}.${rawBody}`).digest('hex');
  return timingSafeEqual(Buffer.from(mac), Buffer.from(v1));
}

A secret an organization's admins can see (a webhook's, say) is only that organization's, and reveals nothing about your app secret or other organizations. Settings → Apps → Publish → rotate issues a new app secret and re-signs every installed component at once.

Install

An org owner or admin installs from Settings → Apps → Available. The consent screen lists every scope in plain language and every component the app will create, then asks for the settings. Through the API (session only):

GET  /v1/apps/directory                     apps this org can install
GET  /v1/apps/directory/{appId}             one app + what installing it grants
POST /v1/apps/installations                 { appId, versionId?, acceptedScopes, settings }

acceptedScopes must contain every scope the version requests — it records that the admin saw them. Installation is all or nothing: components are created in dependency order (objects, fields, actions, hooks, webhooks), each recorded against the installation as it is created; if one fails, the ones already created are removed and the installation is left FAILED with the reason (400 APP_INSTALL_FAILED names the component). Installing the version that is already installed is a no-op (alreadyInstalled: true).

The admin's consent becomes a grant on your OAuth app for the organization (shown under Connected apps). Revoking it there cuts the app's access immediately.

Upgrade

GET /v1/apps/installations/{id}/upgrade-preview lists the scopes and components a newer version adds or removes; POST /v1/apps/installations/{id}/upgrade with the accepted scopes applies it. New components are created, existing ones updated, and components the new version no longer declares are retired — custom fields and objects are kept with their data. If anything fails, the updated components are restored and the installation stays on its old version. Narrowing scopes revokes outstanding extension tokens.

Uninstall

POST /v1/apps/installations/{id}/uninstall with dataPolicy:

  • keep (default): custom fields and objects stay, with their data, and reattach if the app is installed again;
  • delete: they are deleted with every value stored in them.

Actions, hooks, webhooks and every metadata component are removed, and the app's grant and every token it holds in the organization are revoked. If a component can't be removed, the uninstall stops with 409 APP_UNINSTALL_INCOMPLETE; what was removed stays removed and the call can be repeated.

Managed components

Everything an installation created is managed by the app. The settings screens show Managed by <app> with a link to the installation, and the APIs refuse to edit, delete or re-key a managed custom field, custom object, custom action, before-save hook, webhook, function, validation rule, workflow, flow, approval process, custom label, page layout, page, report, dashboard or email template with 409 COMPONENT_MANAGED_BY_APP (turning an action, hook or webhook on or off still works; copying, previewing and testing a managed component is fine). Metadata deploys and change sets skip managed components on retrieve and refuse to change them. Change them by publishing a new version, or uninstall. An org admin can take one over with POST /v1/apps/installations/{id}/components/{componentId}/detach: it becomes an ordinary component of the organization, upgrades and uninstall leave it alone, and the detach is recorded in the audit log.

Events

Subscribe your manifest's webhook to these to learn about your own lifecycle:

EventWhen
app.installedInstalled, or upgraded (previousVersion is set). Sent once the app's webhooks exist.
app.uninstalledUninstalled. Sent before the app's own webhooks are removed, with removedComponents and retainedFields.

Reading your installation

With an OAuth access token issued to your app — including from an extension through api.fetch — GET /v1/app-runtime/installation returns the installation in the token's organization: id, version, granted scopes and settings.

Errors

CodeMeaning
APP_MANIFEST_INVALIDThe manifest failed validation; errors[] has path and message for each problem.
APP_OAUTH_APP_NOT_FOUNDoauth.clientId isn't an OAuth app of the publishing organization.
APP_SCOPE_NOT_ALLOWED / APP_SCOPE_INVALIDA scope the OAuth app doesn't allow, or an identity scope.
APP_SLUG_TAKEN, APP_VERSION_EXISTS, APP_VERSION_NOT_NEWERPublishing conflicts.
APP_CONSENT_REQUIREDacceptedScopes is missing a requested scope.
APP_SETTINGS_INVALIDSettings don't match the schema.
APP_INSTALL_FAILEDA component was refused; nothing was left behind.
APP_INSTALL_IN_PROGRESSSomeone else is changing this installation.
APP_ACCESS_REVOKEDAn admin revoked the app under Connected apps.

On this page