You do not need to be a Motion customer to publish. Most publishers already ship a working product elsewhere: Motion is a parallel channel. The same app keeps serving people on your own site or product UI; companies that use Motion open that same product inside their portal — another interface, not another codebase.
Your app still runs on your servers. Motion shows it in a tab (or a new browser tab), tells it who opened it, and gives it access to that company's data — only what the company's administrator granted when they installed it.
A complete example with no dependency lives in examples/kudos (npm run example:kudos).
The life of an app
- Become a publisher — open a publisher profile (Administration → Developers if you already have a Motion workspace, or ask the Motion team). The name you choose is shown on every app you list; you do not migrate your existing customers onto Motion.
- Point Motion at the app you already run — its address (
/apps/<slug>in every company, unique in the store) and how it opens: in a portal tab (embedded) or in a new browser tab (link).entryUrlis your product, with a Motion-aware launch path. - Write its first release — the listing (name, tagline, description, icon, category, price, privacy policy), where it runs (
entryUrl), its webhook (optional) and the permissions it asks for. - Try the Motion surface — Install in my workspace installs the release you are writing so you can check the portal shell. Your non-Motion users keep using the app as before; nobody else on Motion can install it until it is published.
- Send it for review — the Motion team checks every release before it goes live: what it asks for, where it runs, what the listing promises. Approved, it is in the store; sent back, you get a note in your bell and on the app's page.
- New versions — each release has a higher version. When it is approved, every company that installed the app moves to it at once, unless it asks for a new permission: then each company's administrator accepts it first (they are told in their bell), and until then they keep the previous version.
Credentials
Each app has a client id (mapp_…) and a client secret (msec_…), shown in Administration → Developers → your app. The secret never leaves your server. It:
- signs the launch tokens Motion gives your app (HS256),
- buys API tokens for an installation,
- signs the webhooks Motion sends you.
New secret replaces it at once, in every company.
Opening: the launch token
Motion opens entryUrl?motion_launch=<token>. The token is a JWT:
| Claim | Value |
|---|---|
alg | HS256, signed with your client secret |
iss | motion |
aud | your client id |
exp | five minutes after it was made |
sub | the person's id |
installation | the company's installation of your app: use it for the API |
organization | { id, slug, name } |
user | { id, name, isAdmin }, plus email, jobTitle, department with profile.read |
permissions | what the company granted |
brandColor | the company's color |
portal | the portal's origin: the only window the SDK talks to |
Check it on your server (signature, iss, aud, exp), then give the page a session of your own. Third-party cookies are often blocked in frames: prefer a token your page sends in a header (as Kudos does) over a cookie.
The SDK
<script src="https://<the portal>/sdk/motion-apps.js"></script>
<script>
MotionApp.init().then((motion) => {
// motion.user, motion.organization, motion.permissions, motion.theme, motion.path
});
</script>Load it from the portal origin of the launch token. init() reads the token from the address (and takes it out of it), then asks the portal for its context.
| Call | What it does |
|---|---|
MotionApp.init() | Resolves with { token, installation, organization, user, permissions, theme, brandColor, path, embedded } |
MotionApp.token() | The latest launch token, for your server's requests |
MotionApp.refreshToken() | A new launch token (the first one lasts five minutes) |
MotionApp.toast(message, "success" | "error") | A short message in the portal, under your app |
MotionApp.navigate(path) | Keeps your app's page in the portal's address (?at=): a reload or a shared link reopens it |
MotionApp.open(url) | Opens an https:// address in a new browser tab |
MotionApp.onTheme(listener) | Called with "light" or "dark" when the person changes the portal's theme |
The SDK sets data-motion-theme on your <html> and the CSS variable --motion-brand. Your app is framed with sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads", on its own origin: it never reaches the portal's page, cookies or storage. Allow Motion to frame it (frame-ancestors in your Content-Security-Policy, no X-Frame-Options: DENY).
The API
Base: https://<the portal>/api/v1. Every answer is JSON; an error is { "error": { "code", "message" } }.
An access token, for one installation
POST /api/v1/token
Content-Type: application/json
{ "client_id": "mapp_…", "client_secret": "msec_…", "installation_id": "<from the launch token or a webhook>" }→ { "access_token": "…", "token_type": "Bearer", "expires_in": 3600, "permissions": [...] }. Send it as Authorization: Bearer <access_token>. It stops working when the company removes your app, when its workspace is suspended, or when your app is suspended.
Endpoints
| Call | Permission | What it gives |
|---|---|---|
GET /installation | — | { id, app, organization, permissions } |
GET /users?q=&limit=&cursor= | directory.read | Active people: id, name, email, jobTitle, department, country; nextCursor for the next page (100 at most) |
GET /users/<id> | directory.read | One person |
GET /groups | groups.read | id, name, description, memberIds |
POST /notifications | notifications.send | { userIds: [...], title, body?, path? } → a notification in their bell, leading to path in your app (100 people a call) |
POST /announcements | announcements.publish | { title, summary, body? } → an announcement on the home page and in News, in your app's name |
Permissions
| Key | Asks the company to let the app… |
|---|---|
profile.read | see the name, email address, job title and department of the person who opens it |
directory.read | read the directory |
groups.read | read the groups and who is in each |
notifications.send | put notifications in people's bell |
announcements.publish | publish news on the home page |
Ask for what the app needs, nothing more: the review checks each one against the listing.
Webhooks
If the release has a webhookUrl, Motion posts the installation events to it:
{
"id": "…",
"event": "app.installed",
"createdAt": "2026-10-25T14:03:00.000Z",
"app": { "id": "…", "slug": "kudos", "clientId": "mapp_…" },
"installation": { "id": "…", "permissions": ["…"], "organization": { "id": "…", "slug": "acme", "name": "Acme" } }
}Events: app.installed, app.updated (a company moved to a new release), app.uninstalled (delete what you kept for that installation).
Each request carries Motion-Event and Motion-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with your client secret. Check it in constant time and refuse a t more than five minutes away. Answer 2xx within five seconds; there is one try, and every delivery shows on your app's page for 30 days.
Where an app may run
https:// on a public host. While developing, http://localhost works on a Motion that runs in development; a production Motion never calls into a private network (webhooks included).