Skip to main content
Documentation

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)
}
ts

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[]
    }
ts

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
  }
}
ts

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
bash

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.

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' },
}
ts
SettingWho paysAnonymous callers
'developer' (default)The app owner via APP_OWNER_JWTAllowed
'user'The signed-in callerBlocked 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
}
ts

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 })
}
ts

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' },
})
ts

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 requiresOAuth response 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 } }. Check result.data?.requiresOAuth - never success === false - and send the user to authUrl.
  • There is no separate auth-url endpoint. POST the real endpoint whose scope you need; the requiresOAuth payload carries an authUrl built 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:

  • useAsyncResource for one-shot calls - a lookup, a single completion, a status check.
  • usePagedResource for 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} />
}
tsx

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 for loops or retry-until-success polls
  • Skip 'user'-billed endpoint calls in api.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) })
})
ts

Tips#

  • Run info before 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.integration from 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#