Skip to content

Outbound Integration

Outbound Integration is how your App calls a third-party REST API — OpenAI, an internal service, a partner’s endpoint, anything that speaks HTTP — from inside a handler. Your App code always calls sdk.outbound.*.

This is the only supported way to make an outbound HTTP call. Reach for it instead of raw fetch, even for public endpoints with no credentials. Going through the SDK means your request gets uniform timeout enforcement, per-App quota tracking, and any future destination policy — and, when the endpoint needs a key, the SDK injects that key from Secret Storage so the raw value never touches your source, your logs, or the response you hand back.

There is nothing to set up. Outbound Integration is always available to every App — no provider account and no plan flag. Unlike Messaging or Analytics, there is no platform “provider” to choose: the “provider” is whatever URL you call. One App can call several different APIs, each with its own authentication, from different handlers.

MethodShapeReturns
requestrequest(input)Promise<OutboundResponse>
getget(url, input?)Promise<OutboundResponse>
postpost(url, input?)Promise<OutboundResponse>
putput(url, input?)Promise<OutboundResponse>
patchpatch(url, input?)Promise<OutboundResponse>
deletedelete(url, input?)Promise<OutboundResponse>

request is the primitive — pass url, method, and everything else in one object. The five named methods are convenience sugar: they fix the HTTP method and forward the same options minus url and method.

You reach these through the SDK inside a handler:

const sdk = c.get("sdk")
const res = await sdk.outbound.get("https://api.example.com/status")

The minimal call needs only a URL. The response body is always returned as a buffered ArrayBuffer, regardless of content type — decode it yourself.

const sdk = c.get("sdk")
const res = await sdk.outbound.get("https://api.example.com/widgets")
if (!res.ok) {
return c.json({ error: "upstream failed" }, 502)
}
const data = JSON.parse(new TextDecoder().decode(res.body))
return c.json(data)

OutboundResponse is content-type-agnostic. Use new TextDecoder().decode(res.body) for text or JSON; treat res.body as raw bytes for anything binary.

type OutboundResponse = {
status: number
headers: Record<string, string>
body: ArrayBuffer // always buffered — decode it yourself
ok: boolean
}

Every field beyond url is optional. The sugar methods (get/post/…) accept the same object without url and method.

type OutboundRequestInput = {
url: string
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" // default "GET"
headers?: Record<string, string>
body?: ArrayBuffer | ArrayBufferView | string
auth?: OutboundAuthStrategy
timeoutMs?: number
maxRetries?: number
backoff?: { baseMs?: number; capMs?: number }
circuitBreaker?: boolean | { failureThreshold?: number; resetTimeoutMs?: number }
}

The auth field tells the SDK how to attach credentials. Whenever a strategy names a secret, you give the secret name — the string key you registered in Secret Storage — never the value. The SDK resolves the actual key at call time and injects it directly into the outgoing request; it is never exposed to your handler.

type OutboundAuthStrategy =
| { kind: "none" }
| {
kind: "api-key"
secretKey: string // the secret NAME, e.g. "OPENAI_API_KEY"
header?: string // default "Authorization"
scheme?: string // default "Bearer"; set "" to send the raw key with no prefix
}
| {
kind: "oauth2-client-credentials"
clientIdSecret: string // secret NAME holding the client_id
clientSecretSecret: string // secret NAME holding the client_secret
tokenUrl: string // absolute HTTPS URL
scope?: string
cacheSafetySeconds?: number // default 60
}

Omitting auth is the same as { kind: "none" }.

The most common case: send a key in a header. By default the SDK sets Authorization: Bearer <key>. Override header and scheme for APIs that expect something else — set scheme: "" to send the raw key with no prefix.

const sdk = c.get("sdk")
const res = await sdk.outbound.post("https://api.openai.com/v1/responses", {
auth: { kind: "api-key", secretKey: "OPENAI_API_KEY" },
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "gpt-5", input: "hello" }),
})

The named secret ("OPENAI_API_KEY" here) is one you set yourself. To register it, ask your Builder to store the key, or set it in your App’s secret manager. The raw value appears only inside the outgoing request — never in res, never in a log.

For APIs that issue access tokens from a client id and secret, the SDK runs the client-credentials grant for you. It fetches a token from tokenUrl, caches it for the life of the request environment, and refreshes automatically.

const sdk = c.get("sdk")
const res = await sdk.outbound.get("https://api.partner.com/v2/orders", {
auth: {
kind: "oauth2-client-credentials",
clientIdSecret: "PARTNER_CLIENT_ID",
clientSecretSecret: "PARTNER_CLIENT_SECRET",
tokenUrl: "https://auth.partner.com/oauth/token",
scope: "orders:read",
},
})

