Skip to content

API connections

API connections let your app call an API outside itself: your CRM, a shipping quote, a Slack webhook, an internal service of your own. You add the connection once — a name, the API’s base URL, and the key — and the app calls it through its own origin. The platform attaches the key on the way out.

The key is never in the app’s code, never in the page, and never visible to a visitor. It is stored encrypted and deployed as a secret binding on your own Worker, the same way your Payments and sign-in secrets are.

Method & path Body Returns
ANY /api/_connect/:name/:path whatever that API takes whatever it answers — status, content type and body, untouched
GET /api/_connect/:name/_account {"connected":true or false} — has THIS user connected their account?
GET /api/_connect/:name/_authorize send the person here to connect their account; ?next= where to land after
DELETE /api/_connect/:name/_account {"connected":false} — disconnects this user's account

:name is the connection’s name; :path is the rest of that API’s own path, appended to the base URL you gave. Query strings pass through, and the response comes back exactly as it arrived. A connection named crm with base https://api.crm.com/v2 turns

import { connection } from "connect";
const crm = connection("crm");
const contacts = await crm.get("contacts", { limit: 10 });

into a call to https://api.crm.com/v2/contacts?limit=10 with your key attached. The app never writes that route — connection(name) builds it, escapes every path segment, and refuses a path that tries to climb out of the base URL you configured.

In the project’s API connections tab:

Field What it is
Name What the app calls it — lowercase letters, digits and underscores (crm, slack, weather)
Base URL The API’s base, https:// only. Everything the app calls hangs off this.
How the key is sent Authorization: Bearer <key>, a header you name, a query parameter you name — or each user connects their own account
Key Pasted once, stored encrypted, never shown again

The API’s own docs tell you which it wants. Changing a connection takes effect on the next Ship, like every other capability setting.

The first three options send one key, yours. Every user’s call reaches the same account — right for your CRM, a weather service, an internal endpoint of your own.

The fourth is different: each user connects their own account, and the app acts as them. That is what “let people sync their Google Calendar” needs, and it is the shape most SaaS integrations take.

Register an OAuth app with the provider, then add the connection with:

Field What it is
Client ID / Client secret From the app you registered with the provider
Authorize URL Where the person is sent to approve — e.g. https://accounts.google.com/o/oauth2/v2/auth
Token URL Where the code is exchanged — e.g. https://oauth2.googleapis.com/token
Scope Space-separated, exactly as the provider lists them

Give the provider this redirect URI: https://<your app>/api/_connect/<name>/_callback.

The app then has three things to do, and the agent writes them for you:

import { connection, ConnectError } from "connect";
const gcal = connection("gcal");
// 1. has this person connected yet?
if (!(await gcal.connected())) {
// 2. if not, send them to connect — a real navigation, which connect() performs
gcal.connect("/settings");
}
// 3. afterwards, call the API exactly as any other connection
const events = await gcal.get("calendars/primary/events");

Calling before they have connected throws a ConnectError with .status === 401 and .connect set — the signal to show the connect link, without reading a status code by hand. Access tokens are refreshed automatically, and gcal.disconnect() disconnects.

The base URL is the boundary. A path that climbs above it is refused with a 400, and one connection is never a way to reach a different API. Redirects are not followed, so your key is never replayed to somewhere the API points at.

Outbound only. To receive a call from an outside service — a webhook — the app serves its own route and you give that URL to the service. API connections are the way out, not the way in.

Nothing else can reach another origin. A generated app’s Content-Security-Policy blocks the browser from calling any third-party host directly. That block is what keeps a key out of the page — API connections exists so the app doesn’t need it lifted.

The app never sees the key. It names a connection; the platform holds the secret. So a prompt-injected agent, a leaked bundle, or a curious visitor has nothing to find.

Their errors reach you untouched — a 4xx from the API is the API’s answer, and the app should show what it says. The platform’s own answers are:

Status What it means
400 Unknown connection name, a path outside the base URL, or a connection whose key isn’t set
401 Sign-in is on and nobody is signed in
413 The request body is over 1 MB
429 More than 120 calls a minute from one caller
502 The API couldn’t be reached, or took more than 30 seconds

With Sign-in on, every call needs a session — a call spends your quota with somebody else, so an anonymous visitor can’t run it up.