How apps work
An app is a small web page from outside Design Studio. It runs beside the canvas in a sandbox, and everything it does goes through a checkpoint.
- Your appA sandboxed frame with no network, cookies or storage
- The editorChecks each request against what the person approved
- The serverChecks the install and your manifest, then adds keys
- Outside servicesOnly the hosts in your manifest, over https
What an app can do
- Add a picture to the design, or replace the selected one.
- See whether a picture is selected.
- Reach the services listed in its manifest, through the server.
- Save a small object for each person, up to 64 KB.
- Open links on hosts it lists, in the person’s browser.
- Use each person’s own account with a service, once they connect it.
It can’t read the design’s contents, add text, or see who the person is.
Two ways to use a service
| A shared key | Each person’s account | |
|---|---|---|
| Who signs in | You, once | Each person, with OAuth |
| Whose data | The same for everyone | Only that person’s |
| Good for | Stock photos and public data | Notion, Drive and anything private |
| Read more | Using a key | Connecting accounts |
Never use a shared key for private data. Everyone who installs your app could see whatever that key can reach.
Turn on the portal
The portal is part of your server, and it’s off until you set it up. If you don’t have a server yet, start with the Self-Hosted Guide. The portal needs server version 0.1.69 or newer.
- Pick an address for the portal, such as
developers.example.com, and point its DNS at your server. - Add these settings to
.envnext todocker-compose.yml.
# A second address for the portal. Point its DNS at this server first.
DEVELOPER_URL=https://developers.example.com
# These people invite developers and review versions.
ADMIN_EMAILS=you@example.com
# Encrypts app keys and connected accounts. Don't change it later.
# Create one with: openssl rand -base64 32
APP_KEYS_SECRET=- Restart the server. The bundle’s Caddy gets the new address its HTTPS certificate.
docker compose --profile https up -d- Open your
DEVELOPER_URLand sign in with an email fromADMIN_EMAILS. - Invite anyone else who will build apps. They need a verified email on your server.
On Kubernetes or behind your own proxy, set the same three values on the server and route the portal’s hostname to it.
APPS_ALLOWED to none or to a list of app ids. MARKETPLACE_URL=off stops reading the marketplace.Quickstart
Build an app that adds a placeholder picture to a design. You need access to your server’s portal, and no build tools.
1. Create the files
Put these four files in one folder.
{
"id": "placeholder-pictures",
"name": "Placeholder Pictures",
"version": "1.0.0",
"developer": "Your name",
"description": "Add a placeholder picture to your design.",
"category": "graphics",
"entry": "index.html",
"permissions": ["design:addImage"],
"network": [{ "host": "picsum.photos" }, { "host": "fastly.picsum.photos" }],
"links": [],
"secrets": []
}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Placeholder Pictures</title>
<link rel="stylesheet" href="app.css" />
</head>
<body>
<button id="add" type="button">Add a picture</button>
<p id="status" role="status"></p>
<script src="/app-sdk/v1.js"></script>
<script src="app.js"></script>
</body>
</html>body {
margin: 0;
padding: 14px;
font: 13px/1.4 system-ui, sans-serif;
background: #fff;
color: #1a1f1c;
}
:root[data-theme='dark'] body {
background: #1b1e1b;
color: #edf0ec;
}
button {
width: 100%;
height: 34px;
}const status = document.getElementById('status')
document.getElementById('add').addEventListener('click', async () => {
status.textContent = 'Adding…'
try {
const res = await studio.network.fetch({ url: 'https://picsum.photos/800/600.jpg' })
if (!res.dataUrl) throw new Error('That was not a picture.')
await studio.design.addImage({ dataUrl: res.dataUrl })
status.textContent = ''
} catch (err) {
status.textContent = err.message
}
})/app-sdk/v1.js gives your page window.studio. Scripts must be in their own files, because the sandbox refuses inline ones.
2. Zip them
The manifest must be at the top of the zip, not inside a folder.
zip app.zip manifest.json index.html app.css app.js3. Upload it
- Open your portal and sign in.
- Create the app id
placeholder-pictures. It must match the manifest. - Choose Upload zip. If something is wrong, the portal says what.
4. Try it
Choose Try it, open any design and find the app in the side panel. Only you can see this test version. Press the button, and a picture lands on the page.
To change it, upload the zip again. A draft is replaced as long as the version number stays the same.
5. Submit it
When it works, choose Submit for review. An admin on your server reviews it, as described in Reviewing and publishing.
The manifest
manifest.json sits at the top of your zip. It describes your app and is the full list of what it can do.
| Field | Meaning |
|---|---|
id | Lowercase letters, digits and hyphens, starting with a letter, 2 to 40 characters. Must match the app id in the portal. |
name | Up to 60 characters. |
version | Like 1.2.3. An update needs a higher number. |
developer | Who it is by, up to 80 characters. |
description | What it does, up to 500 characters. |
whatsNew | Optional. What changed in this version, up to 500 characters. People read it before they choose to update. |
category | Optional. One of photos, graphics, ai, text, data, publish, productivity, other. |
entry | The HTML file to open. |
icon | Optional. A file in the package. |
screenshots | Optional. Up to 6 pictures for the marketplace page. |
website, support, privacy | Optional https links for the marketplace page. |
permissions | What it may do in a design. |
network | The only hosts it can reach. |
links | Hosts it may open in the person’s browser. |
secrets | Names of the keys you save in the portal. |
oauth | Optional. Lets each person connect their own account. |
Permissions
| Permission | What people are told |
|---|---|
design:addImage | Add pictures to your design |
design:replaceImage | Replace the picture you have selected |
design:readSelection | See whether a picture is selected |
An unknown permission makes the package invalid. Ask only for what you use: an update that asks for more has to be approved by each person before they get it.
Network rules
| Field | Meaning |
|---|---|
host | The exact host name. No wildcards. |
headers | Added by the server, for this host alone. Allowed: Authorization, Accept, Content-Type, X-Api-Key, and any name ending in -Version. |
perHour | Caps requests to this host. With a shared key the cap is across your whole app; with an account, it is per person. |
cacheSeconds | Shares a GET’s answer between people, up to a day. Not allowed with a person’s own account. |
What the zip can hold
HTML, JS, CSS, JSON, PNG, JPEG, WebP, SVG and WOFF2 files, up to 32 MB and 300 files.
The SDK
Load /app-sdk/v1.js as a plain script. Every call returns a promise, and a refused call rejects with an Error that has a code.
Design
| Call | Needs |
|---|---|
studio.design.addImage({ dataUrl }) | design:addImage |
studio.design.replaceImage({ dataUrl }) | design:replaceImage |
studio.design.getSelection() | design:readSelection |
dataUrl must be a PNG, JPEG or WebP data URL of at most 20 MB. getSelection resolves { type: 'image' | 'other' | 'none' }.
Network
const res = await studio.network.fetch({
url: 'https://api.example.com/search',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
})
// res: { status, contentType, text, dataUrl }Only GET and POST, and only Accept and Content-Type headers from your code. Text and JSON arrive in text, and pictures in dataUrl, ready for addImage.
Storage, links and theme
| Call | Does |
|---|---|
studio.storage.get() | This person’s saved object for your app. |
studio.storage.set(object) | Replaces it, up to 64 KB. |
studio.links.open(url) | Opens a host in your links, in the browser. |
studio.onTheme((theme) => …) | Tells you light or dark. <html data-theme> is also set. |
Accounts
| Call | Does |
|---|---|
studio.account.status() | Resolves { connected }. |
studio.account.connect() | Opens the provider’s sign-in. Call it from a click. Resolves { connected } when they finish, or after 5 minutes. |
studio.account.disconnect() | Forgets this person’s token on this server. |
Error codes
| Code | What to do |
|---|---|
NOT_ALLOWED | Add the permission or link host to your manifest. |
HOST_NOT_ALLOWED | Add the host to network. A redirect to an unlisted host also causes this. |
NOT_CONNECTED | Show your connect button. |
NOT_CONFIGURED | A key is missing. Save it in the portal. |
NOT_INSTALLED | The person removed the app, or hasn’t approved this version. |
READ_ONLY | The design is view-only. Tell the person. |
NO_SELECTION | Ask them to select a picture first. |
RATE_LIMITED | Too many requests. Slow down and try again. |
BUSY | Your perHour limit was reached. Try again later. |
BLOCKED | The response is too large, or a type apps can’t receive. |
PROVIDER_ERROR | The service couldn’t be reached. |
INVALID | The call’s input is wrong, such as a picture in the wrong format. |
Using a key
If a service needs an API key that doesn’t belong to any one person, you save it in the portal and the server adds it to your requests. Your code never sees it.
{
"network": [
{
"host": "api.pexels.com",
"headers": { "Authorization": "{{secret:PEXELS_API_KEY}}" },
"perHour": 150,
"cacheSeconds": 600
},
{ "host": "images.pexels.com" }
],
"secrets": ["PEXELS_API_KEY"]
}- List the key’s name in
secrets, and use it in a header as{{secret:NAME}}. - Upload your zip. A field for each key appears in the portal.
- Paste the key and save it. It is encrypted and can’t be read back.
Your code calls the service without the key. The server adds it for that host alone, and removes it from any response that echoes it.
A key’s limit is shared by everyone using your app. Set perHour a little below it, and use cacheSeconds so people asking the same thing share one answer.
For an app from the Design Studio marketplace, such as Pexels, you supply the key yourself in .env, as APP_SECRET_<APP ID>_<NAME>.
Connecting accounts
To show people their own data, each person connects their own account with OAuth. The server keeps their token and adds it to your requests.
1. Declare it
{
"oauth": {
"authorizeUrl": "https://api.notion.com/v1/oauth/authorize",
"tokenUrl": "https://api.notion.com/v1/oauth/token",
"params": { "owner": "user" },
"clientAuth": "basic",
"tokenFormat": "json"
},
"network": [
{
"host": "api.notion.com",
"headers": {
"Authorization": "Bearer {{account:token}}",
"Notion-Version": "2022-06-28"
},
"perHour": 500
}
],
"secrets": ["OAUTH_CLIENT_ID", "OAUTH_CLIENT_SECRET"]
}| Field | Meaning |
|---|---|
authorizeUrl, tokenUrl | The provider’s addresses, over https. |
scopes | Optional. What to ask the provider for. |
params | Optional. Extra parameters for the sign-in page. |
clientAuth | body (default) or basic: how the server identifies your app to the provider. Check its docs. |
tokenFormat | form (default) or json: how it sends that request. |
A header using {{account:token}} carries the person’s token. That host can’t use cacheSeconds, because the cache is shared.
2. Register with the provider
- Create an OAuth app or connection in the provider’s developer settings.
- Upload your zip. The portal shows the redirect address to give the provider.
- Save the provider’s client id and secret as
OAUTH_CLIENT_IDandOAUTH_CLIENT_SECRET.
3. Use it in your app
const { connected } = await studio.account.status()
if (!connected) showConnectButton()
// From a click, so the browser allows the new window.
button.addEventListener('click', async () => {
const { connected } = await studio.account.connect()
if (connected) showPages()
})
try {
const res = await studio.network.fetch({ url: 'https://api.notion.com/v1/users/me' })
// A 401 means they revoked access at the provider: ask them to connect again.
} catch (err) {
if (err.code === 'NOT_CONNECTED') showConnectButton()
}How it is kept safe
- Tokens are encrypted, and kept per person and per app.
- A token about to expire is refreshed. If that is refused, the person connects again.
- The browser must be signed in to Design Studio as the person who asked, so a link sent to someone else can’t attach their account to the sender’s login.
- Design Studio shows a Disconnect button under your app, so people can always start again, even if your app shows an error.
- Disconnecting or removing your app deletes the token from the server. Revoking it at the provider is up to the person, so handle a 401 by asking them to connect again.
The sandbox
Your app is someone else’s code to the people using it, so the browser treats it as a stranger. Three separate checks mean one mistake doesn’t open everything.
| Not available | Do this instead |
|---|---|
| Inline scripts | Put scripts in their own files. |
| Scripts or styles from other sites | Include them in your zip. |
fetch | studio.network.fetch |
| Pictures from other sites | Fetch them and use the dataUrl as the image’s src. |
Cookies and localStorage | studio.storage |
| Form submission | Handle Enter or a button click yourself. |
| Popups and new tabs | studio.links.open |
| Nested frames | Build the feature into your page. |
The three checks
- The browser. Your files are served with a sandbox policy, so the app can’t read the session or the editor’s page, and has no network of its own.
- The editor. It only accepts messages from your frame, and checks each one against what the person approved. View-only designs refuse changes.
- The server. It checks again that the person installed and approved this version. It reaches only listed hosts, over https on the default port, and refuses private addresses.
Limits
| What | Limit |
|---|---|
| Requests | 600 per person, per app, per hour. Pictures count. |
| Request body | 64 KB, and 2,048 characters for the address |
| Response | 12 MB. JSON, text and pictures only. |
| Pictures added | 20 MB each, and 10 changes every 10 seconds |
| Saved data | 64 KB per person |
| Opening links | One every 2 seconds |
| Connecting an account | Twice a minute |
Reviewing and publishing
Every version is reviewed by an admin on your server before it reaches anyone. An approved version never changes.
- Draft. Upload it, as often as you need. Your zip is checked at once.
- Try. A test version runs in the same sandbox, visible only to you, and to reviewers once submitted.
- Waiting for review. A submitted version is locked.
- Approved or changes requested. A reviewer’s note says what to change. Upload it again and resubmit.
What to check when you review
- It does what its description says, and it works.
- It asks only for what it uses. On an update, you see what it newly asks for.
- A shared key doesn’t expose private data.
Updates
An update needs a higher version number and another review. Once approved, it is offered as Update app, with your whatsNew text. Each person stays on their version until they choose to update, and sees what the update newly asks for. New installs get the latest version.
Reports and removal
Anyone can report an app from its marketplace page. An admin can remove an app for everyone at once, including people who installed it, and the developer sees the reason in the portal.