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.
Quick reference
Section titled “Quick reference”| Method | Shape | Returns |
|---|---|---|
request | request(input) | Promise<OutboundResponse> |
get | get(url, input?) | Promise<OutboundResponse> |
post | post(url, input?) | Promise<OutboundResponse> |
put | put(url, input?) | Promise<OutboundResponse> |
patch | patch(url, input?) | Promise<OutboundResponse> |
delete | delete(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")Making a request
Section titled “Making a request”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}The request input
Section titled “The request input”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 }}Authenticating
Section titled “Authenticating”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" }.
API key
Section titled “API key”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.
OAuth2 client credentials
Section titled “OAuth2 client credentials”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 }).
Resilience — opt in where it helps
Section titled “Resilience — opt in where it helps”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).
Timeouts
Section titled “Timeouts”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 })Retries
Section titled “Retries”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 },})Circuit breaker
Section titled “Circuit breaker”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 },})Handling errors
Section titled “Handling errors”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"}kind | status | When it is thrown |
|---|---|---|
"validation" | 400 | Malformed URL or input, or an ak.-prefixed secret name |
"secret-missing" | 412 | The referenced secret name is not set in Secret Storage |
"auth" | 502 | Upstream rejected the credential, or the OAuth2 token fetch failed |
"rate-limited" | 429 | Throttled by the upstream or your soft per-App quota; retryAfterSec may be set |
"circuit-open" | 503 | The breaker is open for this host; retryAfterSec is present |
"timeout" | 504 | The request exceeded timeoutMs (or the 60 s cap) |
"provider-error" | 502 | Upstream 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}Things to know
Section titled “Things to know”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, usesdk.outbound.get(url)rather than rawfetch. 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. Decoderes.bodyyourself: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.