.`. Check it against the body **exactly as received**, before parsing it, and refuse signatures more than 5 minutes old so an old delivery can't be replayed.
In any runtime with Web Crypto (Workers, Deno, Bun, Node 20+):
```ts
/** Whether a delivery really came from opencloudport, and is recent. */
async function verifyWebhook(body: string, header: string | null, secret: string) {
const t = /t=(\d+)/.exec(header ?? '')?.[1]
const v1 = /v1=([0-9a-f]+)/.exec(header ?? '')?.[1] ?? ''
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${body}`)))
const expected = Array.from(mac, (b) => b.toString(16).padStart(2, '0')).join('')
// Compare every character, so the time taken doesn't give the answer away.
let diff = v1.length ^ expected.length
for (let i = 0; i < expected.length; i++) diff |= (v1.charCodeAt(i) || 0) ^ expected.charCodeAt(i)
return diff === 0
}
export async function POST(request: Request) {
const body = await request.text()
if (!(await verifyWebhook(body, request.headers.get('opencloudport-signature'), process.env.OCP_WEBHOOK_SECRET!))) {
return new Response('Bad signature', { status: 400 })
}
const event = JSON.parse(body)
// … handle event.type
return new Response('ok')
}
```
With Node's own crypto module:
```js
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(body, header, secret) {
const t = /t=(\d+)/.exec(header ?? '')?.[1]
const v1 = /v1=([0-9a-f]+)/.exec(header ?? '')?.[1] ?? ''
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex')
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
}
```
`@opencloudport/sdk` will export the same check as `verifyWebhook(body, header, secret)`. It isn’t on npm yet, so copy the function above for now.
## When a delivery fails [#when-a-delivery-fails]
Anything but a `2xx` within 10 seconds counts as failed. We try again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours (about a day and a half in all), then give up on that event. Each try is in the webhook's delivery log, with what your server answered; you can send any of them again.
A webhook whose deliveries keep failing for 3 days in a row is turned off, and the dashboard says why. Fix the address or your server, then turn it back on. Events and deliveries are kept 30 days.
## The API [#the-api]
All workspace keys; [app keys](/api/keys) can't reach webhooks.
| Method | Path | |
| -------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `GET` | `/api/v1/webhooks` | List them. Secrets aren't shown again; `secret_hint` tells them apart. |
| `POST` | `/api/v1/webhooks` | `{ url, description?, events? }`: `events` is `"*"` (default) or a list of types. The answer has `secret`, once. |
| `GET` / `PATCH` / `DELETE` | `/api/v1/webhooks/:id` | One webhook. `PATCH { url?, description?, events?, enabled? }`; turning it back on clears a failing streak. |
| `POST` | `/api/v1/webhooks/:id/rotate-secret` | A new secret (in the answer, once). The old one stops working at once. |
| `POST` | `/api/v1/webhooks/:id/test` | Send a `webhook.test` now; the answer says how it went. |
| `GET` | `/api/v1/webhooks/:id/deliveries?limit=50` | What was sent, newest first. |
| `POST` | `/api/v1/webhooks/:id/deliveries/:delivery_id/redeliver` | Send that event again. |
```sh
curl -X POST "https://opencloudport.com/api/v1/webhooks" \
-H "authorization: Bearer $OCP_KEY" -H "content-type: application/json" \
-d '{"url": "https://example.com/webhooks/opencloudport", "events": ["deployment.failed", "email.bounced"]}'
```
Once the SDK is published: `ocp.webhooks.create / list / get / update / delete / rotateSecret / test / deliveries / redeliver`, and `verifyWebhook`.
Webhooks only go to `https` addresses on the public internet.
# Introduction (/)
opencloudport gives an app everything it needs to run on the internet, and lets an AI agent set it all up for you. You can work three ways, and they all do the same things:
* **Ask your AI agent.** Connect Claude Code, Cursor or your own agent once, then ask in plain words: "put my bakery's order form online, with a database for orders". It uses opencloudport's tools to build it.
* **Use the dashboard.** Make apps, plug in what they need, look inside your databases and files, and watch what every key did.
* **Call the API.** The same actions over HTTP, or with the TypeScript SDK, from your own code.
opencloudport runs for you: sign up at [`https://opencloudport.com`](https://opencloudport.com) and everything here works with your account. There's nothing to install or host, and you never need an account with the providers behind it.
## How it fits together [#how-it-fits-together]
An **app** starts empty. You plug **services** into it, as many as it needs:
| Service | What it does | Your code reaches it as |
| -------------------------------------- | -------------------------------------------------------------------- | ----------------------- |
| [Compute](/services/compute) | Runs your code and answers at the app's own address. | (it *is* your code) |
| [Database](/services/databases) | Keeps data in SQL tables. | `env.DB` |
| [File storage](/services/file-storage) | Holds uploads, images and downloads. | `env.FILES` |
| [Email](/services/email) | Sends receipts and sign-ups from the app's own address. | `env.EMAIL` |
| [Sandboxes](/services/sandboxes) | Private Linux machines where agents install, build and preview code. | (from the API or SDK) |
Names like `DB` are defaults. You can add several databases or buckets and name each one (`env.ORDERS`, `env.PHOTOS`).
### Who runs each service [#who-runs-each-service]
Each service runs on a named provider. Today that's:
### Paying and keys [#paying-and-keys]
Every workspace has one **prepaid balance**. Each service is measured by what it actually does, and you can see every charge in plain words on the Billing page. Each agent or script gets its own **API key**, which you can limit to one app, make read-only, and give a spending limit.
## Where to go next [#where-to-go-next]
Every page is also plain Markdown: add `.md` to its address, or ask for `text/markdown`. [`/llms.txt`](/llms.txt) lists every page, and [`/llms-full.txt`](/llms-full.txt) is all of them in one file.
# Limits (/limits)
These are opencloudport's own limits, read from the code that enforces them. Each comes back as a clear error with a message saying which limit you hit. If one gets in your way, tell us what you're building.
The limits marked **(raisable)** can be raised for your workspace on request, for example when you run an app per customer: apps, sandboxes, sandboxes per app, services per app (up to 100), domains per app, webhooks, and the request rate per key. See [Building a platform](/guides/platforms#limits-for-a-platform).
## Requests [#requests]
| What | Limit |
| ----------------------------- | ----------------------------------------------------- |
| Requests per API key | 20 a second on average, bursts of up to 60 (raisable) |
| Emails sent per API key | 2 a second on average, bursts of up to 20 |
| Sandboxes started per API key | 1 every 5 seconds, bursts of up to 5 |
| `Idempotency-Key` | 1 to 255 visible characters, remembered for 24 hours |
| Request body | 16 KB, unless listed below |
Over a rate limit you get `429 rate_limited` with `Retry-After` in seconds. See [Rate limits](/api#rate-limits).
## Workspaces and apps [#workspaces-and-apps]
| What | Limit |
| -------------------------------------------- | ------------------------------------------------------ |
| Apps per workspace | 1,000 (raisable) |
| App name | 100 characters |
| `external_id` | 200 printable characters, no spaces |
| `metadata` | 50 values; names up to 40 characters, values up to 500 |
| Databases, buckets and email senders per app | 20 in all (raisable, up to 100) |
| Compute and sandboxes | one of each per app |
| Variables and secrets per app | 100, each value up to 5 KB |
| Domains per app | 20 (raisable) |
## Webhooks [#webhooks]
| What | Limit |
| -------------------------------------- | ------------------------------ |
| Webhooks per workspace | 10 (raisable) |
| Time to answer a delivery | 10 seconds |
| Retries of a failed delivery | 6, over about a day and a half |
| Failing before a webhook is turned off | 3 days in a row |
| Events and deliveries kept | 30 days |
## Code [#code]
| What | Limit |
| ----------------------------------- | ------ |
| Code files (modules) per deployment | 100 |
| Code per deployment | 10 MB |
| Static files per deployment | 20,000 |
| One static file | 25 MB |
| All static files in one upload | 50 MB |
{/* Paused for launch (src/lib/git/enabled.ts). Put back when deploying from GitHub is back:
| A build from GitHub | 15 minutes |
*/}
## Logs [#logs]
| What | Limit |
| ------------------------------ | ---------------- |
| How long lines are kept | 7 days |
| Lines kept per app | 2,000 an hour |
| Printed lines kept per run | 100 |
| One message or stack | 8,000 characters |
| Lines per page from the API | 500 |
| Waiting for new lines (`wait`) | 20 seconds |
## Databases [#databases]
| What | Limit |
| ----------------------------- | ------------------------------------------------------ |
| SQL per request | 100 KB |
| Values for `?` placeholders | 100 |
| Rows returned per statement | 1,000 (the rest are left out, and `truncated` is true) |
| A SQL file to load (`import`) | 10 MB |
| A copy's download link | 15 minutes |
## File storage [#file-storage]
| What | Limit |
| ------------------------- | ---------------------------------------------- |
| A file's path | 1,024 bytes |
| Files listed per page | 1,000 |
| Signed links | 1 minute to 7 days (15 minutes unless you say) |
| Direct bucket credentials | 15 minutes to 12 hours (1 hour unless you say) |
## Email [#email]
| What | Limit |
| --------------------------------- | ------------------- |
| Subject | 500 characters |
| Text version | 200,000 characters |
| HTML version | 500,000 characters |
| Sent emails kept in the dashboard | 30 days |
| Sender domains | 1 per email service |
## Scheduled runs [#scheduled-runs]
| What | Limit |
| ----------------- | ----------------------------- |
| Schedules per app | 5 |
| How often | at most once a minute, in UTC |
## Sandboxes [#sandboxes]
| What | Limit |
| ----------------------------- | ------------------------------------------------ |
| Sandboxes per app | 50 (raisable) |
| Sandboxes per workspace | 200 (raisable) |
| Falls asleep after being idle | 1 minute to 24 hours (15 minutes unless you say) |
| A command | up to 10 minutes (2 minutes unless you say) |
| Files written at once | 500, up to 25 MB in all |
| A variable's value | 4 KB |
## Money [#money]
| What | Limit |
| --------------------------------------- | ----------------------------- |
| Adding funds | $5 to $10,000 at a time |
| Spending limits (keys, apps, end users) | up to $1,000,000, to the cent |
| Usage you can ask for at once | 92 days |
# Quickstart (/quickstart)
You'll need an opencloudport account. Signing up and creating apps are free; you add funds on the **Billing** page before plugging services in.
### Make a key for your agent [#make-a-key-for-your-agent]
In the dashboard, open **API keys** (under Manage) and choose **Create an API key**. Name it after the agent, like "Claude agent". Copy the key: it's shown once. Treat it like a password.
### Connect the agent [#connect-the-agent]
For Claude Code, run this in your terminal:
```bash
claude mcp add --transport http opencloudport \
https://opencloudport.com/mcp \
--header "Authorization: Bearer "
```
Other agents are covered in [Connect an AI agent](/agents).
### Ask for what you want [#ask-for-what-you-want]
> Put my bakery's order form online, with a database for orders and email receipts.
The agent creates the app, adds Compute, a database and email, makes the orders table, and deploys the code. Each step shows up in the key's activity log on the **API keys** page.
### Create an app [#create-an-app]
Open **Apps** and choose **Create app**. Give it a name people will recognise. The app starts empty.
### Add Compute [#add-compute]
On the app's page, the **Get started** list offers **Add Compute**. It gives the app somewhere to run code, and a starter page answers at the app's address straight away.
### Deploy your code [#deploy-your-code]
Open Compute and follow **Show me how**. It shows the exact command for your app, with its id filled in. You'll need an API key from the **API keys** page.
### Add a place for data (optional) [#add-a-place-for-data-optional]
Choose **Add a service** for a database, file storage or email. Your code reaches each one by name, like `env.DB`.
Make a key on the **API keys** page and keep it in an environment variable, then:
### Create an app [#create-an-app-1]
```bash
curl https://opencloudport.com/api/v1/apps \
-H "Authorization: Bearer $OCP_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Bakery site"}'
```
The answer includes the app's `id` (`app_…`).
### Add Compute [#add-compute-1]
```bash
curl https://opencloudport.com/api/v1/apps/$APP_ID/services \
-H "Authorization: Bearer $OCP_KEY" \
-H "Content-Type: application/json" \
-d '{"type": "compute"}'
```
It starts as `pending` and becomes `active` within seconds. Check with `GET /api/v1/apps/$APP_ID/services`.
### Deploy [#deploy]
Write `index.js`:
```js title="index.js"
export default {
fetch() {
return new Response('Hello from opencloudport')
},
}
```
Then upload it:
```bash
curl https://opencloudport.com/api/v1/apps/$APP_ID/deployments \
-H "Authorization: Bearer $OCP_KEY" \
-F 'metadata={"main_module": "index.js"}' \
-F 'index.js=@index.js'
```
The call answers once the code is live.
The same steps with the TypeScript SDK are in the [SDK reference](/api/sdk).
# Compute (/services/compute)
Compute is where an app's code runs. Add it once per app; it answers at the app's own address. Until you deploy, a starter page answers.
**Good for:** websites, APIs, webhooks. **Billed:** per request and processing time. Nothing while idle.
## Your code [#your-code]
Your code is a JavaScript module whose default export has a `fetch` function. It gets each request, and the app's other services and variables in `env`:
```js title="index.js"
export default {
async fetch(request, env) {
const url = new URL(request.url)
if (url.pathname === '/orders') {
const { results } = await env.DB.prepare('select * from orders').all()
return Response.json(results)
}
return new Response(`Welcome to ${env.SHOP_NAME}`)
},
}
```
You can upload several files: the main module and any it imports (`.js`, `.mjs`, `.cjs`, `.wasm`, plus text and data files). Up to 100 files and 10 MB per deployment. If you use a bundler, upload what it builds.
Websites can also bring their pages, styles, scripts and images as static files, served at their own paths before your code runs (your code can fetch them as `env.ASSETS`). A site with no code at all works too: upload the folder your build makes, like `dist/`. Up to 20,000 files, 25 MB each. Settings for single-page apps and 404 pages are in the [API reference](/api/code#static-files).
## Deploy [#deploy]
```bash
curl https://opencloudport.com/api/v1/apps/$APP_ID/deployments \
-H "Authorization: Bearer $OCP_KEY" \
-F 'metadata={"main_module": "index.js"}' \
-F 'index.js=@dist/index.js'
```
```ts
await ocp.app(appId).deploy({
main: 'index.js',
files: { 'index.js': await readFile('dist/index.js', 'utf8') },
})
// A static site: files only.
await ocp.app(appId).deploy({
assets: { 'index.html': await readFile('dist/index.html'), 'style.css': await readFile('dist/style.css') },
assetsConfig: { not_found_handling: '404-page' },
})
```
Ask: "Deploy my site to the Bakery orders app." It uses the `deploy` tool.
The call answers once the new code is live. Every deployment is kept, so going back is one click: **Roll back** on the Deployments tab, `POST …/deployments/{id}/redeploy`, or the `rollback` tool. Details are in the [API reference](/api/code).
{/* Paused for launch (src/lib/git/enabled.ts). Uncomment when deploying from GitHub is back.
## Deploy from GitHub
If your code lives in a GitHub repo, link it instead of uploading files. On Compute's **Git** tab:
1. **Connect GitHub.** GitHub asks which repos opencloudport may read; you can change that later on GitHub.
2. **Pick the repo and branch**, and the folder the app is in if it isn't at the top of the repo.
3. **Import and deploy.** The first build starts straight away.
After that, each push to the branch builds and goes live, and **Deploy now** builds the newest commit whenever you like. Turn off **Deploy on push** to only deploy by hand. Builds run in a sandbox, so their running time is charged like any sandbox's.
Click a build to watch its log as it runs. From there you can **Cancel** it, or **Redeploy this commit** to build an earlier commit again. A build that succeeds is an ordinary deployment, so you can roll back from **Deployments** as usual.
### What gets built
opencloudport looks at the repo and picks the install and build commands for you (pnpm, yarn or npm, from your lockfile):
| Your project | What happens |
|---|---|
| Vite, Create React App, Vue CLI | Built, then served as a single-page app from `dist` or `build` |
| Astro, SvelteKit with `adapter-static` | Built, then served as a static site with its own 404 page |
| A folder of HTML, CSS and images | Served as it is |
| A `wrangler.jsonc` or `wrangler.toml` with `main` | Bundled as a Worker with its static files: plain Workers, Hono, and frameworks with Cloudflare adapters |
| Next.js | Needs the [OpenNext Cloudflare adapter](https://opennext.js.org/cloudflare) and a wrangler config |
If the guess is wrong, set the **build command** and **output folder** in the Git settings. A build can run for up to 15 minutes.
Agents can do the same: `deploy_from_git` builds the newest commit, and `get_build` shows how it's going.
If the opencloudport app loses access on GitHub (it's uninstalled, or the repo is taken off its list), pushes stop deploying and the Git tab says why. The app keeps running the code it has.
*/}
## On a schedule [#on-a-schedule]
Your code can run on a timetable too: every day at 9:00, every 15 minutes, on weekdays. Add up to 5 schedules under **Schedules**; each calls your code's `scheduled()` handler. See [Scheduled runs](/guides/schedules).
## Settings and secrets [#settings-and-secrets]
Variables are how your code gets settings and API keys without putting them in the code. Each one is `env.NAME`.
* **Secret** (the default): the value can be set and replaced, but never shown again.
* **Plain:** the value is shown in the dashboard and the API.
Set them on Compute's **Variables** tab, with `PUT /api/v1/apps/{id}/env/{NAME}`, or the `set_variable` tool. They apply at once, without a new deployment. An app can have up to 100.
Removing Compute removes the app's deployments and all its variables.
# Databases (/services/databases)
A database keeps your app's data in tables, using SQL (the SQLite kind). Add as many as you need; each has its own name, and your code reaches it as `env.NAME`. The first one is called `DB` unless you name it.
**Good for:** users and accounts, orders, settings. **Billed:** per row read and written, plus how much it holds.
## From your app's code [#from-your-apps-code]
No connection string and no password: the database is just there.
```js
const { results } = await env.DB
.prepare('select * from orders where customer = ?')
.bind(email)
.all()
await env.DB.prepare('insert into orders (item, qty) values (?, ?)').bind('Sourdough', 2).run()
```
## From the dashboard [#from-the-dashboard]
Open the database to see its **Data**: your tables and views in a list (with how many rows each, and a box to find one by name), and the picked table's rows like a spreadsheet.
* **Search** looks in the text columns. Pick a column under **In** to search just that one; numbers in a number column match exactly. Tables with more than 10,000 rows search when you press Enter.
* **Click a row** to see every field, edit it, or delete it. **Add row** fills in defaults and the next id for anything you leave empty.
* **Columns** shows what each column holds in plain words, whether it must be filled in, and which tables it points to.
* **Views** (saved queries over other tables) can be read, searched and sorted, but not changed.
* **Binary data** (files kept in a column) shows its size. Open the row to download it, replace it with a file up to 900 KB, or clear it.
For anything else, choose **Run SQL**. Type SQL and press **Run** (or Ctrl/⌘ + Enter). You can run several statements at once.
## From anywhere else [#from-anywhere-else]
For migrations, seeding and scripts on your own machines:
```ts
const db = ocp.app(appId).database('DB')
await db.query('create table if not exists orders (id integer primary key, item text, qty integer)')
const rows = await db.rows('select * from orders where id = ?', [1])
// Refused before it runs if it would change anything (and allowed for read-only keys):
await db.query('select count(*) from orders', [], { readOnly: true })
```
```bash
curl https://opencloudport.com/api/v1/apps/$APP_ID/services/$DB_ID/query \
-H "Authorization: Bearer $OCP_KEY" \
-H "Content-Type: application/json" \
-d '{"sql": "select * from orders where id = ?", "params": [1]}'
```
Each statement returns up to 1,000 rows. SQL that only reads counts as reading, so read-only keys can run it. See [Data](/api/data#run-sql) for the details.
Agents read with the `query_database` tool, which never changes anything, and change data with `execute_sql`, which asks the person first.
## Take a copy [#take-a-copy]
**Download a copy** (on the Data view, or the database's Settings) saves the whole database as a `.sql` file: every table's definition and rows, then its indexes, triggers and views. It's made with ordinary queries a page at a time, so your app keeps working while it runs, and text, numbers, empty values and binary data come back exactly. Use it for backups, or to take your data elsewhere: `sqlite3 new.db < copy.sql` loads it into SQLite.
**Load a SQL file** (Settings) runs a `.sql` file against the database, up to 10 MB: a copy you downloaded, or any SQL. Load a copy into an empty database: rows already there stay, so tables that exist already make it stop. If a statement fails it stops there and says how far it got; what ran before stays.
```bash
# A copy, as a file (read-only keys may take one).
curl https://opencloudport.com/api/v1/apps/$APP_ID/services/$DB_ID/export -H "Authorization: Bearer $OCP_KEY" -o copy.sql
# A link that downloads a fresh copy without a key, for 15 minutes.
curl https://opencloudport.com/api/v1/apps/$APP_ID/services/$DB_ID/export-link -H "Authorization: Bearer $OCP_KEY"
# Load a file into a database.
curl -X POST https://opencloudport.com/api/v1/apps/$APP_ID/services/$DB_ID/import \
-H "Authorization: Bearer $OCP_KEY" -H "content-type: application/sql" --data-binary @copy.sql
```
With the SDK: `db.export()` (the file as text), `db.exportUrl()` and `db.import(sql)`. Agents have `export_database`, which hands back the download link, not the file.
## Names [#names]
Names are letters, digits and underscores, starting with a letter or underscore: `DB`, `ORDERS`, `CUSTOMERS`. A name can't be used twice in one app, including by a variable. An app can have up to 20 named services (databases, buckets and email together).
Removing a database deletes everything in it.
# Email (/services/email)
Add Email to an app and it can send email from its own address. You don't set up a mail server or a domain. The address is shown on the Email panel in the dashboard. When you want email to come from your own domain ([orders@bakery.com](mailto:orders@bakery.com)), [set one up](#your-own-domain).
**Good for:** welcome emails, password resets, receipts. **Billed:** per email sent.
## Send a test [#send-a-test]
Open Email in the dashboard and choose **Send a test email**. It goes to you. **Send an email** on the Sent tab writes one to anyone, from the app's address.
## See what was sent [#see-what-was-sent]
The **Sent** tab lists every email the app sent, or tried to, in the last 30 days: who it went to, the subject, when, and whether it came from your code, the API or the dashboard. Open one to see it as its recipient did, or as plain text. Images from the internet stay hidden until you choose **Show images**. Emails that didn't send say why. The list checks for new emails every 15 seconds while it's open.
The dashboard keeps each email's content for those 30 days (up to 100 KB of it), then deletes it. Removing the Email service deletes its list straight away.
"Sent" means the email provider accepted it. When the provider reports back, the email shows what happened next: **Delivered** (the person's email server received it), **Retrying** (that server asked to try later), **Bounced** (the address doesn't exist or won't take mail), **Rejected**, **Not delivered**, or **Marked as spam**, with the receiving server's reason. **Problems** lists every email that didn't send or didn't arrive. Addresses that bounced or reported spam before are refused straight away, and the email says so.
## From your app's code [#from-your-apps-code]
Your code reaches the service as `env.EMAIL`, and sends with one call. Nothing to install, no key to handle: it can only send email for this app.
```js
const { id } = await env.EMAIL.send({
to: order.email,
subject: 'Thanks for your order',
html: 'Your sourdough will be ready at 9am.
',
end_user: order.customerId, // optional: counts toward that customer's spending
})
```
It takes what [the API's send](/api/data) takes: `to`, `subject`, `text` and/or `html`, and optionally `from` (on [your own domain](#your-own-domain)), `from_name`, `reply_to` and `end_user`. It returns the email's id, and throws with a plain reason if the email can't go: the app is paused, an end user's limit is reached, or too many are sent at once (the same limits as the API). Emails sent this way show as **From your code** in the Sent tab.
### The connection address [#the-connection-address]
The same service is also at `env.EMAIL_URL`: an address carrying a key that can only send for it, for code that calls the API itself (or runs somewhere without the binding):
```js
const url = new URL(env.EMAIL_URL)
const res = await fetch(`${url.origin}${url.pathname}/send`, {
method: 'POST',
headers: { authorization: `Bearer ${decodeURIComponent(url.username)}`, 'content-type': 'application/json' },
body: JSON.stringify({ to, subject, html }),
})
```
`env.EMAIL` used to be that address. Code written for it (`new URL(env.EMAIL)`) should switch to `env.EMAIL_URL`, or to `env.EMAIL.send()`.
## From anywhere else [#from-anywhere-else]
```ts
await ocp.app(appId).email('EMAIL').send({ to: 'ann@example.com', subject: 'Welcome', text: 'Hi Ann!' })
```
## What you can send [#what-you-can-send]
| Field | What it’s for |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `to` | One address. |
| `subject` | Up to 500 characters. |
| `text`, `html` | At least one. |
| `from_name` | The name people see. |
| `from` | With a [verified domain](#your-own-domain): the address to send from, as the part before the @ (`orders`) or the whole address. Without one, email comes from the app's own address. |
| `reply_to` | Where replies go, like your support inbox. |
| `end_user` | Your own id for the person this is for, to see usage per customer. |
Email isn't sent while the app is paused for lack of credit (`app_paused`). If the provider refuses a message, nothing is billed.
## Your own domain [#your-own-domain]
Email can come from your own domain instead of the app's address, so receipts say `orders@bakery.com`.
1. Open Email, then **Settings**, and add your domain under **Send from your own domain**. Optionally give a usual address (`hello`) to send from when an email doesn't say.
2. Add the DNS records it shows where you manage the domain: SPF and an MX for bounces (on a `cf-bounce` subdomain), a DKIM key that signs each email, and DMARC (it starts at `p=none`, which only reports). Each has a copy button. If the domain already has an SPF record on the same name, merge them: a domain may only have one.
3. It's checked again on its own until it's **Verified**. That can take from a few minutes to a day.
Then send with `from`:
```js
await sendEmail(env, { to: order.email, from: 'orders', from_name: 'Holt’s Bakery', subject: 'Your receipt', html: '…' })
```
Until it's verified, email keeps coming from the app's own address, and asking for `from` is refused (`sender_domain_not_verified`). Each domain can be used by one email service. From the API: `PUT /api/v1/apps/:id/services/:serviceId/sender-domain` with `{ "domain": "bakery.com", "default_from": "hello" }`, `GET` to see its records and status, `DELETE` to go back to the app's address. With the SDK: `app.email('EMAIL').senderDomain.set('bakery.com')`.
Cloudflare sends from domains that are on our Cloudflare account. If yours isn't, setting it up says so; contact us and we'll add it.
# File storage (/services/file-storage)
File storage holds files of any size. Your code reads and writes them by key (like a path: `uploads/cake.jpg`), and visitors' browsers can upload and download directly, without going through your code. The first bucket is called `FILES` unless you name it.
**Good for:** image uploads, documents, backups. **Billed:** per upload or download, and by how much is stored.
## From your app's code [#from-your-apps-code]
```js
await env.FILES.put('avatars/ada.png', imageBytes)
const file = await env.FILES.get('avatars/ada.png')
```
## Uploads from a browser [#uploads-from-a-browser]
Your server asks for a short-lived upload link, and the browser sends the file straight to storage:
```ts
// On your server:
const { url } = await ocp.app(appId).storage('FILES').signedUrl('uploads/photo.jpg', { method: 'PUT' })
// In the browser:
await fetch(url, { method: 'PUT', body: file })
```
Links last 15 minutes by default, and up to 7 days. The same works for downloads (`GET`) and deletes (`DELETE`).
## From the dashboard [#from-the-dashboard]
Open the bucket and choose the **Files** tab. You can browse folders, see pictures as thumbnails, preview pictures, video, audio, PDFs, text, Markdown, CSV and JSON, upload files or whole folders (drag them in, or use **Upload**), rename and move files and folders, and delete several at once.
The dashboard keeps two kinds of small files of its own in your bucket, which your code will see if it lists everything:
* `/.keep`: an empty file that keeps a folder made in the dashboard until something is put in it. Buckets don't have real folders, only file names with `/` in them.
* `.thumbs/.webp`: a small preview of each picture uploaded from the dashboard, so the grid loads quickly. Pictures your code saves don't get one, and the dashboard shows the picture itself instead.
Both are hidden in the dashboard and are moved and deleted along with their files. Renaming or moving a file copies it to the new name and then deletes the old one, so it counts as a copy and a delete.
## From your own scripts [#from-your-own-scripts]
```ts
const files = ocp.app(appId).storage('FILES')
await files.put('backups/2026-10-21.sql', dump, { contentType: 'application/sql' })
const res = await files.get('backups/2026-10-21.sql') // a Response, or null
const { data } = await files.list({ prefix: 'backups/' })
```
The SDK asks once for temporary credentials that reach only this bucket, then talks to it directly. You can use those credentials with any S3 tool too: see [credentials](/api/data#temporary-credentials).
Removing a bucket deletes every file in it.
# Sandboxes (/services/sandboxes)
A sandbox is a private Linux machine with Node, pnpm and git, ready in seconds. Put files in, run commands, start a server and get a preview link. Each one is kept apart from your app and from every other sandbox, so it's a safe place for generated or untrusted code.
**Good for:** builds and tests, dev servers with live preview, running code an AI wrote. **Billed:** by size, only while running.
Add the **Sandboxes** service to an app once, then start as many sandboxes as you need (up to 50 per app).
## Sizes [#sizes]
| Size | In the API | Machine | Good for |
| ----------- | ------------ | ----------------------- | ------------------------- |
| Tiny | `lite` | 1/16 CPU, 256 MB memory | Quick scripts |
| Small | `basic` | ¼ CPU, 1 GB memory | Scripts and small tools |
| Medium | `standard-1` | ½ CPU, 4 GB memory | Dev servers (the default) |
| Large | `standard-2` | 1 CPU, 6 GB memory | Bigger builds |
| Extra large | `standard-3` | 2 CPUs, 8 GB memory | Heavy builds and tests |
| Huge | `standard-4` | 4 CPUs, 12 GB memory | The heaviest workloads |
## Asleep costs nothing [#asleep-costs-nothing]
A sandbox falls asleep after 15 minutes without use (you can choose 1 minute to 24 hours), and you stop paying. **Its files are kept**: everything in `/workspace`, installed packages included, is saved when it falls asleep or stops and is back when it wakes, so you pick up where you left off with nothing to reinstall. Up to 2 GB is kept; set `keep_dependencies: false` to save less and reinstall instead. Turn it off with `keep_files: false` for a fresh machine every time.
**Set it up once.** Give it a `setup_command` (or "Run when it starts" in the dashboard), like `pnpm install`. It runs before anything else, and only again if its saved files are gone. For tools installed outside `/workspace`, like `apt-get install -y ffmpeg`, set `setup_runs: "every_start"`.
**Previews are private.** A preview link from the dashboard, the API or the `sandbox_preview_url` tool works for an hour; open it, or share it, and that browser gets in. Without the link, the preview says it's private. Set `public_preview: true` for a preview anyone with the address can open. Platforms that show previews in their own pages can limit who frames them, renew access without reloading and add their own script: see [Previews inside your own pages](/api/sandboxes#previews-inside-your-own-pages).
**Need a browser?** Start it with `image: "browser"` (or "With a browser" in the dashboard) for Chromium and everything Playwright needs, for screenshots, visual checks and browser tests. It costs the same. Your own images aren't supported yet.
**Watching a long command?** The dashboard terminal shows output as it comes, and the API sends it as it happens with `stream: true` (`execStream()` in the SDK).
## Use one [#use-one]
```ts
const app = ocp.app(appId)
const sandbox = await app.sandboxes.create({ size: 'standard-1', label: 'Preview build' })
const box = app.sandbox(sandbox.id)
await box.writeFiles({ 'package.json': pkg, 'src/index.ts': code })
const { exit_code, stdout } = await box.exec('pnpm install && pnpm test')
await box.startProcess('dev', 'pnpm dev --host 0.0.0.0 --port 5173')
const url = await box.previewUrl(5173) // share this
```
* `exec` runs a command and waits (up to 10 minutes). A string runs in a shell; an array runs without one.
* `startProcess` runs something that keeps going, like a dev server. Check on it with `process(name)`, stop it with `stopProcess(name)`.
* Change files while a server runs: tools with hot reload pick the changes up.
Agents do the same with the `create_sandbox`, `sandbox_write_files`, `sandbox_exec` and `sandbox_start_process` tools. In the dashboard, the Sandboxes panel has a terminal to try commands.
## Stop or delete [#stop-or-delete]
Stop a sandbox when you're done to stop paying straight away (**Stop** in the dashboard, `stop()`, or the `stop_sandbox` tool). Start it again with **Start**, `start()` or `start_sandbox`: it comes back with its files (unless it doesn't keep them). Delete it to remove it from the list.