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.
The API
Section titled “The API”| 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.
Adding one
Section titled “Adding one”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.
Whose account is called
Section titled “Whose account is called”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 connectionconst 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.
What it will and won’t do
Section titled “What it will and won’t do”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.
Limits and errors
Section titled “Limits and errors”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.