The token is treated as expired cacheSafetySeconds (default 60) before its real expiry, so it is never used mid-flight near expiration. If the upstream returns 401, the SDK evicts the cached token and refetches once; a second failure raises OutboundError({ kind: "auth", status: 502 }).

A plain call has a 30-second timeout and does not retry. Add resilience only where the endpoint is safe to repeat (idempotent reads, or writes your App can tolerate retrying).

timeoutMs defaults to 30 000 ms. The platform caps it at 60 000 ms — any larger value is silently clamped. Exceeding the timeout raises OutboundError({ kind: "timeout", status: 504 }).

await sdk.outbound.get("https://slow.example.com/report", { timeoutMs: 45_000 })

maxRetries defaults to 0. Setting it allows that many extra attempts — maxRetries: 2 means up to 3 attempts total. Retries are transient-only: they fire on network failure, a 5xx, or a 429 (honoring the upstream Retry-After). A 4xx is never retried — a 400, 401, 403, or 404 is fatal immediately. Backoff is full-jitter exponential, defaulting to a 100 ms base and a 2 000 ms cap, bounded by the time left before the deadline.

await sdk.outbound.post("https://api.example.com/idempotent-task", {
auth: { kind: "api-key", secretKey: "EXAMPLE_API_KEY" },
body: payload,
maxRetries: 2,
backoff: { baseMs: 200, capMs: 4_000 },
})

The circuit breaker is opt-in and tracks failures per destination host. Pass circuitBreaker: true (or a config object) to enable it. When the breaker opens, further calls to that host fail fast with OutboundError({ kind: "circuit-open", status: 503 }) and a retryAfterSec. If the platform’s own breaker store errors, the breaker fails open — it never blocks all your traffic because of a platform-side fault.

await sdk.outbound.get("https://flaky.example.com/health", {
circuitBreaker: { failureThreshold: 5, resetTimeoutMs: 30_000 },
})

Every failure path raises an OutboundError. It carries a kind, an HTTP status you can forward verbatim, and — for throttle and breaker cases — a retryAfterSec.

type OutboundErrorKind =
| "validation"
| "secret-missing"
| "auth"
| "rate-limited"
| "circuit-open"
| "timeout"
| "provider-error"
| "not-available"
class OutboundError extends Error {
readonly kind: OutboundErrorKind
readonly status: number
readonly retryAfterSec?: number // set on "rate-limited" and "circuit-open"
}
kindstatusWhen it is thrown
"validation"400Malformed URL or input, or an ak.-prefixed secret name
"secret-missing"412The referenced secret name is not set in Secret Storage
"auth"502Upstream rejected the credential, or the OAuth2 token fetch failed
"rate-limited"429Throttled by the upstream or your soft per-App quota; retryAfterSec may be set
"circuit-open"503The breaker is open for this host; retryAfterSec is present
"timeout"504The request exceeded timeoutMs (or the 60 s cap)
"provider-error"502Upstream 5xx or network failure after retries

Catch the error and forward its status. Build the response from err.message and err.status directly:

const sdk = c.get("sdk")
try {
const res = await sdk.outbound.get("https://api.example.com/data", {
auth: { kind: "api-key", secretKey: "EXAMPLE_API_KEY" },
})
return c.json(JSON.parse(new TextDecoder().decode(res.body)))
} catch (err) {
if (err instanceof OutboundError) {
return new Response(err.message, { status: err.status })
}
throw err
}

These are the details that most often trip people up when wiring outbound calls into handlers.

  • Always go through sdk.outbound. Even for a public endpoint with no key, use sdk.outbound.get(url) rather than raw fetch. Only the SDK path gets quota tracking, timeout enforcement, and any future destination policy applied uniformly.

  • Never inline a key. Reference secrets by name. The value is resolved and injected by the SDK at call time and never leaves that boundary — not into your source, not into a log, not into res.

  • The body is always an ArrayBuffer. Responses are buffered, not streamed. Decode res.body yourself: new TextDecoder().decode(res.body) for text or JSON, raw bytes for binary.

  • Retries are transient-only and opt-in. With the default maxRetries: 0, nothing is retried. When you enable retries, only network errors, 5xx, and 429 are repeated — 4xx is always fatal.

  • There is a soft per-App quota. The platform tracks an approximate outbound request quota per minute (default 600). It is fail-open: a quota-store fault never blocks your request. Sustained overuse surfaces as OutboundError({ kind: "rate-limited", status: 429 }).

  • No destination allow-list in v1. Any absolute HTTPS URL is accepted; there is no SSRF filtering today. Be deliberate about calling internal or private-network endpoints — you are responsible for the URLs you pass.