External APIs
Call third-party APIs through the platform's integration proxy.
On this page
DeepSpace fronts 215+ third-party API endpoints - LLMs (Claude, GPT, Cerebras), search (Exa, Tavily), media (LiveKit, Resend), finance (Finnhub, Alpha Vantage), social (Discord, Slack), Google Workspace, weather, and more - through a single signed proxy. You don't store API keys, configure webhooks, or build per-vendor SDKs. You call integration.post(...), the platform handles billing, rate-limiting, and provider routing.
Calling an integration#
import { integration } from 'deepspace'
const result = await integration.post('openweathermap/geocoding', { q: 'Brooklyn' })
if (result.success) {
console.log(result.data)
} else {
console.error(result.error)
}
Endpoint names are always two segments: <integration>/<endpoint>. The response is a discriminated envelope:
type IntegrationResponse<T> =
| { success: true; data: T }
| {
success: false
error: string // human-readable, safe to render directly
code?: string // machine slug (e.g. 'insufficient_credits') — branch on this, never render it
status?: number // HTTP status of the failed response; 0 on transport failure (timeout/abort/network)
details?: Record<string, unknown> // structured fields the server sent (e.g. availableCredits)
issues?: ValidationIssue[]
}
On failure, render error and branch on code - the client normalizes every error so error is always the human sentence and code is the stable slug. When the api-worker's Zod validator rejects a body, issues carries field-level errors:
if (!result.success) {
showToast(result.error) // human text, safe to render
if (result.code === 'insufficient_credits') openBilling()
for (const issue of result.issues ?? []) {
console.log(issue.path, issue.message) // field-level, on a validation failure
}
}
Discover endpoints - deepspace integrations#
The CLI exposes the full catalog and lets you invoke endpoints interactively. Discovery is free; calls are billed - list and info work without authentication, so you can scope integration work before deciding whether to log in.
No login required.
# Human-readable
npx deepspace integrations list
# Machine-readable (JSON)
npx deepspace integrations list --json
Prints every endpoint key grouped by integration, each with a one-line description, its billing model and base cost, and an [oauth] tag where the platform manages the OAuth connection. Run info on anything you want the full schema for.
No login required.
npx deepspace integrations info openai/chat-completion
npx deepspace integrations info openai/chat-completion --json
Prints the endpoint key, its description, its billing (cost and unit), a Requires-OAuth explanation where the endpoint needs a connection, the input schema as JSON Schema, and - for endpoints whose success shape the platform curates - the output schema too. Use this before guessing field names: the input schema is what the api-worker validates against, and the output schema is the response shape to expect.
The printed example body is the catalog's own example when it has one; otherwise it is synthesized from the input schema's required keys, with placeholders taken from the schema (example, default, first enum) or the type ("<string>", 0, false), so it is never {} for an endpoint that rejects {}. It is a starting point, not a valid request - replace the placeholders. Not every endpoint carries an output schema; where none is shown, make one invoke test call and read the real envelope rather than guessing response nesting.
Login required, billed to the logged-in user.
# Body inline
npx deepspace integrations invoke openai/chat-completion --body '{
"model": "claude-sonnet-5",
"messages": [{"role": "user", "content": "Hello"}]
}'
# Body from file or stdin
npx deepspace integrations invoke openai/chat-completion --body-file request.json
cat request.json | npx deepspace integrations invoke openai/chat-completion --body-file -
Useful for verifying a body shape end-to-end before wiring the call into your app.
A paid endpoint asks first. At an interactive terminal the prompt names the price and defaults to No; declining is a clean success with cancelled: true. Under --json, with a piped stdin (--body-file -), or in CI there is no prompt: the call refuses cost_confirmation_required before anything is billed, and --yes is how you consent. Free endpoints never prompt.
See the CLI reference for the full flag list.
Billing - developer vs user#
Every integration has a billing setting in src/integrations.ts:
// src/integrations.ts
export const integrations: Record<string, { billing: 'developer' | 'user' }> = {
google: { billing: 'user' }, // already in scaffold (OAuth requires user-pays)
openai: { billing: 'developer' }, // owner pays
exa: { billing: 'developer' },
}
| Setting | Who pays | Anonymous callers |
|---|---|---|
'developer' (default) | The app owner via APP_OWNER_JWT | Allowed |
'user' | The signed-in caller | Blocked with 401 |
Billing routing is decided entirely by src/integrations.ts and the JWT the proxy forwards - the caller cannot redirect billing with a header. The api-worker always charges the JWT subject and ignores billing-override headers on integration calls, so no client-supplied header can switch who pays.
Response shapes#
data shape varies by endpoint. Common patterns:
// List endpoint
const r = await integration.post('exa/search', { query: 'climate change papers' })
if (r.success) {
for (const result of r.data.results) {
console.log(result.title, result.url)
}
}
// Detail endpoint
const r = await integration.post('finnhub/stock-quote', { symbol: 'AAPL' })
if (r.success) {
console.log(r.data.c, r.data.h, r.data.l) // current, high, low
}
Calling from your worker#
Inside server actions and cron tasks, use tools.integration or ctx.integrations.call:
// Server action
export const summarizeDay: ActionHandler<Env> = async ({ tools }) => {
const r = await tools.integration('openai/chat-completion', {
model: 'gpt-5.6-terra',
messages: [{ role: 'user', content: 'Summarize today\'s activity' }],
})
// ...
}
// Cron task
export async function runTask(name: string, env: Env) {
const ctx = buildCronContext(env, env.OWNER_USER_ID, `app:${env.DEEPSPACE_APP_ID}`)
const r = await ctx.integrations.call('resend/send-email', { to, subject, text })
}
Both routes go through the api-worker proxy. Billing follows src/integrations.ts - when called from a server action, 'user' integrations bill the caller and 'developer' ones bill the owner.
Request options#
The post, get, put, and delete methods accept an options object:
const r = await integration.post('exa/search', body, {
timeoutMs: 30_000, // default 120s
headers: { 'X-Custom': 'value' },
})
OAuth integrations#
Google Workspace endpoints (google/* - Gmail, Calendar, Drive, Contacts) require per-user OAuth and are always billed as 'user' - the scaffold ships Google that way, and it must stay that way. See Google OAuth for the full contract; the essentials:
- The
requiresOAuthresponse is success-shaped. When the user has no tokens, lacks a scope, or their token was revoked, the endpoint returns HTTP 200{ success: true, data: { requiresOAuth: true, provider, scopes, authUrl } }. Checkresult.data?.requiresOAuth- neversuccess === false- and send the user toauthUrl. - There is no separate auth-url endpoint. POST the real endpoint whose scope you need; the
requiresOAuthpayload carries anauthUrlbuilt for exactly those scopes, so one code path serves the connect button and the data load. - Gate features per scope, never on a composite "is connected" boolean.
UI states for integration data#
Every integration-backed view needs four states: loading, error with a local retry, empty, and success. Empty is not error (see the note above), and error must recover in place - a failed resource must never reload the whole page. Full-page reloads throw away app state, re-fire every other request on the page, and turn one flaky upstream into a broken app.
The SDK ships two hooks for this - use them instead of hand-rolling useEffect fetch state:
useAsyncResourcefor one-shot calls - a lookup, a single completion, a status check.usePagedResourcefor feeds - it pages on demand and clamps oversized pages, so a feed stays bounded instead of fetching an entire upstream dataset.
import { integration, useAsyncResource } from 'deepspace'
function Quote({ symbol }: { symbol: string }) {
const quote = useAsyncResource(
async (signal) => {
const r = await integration.post('finnhub/stock-quote', { symbol }, { signal })
if (!r.success) throw new Error(r.error) // throw so the hook captures the error
return r.data as { c: number }
},
[symbol],
)
if (quote.status === 'loading') return <Spinner />
if (quote.status === 'error') return <ErrorNote message={quote.error} onRetry={quote.reload} />
if (!quote.data) return <EmptyState label="No quote" />
return <Price value={quote.data.c} />
}
The retry button calls reload() (or retry() on the paged hook) - the request re-fires in place and the rest of the page stays untouched. Full signatures live in the integrations reference.
Testing integrations#
Integration calls hit real third-party services and cost real money. Keep integration assertions minimal:
- One
integration.post(...)per endpoint per test run, not a matrix - Never put integration calls inside
forloops or retry-until-success polls - Skip
'user'-billed endpoint calls inapi.spec.ts- test accounts have no credits and will 402 - Never flip billing modes to make a test pass. Switching an integration from
'user'to'developer'so a test account can call it changes the app's production security and spend model to satisfy a test - fix the test instead (skip the call, or mock the boundary) - Mock only the external integration boundary. When a real call would charge money, mutate provider state, or require unavailable credits,
page.route(...)the integration response - and keep app-internal hooks, routes, and components real, so the test still exercises your actual code
For the integration call itself, assert the envelope shape - not the upstream provider's exact response:
test('weather lookup returns coords', async ({ request }) => {
const token = await signInAndGetToken(request)
const r = await request.post('/api/integrations/openweathermap/geocoding', {
headers: { Authorization: `Bearer ${token}` },
data: { q: 'Brooklyn' },
})
const body = await r.json()
expect(body.success).toBe(true)
expect(body.data[0]).toMatchObject({ lat: expect.any(Number), lon: expect.any(Number) })
})
Tips#
- Run
infobefore guessing a body shape. The Zod schemas the api-worker validates against are the source of truth -npx deepspace integrations info <endpoint>prints them with an example body. - Use
tools.integrationfrom server actions for owner-pays endpoints. Keeps the JWT scope correct and centralizes routing. - For LLM streaming, use the AI chat pipeline (see AI chat) rather than
integration.post. The proxy returns the full response; the AI helpers stream it.
Next steps#
- Google OAuth - the per-user consent contract for
google/*. - LiveKit rooms - audio/video room lifecycle and reserve-then-settle billing.
- AI chat - streamed LLM responses with tool use.
- Server actions - call integrations from worker code.
- Integrations reference - full
integrationAPI.