= {};
const title = /| ]*class="title"[^>]*>([^<]+)<\/td>/i.exec(row);
const score = / | ]*class="score"[^>]*>\s*(\d+)\s*<\/td>/i.exec(
row,
)?.[1];
if (title) item['title'] = title[1]!.trim();
if (score !== undefined) item['score'] = Number(score); // omitted when the selector misses
return item;
});
return { items };
}
const catalog = stitch({
baseUrl: 'https://api.example.com',
path: '/catalog',
transform: scrape, // HTML string -> { items: [...] }
pick: 'items',
output: drift(
// `score` is REQUIRED — its loss is the breakage we want caught.
// A required field that goes missing throws (`STITCH_DRIFT`, `change: 'invalid'`).
z.array(z.object({ title: z.string(), score: z.number() })),
),
});
const items = await catalog();
```
## How it works [#how-it-works]
Each call parses the page and validates the **transformed** shape —
`[{ title, score }]`, not the HTML body — against the schema. No baseline to
generate or commit: the schema *is* the contract.
When the provider renames the score cell's class (`score` → `rank`), the
selector stops matching and `scrape` drops `score` from each item. Because the
schema requires `score`, validation sees a missing required field and throws
[`STITCH_DRIFT`](/docs/errors/stitch-drift) — a finding with
`{ level: 'error', change: 'invalid', path: '[].score' }`. The path is the
proof: `[].score` exists only on the parsed object, so a finding on it can only
come from drift inspecting the `transform` output, not the raw markup. The
silent `undefined` is now a loud, immediate failure.
Other non-fatal soft-drift findings (`undeclared`, `coerced`, `defaulted`) ride
the [event stream](/docs/concepts/event-stream) without throwing — you can
[watch them](/docs/recipes/watch-a-call-as-it-happens) before any becomes a
hard failure.
Declaring `score` **required** is the whole trick. Drift honors the schema's
optionality: if `score` were `.optional()`, a response without it would
validate clean and the rename would slip past — which is correct for a field
that genuinely may be absent. Make a field required precisely when its
disappearance is a bug.
**Anti-pattern:** do not drift the raw HTML body and skip `transform`.
Snapshotting the markup makes every cosmetic edit — a reordered attribute, a
whitespace change — look like drift, burying the one rename that matters.
Parse to the structured shape first, then rely on required fields in the
schema to catch the breakage you actually depend on.
## See also [#see-also]
# Define an entity once, derive every request shape (/docs/recipes/define-an-entity-once)
## Task [#task]
You have a resource — a `User` — with the usual CRUD endpoints. You want to
describe its shape **once** and derive the create body (no server-assigned
fields), the update body (every field optional), and a query filter from that
one definition — without retyping fields and without learning a
StitchAPI-specific schema type.
## Example [#example]
Your validator already does this. Define the entity with your schema library,
then derive the request shapes with its own composition methods and hand each
one straight to a stitch's [`input`/`output`](/docs/guides/validation/validation).
```ts twoslash
import { stitch } from 'stitchapi';
import { z } from 'zod';
// Define the entity once — one source for the runtime schema and the TS type.
const User = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
createdAt: z.string(),
});
type User = z.infer;
// Derive request shapes with the validator's own methods — not a StitchAPI layer.
const NewUser = User.omit({ id: true, createdAt: true }); // drop server-assigned fields
const UserPatch = NewUser.partial(); // every field optional
const UserFilter = User.pick({ email: true }).partial(); // optional ?email= filter
const baseUrl = 'https://api.example.com';
const listUsers = stitch({
method: 'GET',
baseUrl,
path: '/users',
input: { query: UserFilter },
output: z.array(User),
});
const createUser = stitch({
method: 'POST',
baseUrl,
path: '/users',
input: { body: NewUser },
output: User,
});
const updateUser = stitch({
method: 'PATCH',
baseUrl,
path: '/users/{id}',
input: { body: UserPatch },
output: User,
});
```
`NewUser`, `UserPatch`, and `UserFilter` all trace back to the one `User`
schema — rename a field there and every derived shape, every stitch, and the
`User` type move with it.
## How it works [#how-it-works]
`.omit()`, `.partial()`, and `.pick()` are **your validator's** methods, not
StitchAPI's. The runtime never sees them — it only ever calls `validate()`
through the [`Validator`](/docs/reference/config-types) the engine adapts each
schema into, so each derived schema is just another schema as far as a stitch is
concerned. One `z.object({...})` definition then feeds three things at once: the
runtime validation on every slot (`query`, `body`, `output`), and — via
`z.infer` — the `User` TS type the `stitch` generic uses.
This is **not** StitchAPI-specific. Valibot, ArkType, and Effect Schema each
ship their own composition (`v.partial`, `.pick`, `.omit`, `.and`); the recipe
is identical, only the method names change. Any
[Standard Schema](/docs/guides/validation/standard-schema) validator passes to a
stitch the same way.
**Why there's no `pattern` primitive.** StitchAPI deliberately does *not*
ship its own schema type with `.partial()`/`.pick()`/`.omit()` — that would
re-implement a validator inside a validator-agnostic core. The schema
library you already use owns composition; StitchAPI consumes the result. The
full reasoning is recorded in [ADR
0011](https://github.com/rejifald/StitchAPI/blob/main/docs/adr/0011-no-pattern-primitive-schema-reuse-is-the-validators-job.md)
— schema reuse is the validator's job.
## See also [#see-also]
# Let an agent call your API without handing it the key (/docs/recipes/expose-a-stitch-to-an-agent)
## Task [#task]
You want an LLM agent to call your APIs, but you cannot give it the API key — and
you do not want to flood its context with one tool per endpoint.
## Example [#example]
One way: author named stitches that hold their own auth, then serve them over
MCP.
```ts twoslash
// stitches.ts — author each stitch with its auth and a name.
import { stitch } from 'stitchapi';
import { bearer, env } from 'stitchapi/auth';
export const getUser = stitch({
name: 'getUser',
baseUrl: 'https://api.example.com',
path: '/users/{id}',
auth: bearer(env('API_TOKEN')),
});
```
Serve the module over MCP from the shell:
```bash
stitch mcp --module ./stitches.ts
```
The agent discovers names with `list_stitches`, then calls one through the single
`run_stitch` tool. The name is the export name, `getUser`, and the agent can
reach nothing that `list_stitches` does not list:
```json
{
"name": "run_stitch",
"input": { "name": "getUser", "input": { "params": { "id": 1 } } }
}
```
## How it works [#how-it-works]
`run_stitch` covers **every** registered stitch with one tool — `name` selects
it, `input` is its `{ params, query, body, headers }` — so the agent's tool list
stays at two entries (`run_stitch` + `list_stitches`) no matter how many stitches
you register, keeping its context flat. The auth runs inside the runtime: the
agent names a [capability](/docs/concepts/capability-not-credential), the server
holds the `bearer(env('API_TOKEN'))` secret, and the result returns without the
token. If a call fails, the agent gets the error message with credentials
scrubbed from any URL in it.
`--module` exposes every stitch the module exports. To expose only some of them,
import `serveStdio` from the `stitchapi/mcp` subpath and pass it the registry
you mean: `serveStdio({ getUser })`. That also runs the server in your own
process.
## See also [#see-also]
# Retry a write without double-charging (/docs/recipes/idempotent-writes)
## Task [#task]
You want to retry a `POST` that creates a charge or an order — but a retry that
actually succeeded server-side the first time would double-charge. You need the
server to recognize the replay as the same logical write.
## Example [#example]
One way: pair `retry` with an `idempotency` key derived from the request.
```ts twoslash
import { stitch } from 'stitchapi';
const createOrder = stitch({
method: 'POST',
baseUrl: 'https://api.example.com',
path: '/orders',
retry: { attempts: 3, on: [429, 503] },
idempotency: {
// body is unknown, so narrow it before reading a property.
keyOf: (input) => `order-${(input.body as { ref: string }).ref}`,
},
});
const order = await createOrder({ body: { ref: 'inv-1001' } });
```
## How it works [#how-it-works]
Each logical call carries a stable `Idempotency-Key` header. When
[`retry`](/docs/guides/resilience/retry) replays the write, the server sees the
same key and dedupes the side effect instead of running it twice. The default
key is a random uuid generated once per call — already retry-safe; `keyOf` derives a stable value from the
[input](/docs/reference/config-types) (here the order ref) so the same logical
write always maps to the same key — which collapses two separate submissions
(a double-click) even with no retry in play.
A derived `keyOf` must be stable for a given logical call and unique across
distinct ones — colliding keys make the server dedupe two different writes
into one.
## See also [#see-also]
# Recipes (/docs/recipes)
A recipe is a worked example of a common task — the whole thing in one block,
runnable as a starting point and one way to get the job done, not the only way.
Where a [guide](/docs/guides/authoring/stitch) explains one feature, a recipe shows several
working together — then links down to the guides when you want the detail.
Reach for a recipe when you know the task and want somewhere to start. Reach for
a guide when you want to understand a feature on its own.
## Start here [#start-here]
## Stay resilient [#stay-resilient]
## Validate & observe [#validate--observe]
## Run at scale [#run-at-scale]
## Authenticate [#authenticate]
## Agents & surfaces [#agents--surfaces]
These assume the runtime is installed and you can declare a stitch. New to
it? Start at the [Quickstart](/docs/getting-started/quickstart).
# Inspect what the server actually sent (/docs/recipes/inspect-what-the-server-sent)
## Task [#task]
The schema coerced a field, filled a default, or stripped a key — and now you
want to see what the server *actually* sent. An `await getUser(...)` hands back
only the validated value; the raw body and the soft drift findings rode the
[event stream](/docs/concepts/event-stream) and are gone. You want both, on the
resolved result, without consuming a stream and without throwing when the
response breaks the contract.
## Example [#example]
`.inspect()` runs one fresh call and resolves to an `Inspection` —
`{ value, raw, findings, status, error }`. It never throws: a hard contract
violation comes back as `value: null` with `error` set, and `raw` / `findings` /
`status` still populated.
```ts twoslash
// [!code fold:start]
import { drift, stitch } from 'stitchapi';
import { z } from 'zod';
const getUser = stitch({
baseUrl: 'https://api.example.com',
path: '/users/{id}',
pick: 'data',
output: drift(
z.object({ id: z.number(), name: z.string(), role: z.string() }),
),
});
// [!code fold:end]
const result = await getUser.inspect({ params: { id: 1 } });
result.data; // the validated User (coerced/defaulted/stripped) — or null on a hard fail
result.findings; // every drift finding: undeclared, coerced, defaulted, invalid
result.status; // the HTTP status, so `raw` is interpretable (a 422 body reads unlike a 200)
result.error; // a StitchError on a contract violation, else null — value/error are inverse
// `raw` is the pre-validation body the findings are diffed against. Reach for it
// deliberately (see the caveat below); here, only when something actually drifted.
if (result.findings.length > 0) {
console.log('server sent:', result.raw);
}
```
`value` is the same validated value `await getUser(...)` returns; `raw` is the
body *before* validation, the coordinate space each `finding.path` is anchored
to. The pair is self-explaining: a `coerced` finding at `role` plus
`result.raw.role` tells you exactly which wire value the schema rewrote.
## How it works [#how-it-works]
`.inspect()` consumes one run with the raw body retained and surfaces it on a
**non-enumerable** field. The other four fields are ordinary properties; `raw`
alone is hidden, so it cannot leak into a log by accident. On a failure `error`
serializes through [`StitchError.toJSON()`](/docs/errors), which leaves the
upstream body out and scrubs the URL, so the wrapper's JSON carries no response
body there either. `.report()` returns the same wrapper plus run diagnostics;
its `config` is the stitch's public config, with any secret written into a
literal `url`, `baseUrl`, `path` or `headers` already replaced by `REDACTED` /
`[REDACTED]`.
**Anti-pattern:** don't log the whole wrapper — `raw` is **unredacted**, so
a stray token or PII in an undeclared field would land in your logs. Because
`raw` is non-enumerable, `JSON.stringify(result)`, `{...result}`, and trace
sinks all skip it; that protection only holds if you don't defeat it. Read
`result.raw` deliberately, at the one place you mean to.
**`.inspect()` always hits the network.** It is a fresh probe — it bypasses
the cache by default, reading and writing neither — so it is *not* an observer
of what your cached `await` call did. Pass `{ cache: true }` to honour the
cache policy instead; on a cache hit the entry stores only `{ value, status }`,
so `raw` is then `null`.
```ts twoslash
// [!code fold:start]
import { stitch } from 'stitchapi';
const getUser = stitch({
baseUrl: 'https://api.example.com',
path: '/users/{id}',
cache: { ttl: '60s' },
});
// [!code fold:end]
// Honour the cache; on a hit, `raw` is null (the cache holds no raw body).
const cached = await getUser.inspect({ params: { id: 1 } }, { cache: true });
```
`true` is shorthand for `{ cache: true }` — `getUser.inspect(input, true)` reads
the same way at a call site.
`raw` is `null` on a [streaming surface](/docs/concepts/event-stream) too — the
engine refuses to buffer an unbounded delta spine — while `findings` and `status`
still populate; use `.stream()` for incremental inspection there.
## See also [#see-also]
# Make a flaky call succeed on its own (/docs/recipes/make-a-flaky-call-succeed)
## Task [#task]
An API you depend on returns the occasional `429` or `503` that clears on its
own. You do not want every call site wrapped in a try/catch and a sleep.
## Example [#example]
One way: declare a retry policy on the stitch and let the caller just `await` the
value.
```ts twoslash
import { stitch } from 'stitchapi';
const getUser = stitch({
baseUrl: 'https://api.example.com',
path: '/users/{id}',
retry: {
attempts: 3,
on: [429, 503],
backoff: 'expo-jitter',
},
});
// A transient 429 or 503 is retried up to twice; the caller just gets the value.
const user = await getUser({ params: { id: 1 } });
```
## How it works [#how-it-works]
`attempts` is the **total** number of tries including the first, so `3` means up
to two retries. `on` is the set of status codes that trigger a retry.
`backoff: 'expo-jitter'` doubles the wait each attempt and spreads it randomly to
avoid thundering herds — though when the server sends a `Retry-After` header, that
wait wins over the computed one, which is the default (`respect: false` opts out).
Each retry rides the
[event stream](/docs/concepts/event-stream) as a `progress` event (phase
`retry`), so you can [watch it happen](/docs/recipes/watch-a-call-as-it-happens).
When the dependency is not just flaky but failing, compose retry with a
[timeout and circuit breaker](/docs/recipes/survive-a-flaky-dependency).
## See also [#see-also]
# Mirror a paginated API to NDJSON (/docs/recipes/mirror-a-paginated-api)
## Task [#task]
You want a local copy of every record behind a paginated API — one that happens
to be OAuth2-protected, rate-limited, and prone to the odd `429` or `503`. By
hand that means a token dance, a manual page loop, backoff code, and a rate
limiter before you write a single row.
## Example [#example]
One way: declare all four concerns on a single stitch and `await` it once.
```ts twoslash
import { stitch } from 'stitchapi';
import { env, oauth2 } from 'stitchapi/auth';
const mirror = stitch({
baseUrl: 'https://api.example.com',
path: '/users',
auth: oauth2({
tokenUrl: 'https://api.example.com/oauth/token',
client: { id: env('CLIENT_ID'), secret: env('CLIENT_SECRET') },
scope: 'users.read',
}),
retry: {
attempts: 4,
on: [429, 503],
backoff: 'expo-jitter',
},
throttle: { rate: '5/s', concurrency: 2, pool: 'host' },
paginate: {
next: (prevBody) => {
const cursor = (prevBody as { nextCursor?: string }).nextCursor;
return cursor ? { query: { cursor } } : undefined;
},
items: (value) => (value as { results: unknown[] }).results,
pages: 100,
},
});
// One await runs the whole paged loop — auth, retry, and throttle apply to
// every page — and aggregates every row into a single array.
const rows = (await mirror()) as unknown[];
// Newline-delimited JSON: one row per line.
const ndjson = rows.map((row) => JSON.stringify(row)).join('\n');
```
Write the rows to disk:
```ts
import { writeFile } from 'node:fs/promises';
await writeFile('users.ndjson', ndjson);
```
## How it works [#how-it-works]
The four concerns are independent keys on one declaration, so they compose
without any glue. [`oauth2`](/docs/guides/auth/oauth2) fetches, caches, and
refreshes the token behind the
[capability boundary](/docs/concepts/capability-not-credential) — your code
never sees it. [`paginate`](/docs/guides/data/pagination) reads `nextCursor`
off each raw body and asks for the next page until `next` returns `undefined`,
aggregating every page's `results` into the array you `await`.
[`retry`](/docs/guides/resilience/retry) and
[`throttle`](/docs/guides/resilience/throttle) apply **per page**, not once for
the whole run: a `429` on page seven recovers on its own, and the loop stays
under five requests a second the entire time. Without an
[`output` schema](/docs/guides/validation/validation) the rows arrive as
`unknown[]`, so the cast just tells TypeScript what you already know — add a
schema and each row is typed and validated, and the cast goes away. Either way,
serializing to NDJSON is one `.map()`.
`next` reads the raw body; `items` reads the value after pick. Reach for the
cursor in `next` from where it actually lives in the response — see
[Pagination](/docs/guides/data/pagination).
## See also [#see-also]
# Share one rate limit across every worker (/docs/recipes/one-rate-limit-across-workers)
## Task [#task]
You run several workers that all hit the same rate-limited API. Each process
enforcing `"10/s"` on its own means the fleet does `10/s × workers` — and trips
the limit. You want one budget the whole fleet shares.
## Example [#example]
One way: keep the `throttle` policy, but move its counters to a shared `store`.
```ts twoslash
import { stitch } from 'stitchapi';
import type { StitchStore } from 'stitchapi';
// A Redis- or Postgres-backed store shared by every worker.
declare const sharedStore: StitchStore;
const search = stitch({
baseUrl: 'https://api.example.com',
path: '/search',
throttle: { rate: '10/s', pool: 'host' },
store: sharedStore, // the budget now spans all workers using this store
});
const hits = await search({ query: { q: 'mango' } });
```
## How it works [#how-it-works]
By default throttle counters live in the in-memory store, so each worker enforces
the limit alone and the fleet runs at `10/s × workers`. Point `store` at one
shared backend and the rate counter becomes a single fixed-window count every
worker increments — one `10/s` budget across the fleet. `pool: 'host'` keys the
budget by origin, so every stitch hitting the host pools into it; workers share
only when they point at the same store and resolve to the same key. A
[`StitchStore`](/docs/guides/state/pluggable-store) needs only `get` / `set` with
TTL; the throttle goes fleet-wide through the optional verbs a store adds
(`reserve`, `increment`, the lease pair — see the
[capability table](/docs/integrations/stores#capabilities)), and says so once when
it can't. The same store also gives you
[shared auth sessions](/docs/guides/state/shared-sessions).
## See also [#see-also]
# Call one stitch as a function, a CLI command, and an agent tool (/docs/recipes/one-stitch-every-surface)
## Task [#task]
You have defined an API call once. You want to use it in app code, run it ad-hoc
from the shell, and let an agent call it — without redefining it three times.
## Example [#example]
One way: define the stitch once with a `name`, then reach it through each
surface.
```ts twoslash
// stitches.ts — define it once, export it by name.
import { stitch } from 'stitchapi';
export const getUser = stitch<{ id: number; name: string }>({
name: 'getUser',
baseUrl: 'https://api.example.com',
path: '/users/{id}',
});
// In app code: call it as a typed async function.
const user = await getUser({ params: { id: 1 } });
```
From the shell, the CLI loads the module and runs it by name:
```bash
stitch run getUser --id 1
```
To an agent over MCP, the single `run_stitch` tool covers it:
```json
{
"name": "run_stitch",
"input": { "name": "getUser", "input": { "params": { "id": 1 } } }
}
```
## How it works [#how-it-works]
One definition, four surfaces. The [in-process function](/docs/surfaces/function)
awaits or `.stream()`s it; the [CLI](/docs/surfaces/cli) (`stitch run`) loads the
module and runs it by name; [MCP](/docs/surfaces/mcp) exposes it to agents through
the single `run_stitch` tool; and it can be [served over HTTP](/docs/surfaces/http-serve).
The export name, `getUser`, is what the CLI, HTTP and MCP resolve. The
configured `name` labels the stitch's traces. Nothing else about the definition
changes between front doors.
## See also [#see-also]
# Loop a cursor API into one array (/docs/recipes/paginate-into-one-array)
## Task [#task]
An endpoint returns one page at a time behind a cursor. You want every item in a
single array — without writing the `while` loop, the cursor bookkeeping, and the
concat by hand.
## Example [#example]
One way: declare how to find the next page and which array to collect, and
`await` once.
```ts twoslash
import { stitch } from 'stitchapi';
const allUsers = stitch({
baseUrl: 'https://api.example.com',
path: '/users',
paginate: {
next: (prevBody) => {
const cursor = (prevBody as { nextCursor?: string }).nextCursor;
return cursor ? { query: { cursor } } : undefined;
},
items: (value) => (value as { results: unknown[] }).results,
pages: 20,
},
});
// One await follows every page and returns the aggregated items.
const users = (await allUsers()) as unknown[];
```
## How it works [#how-it-works]
`next(prevBody)` reads the cursor off the previous page's raw body and returns
the [input](/docs/reference/config-types) for the next page, merged over the
original call — set only what changes. Return `undefined` to stop. `items`
selects the array to aggregate from each page (after [pick](/docs/guides/data/pick)).
`pages` caps the page count as a safety net (default `50`). Auth, retry, and
throttle apply per page, not once for the whole run. Without an
[`output` schema](/docs/guides/validation/validation) the aggregated result is
typed `unknown[]`, hence the cast — add a schema to type the rows.
## See also [#see-also]
# Keep a failing dependency from taking you down (/docs/recipes/survive-a-flaky-dependency)
## Task [#task]
A downstream API starts timing out and erroring. Retrying alone just piles more
load on something already struggling, and a hung request ties up a caller
indefinitely. You want bounded calls, a few smart retries, and a breaker that
stops hammering a dependency that is clearly down.
## Example [#example]
One way: layer `timeout`, `retry`, and `circuit` on a single stitch.
```ts twoslash
import { stitch } from 'stitchapi';
const orders = stitch({
baseUrl: 'https://api.example.com',
path: '/orders',
timeout: { total: '10s', each: '3s' },
retry: { attempts: 3, on: [429, 503], backoff: 'expo-jitter' },
circuit: { failures: 5, cooldown: 30_000 },
});
const result = await orders();
```
## How it works [#how-it-works]
Three layers compose. [`timeout`](/docs/guides/resilience/timeout) bounds each
attempt (`3s`) and the whole call (`10s`) with a real `AbortSignal`, so a hung
request is actually cancelled, not leaked. [`retry`](/docs/guides/resilience/retry)
recovers transient `429`/`503`s with jittered backoff inside that budget.
[`circuit`](/docs/guides/resilience/circuit-breaker) watches the failures: after
five consecutive it trips **open** and calls fast-fail for 30 seconds
([`STITCH_CIRCUIT_OPEN`](/docs/errors/stitch-circuit-open)) instead of hammering
a dead dependency, then lets a single trial call probe recovery. Together: a blip
is retried, a hang is cut, and an outage stops being yours to absorb. All three
report on the [event stream](/docs/concepts/event-stream) (phases `retry`,
`circuit`).
## See also [#see-also]
# Send JSON and get a typed result back (/docs/recipes/typed-json-round-trip)
## Task [#task]
You are POSTing JSON to an API and want the result back as a typed value — and
you would like bad input rejected before it leaves the process, not returned to
you as a `400` from the server.
## Example [#example]
One way: validate the `body` going out and the response coming back, and type the
result with the `stitch` generic.
```ts twoslash
import { stitch } from 'stitchapi';
import { z } from 'zod';
const createUser = stitch<{ id: number; name: string }>({
method: 'POST',
baseUrl: 'https://api.example.com',
path: '/users',
input: { body: z.object({ name: z.string() }) },
output: z.object({ id: z.number(), name: z.string() }),
});
// A bad body throws before the POST; a bad response throws after.
const user = await createUser({ body: { name: 'Ada' } });
```
`user` is typed `{ id: number; name: string }`.
## How it works [#how-it-works]
`input.body` is checked **before** the request — wrong input fails fast with no
network call. `output` is checked **after** the response returns. Either failure
throws [`STITCH_VALIDATION`](/docs/errors/stitch-validation). The `stitch`
generic types the resolved value, and the config takes the `z.object(...)`
schema directly. Any [Standard
Schema](/docs/guides/validation/standard-schema) validator (Valibot, ArkType)
works the same way, passed straight to `input`/`output`.
## See also [#see-also]
# Watch retries and throttling as they happen (/docs/recipes/watch-a-call-as-it-happens)
## Task [#task]
A call is slow or flaky and you want to see why — the retry that fired, the
throttle that paused, the field that drifted — not just the final value or a
thrown error.
## Example [#example]
One way: iterate the stitch's event stream instead of awaiting it.
```ts twoslash
import { stitch } from 'stitchapi';
const search = stitch({ baseUrl: 'https://api.example.com', path: '/search' });
// Await for just the value…
const value = await search({ query: { q: 'mango' } });
// …or iterate the stream for everything that happened on the way there.
for await (const event of search({ query: { q: 'mango' } }).stream()) {
if (event.type === 'progress') console.log(event.phase, event.attempt);
if (event.type === 'drift')
console.log(event.finding.level, event.finding.path);
if (event.type === 'result') console.log(event.data);
}
```
## How it works [#how-it-works]
A stitch yields a typed event stream — `start → progress → drift → result →
done` — and `await` simply consumes it for the final value. `.stream()` hands you
the events instead, discriminated on `type`, so narrowing one field tells the
compiler the shape of the rest. `progress` carries a `phase` (`auth`,
`throttled`, `retry`, `pagination`, `circuit`) and an `attempt`; `drift` carries
leveled `finding`s — including the `warn`/`info` ones that never throw; then
exactly one terminal `result` or `error`, then `done`. This is the one source
every cross-cutting feature reports through, and exactly what a
[trace sink](/docs/guides/observability/trace-sinks) consumes.
## See also [#see-also]
# Auth strategies (/docs/reference/auth-strategies)
The signatures for every auth strategy and secret resolver. Each strategy holds
the credential and resolves it at call time, so the caller never sees it. The
guides under [Auth](/docs/guides/auth/secret-resolvers) show how to use them.
## bearer [#bearer]
Attaches `Authorization: Bearer `.
```ts twoslash
import { bearer } from 'stitchapi/auth';
```
## apiKey [#apikey]
Sends a secret in a header (`X-API-Key` by default), a query parameter
(`in: 'query'`, `api_key` by default, URL-encoded), or a cookie (`in: 'cookie'`).
`name` labels the key in every arm, resolved at call time.
```ts twoslash
import { apiKey } from 'stitchapi/auth';
```
## basic [#basic]
Base64-encodes `user:pass` into `Authorization: Basic …`.
```ts twoslash
import { basic } from 'stitchapi/auth';
```
## cookieSession [#cookiesession]
Logs in once, captures the cookie from `Set-Cookie`, replays it on each request,
and re-logs in on a status code or a soft 200 wall.
## oauth2 [#oauth2]
Performs the `client_credentials` grant, caches the access token, refreshes it
before expiry, and attaches it as `Authorization: Bearer …`.
### OAuth2ClientOptions [#oauth2clientoptions]
The shape behind `oauth2({ client })` — who the client is at the token endpoint,
and how it proves it. `id` and `secret` are both required, so `client: {}` is a
compile error; there is no scalar shorthand, because neither field dominates the
other. `auth` picks `client_secret_post` (the default) or `client_secret_basic`.
## env [#env]
The environment-variable resolver, and the only one whose source is the process
environment. It is **callable** for the required case, with the optional variant
hanging off it as a modifier — one name for one source, requiredness named at the
call site.
Resolves a **required** secret from an environment variable at call time. It
needs a non-empty value: an unset variable *and* a set-but-empty one (`MY_TOKEN=`)
both throw, so a blank credential is never silently sent. For the
may-or-may-not-be-set case use [`env.optional`](#envoptional).
```ts twoslash
import { env } from 'stitchapi/auth';
```
### env.optional [#envoptional]
Like `env`, but optional: resolves the variable's value, or *absent* when it is
unset or empty — it never throws. Pass it to `bearer` to attach the credential
only when present (`bearer(env.optional('GITHUB_TOKEN'))`); a missing variable
just sends the request unauthenticated. See
[the Bearer guide](/docs/guides/auth/bearer).
```ts twoslash
import { env } from 'stitchapi/auth';
const token = env.optional('GITHUB_TOKEN');
```
## credential [#credential]
The resolvers whose source is **not** the process environment, grouped under one
noun with the source named at the call site. Both members are required resolvers
— they throw rather than hand back a blank credential, mirroring [`env`](#env) —
and both return the same `() => string` thunk every strategy accepts.
The noun is `credential` rather than `secrets` because `secrets` is already the
root barrel's trace-redaction namespace (`secrets.register` / `has` / `redact`);
two different objects behind one word on two entry points is the one-word-one-concept
rule this project holds itself to.
### credential.file [#credentialfile]
Resolves a secret from `~/.stitch/secrets.json` (plaintext JSON — keep it
private), falling back to the environment variable of the same name when the file
is absent or lacks the key, and throwing when neither has it.
```ts twoslash
import { credential } from 'stitchapi/auth';
const token = credential.file('BILLING_TOKEN');
```
`~/.stitch/secrets.json` is **unencrypted plaintext JSON**. Restrict its
permissions (`chmod 600 ~/.stitch/secrets.json`) and never commit it.
### credential.from [#credentialfrom]
Resolves a **required** secret from an arbitrary injected `source` at call time —
for DI'd apps that supply config without touching `process.env` (a
`ConfigService`, a secrets-manager client, a validated config object). The source
is either an object with a `get(name)` method or a plain `(name) => value`
function; a missing or empty value throws, mirroring [`env`](#env). Compose it
with any strategy exactly like `env()`.
```ts twoslash
import { bearer, credential } from 'stitchapi/auth';
// Any object with a `get(name)` method — e.g. a NestJS ConfigService.
declare const configService: { get(name: string): string | undefined };
const auth = bearer(credential.from(configService, 'GITHUB_TOKEN'));
```
## See also [#see-also]
# Config types (/docs/reference/config-types)
The configuration object accepted by `stitch()`. Every field is optional; a
stitch is the result of deep-merging these fragments through `extends`.
## StitchConfig [#stitchconfig]
```ts twoslash
import type { StitchConfig } from 'stitchapi';
```
### wire [#wire]
Every wire-format choice lives in one envelope, grouped by category rather than by
request/response phase:
No single field dominates, so `wire` takes no scalar shorthand — like `input`, it is
always the object form, and the opaque `wire: {}` is rejected.
#### wire.array [#wirearray]
Controls how arrays are serialised on **both** `application/x-www-form-urlencoded`
surfaces — the query string and a `wire: { body: 'form' }` request body. They run the same
walker, so one setting governs both. The default (`'indices'`) matches `qs` behaviour.
| Value | Wire format |
| --------------------- | --------------------------- |
| `'indices'` (default) | `ids%5B0%5D=1&ids%5B1%5D=2` |
| `'brackets'` | `ids%5B%5D=1&ids%5B%5D=2` |
| `'repeat'` | `ids=1&ids=2` |
```ts
// repeat — Laravel, Rails, and many PHP frameworks expect this
const getUsers = stitch({ baseUrl, path: '/users', wire: { array: 'repeat' } });
await getUsers({ query: { ids: [1, 2] } });
// → GET /users?ids=1&ids=2
// the same setting shapes a form body
const search = stitch({
method: 'POST',
baseUrl,
path: '/search',
wire: { body: 'form', array: 'repeat' },
});
await search({ body: { ids: [1, 2] } });
// → POST /search ids=1&ids=2
```
Nested objects always expand `qs`-style to `a[b]=c` on both surfaces — `wire.array`
selects the array axis only:
```ts
await search({ body: { page: { size: 10 } } });
// → POST /search page%5Bsize%5D=10
```
The two surfaces differ in one detail: a space is `%20` in a query string
and `+` in a form body. Both are valid urlencoded and both round-trip.
#### wire.multipart [#wiremultipart]
A `wire: { body: 'multipart' }` body has its own grammar and is **not** governed by
`wire.array` — use `wire.multipart`'s `nesting` instead. Setting
`multipart` without `wire: { body: 'multipart' }` is a type error, since the slot would
otherwise be silently ignored.
A bare `MultipartNesting` string is shorthand for the object form —
`multipart: 'dot'` ≡ `multipart: { nesting: 'dot' }`; the opaque `multipart: {}` is
rejected.
## Option shapes [#option-shapes]
The resilience, verdict, idempotency, cache, pagination, and drift fields above are
themselves typed shapes. Each is documented from the source so it can never drift from
the runtime.
### RetryOptions [#retryoptions]
#### BackoffOptions [#backoffoptions]
The shape behind `retry.backoff`. A bare curve is the shorthand for `{ curve }` —
`backoff: 'fixed'` ≡ `backoff: { curve: 'fixed' }`; the envelope adds `base` and `max`.
### ThrottleOptions [#throttleoptions]
### TimeoutOptions [#timeoutoptions]
### CircuitOptions [#circuitoptions]
### VerdictOptions [#verdictoptions]
The shape behind `verdict` — what counts as success. Both members move the verdict in
exactly one direction: `accept` can only turn a failure into a success, `flag` only a
success into a failure. Neither invents a verdict from absence.
### IdempotencyOptions [#idempotencyoptions]
The shape behind `idempotency`. `true` is shorthand for the defaults — a random uuid
minted once per logical call and reused across that call's retries.
### CacheOptions [#cacheoptions]
The shape behind `cache`. Off unless set; the key is **derived** from the resolved
request, so it cannot drift from what it names.
`methods` defaults to `['GET','HEAD']`. Two body-carrying reads opt in by naming
their method: a GraphQL **query** (`methods: 'POST'`) and
[`QUERY`](/docs/guides/authoring/stitch#the-query-method)
(`methods: 'QUERY'`), whose responses are cacheable by spec. Neither is a default —
a `POST`'s read-vs-mutate intent cannot be inferred, and caching a body-carrying
request is a decision worth writing down. The key already folds the request body in,
so two different `QUERY` bodies to the same URL get two entries and cannot collide.
Bulk invalidation — `stitch.cache.invalidate()` and `seam.invalidate()` — writes a
fresh **generation** with the store's `set`, so every store supports it, including a
`get`/`set`-only one; entries under the old generation become unreachable and expire
on their own TTL. It takes effect as fast as the store shows a write: on an
eventually-consistent store such as Cloudflare Workers KV a bump can take about 60
seconds to reach every location, and until then a reader elsewhere may still serve
the invalidated entries.
#### CacheFingerprintOptions [#cachefingerprintoptions]
The shape behind `cache.fingerprint` — how a stored value is detected as stale against
its contract (the [ADR 0004](https://github.com/rejifald/StitchAPI/blob/main/docs/adr/0004-standard-schema-fingerprint-for-cache-invalidation.md)
ladder). Every member is a rung of that ladder; `ttl`, `tenancy`, `vary` and the rest of
`CacheOptions` answer a different question and stay outside. A bare version tag is the
shorthand for `{ version }` — `fingerprint: 3` ≡ `fingerprint: { version: 3 }`, the
always-available manual override.
Unset is the automatic path: a registered `@stitchapi/fingerprint-*` strategy makes
`output` changes self-invalidate, and an un-fingerprintable schema refuses to cache.
#### CacheTransformOptions [#cachetransformoptions]
The shape behind `cache.fingerprint.transform` — what the cache knows about an opaque
`transform`. A bare version tag is the shorthand for `{ version }` here too, one level
in: `fingerprint: { transform: 3 }` ≡ `fingerprint: { transform: { version: 3 } }`. A
stitch that has a `transform` and none of this **refuses to cache**: the closure cannot
be hashed, and re-validation cannot detect a transform change, so no policy would be
sound. `version` wins when both are set.
### PaginateOptions [#paginateoptions]
The shape behind `paginate`. `next` drives the loop; auth, retry, and throttle apply to
every page.
### DriftOptions [#driftoptions]
## See also [#see-also]
# Conformance kits (/docs/reference/conformance-kits)
Every pluggable seam in StitchAPI is a **contract, not a dependency**: the
transport (`Adapter`), the state store (`StitchStore`), and the trace sink
(`TraceSink`) each have a small documented surface, core ships only platform
defaults for them (`fetchAdapter`, `memoryStore`, `createTrace`), and vendor
implementations — a Redis store, an axios transport, a Datadog sink — live in
their own packages.
The `stitchapi/testing` subpath makes that contract enforceable. It ships one
`conformance` namespace with one member per seam; any implementation that
passes its seam's member is a valid seam implementation, and third-party
authors can prove it in their own CI without depending on StitchAPI internals.
```ts
import { conformance } from 'stitchapi/testing';
conformance.store; // the StitchStore contract
conformance.adapter; // the Adapter transport contract
conformance.sink; // the TraceSink contract
conformance.fingerprint; // the SchemaFingerprinter contract (ADR 0004)
conformance.assert; // turn a report into a failing test
conformance.fixture; // the echo server `conformance.adapter` verifies against
```
The seam is the dimension, so it is the name — the same shape the `duration` /
`size` / `rate` grammars and `secrets` take on the main entry.
The kit is framework-agnostic and browser-safe: every verifier is a plain
function that **returns** a report (no vitest/jest imports, no `node:*`
modules), so it runs inside any test runner — or a browser.
## The report [#the-report]
Each verifier resolves to a `ContractReport`:
```ts
interface ContractReport {
seam: string; // 'store' | 'adapter' | 'sink' | 'fingerprint'
ok: boolean; // true when every rule passed
passed: string[]; // names of the rules that passed
violations: { rule: string; detail: string }[];
}
```
`seam` names which member produced the report — the same dimension the
namespace is keyed by.
Rules are checked independently — one violation never masks another — so a
failing report lists every broken rule at once.
### conformance.assert [#conformanceassert]
`conformance.assert(report)` throws one readable `Error` listing every
violation (and is a no-op on a clean report). It is the one-liner for a test
body:
```ts
import { conformance } from 'stitchapi/testing';
test('myStore implements the StitchStore contract', async () => {
conformance.assert(await conformance.store(() => myStore()));
});
```
## conformance.store [#conformancestore]
`conformance.store(makeStore, opts?)` verifies a [pluggable
store](/docs/guides/state/pluggable-store) — the surface the engine uses for
throttle state, the response cache and auth/session state. Only `get` and `set`
are required, so the rules come in four groups, one per capability, and a group
runs only when the store has that capability. A store passes by honouring the
groups it implements:
* **base** (always) — `set`/`get` round-trips a value, and a safe integer comes
back as a `number` (the cache's bulk invalidation stores its generation that
way, and a store that returns `'123'` makes `invalidate()` a silent no-op); a
missing key resolves to `undefined`; a second `set` overwrites;
`set(key, undefined)` deletes; writes are isolated by key. A `set` with `ttl`
expires; a `set` without `ttl` does not.
* **counter** (`increment`) — initializes a missing key to 1, increments an
existing counter, restarts at 1 once its TTL window lapses, keeps counters
isolated by key, and is atomic within a process: 20 concurrent calls must
return 1..20 exactly.
* **pacing** (`reserve`) — a cold cursor grants at `now`, successive grants
advance by exactly one spacing, an idle cursor resets to `now`, 20 concurrent
grants are 20 distinct evenly-spaced instants, and cursors are isolated by key.
* **lease** (`lease` + `release`, checked only when both are present; a store
with just one of the two fails a `lease/release` rule, because the throttle
ignores a half pair and quietly stays per-process) — fills to
the cap then refuses, a release frees a slot, re-leasing renews rather than
taking a second slot, a lapsed holder's slot comes back, releasing an unheld
token is a no-op, 20 concurrent callers on 5 slots yield exactly 5 winners, and
semaphores are isolated by key.
A missing group is not a violation — `@stitchapi/cloudflare-kv`, a `get`/`set`
store, passes on the base group alone. So the report doesn't tell you a verb went
missing: if your store ships a capability, assert it is there too
(`expect(typeof store.increment).toBe('function')`). The
[capability table](/docs/integrations/stores#capabilities) shows what each
first-party store implements.
TTL rules use real timers with a small window (default 60ms). Pass
`{ ttl }` to give a backend with coarser expiry more room. Keys are
namespaced per run, so rerunning against a persistent backend never collides.
## conformance.adapter [#conformanceadapter]
`conformance.adapter(adapter, { baseUrl })` verifies a custom transport
against a documented echo contract served at `baseUrl`:
* Status passthrough: 200, 404, and 500 all **resolve** — an adapter never
throws on a non-2xx status; only network and abort errors reject.
* Request delivery: method, headers, and the JSON-encoded body reach the
server intact.
* Response headers are readable using **lowercased** names (the
`AdapterResult` case convention).
* A `text/plain` body round-trips as a string; an `application/json` body
round-trips as parsed data.
* A pre-aborted `AbortSignal` rejects, and an in-flight abort rejects
promptly instead of waiting out the response.
### conformance.fixture [#conformancefixture]
The echo contract itself ships as `conformance.fixture` — a **pure**
function (request in, response out, no server), so you can mount it on
anything: `node:http`, hono, a service worker. The host lowercases request
header names, hands over the raw body text, and honors the fixture's
`delay` (the in-flight abort rule depends on it):
```ts
import { createServer } from 'node:http';
import { conformance } from 'stitchapi/testing';
const server = createServer((req, res) => {
let raw = '';
req.setEncoding('utf8');
req.on('data', (chunk) => (raw += chunk));
req.on('end', () => {
const out = conformance.fixture({
method: req.method ?? 'GET',
path: req.url ?? '/',
headers: Object.fromEntries(
Object.entries(req.headers).map(([k, v]) => [
k.toLowerCase(),
Array.isArray(v) ? v.join(', ') : (v ?? ''),
]),
),
...(raw === '' ? {} : { body: raw }),
});
const respond = () => {
res.writeHead(out.status, out.headers);
res.end(out.body);
};
if (out.delay) setTimeout(respond, out.delay);
else respond();
});
});
```
The routes are documented on the function's JSDoc (`/status/{code}`, `/echo`,
`/text`, `/json`, `/slow`).
## conformance.sink [#conformancesink]
`conformance.sink(makeSink)` feeds one sink instance a canonical fixture
sequence covering **all** `StitchEvent` variants — `start`, `progress`,
`drift`, `delta`, `result`, `error`, `done` — and asserts `handle` accepts
each without throwing, then exercises the optional `flush()` when present.
The sequence includes `delta`, which the type declares ahead of engine
support — a conforming sink must already tolerate events it does not
recognize.
## Worked example: a community Redis store [#worked-example-a-community-redis-store]
A third-party author publishing `@acme/stitch-redis-store` proves compliance
in their own CI — no StitchAPI internals, just the kit:
```ts
import { redisStore } from '../src/redis-store';
import { createClient } from 'redis';
import { conformance } from 'stitchapi/testing';
import { test } from 'vitest';
test('redisStore implements the StitchStore contract', async () => {
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
try {
conformance.assert(
await conformance.store(() => redisStore(client), {
// Give Redis expiry more room than the 60ms default.
ttl: 250,
}),
);
} finally {
await client.quit();
}
});
```
If the store cuts a corner — a non-atomic `INCR`, a dropped TTL — the test
fails with every broken rule named:
```
Error: StitchAPI store contract: 2 violation(s), 10 rule(s) passed
- set: a ttl entry expires: value survived its 250ms TTL: got "soon-gone"
- increment: 20 concurrent calls net exactly +20: sorted results of 20 concurrent increments ...
```
Passing the kit is the compatibility claim: a store that passes can back
[distributed throttle](/docs/guides/state/distributed-throttle) and shared
sessions with no change at the call site.
## See also [#see-also]
# Event types (/docs/reference/events)
The events a stitch emits as it runs. A stitch streams an async iterable of
`StitchEvent` values; this page documents their shapes.
## StitchEvent [#stitchevent]
`StitchEvent` is a discriminated union on `type`. The `T` parameter is the
type of the value carried by the `result` event.
```ts twoslash
import type { StitchEvent } from 'stitchapi';
```
## The events [#the-events]
A union table can render sparsely, so here is each of the eight variants and its
fields. Every event carries an `at` field — a millisecond timestamp.
* **`start`** — the request is about to go out. Fields: `name` (the stitch
label), `method`, `url`, `input` (the `StitchInput` for this call),
`surface?` (the surface id: `http`, `graphql`, `shell`, …), `transport?`
(`http` when the HTTP adapter carries the request, else the id of the
surface that replaces it), `template?` (the stitch's unexpanded path
template, `/users/{id}`, when one is known), the run identity (`spanId?`,
`traceId?`, `parentSpanId?`), `at`.
* **`progress`** — a lifecycle beat within an attempt. Fields: `phase` (a
`ProgressPhase`), `attempt` (1-based), `detail?`, `waited?` (set when the
phase involved a wait, e.g. `throttled` or `retry`), `status?` (the response
status behind a `retry`, an `auth` refresh or a `paginate` step),
`errorType?` (the class of the error a `retry` re-attempts), `at`. A
`request` step carries `spanId?`/`parentSpanId?` — the id the engine minted
for that physical request and its parent (the run, or the page) — and a
`paginate` step carries the id of the page it closes. The OTLP sink builds its
[span tree](/docs/guides/observability/otlp#the-span-tree) from them.
* **`info`** — an announcement, never carrying a secret. Fields: `topic`,
`detail?`, `at`. An auth strategy uses it for a decision it made (topic
`auth` — which env var a `bearer` token resolved from, or that `oauth2`
fetched a token). The engine uses it for a teaching note:
`adapter.upload-progress-unsupported` when the transport can't report upload
progress, and `throttle.per-process` — once per throttle — when a `rate` or
`concurrency` limit can't be fleet-wide because the store lacks the verbs
(see the [capability table](/docs/integrations/stores#capabilities)). `topic`
is an open string, so a sink should ignore topics it doesn't know.
* **`drift`** — a single contract-drift observation against the response.
Fields: `finding` (a `DriftFinding` with `level`, `path`, `change`,
`detail?`), `at`.
* **`delta`** — a streamed chunk of the response body. Fields: `chunk`
(`unknown`), `at`.
* **`result`** — the call succeeded and produced a value. Fields: `data` (the
`T`), `status` (HTTP status), `attempts` (how many it took), `at`.
* **`error`** — the call failed. Fields: `name`, `message`, `errorType?` (the
failing error's class — `TimeoutError`, `RateLimitError`, `TypeError` — omitted
for a plain `Error`, such as an HTTP status failure), `status?`, `attempts`,
`at`.
* **`done`** — the stream is finished, success or failure. Fields: `ok`, `elapsed`
(total duration), `attempts`, `at`.
### Span ids [#span-ids]
Wherever an event carries `spanId`, it is the id of **the span the event belongs
to**, and `parentSpanId` is that span's parent. Which span depends on the event:
| Event | `spanId` is | `parentSpanId` is |
| ----------------------- | -------------------------------- | ----------------------------------------- |
| `start` | the **run** | the run that spawned this one, if any |
| `progress` · `request` | the **attempt** (one request) | the run, or the page for a paginated call |
| `progress` · `paginate` | the **page** that just completed | the run |
Only those carry ids today. The meaning does not change when more events do: a
later change that stamps ids on every event
([#874](https://github.com/rejifald/StitchAPI/issues/874)) fills in fields that
already exist, so a consumer that reads `spanId` as "the span this event belongs
to" keeps working without a rename.
`ProgressPhase` is one of `'auth'`, `'request'`, `'throttled'`, `'retry'`,
`'paginate'`, or `'circuit'`.
`result` and `error` are mutually exclusive within one stream, but `done`
always fires last — read `done.ok` to know which way it went.
## See also [#see-also]
# Helpers (/docs/reference/helpers)
The exported helper functions you wire into a stitch: the default transport, the
trace sinks (including OTLP export), the secret-redaction hooks, the default
store, and the parsers behind the duration, size, and rate tokens. Each signature
is below; the guides cover usage in depth and are linked under
[See also](#see-also).
## Transport [#transport]
Signatures below; [Transport & adapters](/docs/guides/transport/adapters) is the
guide — which transport to pick, what each can and can't do, and how to write your
own.
### fetchAdapter [#fetchadapter]
The default fetch-based transport. Returns an `Adapter` backed by the global
`fetch`; it never throws on a non-2xx response — only network and abort errors
propagate, leaving the engine to decide what a status means.
```ts twoslash
import { fetchAdapter } from 'stitchapi';
const adapter = fetchAdapter();
```
It accepts an optional `FetchAdapterOptions` to thread a **per-stitch undici
dispatcher** — an `Agent` for a proxy, a custom CA, or interface binding —
straight through as Node's non-standard `dispatcher` fetch init option. The
runtime stays zero-dependency, so StitchAPI never imports undici; you bring your
own `Agent`. An optional `fetch` override swaps the global `fetch` for testing or
custom runtimes. With no options, behavior is identical to `fetchAdapter()` (no
`dispatcher` is set).
```ts twoslash
import { fetchAdapter, stitch } from 'stitchapi';
// Your real `new Agent(...)` from undici is structurally an unknown dispatcher here —
// no undici import inside StitchAPI or this snippet.
declare const agent: unknown;
const getUser = stitch({
url: 'https://api.example.com/users/{id}',
adapter: fetchAdapter({ dispatcher: agent }),
});
```
The `axiosAdapter` equivalent is axios's own `httpAgent` / `httpsAgent`, passed
through its `defaults` (`axiosAdapter(axios, { httpsAgent })`) — see below.
### axiosAdapter [#axiosadapter]
Route a stitch through a caller-supplied axios instance instead of `fetch`. The
runtime stays zero-dependency, so you pass *your* axios (or an `axios.create()`)
rather than an import inside StitchAPI; body encoding, headers, and parsing match
`fetchAdapter`, so swapping transports never changes behavior. It is
**buffered-only** — it throws on a `stream`/`sse` surface; use `fetchAdapter` to
stream. It *does* report byte progress: the adapter wires axios's native
`onUploadProgress`/`onDownloadProgress` into `onProgress` (both phases), so an
upload bar works on a modern axios without leaving your instance.
```ts twoslash
import { type AxiosLike, axiosAdapter, stitch } from 'stitchapi';
// Your real axios instance is structurally an AxiosLike — no axios import needed here.
declare const axios: AxiosLike;
const getUser = stitch({
url: 'https://api.example.com/users/{id}',
adapter: axiosAdapter(axios),
});
```
### xhrAdapter [#xhradapter]
An `XMLHttpRequest`-backed transport whose reason to exist is **upload progress**
(`fetch` cannot report bytes sent). Browser-only by default; pass a constructor to
inject a polyfill or a fake. Buffered-only, like `axiosAdapter`.
```ts twoslash
import { stitch, xhrAdapter } from 'stitchapi';
const upload = stitch({
method: 'POST',
url: 'https://api.example.com/files',
adapter: xhrAdapter(), // defaults to globalThis.XMLHttpRequest
});
```
### Adapter capabilities [#adapter-capabilities]
Each built-in adapter declares what it **supports** as an optional `capabilities`
descriptor on the returned function — `{ name?, supports }`, where `supports`
lists the optional features the transport has (`'stream'`, `'uploadProgress'`,
`'downloadProgress'`); anything not listed, it can't do.
| adapter | `stream` | `uploadProgress` | `downloadProgress` |
| -------------- | :------: | :--------------: | :----------------: |
| `fetchAdapter` | ✓ | — | ✓ |
| `xhrAdapter` | — | ✓ | ✓ |
| `axiosAdapter` | — | ✓ | ✓ |
Only `fetchAdapter` streams (`xhr`/axios buffer and reject `stream`); only `fetch`
**cannot** report upload progress. `axiosAdapter` wires axios's native
`onUploadProgress`/`onDownloadProgress`, so it needs an axios that supports them (v1+).
```ts twoslash
import { fetchAdapter } from 'stitchapi';
// { name: 'fetchAdapter', supports: ['stream', 'downloadProgress'] }
const caps = fetchAdapter().capabilities;
```
Declaring capabilities is **opt-in**: a custom adapter is still just
`(req) => Promise`, and one that declares nothing is treated as
unknown — no checks run against it.
The engine checks `supports` for `'uploadProgress'` to turn a silent footgun into a
teaching note. If a call passes `onProgress` **with a request body** and the active
adapter's `supports` omits `'uploadProgress'` (only `fetch`, among the built-ins), the stitch emits one
`info` event — topic `adapter.upload-progress-unsupported` — through your trace
sink, pointing you at `xhrAdapter`:
> onProgress is set with a request body, but fetchAdapter cannot report upload
> progress — only 'direction: download' events fire. Use xhrAdapter() to draw an
> upload progress bar.
It is a **note, not an error**: `fetch` still reports `direction: 'download'`
progress, so a download bar on a `POST` keeps working — only the upload phase is
dark. To see the note, attach any `trace` sink (e.g. `consoleSink`); to act on it,
switch the call to `xhrAdapter()`.
## Tracing & OTLP [#tracing--otlp]
Tracing is **off by default** — a stitch traces nothing until you pass a `trace`
sink (or set `STITCH_TRACE_*`). These are the sinks you reach for; see
[Trace sinks](/docs/guides/observability/trace-sinks) for the full opt-in model.
### consoleSink [#consolesink]
A console-only sink: the colored one-line-per-event stream to stderr, nothing on
disk. The `trace: 'console'` shorthand resolves to this.
```ts twoslash
import { consoleSink } from 'stitchapi';
const sink = consoleSink();
```
### fileSink [#filesink]
A file-only sink: append every event as JSONL to `path` (defaults to
`~/.stitch/runs/proto.jsonl`). Writing to disk is a side effect, so you pass it
explicitly — a stitch never opens a trace file on its own. An optional second
argument tunes privacy: `body` (a character cap, default `2048`; `{ chars: false }` for full capture)
caps body/result [truncation](/docs/guides/observability/trace-sinks#body-truncation-and-full-capture),
and `redactHeaders` widens the redaction denylist.
```ts twoslash
import { fileSink } from 'stitchapi';
const sink = fileSink('./runs/today.jsonl', { body: { chars: false } });
```
### createTrace [#createtrace]
Build a zero-infra trace sink: a compact one-line-per-event summary on the
console and/or an appended JSONL record per event. `console` defaults to `true`;
`file` defaults to a path under `$HOME`, or `false` to disable the JSONL stream;
`body` and `redactHeaders` tune the JSONL sink's
[privacy defaults](/docs/guides/observability/trace-sinks#default-secret-redaction)
(header/URL redaction and body truncation are always applied to the file stream).
(This is the low-level builder behind `consoleSink`/`fileSink`; the off-by-default
behavior lives at the stitch's `trace` field, not here.)
```ts twoslash
import { createTrace } from 'stitchapi';
const sink = createTrace({ console: true, file: false });
```
### multiplex [#multiplex]
Fan one event stream out to several sinks — for example console/JSONL alongside
OTLP — flushing each in turn.
```ts twoslash
import { createTrace, multiplex } from 'stitchapi';
import { otlp } from 'stitchapi/otlp';
const sink = multiplex(createTrace(), otlp.sink());
```
### loggerSink [#loggersink]
Bridge the event stream to any host logger — pino, winston, the `console`,
anything with `error` / `warn` / `info` / `debug` methods (`LoggerLike`). It is
**payload-free** (logs only metadata — name, method, scrubbed URL, status, attempt
counts, drift path/level, timing — never the body, the result's `data`, or a `delta`
chunk) and maps each event to a level: `result` → `info`, `error` → `error`,
`drift` → the finding's level, `start`/`progress`/`info`/`done` → `debug`, and
`delta` is never logged. Override per type with `levels`. The logger-agnostic core
twin of `@stitchapi/nest`'s `nestLoggerSink`.
```ts twoslash
import { loggerSink } from 'stitchapi';
// `console` satisfies LoggerLike; pass `pino()` or a winston logger just the same.
const sink = loggerSink(console, { levels: { result: 'debug' } });
```
### otlp.sink [#otlpsink]
An opt-in OpenTelemetry sink, imported from `stitchapi/otlp` (it is not on the
root entry): it maps each stitch call's events to a span tree
— an `INTERNAL` run span, a page span per page, and a `CLIENT` span per HTTP
request — and hands finished spans to a `SpanExporter`, together with the
resource it resolved. With no exporter supplied it builds `otlp.exporter()`.
Configured by `OtlpOptions` (below). The tree and its attributes are on
[OTLP export](/docs/guides/observability/otlp#the-span-tree).
```ts twoslash
import { otlp } from 'stitchapi/otlp';
const sink = otlp.sink({
endpoint: 'https://api.example.com:4318',
resource: { 'service.name': 'checkout' },
});
```
`otlp` groups the three layers of one export path the same way
[`duration`](#duration) groups a parse/format pair: one name on the subpath, the
role at the call site. They are layers, not siblings — `sink` builds spans and
hands them to `exporter`, which POSTs what `json` serialized — so reach for the
one you actually need. `otlp.sink()` alone covers the common case.
### otlp.exporter [#otlpexporter]
The default exporter: POST spans as OTLP/JSON to `${endpoint}/v1/traces`. The
endpoint defaults to `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` (a full URL, used
as-is), else `OTEL_EXPORTER_OTLP_ENDPOINT`, else `http://localhost:4318`; headers
come from `OTEL_EXPORTER_OTLP_HEADERS` under any you pass
([the variables](/docs/guides/observability/otlp#options)). It serializes each batch with the resource the sink hands it, so the resource
is set on the sink, not here. Fire-and-forget — a missing collector never
breaks a stitch call.
```ts twoslash
import { otlp } from 'stitchapi/otlp';
const exporter = otlp.exporter({
endpoint: 'https://api.example.com:4318',
headers: { authorization: 'Bearer token' },
});
```
### otlp.json [#otlpjson]
Serialize finished spans to the OTLP/JSON `ResourceSpans` shape a collector
accepts on `/v1/traces`. This is the seam for a transport core doesn't ship —
gRPC, a queue, a file — so a custom `SpanExporter` reuses the same wire mapping
rather than re-deriving it. Pass it the `resource` your exporter's `export`
receives; without one it resolves the resource from the environment.
```ts twoslash
import { otlp } from 'stitchapi/otlp';
import type { SpanExporter } from 'stitchapi/otlp';
const fileExporter: SpanExporter = {
export(spans, resource) {
console.log(JSON.stringify(otlp.json(spans, resource)));
},
};
```
#### OtlpOptions [#otlpoptions]
## Secret redaction [#secret-redaction]
One namespace over one denylist. Reach for it when a credential of yours rides
under a name the built-in list doesn't know. Before an event reaches a sink, the
value of any secret-bearing key is replaced with `REDACTED` — matched by exact
name (`key`, `sig`, `access_key`) or by a contained stem (`token`, `secret`,
`signature`, `credential`, `api_key`), which is how `access_token`,
`client_secret`, and `x-amz-signature` are all caught without listing every
vendor spelling. A host that calls its credential `session_ref` matches neither,
so you name it yourself.
`secrets` groups the three calls that share that denylist the same way
[`duration`](#duration) groups a parse/format pair: one name on the barrel, the
verb at the call site.
### secrets.register [#secretsregister]
Add one key name to the denylist. Every scrubber reads from it: the console and
JSONL `start.url`, the OTLP `url.full` attribute, the structured `input.query`,
every payload the JSONL sink persists (request body, GraphQL variables, response
`data`, streamed `chunk`), the `start` frame `stitch serve` and `stitch run`
emit, and the literal `url` and `headers` a stitch exposes on its public
`__config` (what `.report().config`, `stitch diagram` and MCP `describe_stitch`
show).
**Register before you build the stitch.** A stitch's `__config` is a snapshot
taken when the stitch is created, so a name registered afterwards reaches every
sink, the `serve` / `stitch run` frames and the query keys, but not a `headers`
value the earlier stitch already shows on `__config`. Register at start-up, ahead
of the stitches that use the name.
The **query bindings** read from it too. `@stitchapi/query-core` derives every
cache key through the same predicate, so a registered name is redacted from the
query keys built by [React](/docs/integrations/react),
[Vue](/docs/integrations/vue), [Svelte](/docs/integrations/svelte),
[Solid](/docs/integrations/solid) and [Angular](/docs/integrations/angular), and
from [`swrKey`](/docs/integrations/swr). That reach matters more than a trace
sink's: query keys are persisted by cache providers and shown in devtools, so a
credential in one outlives the process.
```ts twoslash
import { secrets, stitch } from 'stitchapi';
secrets.register('session_ref');
const listUsers = stitch({
baseUrl: 'https://api.example.com',
path: '/users',
trace: 'console',
});
// The traced URL reads …/users?role=admin&session_ref=REDACTED
await listUsers({ query: { role: 'admin', session_ref: 'sr_live_x' } });
```
Additive and process-wide, mirroring the built-in list — a name goes in and never
comes out. Registering the same name twice is a no-op, and the match is
case-insensitive, so one lowercase registration covers `SESSION_REF` too.
`apiKey({ in: 'query', name })` registers its configured `name` at construction,
so a key the [apiKey strategy](/docs/guides/auth/api-key) puts in the URL is
covered already. This is the hook for a credential no strategy declares — one
your own code appends to a query, or one that arrives in a body field.
### secrets.has [#secretshas]
The predicate the scrubbers run, exported so you can ask it yourself: does this
key name — a query param, a body field — already redact? Audit your own config
with it before deciding whether `secrets.register` has anything left to add.
```ts twoslash
import { secrets } from 'stitchapi';
secrets.has('access_token'); // true — carries the `token` stem
secrets.has('Client_Secret'); // true — the match is case-insensitive
secrets.has('page'); // false — benign params survive for observability
secrets.has('session_ref'); // false — until you register it
```
**Anti-pattern:** don't reach for this as your *only* test of whether a
**header** is secret. It answers about query params and body keys, so
`secrets.has('authorization')` and `secrets.has('x-api-key')` both come back
`false` even though both headers are redacted. Headers get their own denylist:
at the trace-sink boundary you widen it with
[`redactHeaders`](/docs/guides/observability/trace-sinks#default-secret-redaction),
not with `secrets.register`.
Query keys and header scrubbers are where the two meet. The key builders, the
JSONL sink, the `serve` / `stitch run` stream and the public `__config` all
check the header denylist, **then** two header-only rules — a name ending in
`-key` (`api-key`, `Ocp-Apim-Subscription-Key`, `X-RapidAPI-Key`; not the
dedupe token `Idempotency-Key`) or naming a session (`x-session-id`) — **and then** fall through to `secrets.has`, so a
secret-named header (`x-auth-token`) or a name you register is redacted there
even though `secrets.has` is the wrong question to ask about a header on its
own.
### secrets.redact [#secretsredact]
Deep-clone a value and replace every secret-named key inside it with `REDACTED`,
so a payload you are about to log or forward carries no credential. It walks
plain objects and arrays, leaves primitives alone, and hands back a new value —
the input is never mutated.
```ts twoslash
import { secrets } from 'stitchapi';
const safe = secrets.redact({
id: 7,
profile: { email: 'ada@example.com', api_key: 'sk_live_x' },
});
// { id: 7, profile: { email: 'ada@example.com', api_key: 'REDACTED' } }
```
A second argument adds patterns on top of the shared denylist — an exact key
name, a dotted path, a `*` single-segment wildcard, or a prefix that claims
everything under it — for a field only this payload treats as sensitive:
```ts twoslash
import { secrets } from 'stitchapi';
secrets.redact({ note: 'kept', meta: { note: 'gone' } }, ['meta.note']);
// { note: 'kept', meta: { note: 'REDACTED' } }
```
This is what
[`.inspect({ redact: true })`](/docs/recipes/inspect-what-the-server-sent) runs
over its otherwise-unredacted `raw` body, and an array there
(`{ redact: ['meta.note'] }`) is forwarded as this second argument. Call it
directly for a value that didn't come from `.inspect()`.
## Store [#store]
### memoryStore [#memorystore]
The default in-memory `StitchStore`: single-process, with TTL and atomic
increment. Swap it for a Redis/Postgres-backed store to make throttling
distributed and sessions shared across workers, with no change to the call site.
```ts twoslash
import { memoryStore } from 'stitchapi';
const store = memoryStore();
```
## Duration, size, and rate tokens [#duration-size-and-rate-tokens]
Three grammars, one namespace each, every one a `parse`/`format` pair. A stitch
reads its own config through `parse`; the pair is exported so a peer package that
takes an authored token — a distributed limiter's rate, a command runner's buffer
cap — resolves it to the same number a stitch would, instead of mirroring the
grammar in a second regexp that then drifts from it.
`format` is the **exact** inverse, not a pretty-printer: `parse(format(v))`
returns `v` unchanged for every value `parse` can produce. That is what makes it
safe to write a token back — into a config file, a CLI flag, an error message
quoting the limit it enforced — and it is where these differ from `ms`, whose
`ms(90_000)` is `'2m'` and reads back as 120 000.
### duration [#duration]
`5_000`, `'5s'`, `'1m'` → milliseconds. This is the grammar behind every authored
duration on a stitch: `timeout.total`, `timeout.each`, `cache.ttl`,
`backoff.base`, `circuit.cooldown`, a surface `interpret` hook's `after`. A
number is already ms; `` takes `ms` | `s` | `m` | `h` | `d`, with
fractions allowed.
`duration.format` picks the largest unit that stays exact **and** readable, and
falls back to plain milliseconds when no unit divides the value cleanly.
```ts twoslash
import { duration } from 'stitchapi';
duration.parse('1.5s'); // 1500
duration.parse('2m'); // 120_000
duration.parse(5_000); // 5000 — a number is already ms
duration.parse('soon'); // undefined
duration.format(90_000); // '1.5m'
duration.format(3_600_000); // '1h'
duration.format(90_001); // '90001ms' — no unit divides it exactly
```
**Anti-pattern:** don't format a duration StitchAPI emitted into a field
something else will read — keep it a number. Every emitted duration
(`event.waited`, a result's `elapsed`, `retryAfter`) is raw milliseconds,
and that is the contract a consumer parses against. `format` is for values a
*person* reads, or for writing a token back into config; putting one in an
emitted payload turns a number every reader can use into a string each of
them has to parse.
### size [#size]
`4096`, `'64kb'`, `'1mb'` → bytes, in **powers of 1024** — the npm-`bytes`
convention every Node config parser already speaks, so `'1mb'` is `1_048_576`.
Units are `b` | `kb` | `mb` | `gb` | `tb`, case-insensitive, and the IEC
spellings (`kib`, `mib`, …) name the same values for callers who want the base
explicit. It backs the byte caps — `serve.body.max`, and `buffer.max` on
`@stitchapi/shell`.
`size.format` emits the short spelling, and prefers the base unit over an exact
but unreadable quotient — 1024 is a power of two, so almost every byte count
divides into `kb` exactly and almost none of them divide into it *legibly*.
```ts twoslash
import { size } from 'stitchapi';
size.parse('64kb'); // 65_536
size.parse('1mb'); // 1_048_576
size.parse(4096); // 4096 — a number is already bytes
size.parse('big'); // undefined
size.format(1_048_576); // '1mb'
size.format(1536); // '1.5kb'
size.format(1537); // '1537b' — '1.5009765625kb' is exact and useless
```
**Anti-pattern:** don't reach for this on a `chars` field —
[`stream.buffer.chars`](/docs/reference/surfaces#streambuffer) and
`trace.body.chars` count UTF-16 code units of decoded text, not bytes off
the socket, so a `'64kb'` token there measures the wrong thing. Those fields
take a bare number and reject a string at compile time. Worth holding onto
now that the name is `size` rather than `parseBytes` — the call site no
longer says "bytes" out loud, so the type is the only thing left warning
you.
### rate [#rate]
`'2/s'`, `'1000/h'` → `{ count, per }`, where `per` is the window length in ms.
The denominator is a `duration` token and a bare unit means one of that unit
(`'2/s'` ≡ `'2/1s'`), so this is the grammar behind
[`throttle.rate`](/docs/guides/resilience/throttle#the-rate-grammar), in-process
and store-backed alike. `rate.format` writes the pair back, using the bare-unit
spelling for a one-unit window.
```ts twoslash
import { rate } from 'stitchapi';
rate.parse('2/s'); // { count: 2, per: 1000 }
rate.parse('1000/h'); // { count: 1000, per: 3_600_000 }
rate.parse('100/15m'); // { count: 100, per: 900_000 }
rate.format({ count: 2, per: 1000 }); // '2/s'
rate.format({ count: 100, per: 900_000 }); // '100/15m'
```
A rate declares a minimum spacing rather than a bucket, so `'2/500ms'`, `'4/s'`
and `'240/m'` all describe the same limiter. `format` returns the token for the
pair it was handed rather than reducing it to one canonical member of that set —
`{ count: 2, per: 500 }` comes back as `'2/500ms'`, the numbers the author wrote.
**An unreadable token throws here, where the other two resolve to `undefined`.**
The divergence runs with their fallback rather than against it. For a cap,
falling back to the field's default leaves the ceiling where it was, so a typo
can never widen it to "unbounded". A rate has no such safe default —
`undefined` means *no rate limit at all* — so a fallback would let a typo
silently remove the limit instead of narrowing it. `'fast'`, `'0/s'`, and
`'1/30d'` (a spacing past the \~24.8-day timer ceiling) all throw, and
`rate.format` rejects the same three rather than writing a token that would
throw on the way back in.
## See also [#see-also]
# seam() (/docs/reference/seam)
Every prop `seam()` accepts, and the two handles it hands back. The option shapes
those props take — `retry`, `throttle`, `cache`, and the rest — are documented
once in [Reference → Config types](/docs/reference/config-types).
## seam() [#seam]
`seam(options?)` takes a [`SeamConfig`](#seamconfig) fragment and returns a
[`Seam`](#seam): the long-lived entity its members belong to, owning one store,
one auth vault, one throttle bucket, and one trace sink. Members come from
`.stitch()` and `.graphql()`, and inherit the seam's props as the base they
extend.
```ts twoslash
import { seam } from 'stitchapi';
import { bearer, env } from 'stitchapi/auth';
const api = seam({
baseUrl: 'https://api.example.com',
auth: bearer(env('API_TOKEN')),
retry: { attempts: 3, on: [429, 503] },
});
const getUser = api.stitch({ path: '/users/{id}', pick: 'data' });
```
## SeamConfig [#seamconfig]
### The eight keys a seam refuses [#the-eight-keys-a-seam-refuses]
`SeamConfig` is `StitchConfig` minus
the keys that are intrinsically per-endpoint: the address (`path`, `url`,
`method`, `document`) and the request and response shape (`name`, `input`,
`output`, `kind`). A member sets those. Naming one on the seam is a type error
rather than a silently-dropped field, so the type answers what belongs at the
seam on its own.
```ts twoslash
// @errors: 2353
import { seam } from 'stitchapi';
const api = seam({
baseUrl: 'https://api.example.com',
path: '/users/{id}',
});
```
### Four props name shared runtime, not a copied value [#four-props-name-shared-runtime-not-a-copied-value]
**`throttle` is one bucket, not one per member.** Every member acquires under a
single seam-stable key, so the rate and concurrency budget pools across the whole
surface; `pool: 'host'` keeps the engine's per-host key instead. A member's own
`throttle` stacks on top — it gates that one stitch tighter, and never escapes the
shared budget.
**`store` is one instance the members share**, not a copy each: the same cache
entries, throttle counters, and sessions. It defaults to an in-memory store.
**`trace` is one sink for the whole surface.** Every member's events land in it,
and `flush()` drains it.
**`vault` splits the secrets off by visibility, not by backend.** Auth tokens and
sessions live in the vault, which defaults to a reserved, redacted namespace over
`store`; point `vault` at a KMS-, Vault- or keychain-backed store to harden those
alone. Either may be distributed. It is an ordinary `StitchConfig` prop, so a
standalone `stitch()` takes it too — the seam shares one vault across its members
rather than being the only way to have one.
## Seam [#seam-1]
The root handle: the member builders, the principal boundary, and the lifecycle
over the runtime it owns.
## PrincipalSeam [#principalseam]
`seam.as(id)` binds a principal in the closure and returns a `PrincipalSeam` — the
same member builders, minus `flush`, `close`, and `invalidate`. Its members carry
the principal in their auth context, so each principal gets its own sessions while
the throttle bucket stays shared.
```ts twoslash
import { seam } from 'stitchapi';
const api = seam({ baseUrl: 'https://api.example.com' });
// One handle per request — bind the caller's identity, then create members.
const forTenant = api.as('tenant-42');
const getUser = forTenant.stitch({ path: '/users/{id}', pick: 'data' });
```
**Anti-pattern:** don't hand the root `seam` to the least-trusted caller —
it carries `close()` and `invalidate()` over the runtime every principal
shares. Pass a `seam.as(id)` handle instead: it creates members and re-binds
the principal, and has no lifecycle lever to pull.
## See also [#see-also]
# Stability (/docs/reference/stability)
Seven StitchAPI surfaces are **experimental**: they ship in the same version as everything else, but a minor release may change their shape or behaviour. Read this page before you build on one.
{/* #842 extends this page with the full compatibility policy — what counts as a breaking change, the TypeScript and Node floors, the deprecation window. Add each as a sibling `##` section. "Experimental surfaces" below is self-contained and stays as it is. */}
## Experimental surfaces [#experimental-surfaces]
### What experimental means [#what-experimental-means]
**A minor release can change it, with a migration line instead of a deprecation cycle.** A stable surface renames behind a `@deprecated` alias and drops the old name in the next major. An experimental surface changes in place, in a minor release, and the changelog lists each change with a one-line migration.
**A patch release does not, with one exception.** A patch release does not change an experimental surface's shape or behaviour, and that includes a bug fix that alters what a documented call returns: such a fix ships in a minor. The exception is a security fix: a patch may change behaviour (never shape) to close a vulnerability, makes the minimal change that does, and lists it under the release's `### Security` heading in the changelog with a one-line migration. So if you build on one, pin the package that ships it to a patch range such as `~1.0.0` and you stay on exactly what you built against, apart from security fixes, until you choose to move. For `stitchapi/llm`, `stitchapi/postmessage` and `stitchapi/pipe` that package is `stitchapi`.
**Same version, narrower promise.** An experimental surface ships at the same version as the rest — no `0.x` line, no prerelease tag — so installing it looks like installing anything else. The promise is what differs.
**The rest of the contract still applies.** Naming, typing, and config shape follow the same rules as the stable surface. The tier changes how a change ships, not what a surface may look like.
**A package's command line is part of it.** `@stitchapi/openapi` ships a CLI, `stitch-openapi`. Its flags, exit codes and output are covered by the same rules as the package's exports.
**Marked in three places, and wherever a page teaches it.** Every symbol an experimental surface exports carries an `@experimental` JSDoc tag, on every overload signature, so your editor shows it on hover. The surface's docs page — or its README, where no page exists yet — opens with an *Experimental* banner, and any other docs page whose code uses the surface carries a short note of its own. And the surface is on the list below. A stable entry point never re-exports or names an experimental symbol, so staying on stable surfaces never ties your code to an experimental shape.
### The surfaces [#the-surfaces]
| Surface | Since | Why it is experimental |
| -------------------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stitchapi/llm` | 1.0.0 | A truncated completion reads as a success ([#699](https://github.com/rejifald/StitchAPI/issues/699)), the provider contract you implement — `LlmProvider`, `LlmMessage` — is unfinished, and some config the types accept the runtime ignores ([#849](https://github.com/rejifald/StitchAPI/issues/849)). |
| `stitchapi/postmessage` | 1.0.0 | No docs-site page yet; the README and ADR 0009 describe it. |
| `stitchapi/pipe` | 1.0.0 | The combinators broadcast one input to every member ([#643](https://github.com/rejifald/StitchAPI/issues/643)). |
| `@stitchapi/openapi` | 1.0.0 | The generated code does not compile against current core ([#885](https://github.com/rejifald/StitchAPI/issues/885)), and the eject contract — manifest, ownership, generated names — is unfinished ([#876](https://github.com/rejifald/StitchAPI/issues/876)). |
| `@stitchapi/vercel-ai` | 1.0.0 | The documented usage does not type-check against `ai@6` ([#887](https://github.com/rejifald/StitchAPI/issues/887)). See the [Vercel AI SDK page](/docs/integrations/vercel-ai). |
| `@stitchapi/fingerprint-typebox` | 1.0.0 | It cannot dispatch a TypeBox 0.34 schema ([#884](https://github.com/rejifald/StitchAPI/issues/884)). |
| `@stitchapi/shell` | 1.0.0 | No docs-site page yet, and its options type accepts config the runtime ignores ([#849](https://github.com/rejifald/StitchAPI/issues/849)). |
`Since` is the version in which the surface entered the tier.
`toOpenApi` in `stitchapi/registry`, which exports a stitch as an OpenAPI document, is not on the list: it keeps the full 1.x promise. `@stitchapi/openapi`, the generator that ejects stitch source from a document, is on it — ejected code is yours, so changing what the generator writes breaks nobody who already ran it.
### Graduating and demoting [#graduating-and-demoting]
A surface graduates when its tag comes off, in a minor release. All three conditions have to hold:
1. **A dedicated docs-site page exists** for it. A README, or a note on another page, does not count.
2. **No open issue proposes a change to its public shape.**
3. **One minor release has shipped since its `Since` version** with no breaking changelog entry for it: nothing marked **BREAKING CHANGE** that names the surface or one of its exports.
The changelog records the graduation, and from that release the surface carries the full 1.x promise. The reverse direction is a major: moving a stable surface into this tier withdraws a promise already made, so it never happens in a minor.
The rule is [P26 of the API contract](https://github.com/rejifald/StitchAPI/blob/main/docs/CONTRACT.md#p26--an-experimental-tier-sits-outside-the-freeze); a lint reads its member list and fails the build if an exported symbol of a listed surface is missing its tag, or if a stable file imports one.
## See also [#see-also]
# stitch() (/docs/reference/stitch)
The authoring surface: the exports that produce a stitch. For what goes inside
the config object, see [Reference → Config types](/docs/reference/config-types).
## stitch() [#stitch]
`stitch(config)` takes a path string or a partial config and returns a
`Stitch`. Compose shared configuration via the `extends` key.
```ts twoslash
import { stitch } from 'stitchapi';
const user = stitch<{ id: string }>({
path: 'https://api.example.com/me',
});
```
## graphql() [#graphql]
`graphql(config)` is a thin wrapper over `stitch()` for GraphQL-over-HTTP: it POSTs
`{ query, variables }` and picks `data`. The config must carry a `document`.
```ts twoslash
import { graphql } from 'stitchapi';
const viewer = graphql<{ viewer: { id: string } }>({
path: 'https://api.example.com/graphql',
document: 'query { viewer { id } }',
});
```
See [Guide → graphql](/docs/guides/data/graphql) for usage.
## See also [#see-also]
# Request surfaces (/docs/reference/surfaces)
A **surface** is the request *style* a stitch speaks. `http` is the default — a
plain JSON-over-HTTP call, what most of these docs use. GraphQL, Server-Sent
Events, a raw byte stream, and a file download are **peer surfaces**: each shapes
and interprets its own request, but they all ride the same engine, so `auth`,
`retry`, `throttle`, `timeout`, validation, and the [event
stream](/docs/concepts/event-stream) compose with every one.
> A request surface is how a stitch shapes its *request*. Don't confuse it with
> the four [invocation surfaces](/docs/surfaces/function) — function, CLI, HTTP,
> MCP — which are how you *call* a stitch. The two are orthogonal: any request
> surface is reachable through any invocation surface.
Every non-`http` surface ships as its own **subpath import**, so
`import { stitch }` from the root pulls in only the `http` engine — a surface's
code (and its parser/decoder) loads only when you import it.
| Surface | Import | Shapes | `await` resolves to |
| ---------- | -------------------- | ----------------------------------------- | ------------------------------ |
| `http` | `stitch` (default) | a JSON-over-HTTP call | the validated body |
| `graphql` | `stitchapi/graphql` | POST `{ query, variables }`, pick `data` | the `data` payload |
| `sse` | `stitchapi/sse` | a `text/event-stream` reader (over fetch) | every parsed event, collected |
| `stream` | `stitchapi/stream` | a raw `ReadableStream` reader | every decoded chunk, collected |
| `download` | `stitchapi/download` | a buffered binary GET | `{ blob, filename }` |
## http — the default [#http--the-default]
`stitch(...)` with no `kind` is the `http` surface: send the request, parse JSON
when the response is JSON-ish, validate, return. Nothing to import.
```ts
import { stitch } from 'stitchapi';
const getUser = stitch('https://api.example.com/users/{id}');
```
### httpSurface [#httpsurface]
The default is a **named value, not an engine branch**. Composing a stitch
resolves an omitted `kind` to `httpSurface`, so `__config.kind` reads back
`'http'` on a plain stitch the same way it reads `'download'` on a download one —
a surface is always selected, and a consumer reading a declaration never has to
special-case an empty slot.
```ts twoslash
import { httpSurface, stitch } from 'stitchapi';
const getUser = stitch({ url: 'https://api.example.com/users/{id}' });
getUser.__config.kind; // 'http' — resolved at compose time, never absent
// The same stitch, with the default said out loud.
stitch({ kind: httpSurface, url: 'https://api.example.com/users/{id}' });
```
Importing it by name pays off in one place: a base that supplies `kind` passes
that surface down through [`extends`](/docs/guides/authoring/extends), and
`kind: httpSurface` is how a single member declares itself a plain HTTP call
against that base instead of inheriting the surface.
```ts twoslash
import { graphqlSurface, httpSurface, stitch } from 'stitchapi';
const gqlBase = { baseUrl: 'https://api.example.com', kind: graphqlSurface };
// Every other member POSTs a document; this one is an ordinary GET.
const getUser = stitch({
extends: [gqlBase],
kind: httpSurface,
path: '/users/{id}',
});
```
`graphqlSurface` is the generic spelling of the `graphql()` helper below, and it
shares the root barrel with `httpSurface`. Every other surface exports its
`*Surface` value from its own subpath — `sseSurface` from `stitchapi/sse`,
`downloadSurface` from `stitchapi/download`.
**Anti-pattern:** don't compose `httpSurface.interpret` into a surface of
your own. It is reachable, and it is the rule `sse` and `stream` fall back
to when they declare no `interpret` themselves — but it ends in "the body is
the value", which is the http surface's own choice of result rather than a
shared one. Compose
[`verdictOf`](/docs/guides/resilience/verdict#beyond-configuration) instead:
that is the caller's `verdict` config applied to a response, and it leaves
the success value to you.
## graphql [#graphql]
`graphql()` POSTs `{ query, variables }` and picks `data`; a `200` carrying
`errors[]` is treated as a failure, never silently passed.
```ts
import { graphql } from 'stitchapi/graphql';
const getThing = graphql({
baseUrl: 'https://api.example.com',
document: 'query ($id: ID) { thing(id: $id) { name } }',
});
const thing = await getThing({ variables: { id: 1 } });
```
The surface owns the body encoding, so `wire.body` is not accepted here — setting
one is a compile error. File uploads need the [GraphQL multipart request
spec](https://github.com/jaydenseric/graphql-multipart-request-spec) envelope,
which this surface does not implement; see
[Guide → GraphQL](/docs/guides/data/graphql#file-uploads-are-not-supported).
## sse [#sse]
`sse` parses the `text/event-stream` wire format over `fetch` + Web Streams —
never `EventSource`, so it keeps your `auth`, headers, and retries. Each event
becomes a `delta` chunk; `await` collects them, `.stream()` yields them live.
```ts
import { sse } from 'stitchapi/sse';
const ticks = sse({ url: 'https://api.example.com/ticks' });
for await (const ev of ticks.stream()) {
if (ev.type === 'delta') handle(ev.chunk); // a parsed SseEvent
}
```
`data` is JSON-parsed when it parses, else kept as the raw string:
### Resumable SSE — sse.reconnect [#resumable-sse--ssereconnect]
**Off by default**: with no `sse.reconnect` the engine opens the body exactly once. Turn
it on and a dropped stream is reopened, replaying the last seen `id:` as the
`Last-Event-ID` request header so the stream continues from where it broke.
```ts
const ticks = sse({
url: 'https://api.example.com/ticks',
sse: { reconnect: { attempts: 5, delay: '2s' } },
});
```
`reconnect: true` is shorthand for the defaults; the object form tunes the cap and the
fallback delay. A server-sent `retry:` on the dropped connection always wins over both.
#### It needs a feed that emits `id:` [#it-needs-a-feed-that-emits-id]
Two things must hold before a body is reopened, and both are properties of the stream
rather than of the flag:
* **It has to have dropped.** A body that ran out is a stream that *finished* — it is
never reopened. Only a transport failure mid-flight counts.
* **It has to be resumable.** The reconnect replays the last `id:` as `Last-Event-ID`, so
a feed that never sends `id:` has no resume point. Reopening it could only re-request
the whole body, so it is not reopened either.
That second point rules out OpenAI-shaped completions — `data: {…}` frames with no `id:`,
terminated by a `[DONE]` sentinel. Turning `reconnect` on for one of those does nothing;
it will not replay the completion. Use it for feeds that emit `id:` and honour
`Last-Event-ID` — ticks, change feeds, event logs.
## stream [#stream]
`stream` is the raw streaming sibling — it hands back the response body decoded
per `stream.decode`: `'bytes'` (the default, lossless `Uint8Array` chunks),
`'lines'` (UTF-8 lines), or `'ndjson'` (a parsed value per line). The bare
decoder is shorthand for the object form — `stream: 'ndjson'` ≡
`stream: { decode: 'ndjson' }` (the opaque `stream: {}` is rejected).
```ts
import { stream } from 'stitchapi/stream';
const logs = stream({
url: 'https://api.example.com/logs',
stream: 'ndjson',
});
for await (const ev of logs.stream()) {
if (ev.type === 'delta') console.log(ev.chunk); // one parsed JSON value per line
}
```
### stream.buffer [#streambuffer]
Bounds what the decoder may hold for a single un-terminated unit — an unclosed JSON
value, a line with no `\n`, or an SSE frame with no blank line — so a never-closing
stream can't grow client memory without limit. The scalar is shorthand for the dominant
field: `buffer: 4_000_000` ≡ `buffer: { chars: 4_000_000 }`.
The cap counts characters of the **decoded text**, not bytes off the socket, so it is not
meaningful for `decode: 'bytes'` (raw and unbuffered).
Streaming members open on the same auth/retry/throttle spine, with one twist:
opening a stream **charges the rate limiter once** but **never holds a
concurrency slot**, so a long-lived connection can't pin a [seam](/docs/concepts/the-seam)'s
concurrency budget.
## Validating streamed values [#validating-streamed-values]
A streaming surface has no single response body, so an `output` contract
validates **each `delta` before it is emitted** — never the collected array. A
value that fails the schema fails the stream and is never delivered; soft
drift (a coerced or undeclared field) surfaces as a leveled `drift` finding
and the value still flows, so a `.stream()` consumer only ever sees values
that passed the contract.
```ts
import { stream } from 'stitchapi/stream';
import { z } from 'zod';
const logs = stream({
url: 'https://api.example.com/logs',
stream: { decode: 'ndjson' },
output: z.object({ level: z.string(), msg: z.string() }), // checked per record
});
```
For `sse` the contract describes the event's `data` payload, not the
`{ event, data, id, retry }` envelope. `transform` and `pick` reshape a whole
buffered body and aren't applied to the delta path.
## download [#download]
`download` fetches a file: a `GET` buffered into a `Blob`, with byte progress,
a `Content-Disposition` filename (with a URL-last-segment fallback), and a
per-call `AbortSignal`. It **never writes to disk** — saving the `Blob` is your
call, which keeps it browser-first.
```ts
import { download } from 'stitchapi/download';
const getReport = download({ url: 'https://api.example.com/report.pdf' });
const { blob, filename } = await getReport({
signal: controller.signal,
onProgress: (p) => console.log(p.loaded, '/', p.total),
});
```
### onProgress [#onprogress]
The per-call `onProgress` callback above receives one of these per tick. It is per-call,
not config — a different bar per download:
Which phases report depends on the adapter — see
[Guide → Adapters](/docs/guides/transport/adapters).
The surface owns the request shape, so `method` and `wire.response` are not
accepted here — setting either is a compile error rather than a silently ignored
value. To download the result of a `POST`, use a plain
[`stitch()`](/docs/guides/authoring/stitch) with `wire: { response: 'blob' }`;
the only thing you give up is the `Content-Disposition` filename parsing.
```ts
// ✗ dead config — the surface always issues a GET and always reads a Blob
download({ url, method: 'POST' });
// ✓ the same request, on the surface that actually honours it
stitch({ url, method: 'POST', wire: { response: 'blob' } });
```
Only that one member of the envelope is closed: `wire.array` and the rest stay
authorable on a download stitch.
## Both spellings, one engine [#both-spellings-one-engine]
Every surface has two co-existing spellings. The monomorphic helper above
(`sse(...)`, `download(...)`, …) is pre-bound to its surface and gives
surface-specific types. The generic form passes the surface as `kind` on the
core `stitch`, so a `seam` can mint members of any surface without forking:
```ts
import { seam, stitch } from 'stitchapi';
import { downloadSurface } from 'stitchapi/download';
const file = stitch({ kind: downloadSurface, url: 'https://x/report.pdf' });
const api = seam({ baseUrl: 'https://api.example.com' });
const member = api.stitch({ kind: downloadSurface, path: '/report.pdf' });
```
Either way the surface is named in `__config` by a stable string `id`
(`'sse'`, `'download'`, …), so a stitch's declaration still round-trips to JSON
for MCP, the playground, and `llms.txt` — the behaviour hooks stay a runtime
detail. An `id` a consumer doesn't recognise degrades to "an http-shaped call of
unknown style", never a crash.
## See also [#see-also]
# The agent picks the arguments (/docs/scenarios/agent-holds-the-tool)
## The problem [#the-problem]
You already call a vendor API from your code. Now an agent needs to call it too, over MCP. The
model picks **which** call and **what arguments** — from a prompt that may contain text you did
not write.
That inverts the usual trust story. An agent tool is an API exposed to an untrusted caller, but
it is almost always written as if it were an internal function. Three hazards, all measured in
the wild:
* **The credential must never reach the model.** A survey of over 10,000 public MCP servers
found credentials, keys and PII leaking at rates
[exceeding 10%](https://checkmarx.com/learn/mcp-security-risks-real-world-incidents-and-security-controls/).
A token in a schema, an argument, a result or an error message is a token in the model's
context — and therefore in its output, its logs, and any downstream tool it calls.
* **The arguments are attacker-influenced.** A scan of popular MCP servers found 43% with
command-injection flaws, 22% allowing path traversal and 30% exploitable via SSRF. The input
schema stops being ergonomics and becomes the security boundary.
* **The loop is the cost.** One agent scanning a network reached a
[$6,531 bill](https://www.nexgismo.com/blog/ai-agent-budget-guards-stop-runaway-api-costs) in
days with no hard limits.
## The common solutions [#the-common-solutions]
| Approach | What it is | Where it breaks |
| -------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| **One MCP server per vendor** | Hand-write a server wrapping the API. | Full control — and you write the auth, validation and limits yourself. That is what the >10% leak rate is measuring. |
| **One tool per endpoint** | Narrow, typed tools the model picks between. | The safest shape: the schema *is* the allow-list. Costs a tool definition per endpoint, and a lot of context. |
| **One generic "run it" tool** | The model names the call and passes arguments. | Compact, and far more dangerous: the argument object becomes the attack surface. |
| **Gateway in front** | Policy, quotas and egress rules outside the app. | The enterprise answer. Another hop, and it cannot see intent. |
| **Human confirmation on writes** | Ask before anything irreversible. | The one control that survives prompt injection. Needs a place to hook it. |
## What StitchAPI does [#what-stitchapi-does]
`stitch mcp` is **code-mode**: three generic tools — `run_stitch`, `list_stitches`,
`describe_stitch` — rather than one tool per endpoint. That is the compact-and-dangerous row of
the table above, so the two boundaries are worth separating, because they landed differently.
### The credential boundary held [#the-credential-boundary-held]
This is the product's central promise and it survived the sharpest test available. Across **34
JSON-RPC exchanges and 30 payload scans (14,529 bytes)** — `initialize`, `tools/list`,
`describe_stitch` on all ten stitches, successful calls on `bearer`, `apiKey` in header, query
and cookie form, `cookieSession`, a vendor 401 whose **body contained a credential-shaped
string**, a validation failure, an unknown stitch, an unknown tool, a malformed JSON-RPC method,
and the same run over stdio — **not one of the five held credentials appeared, by value,
anywhere.**
The controls confirm the calls were real: the same exchanges put `Bearer sk_live_…`,
`X-API-Key: ak_live_…`, `api_key=ak_live_…` and `Cookie: SESSION=sess_live_…` on the wire, and
the vendor authenticated every one.
**And the model cannot forge a header at all.** `sanitizeAgentInput` deletes `input.headers`
unless the stitch explicitly declares an `input.headers` schema: six model-supplied headers
including `authorization`, `cookie` and `host` reached the vendor as **zero** headers. Even
where an operator opts in, the credential header specifically is unforgeable, because `auth` is
applied to a clone *after* the merge — a model-set `authorization` was overwritten with the real
token on every attempt.
This refuted our own starting hypothesis. `engine.ts` does merge input
headers over config headers — but on the MCP path the agent's headers are
removed before that merge ever runs.
**One leak sat at the edge of the boundary, and it is now closed.** When this audit ran,
`run_stitch` passed an error message through unfiltered, so text the *transport* wrote reached
the model verbatim. With `apiKey({ in: 'query' })` on the default adapter, a mistyped port put
`…/v1/metrics?api_key=ak_live_…` into the model's context, from zero lines of user code. We filed
it as [#866](https://github.com/rejifald/StitchAPI/issues/866). Every URL in an MCP error message
now loses its userinfo and its secret query values, so the model reads `api_key=REDACTED`. The
same text reached the model through `@stitchapi/vercel-ai` too, so
[#890](https://github.com/rejifald/StitchAPI/issues/890) moved the scrub to where the engine turns a
transport's throw into the error, and every surface reads the scrubbed message. A key
in a header is still the better placement: the
[auth guide](/docs/reference/auth-strategies) explains why a key in a URL leaks wherever URLs go.
### The argument boundary is yours [#the-argument-boundary-is-yours]
Everything else in `input` reaches the request. Five levers, measured on an **ordinary stitch** —
one that declares no input schemas:
| Lever | Measured |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A query parameter pinned in the path is a *default*** | `path: '/v1/orders?tenant=acme'` → the model sent `tenant=globex`, **and the vendor returned the other tenant's data** |
| **The whole request body of a write**, when no `body` schema | a 999,999 refund |
| **Reserved expansion traverses endpoints** | `{+id}` reached `/v1/api-keys` **with the bearer token attached**; ordinary `{id}` correctly encoded it to `..%2F..%2F` |
| **A templated endpoint reaches anywhere** | `url: '{+endpoint}'` → `https://metadata.internal/latest` |
| **`cookieSession` joined where `apiKey` replaced** | with headers opted in: `SESSION=attacker; SESSION=sess_live_…`. Fixed in [#866](https://github.com/rejifald/StitchAPI/issues/866): the session pair now replaces the forged one |
The first row is the sharpest, because `?tenant=acme` in a configured path reads like an
operator invariant and is spelled like one. It is `{ ...predefined, ...input.query }`.
**A declared input schema now closes it.** When this audit first ran, `validateInput` threw on
failure but **discarded the parsed value** — a schema was a check, not a filter, and a `query`
validator that returned `{ limit: 10 }` still put `?tenant=globex&limit=10` on the wire. We filed
that as [#648](https://github.com/rejifald/StitchAPI/issues/648);
[#663](https://github.com/rejifald/StitchAPI/pull/663) fixed it. A declared slot now sends **what
its schema returns** — coerced, defaulted, stripped — so the same validator puts
`?tenant=acme&limit=10` on the wire: the model's `tenant=globex` gone, the operator's pin
restored. Our probe keeps that pinned as a regression check.
The surviving caveat is the real security point: **a slot with no schema is untouched.** A schema
filters the one slot it is declared on, and an undeclared slot stays the full passthrough the
table above measures — declaring `params` says nothing about `query`, and nothing requires a slot
to be declared.
### The safe exposure is 40 lines across 3 seams [#the-safe-exposure-is-40-lines-across-3-seams]
No fork, no config key. Replayed against the same vendor, the naive exposure sent
`?tenant=globex`, `?include=internal_notes` and a 999,999 refund; the safe one sent
`?tenant=acme&limit=5` and nothing else, refused the POST with a reason the model can read, and
still authenticated every read.
```ts
const server = createMcpServer(
expose({ getOrder: only(getOrder, { params: ['id'], query: ['limit'] }) }),
{ adapter: readsOnly(fetchAdapter) },
);
```
* **`expose`** — the registry object you hand `createMcpServer` *is* the allow-list. When this
audit ran it also had to reject a stitch whose configured `name` differed from its key, because
`selectStitch` fell back to that name: a renamed stitch stayed callable while vanishing from
`list_stitches`. Since [#866](https://github.com/rejifald/StitchAPI/issues/866) a call resolves
the registry's own keys only, so a rename really hides the old name.
* **`only`** — a `Proxy` apply-trap that **rebuilds** the input from an explicit key list before
the engine sees it. Since [#663](https://github.com/rejifald/StitchAPI/pull/663) a declared
schema filters its own slot, so `only` is no longer forced — it earns its place covering the
slots a stitch leaves undeclared, in one line, and as defence in depth on the rest.
* **`readsOnly`** — an `Adapter` wrapper, the last seam before the transport, refusing non-GET.
`throttle` and `circuit` both apply on the MCP path, because `run_stitch` calls the stitch and
the stitch *is* the engine: `throttle: '50/s'` paced ten tool calls; `circuit: { failures: 3 }`
turned twenty tool calls into three vendor requests and seventeen fast-fails.
## What StitchAPI does not solve [#what-stitchapi-does-not-solve]
1. **There is no confirmation seam, in either direction.** The server advertises only
`capabilities: { tools }` and cannot originate a message, so it cannot ask. Since
[#866](https://github.com/rejifald/StitchAPI/issues/866) the tools carry MCP `annotations`,
and `list_stitches`/`describe_stitch` are marked read-only. But code-mode puts every endpoint
behind one tool name, so `run_stitch` has to be annotated as a possible write
(`readOnlyHint: false`, `destructiveHint: true`). Reading an order and issuing a 25,000 refund
still arrive at the host as the same `run_stitch` call. User code can **refuse**
(`hooks.onRequest` throws; the vendor got zero requests, and `retry: { attempts: 3 }` asked
the gate exactly once) but never **ask**.
2. **One tool call is not one request.** `retry: { attempts: 5 }` made five; `paginate` made
twelve, with a default ceiling of 50 — and neither is signalled in the tool result. A host
that budgets 20 tool calls has budgeted up to 1,000 vendor requests.
3. **No bound expresses cost.** Every limiter is a count (`throttle.rate`, `retry.attempts`,
`paginate.pages`, `circuit.failures`) or a duration (`timeout`, `circuit.cooldown`). Nothing
expresses tokens, bytes or money — the axis the runaway-bill incidents actually ran along.
4. **`describe_stitch` is a map.** Per stitch it discloses the full internal endpoint URL, the
surface, the auth *scheme*, which resilience features are on, and a Mermaid diagram — about
1 KB. Not the credential, not your configured request headers, not the env var name. Whether
that is disclosure or documentation depends on who is connected.
5. **`stitch mcp --module ./stitches.ts` exposes everything.** `collectStitches` sweeps up every
exported stitch — in our fixture, a write and a login stitch alongside the intended read. The
registry filter is a real seam, and the [MCP surface](/docs/surfaces/mcp) page now warns that
`--module` does not filter, but the documented starter still does not use it.
Nothing in `StitchConfig`'s 32 top-level slots excludes a stitch from MCP.
The nearest-looking key, `sensitive: true`, is a **cache** opt-out — a
stitch carrying it was still listed and still ran.
## See also [#see-also]
* [The MCP surface](/docs/surfaces/mcp) — the three tools and the registry
* [Auth strategies](/docs/reference/auth-strategies) — why `{ in: 'header' }` beats `{ in: 'query' }`
* [Scenario: the webhook you cannot verify](/docs/scenarios/webhook-receipt) — the other surface
where the caller is untrusted
* [Scenario: one tenant takes the others down](/docs/scenarios/multi-tenant-blast-radius) —
keying limiters, which is the containment half of this problem
# Submit, poll, download — the async job triangle (/docs/scenarios/async-job-polling)
## The problem [#the-problem]
You ask for something slow — a Salesforce Bulk export, a report render, a transcode. The API
answers **`202 Accepted`** with a `Location`, and you come back later. That is the
[asynchronous request–reply pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/asynchronous-request-reply),
and it is not one call but three endpoints with a loop between them:
1. `POST /jobs` → `202`, `Location: /jobs/{id}`, often `Retry-After`
2. `GET /jobs/{id}` → repeatedly, until the state is terminal
3. `GET ` → the payload, often a pre-signed link that expires or is single-use
Each step brings its own difficulty. **The next URL is in a response header**, not the body.
**Terminal state is in-band** — Salesforce runs `InProgress → JobComplete | Failed`, all at
HTTP 200, so failure arrives as a successful response. **The wait is the server's to set**,
via `Retry-After`, over minutes to hours. **The budget spans the whole triangle**, not one
call. And **a restart loses the job**, which is still running server-side — resubmitting
duplicates hours of work.
The tell that this has no easy answer: in
[jsforce#298](https://github.com/jsforce/jsforce/issues/298) the poll
timeout is **hardcoded**, and the documented workaround is to turn the
helper off and write the loop yourself. Same request in
[go-salesforce#139](https://github.com/k-capehart/go-salesforce/issues/139)
and
[salesforcer#13](https://github.com/StevenMMortimer/salesforcer/issues/13).
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------- |
| The SDK's built-in waiter | Hardcoded timeout. Fine until the job is big, then unfixable without abandoning the helper. |
| Hand-rolled `while` + `sleep` | Correct and universal. Timeout, circuit breaking and tracing see three unrelated calls, not one operation. |
| Fixed-interval polling | Hammers the API for hour-long jobs and ignores `Retry-After`. |
| Exponential backoff | The right default with no `Retry-After` — but uncapped, the last gap overshoots the finish by minutes. |
| Webhook callback | Strictly better where offered, and *additional* work: you still need a fallback poll for missed deliveries. |
| Queue + separate worker | The production answer for hour-long jobs, and the only one that survives a restart. Costs infrastructure. |
## What StitchAPI does [#what-stitchapi-does]
There is no poll primitive. Polling is spelled as **retry, where the failure is "not done
yet"** — a custom [surface](/docs/reference/surfaces) whose `interpret` reads the in-band
state:
```ts
export function jobPollSurface(clock: Clock): Surface {
return {
id: 'job-poll',
interpret: (res, cfg) => {
// verdictOf FIRST, or a 404 comes back as a successful poll.
const failed = verdictOf(res, cfg);
if (failed) return failed;
const state = stateOf(res.body);
if (state === 'InProgress') {
const after = retryAfterMs(res.headers['retry-after'], clock);
return after === undefined
? { ok: false, retry: true, message: 'InProgress' } // capped expo fallback
: { ok: false, retry: true, message: 'InProgress', after };
}
if (state === 'Failed')
return {
ok: false,
message: `job failed: ${errorMessageOf(res.body)}`,
};
return { ok: true, data: res.body };
},
};
}
```
Three stitches then run under [`linked`](/docs/reference/helpers), which chains them into one
trace, with a caller-owned `AbortSignal` as the operation deadline.
**Measured:** 1 submit → 5 polls at the server's own 300 s pacing → 1 download, 20 virtual
minutes, `Failed` terminating on the first terminal body with 17 of 20 poll attempts unspent.
`linked` produced **one traceId across three spans**, each parented to the last. A crash
mid-poll resumed after 3 polls with **1 submit total**.
## The trade you have to make [#the-trade-you-have-to-make]
This is the part worth knowing before you start. There are two constructions, and **you can
have one deadline over the whole triangle, or per-hop retry policies — not both.**
| | One stitch (hook rewrites the URL) | Three stitches under `linked` |
| -------------------------- | ----------------------------------------------------- | -------------------------------------------------- |
| Deadline over the triangle | **`timeout.total`** — measured 253 ms | caller-owned `AbortSignal` on every `input.signal` |
| Per-hop retry policy | **no** — one stitch is one `retry` block | **yes** — 20 poll attempts, 1 download attempt |
| Single-use result URL | burned all 8 shared attempts on the dead link | download retried exactly once |
| Trace | 1 span, `attempts: 5` — the three endpoints invisible | 3 spans, `submit → poll → download` |
| Concurrency | **unsafe** — see below | safe |
The one-stitch form buys the config-level deadline and loses the per-hop split; `linked` keeps
both of those and replaces the deadline with a signal you own.
## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution]
The hand-rolled `while` loop produces a **byte-identical** request sequence and pacing, in
**49 lines against 110**. What the extra lines buy was measured, not asserted: one `start` and
one `done` per hop with the polls folded in as `attempts: 3` rather than three unrelated calls,
one traceId chaining `job-submit → job-poll → job-download`, and a per-hop retry policy. All of
the *semantics* — the state machine, the pacing, the resume — are still yours either way.
If you don't need the trace or the per-hop policies, the `while` loop is the honest answer.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **No poll or until primitive.** Nothing in the config vocabulary waits for a state.
2. **`retry.respect` does not reach the body-driven path.** With the server asking for 30 s,
the measured gaps were **7 ms** — the computed backoff. The engine reads `Retry-After` only
on the status-driven path; a surface must pick the header up itself.
3. **`parseRetryAfter` is not exported.** The HTTP-date form has to be re-implemented by every
surface author, and if you don't, the server's pacing is discarded silently.
4. **No operation-scoped deadline field.** `linked` takes a body and nothing else. The budget
is an `AbortSignal` you build and thread through every `input.signal`; when it fires, the
rejection carries your own abort `reason`.
5. **No operation-level span.** `linked` emits nothing of its own, so a mid-operation failure
is attributable to the *step*, never to "the export failed".
6. **Nothing persists the job id.** The store is engine state — throttle, auth, cache. Resume
is entirely yours.
7. **Every failure arrives as `StitchError`, and only the deadline is typed underneath.** The
engine's live `TimeoutError` rides `error.cause` (the class is unexported — check
`cause?.constructor.name`), so "deadline fired" is structurally separable. "Poll budget
exhausted" and "the job failed" carry no cause, `status` is `undefined` on all three, and
telling those two apart is still string matching.
8. **`paginate` cannot do this** — and not for the reason you'd guess. It *does* loop (the
default `items` wraps a non-array body as one item, so the empty-page break never fires),
but it cannot **wait**: measured gaps of `0, 0, 0`, with no delay field. A paginated poll
also cannot fail — `Failed` is aggregated as just another value.
**Two concurrency traps, both measured.** A stitch that rewrites its own URL
in a hook is **not safe to call twice at once**: two concurrent calls
submitted two jobs, polled the second one twice, and handed *both* callers
the second job's result — the first job ran to completion, unread. And a
poll surface without a `hooks.onRequest` guard **re-submits**: 4 POSTs, 4
duplicate jobs, under `attempts: 4`. Build one stitch per in-flight job.
## See also [#see-also]
# Batch writes that fail one item at a time (/docs/scenarios/batch-partial-failure)
## The problem [#the-problem]
You write in bulk — DynamoDB `BatchWriteItem`, Elasticsearch `_bulk`, SQS `SendMessageBatch`,
Salesforce sObject Collections. One request carries 25 or 1,000 items, the API answers **HTTP
200**, and inside the body it says some of them didn't land.
**The retry unit is smaller than the request.** Every HTTP client retries by replaying the
identical request, which here re-applies the writes that already succeeded. The correct
behaviour is to rewrite the body to the failed subset, resend, wait longer each round, and
report whatever never landed.
Three things make it worse than it sounds: backoff is **mandatory** (AWS is explicit — the
cause is capacity, so an immediate resend throttles again), failures are **not uniform**
(a `429` item should be resent, a `400` mapping error must not be), and the loop needs to
hand back **the residue** — the caller needs the items, not just an error.
This is the failure mode behind
[`elastic/logstash#1631`](https://github.com/elastic/logstash/issues/1631) —
"rejected docs in bulk indexing partial failure are **silently lost**" — and
[`elasticsearch-py#1004`](https://github.com/elastic/elasticsearch-py/issues/1004),
where errors are aggregated *without their data*, so you cannot tell which
items to resend.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| The client's built-in retry | Replays **all** items. Fixes 7 by re-writing 93 — duplicate side effects on a non-idempotent endpoint. |
| Hand-rolled `while` loop | Correct, and what most teams write. Lives outside the client, so timeout, circuit breaking and tracing stop seeing the real call. |
| Vendor SDK helper | Only where an SDK exists, and the policy is theirs — `streaming_bulk` retries `429` only, and drops the failed items' data. |
| Check the status, move on | The Logstash bug. Silent data loss, found later by absence. |
| One request per item | Trivially retryable, at 100× the requests the batch endpoint existed to avoid. |
## What StitchAPI does [#what-stitchapi-does]
**Not `paginate`, however much it looks like the answer.** `paginate.next`
genuinely expresses the residue resend — measured: 3 requests for 6 items,
**zero** duplicate writes. Then it loses your data. A round in which
*nothing* lands aggregates zero items, and the loop treats that as the end:
measured **4 of 6 rows never written, `ok: true`, no error**. That is the
ordinary response from a table that is out of capacity. Hitting the `pages`
cap also returns `ok: true`, so "finished" and "gave up" are the same value
— and a residue ledger built inside `next` is stale by one round, naming an
item that already landed.
The seam that works is `Surface.interpret` — which reads the 200 body and asks for another
attempt with a wait that grows — paired with **`hooks.onRequest`, the only place in the library
that can change a request between attempts**:
```ts
const kind: Surface = {
id: 'batch-residue',
interpret: (res, cfg): SurfaceOutcome => {
// A 500 is a transport failure before it is a batch envelope — and must still
// open the circuit. Let the declarative verdict compose first.
const failed = verdictOf(res, cfg);
if (failed) return failed;
ledger.rounds += 1;
ledger.landed.push(...landedOf(res.body));
ledger.terminal.push(...terminalOf(res.body)); // 400s — never resent
ledger.residue = residueOf(res.body); // 429s / UnprocessedItems
if (ledger.residue.length === 0) return { ok: true, data: ledger };
if (ledger.rounds >= rounds) {
// Out of rounds. Resolve SUCCESSFULLY with the residue in the payload —
// an error would throw the landed items away, and dropping it is the Logstash bug.
ledger.gaveUp = true;
return { ok: true, data: ledger };
}
// `message` is required on the retry arm — it becomes the `retry`
// event's detail, so the trace says WHY the round is being re-run.
return {
ok: false,
retry: true,
message: `${ledger.residue.length} unprocessed after round ${ledger.rounds}`,
after: backoff(ledger.rounds),
};
},
};
const hooks = {
onRequest: (ctx) => {
if (!ctx.req || ctx.attempt === 1) return;
// Assign, never mutate: each attempt's request is a shallow clone of one baseReq,
// so an in-place edit of `body` rewrites the caller's own array too.
ctx.req.body = bodyOf(ledger.residue);
},
};
```
**Measured** against a capacity-limited table: 4 rounds at t = 0, 1000, 3000, 7000 —
`abcdef → cdef → def → f` — **zero duplicate writes**, every item landed, and the wait is
the engine's own sleep rather than a hidden one. It survives three consecutive zero-progress
rounds, which is precisely the case `paginate` drops.
## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution]
**It is more code, not less** — 50 lines against 28 for the hand-rolled `while` loop. The
trade is what the hand-rolled loop gives up, and this was measured rather than assumed:
| | hand-rolled loop | on the surface seam |
| --------------------------------- | --------------------- | ------------------------------------------------- |
| `start` events for one logical op | 3 | **1** |
| reported `attempts` | 1, three times over | **3** |
| `retry` events | 0 | **2**, with detail |
| circuit breaker | never sees the rounds | **opens after 2 × 500** |
| `timeout: { total: 100 }` | bounds each round | **bounds the operation** — cut at round 2, 101 ms |
If you don't need any of that, the `while` loop is honestly the smaller answer. Reach for this
when the batch call has to behave like one call to everything else in your system.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **There is no batch-residue concept.** Nothing in the config vocabulary expresses "the retry
unit is smaller than the request". Everything above is assembled.
2. **`retry` is blind to per-item failure, and harmful when forced.** The status is 200, and
`retry.on` receives only the status. `retry.on: 200` typechecks, fires — and made **10
duplicate writes on a batch that never failed at all**.
3. **`paginate` has no wait and no field to declare one.** Six rounds fired at t=0. `throttle`
paces them, but as one fixed ratio with no curve — and it paces every other call through
that stitch (`pool: 'host'` pushes unrelated reads too).
4. **A growing backoff is user code the engine can't see.** A sleep in `onRequest` works, but
2.5 s of real waiting produced **0 `throttled` events** and no `waited` in the run report.
5. **`SurfaceOutcome` cannot see the request or the attempt number**, so a surface can neither
rewrite what it retries nor know it is on its last round. The count has to be kept by hand.
6. **The engine owns no residue channel.** Not the result, the error, `.inspect()`, `.report()`,
the event stream, or a trace sink. If you don't capture it yourself, it is gone.
7. **The ledger is per-stitch, not per-call.** Two concurrent calls through one such stitch
corrupt each other — measured: both callers told everything landed, two rows written by
nobody. Build one stitch per in-flight batch, or add your own scoping.
## See also [#see-also]
# The free poll — ETag revalidation and the bodyless 304 (/docs/scenarios/conditional-requests-304)
## The problem [#the-problem]
You poll for changes and nothing has changed. HTTP has a way to make that free: keep the
`ETag`, send it back as `If-None-Match`, and the server answers **`304 Not Modified`** with no
body. On GitHub a 304 **doesn't count against your primary rate limit** — 600 polls, 90%
unchanged, cost 60 requests.
The awkwardness is that a 304 is a *status* meaning "use what you have", and both obvious
readings are wrong. Treat it as a failure and every unchanged poll is an error; treat it as a
success and the caller gets `undefined` where the resource should be. The only correct
behaviour is to **substitute the previously cached body**, which means the cache and the
request path have to know about each other.
Four constraints most clients get wrong:
* **ETags are per-credential.** GitHub caches them per token. A store keyed only by URL will
replay one principal's validator for another.
* **ETags are per-page, not per-collection.** A 304 on page 1 of 5 says nothing about 2–5.
* **Weak validators compare weakly.** `W/"abc"` and `"abc"` are not interchangeable, and the
comparison is the *server's* to make — so the client must not normalize the tag.
* **Some servers never match.** Apache's default ETag embeds the file inode, so behind a load
balancer revalidation never succeeds and the feature silently does nothing.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| TTL cache only | Never revalidates. Every refresh is billed **and** the data is stale up to the TTL — you pay full price for staleness. |
| Hand-rolled ETag store | Correct, and what most teams write. Easy to key wrong; the swap must happen below parsing and validation. |
| An HTTP caching proxy | The most standards-correct answer. Adds a dependency or a hop. |
| Treat 304 as an error and retry | Actively wrong — turns the success case into an error and re-sends the same validator. |
| Ignore conditional requests | What most integrations do. On GitHub it costs 10× the rate-limit budget for identical data. |
## What StitchAPI does [#what-stitchapi-does]
There is no conditional-request feature — `If-None-Match` and `304` appear nowhere in the core.
What there is, is the one seam that owns a request *and its own response* in a single function:
**`Surface.execute`**. It is a one-argument `Adapter` the engine calls *in place of* the
transport — a surface with `execute` ignores `StitchConfig.adapter` — so the real transport is
handed to the surface's factory and closed over (`transport` below), next to the store.
```ts
execute: async (req) => {
const key = `${req.method} ${req.url} ${credentialOf(req.headers)}`; // per-credential, see below
const cached = store.get(key);
if (cached) req.headers['If-None-Match'] = cached.etag; // byte-exact, never normalized
else clearValidator(req.headers); // a re-attempt must be able to DROP it
const res = await transport(req);
if (res.status === 304 && cached) return { ...res, body: cached.body };
const etag = res.headers['etag'];
if (res.status === 200 && etag) store.set(key, { etag, body: res.body });
return res;
},
```
The `status === 200` gate on the write is load-bearing. Servers mint ETags on responses you
must not learn from — Express fingerprints error bodies too, and CDNs validate error objects —
and without the gate one ETagged error writes `{ etag, body: error }` into the store, which
every later 304 then serves as `ok: true` data. The full 87-line version this abridges adds
the rule the gate can't cover: a 304 with **no** stored body (an orphaned validator — a
restarted process, an evicted entry) is refetched unconditionally instead of returned bare,
which would rebuild the empty-304 trap by hand. It also bounds the store and keeps the
counters (`revalidated` / `stored` / `orphans` / `unvalidatable`) the callout below asserts on.
It needs **no custom `interpret`**. The substituted body rides back on a response whose status
is still 304, and `classifyStatus` only fails at `>= 400` — so `.inspect().status` honestly
reports 304 while `.data` is the resource.
**Measured**, ten polls at one-minute intervals with the resource changing once before poll 6:
| | billed | versions seen |
| ------------------ | ------ | ----------------------------------------------------------- |
| no caching | 10/10 | correct, never stale |
| TTL cache (30 min) | 1/10 | `[1,1,1,1,1,1,1,1,1,1]` — **never sees the change** |
| revalidation | 2/10 | `[1,1,1,1,1,2,2,2,2,2]` — picked up on the poll it happened |
Eight of ten polls became free with **zero** staleness. That middle row is the argument: a TTL
cache is cheaper *and* wrong, serving a superseded version on 5 of 10 polls.
`interpret` **does** run on a 304 — measured with a counter across `[200,
304, 404]`. The engine interprets every response, not just 2xx. (The
"`interpret` never runs" finding on [the streaming
page](/docs/scenarios/mid-stream-failure) is specific to streams.) So
`Surface.interpret` + `hooks.onRequest` is a valid second route; `execute`
is just fewer moving parts.
## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution]
**The StitchAPI version is longer — 87 lines against 79** for a feature-matched hand-rolled
twin, and both produce identical versions, statuses and billed counts on all four shapes
(quiet, changed, weak validators, never-matching). So the extra lines are not buying
behaviour.
They attribute exactly: `credentialOf` and `clearValidator`, two helpers that exist only
because the engine hands a surface a shared header record it never case-folds. What the
hand-rolled twin lacks is everything that stayed *configuration* on the stitch — `auth`,
`output`, `retry`, `timeout`, `seam.as()` and the trace spine. Every one of those would have to
be written into the hand-rolled file to match.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **`cache` cannot revalidate — at all.** It is a *value* store, not a response store: the
entry is `{ v, s, vary }` and what gets written is the post-`interpret`, post-validation
value. No response header, and therefore no ETag, can reach it.
2. **`revalidateOnHit` is a false friend.** It re-checks the stored value against the `output`
**schema**, never the network.
3. **A cache hit short-circuits everything below it.** Measured over 3 calls: hooks fired once,
`interpret` ran once, one request. The hit spine is `[start, cache:hit, result, done]` — no
`request` phase at all — so revalidation can't run under a hit.
4. **The cache cannot store a 304 either.** Forced into its own key, three conditional calls
measured `[undefined, undefined, undefined]` — a stored `undefined` reads as a permanent miss.
5. **The one workaround turns caching off.** Folding the ETag into the value via `transform`
makes the stitch un-fingerprintable, so it fails closed and refuses to cache.
6. **A surface cannot see the bound principal.** `ResolvedStitchConfig` has no `principal`, and
`buildRequest` runs *before* `auth.apply` — so it can't even read the credential header. Only
`hooks.onRequest` and `Surface.execute` are downstream of auth.
7. **`buildRequest` runs once per run, not per attempt.** A validator set there is baked into
every retry — measured 3 identical validators across 3 attempts and a failed run.
8. **Adding an `output` schema to a bare conditional poll breaks it.** The empty 304 body fails
the contract: `ok: false`, `contract violation (drift)`. With substitution in place the
schema is handed the real body and never sees `undefined`.
9. **`cache.ttl` ignores an injected `clock`** — it reads `Date.now()`. Measured: one request
after advancing a `manualClock` by a virtual hour against a 1-second TTL.
**The cross-principal leak, and why it hides.** Against a server with content-derived
validators, an ETag store keyed on `METHOD URL` alone leaked: one store entry, and **bob
received alice's data** (`viewer: tok-alice`). The reason it survives review is that the
rate-limit metrics *improve* while it happens — a 50% 304 rate is exactly what a working
revalidator looks like. `cache.tenancy` does not protect a store you wrote yourself. Put
the credential in the key, as above.
**And the silent no-op.** Against a server minting a fresh validator per response (the
load-balancer inode case), 10 polls billed 10, got **zero** 304s, and raised nothing.
Assert on it: `revalidated === 0` while `stored === 10` is the only signal you get.
## See also [#see-also]
# Rate limits priced in query cost (/docs/scenarios/cost-based-rate-limits)
## The problem [#the-problem]
Shopify's GraphQL Admin API meters a **1,000-point bucket refilling at 50 points/second**,
and each query has its own price — 11 points for one, 900 for another. Four properties each
defeat a different standard tool:
1. **The unit is cost, not requests.** No single requests-per-second is correct for both an
11-point query and a 900-point one.
2. **Overspending answers `200 OK`.** The failure arrives in the body as a `THROTTLED` entry
in `errors[]` — never a `429`. Retry policies keyed on status see success.
3. **The wait is arithmetic, not a guess.** Every response carries
`extensions.cost.throttleStatus`; the correct wait is
`(requestedQueryCost − currentlyAvailable) / restoreRate`. A backoff curve over-waits when
the bucket is full and under-waits when it is empty.
4. **The bucket belongs to the shop, not to you.** Another app draining it moves your
headroom between two of your own requests
([shopify-api-js#602](https://github.com/Shopify/shopify-api-js/issues/602)), so a
local ledger can never be authoritative — it must be overwritten from every response.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Retry on 429 + exponential backoff | Never fires. The response is a `200`, and the THROTTLED envelope is returned as data. |
| Body-sniffing retry, then exponential backoff | Fires correctly, then ignores the arithmetic the server already supplied. |
| Compute the wait from `throttleStatus` | Correct — but needs `extensions`, which GraphQL clients discard when they unwrap `data`. |
| Local cost ledger, pause below a threshold | Paces your own traffic; blind to other apps on the same shop. |
| Fixed rate limiter (`N/sec`) | Wrong unit. Sized for the worst query it wastes the quota; sized for the average it throttles. |
| Single-worker global queue | Correct and common. Costs concurrency and a piece of infrastructure. |
## What StitchAPI does [#what-stitchapi-does]
Not `retry`, and not `throttle` — measured, both miss, and the section below says exactly how.
The seam that fits is a **custom [surface](/docs/reference/surfaces)**: `interpret` sees every
response body before the engine decides anything, and the `SurfaceOutcome` it returns can ask
for a retry **after a wait you computed**.
```ts
import { graphqlSurface, verdictOf } from 'stitchapi';
import type { Surface, SurfaceOutcome } from 'stitchapi';
export function shopifyCostSurface(ledger: CostLedger): Surface {
return {
id: 'graphql',
buildRequest: graphqlSurface.buildRequest,
interpret: (res, cfg): SurfaceOutcome => {
const failure = verdictOf(res, cfg);
if (failure) return failure;
// EVERY response updates the budget — successes carry throttleStatus too,
// and the server's number is authoritative because the bucket is shared.
const cost = costOfBody(res.body);
if (cost) ledger.record(cost);
// The 200-with-THROTTLED, and the wait the server's own arithmetic dictates.
if (isThrottled(res.body) && cost)
return {
ok: false,
retry: true,
message: `THROTTLED — need ${cost.requestedQueryCost}`,
after: deficitWaitMs(cost), // ← (requested − available) / restoreRate
};
return (
graphqlSurface.interpret?.(res, cfg) ?? {
ok: true,
data: res.body,
}
);
},
};
}
```
Pair it with an `onRequest` hook that pauses while the ledger says the next query is
unaffordable, and the reactive half only handles what the proactive half cannot predict —
another app draining the shop.
**Measured:** the computed wait was honored exactly (**6000 ms**, succeeding on attempt 2,
where the built-in curve waited 100 ms and failed all three attempts). Against a neighbour
emptying the bucket before every single call, **8/8 queries succeeded**, absorbing 8
throttles. The whole thing is **73 lines**.
## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution]
The retry loop stays the engine's. Because the cost logic lives in `interpret` rather than in
a wrapper around the call, `timeout.total` still bounds the whole thing, the circuit breaker
still counts failures, and every wait shows up as a `retry` progress event on the
[event stream](/docs/concepts/event-stream). The obvious alternative — wrapping the adapter —
was measured going blind: a call that made 2 requests and slept 6 seconds reported
`attempts: 1` and emitted **zero** retry events.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **`retry.on` cannot see the body.** The predicate receives exactly one argument, the status
number. A 200-with-THROTTLED is invisible to every built-in retry policy.
2. **`retry.on: 200` retries your successes.** The status matcher runs before `interpret` and
cannot tell the two apart — measured 3× the requests and 3× the points for one result.
3. **`backoff` has no function form.** A wait computed from the payload cannot enter through
it, and casting past the type error is not a way in either: construction **throws
`bad backoff`** rather than silently degrading to the default curve, as it once did
([#651](https://github.com/rejifald/StitchAPI/issues/651), fixed by #666).
4. **`retry.respect` reads a `Retry-After` header** that Shopify never sends.
5. **`throttle.rate` cannot express cost.** Requests-per-interval only, minimum spacing, no
burst. Approximating the 1,000-point bucket took **18 s** for work the bucket absorbs
instantly — and with mixed costs no single spacing is correct for both.
6. **`throttle.delegate` is status-keyed.** The escape hatch the throttle docs point at for
vendor-accounted quotas does not reach a body-reported one.
7. **A rejected body never reaches the caller.** On a surface failure the `StitchError` has no
`body`, the error event has no field for one, and `.inspect().raw` is `null`. Whatever you
need from the body must be captured inside `interpret` or `hooks.onResponse`.
8. **`interpret` is synchronous.** A *distributed* cost ledger cannot live in the seam that
otherwise solves this — single process only.
**Do not reach for `verdict.flag` here.** It is the one built-in that reads
the body for a verdict, so it is the natural guess — and on a throttled
Shopify response it is **inert**: the payload has no `data` key, an absent
path is "no signal", the 200 stands, and the call returns **`ok: true` with
the THROTTLED envelope as your data**. Silent, and it looks like a
successful sync.
## See also [#see-also]
# The vendor told you for six months, in a header (/docs/scenarios/deprecation-headers)
## The problem [#the-problem]
A vendor is retiring the endpoint you depend on. They announced it in a blog post, sent one
email, and — if they follow the standards — have been telling you **on every single response**
for six months, via `Deprecation` (RFC 9745) and `Sunset` (RFC 8594).
**The signal arrives on responses that succeeded.** Nothing failed, nothing retried, no status
changed — so every mechanism a client has for noticing trouble points the wrong way. As one
write-up puts it: *"The clients that broke never read the blog post — but their code reads your
HTTP responses on every single request."*
Three specifics make it awkward: **two headers, two formats** (`Deprecation` is a
structured-field date `@1735689600`, `Sunset` is an HTTP-date); **it is a hint, not a
guarantee**, so failing the call is wrong and ignoring it is also wrong; and **the useful unit
is the fleet** — "which of my forty endpoints are deprecated, and which sunsets first" — which
no per-call log line can answer.
## Where a response header is reachable [#where-a-response-header-is-reachable]
This is the table worth keeping. Measured across every accessor on a **successful** call:
| | carries response headers |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `adapter` | **yes** — it built the response, but knows no stitch name and can't change the result |
| `hooks.onResponse` | **yes** — the whole `AdapterResult`, plus `ctx.name` |
| **`Surface.interpret(res, cfg)`** | **yes** — and it returns the value the call resolves to |
| `await` / `.unwrap()` / `.safe()` | no |
| `.inspect()` (5 keys) / `.report()` (9 keys) | no — `status`, but no headers |
| `StitchError` (5 keys) | no |
| `transform` | no — its one parameter is the body |
| the **entire event spine** — 4 events, 15 distinct keys | **no** |
That last row is the one with consequences: because no event carries a header, a `TraceSink`
inherits the same hole. It can only aggregate what a `Surface` folded into the value.
`Surface.interpret` is the only seat where a header **and** the returned value are in scope
together, which makes it the answer to this scenario and to any other "the signal is in a
header" problem.
## What StitchAPI does [#what-stitchapi-does]
**Not one config key knows what a `Deprecation` header is.** The answer is three seams and
about 112 lines: `Surface.interpret` reads the headers and renders a verdict, a seam-level
`trace` sink aggregates by `ctx.name`, and the injected `clock` makes the sunset crossing
deterministic.
**The fleet view works, and the [scenario-12](/docs/scenarios/intermittent-drift) shape
transfers.** One sink configured once on a seam, 500 calls across 5 endpoints:
```
3 endpoints deprecated (users, search, orders), earliest sunset in 12 days: users
```
One row per endpoint however many calls arrive — and with traffic skewed **200:1** toward the
healthy endpoints the report was unchanged, which is exactly what a per-call log line cannot do.
**And the tripwire is elegant.** `interpret`'s `{ ok: false }` arm plus `cfg.clock` makes the
crossing deterministic to the millisecond — `[sunset−1ms, sunset, sunset+1ms]` measured
`["ok", "FAILED", "FAILED"]`. Two things make it safe to deploy, both measured: it does **not**
burn retry attempts (5 configured, 1 request made) and it does **not** open the circuit breaker
(5 consecutive trips past `failures: 2`, every message still the real one), because
`classifyStatus` rules on the status and the transport was healthy.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **No config key, and no reachable parser.** `parseRetryAfter` handles delta-seconds **or** an
HTTP-date against an injectable clock and returns ms-until — precisely the `Sunset`
requirement — and it is not on any of the 17 published subpaths. The public barrel exports
`parseDuration`, `parseBytes`, `parseRate`; none parses a date.
2. **No event carries headers**, so a `TraceSink` sees `null` for a response that carried both —
unless a `Surface` folded them into the value. And then an ordinary `output` contract that
doesn't declare the folded field **deletes it again** before the `result` event fires, with no
warning.
3. **No API mints a levelled finding.** A `Validator` returns a value or *issues*, and an issue
is fatal. The one door onto the drift channel is folding a field into the value and letting an
`output` contract report it `info | undeclared` — which is non-fatal and re-levellable, but
**carries neither the header value nor the endpoint**. (Smuggling the date into the *path*
works — `_sunset_2026-01-01` — at the cost of a new finding path per date.)
4. **No de-duplication primitive.** 600 calls produced **360 log lines carrying 3 distinct
facts**; the built-in `loggerSink` is louder at 2400. `levelOf` can drop events but cannot
answer "have I said this already" — it's a pure function of one event. A 3-line `Set` latch
fixed it; nothing on the barrel latches, samples or de-duplicates an observation.
5. **The line count goes against the library** — **132 lines against 81** hand-rolled for
identical output. Wiring alone favours it (20 vs 52); the total doesn't, because reaching
`res.headers` costs a `Surface` object and holding the per-endpoint map costs a `TraceSink`
object, where a hand-rolled client does both inline in the method that already had the
response.
**`hooks.onResponse` can rewrite the call, and the hooks guide says it
cannot.** The guide's "hooks never change what a stitch returns" is true
only of the *return value*. `ctx.res` is the engine's live object, read
again by `interpret` afterwards — so mutating `res.body` added a key to the
caller's value, mutating `res.status` turned the vendor's **200 into a
thrown `HTTP 503`**, and mutating `res.headers` made a surface read
`"REWRITTEN BY HOOK"` instead of the real `Sunset`.
**A seam-level `kind` is a compile error that works perfectly at runtime.** `TS2353: 'kind'
does not exist in type 'SeamConfig'` — yet members inherited the surface and folded
correctly. A typed codebase writes the surface on all 40 members for a capability that
already works from one.
**`ctx.name` defaults to the literal `"stitch"`**, so two unnamed endpoints silently merge
into one row at the sink. **A cache hit re-serves a notice captured on the one wire
response** — 9 of 10 rows were a replayed header, and a long TTL will report a passed sunset
as "in 12 days". And **`Date.parse("@1735689600")` is `NaN`**, so the naive parser reads
`Sunset` correctly and reports *no deprecation* for the format RFC 9745 actually mandates.
## See also [#see-also]
# The migration you have to run twice (/docs/scenarios/dual-run-migration)
## The problem [#the-problem]
Your vendor is retiring v1. [Scenario 17](/docs/scenarios/deprecation-headers) is how you found
out; this is what you do next. You cannot flip on faith, so you run both against real traffic,
compare, and cut over when the diff goes quiet.
**Every shadow-traffic guide in the field is written for the service owner.** The canonical
architecture mirrors at the gateway — client → proxy → primary, with a copy to the shadow and a
shadow database beside it. You own the gateway, both services and both datastores.
As the **consumer** of a third-party API you own none of that, and every term changes:
* **There is no proxy to mirror at.** The duplication happens in your own client code, on the
call path, which is exactly where you cannot afford it to go wrong.
* **The shadow spends the vendor's meter.** "Sample heavily" is cost control, not statistics.
* **You cannot shadow a write.** A mirrored `POST /charges` charges twice — so the technique is
read-only, and writes are the calls you are most afraid of migrating.
* **Telling a real diff from a benign one is the actual work.** A v2 that renames `created` to
`created_at`, returns ISO instants instead of epochs and orders an array differently is
**correct**, and diffs on every call.
## The common solutions [#the-common-solutions]
| Approach | What it is | Where it breaks |
| ----------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------- |
| **Mirror at the gateway** | Proxy duplicates the request. | The standard answer, unavailable when the endpoint is someone else's. |
| **Dual-call in the client** | Issue both, return v1, log the diff. | Available to you — and now the shadow is inside your latency and failure budget. |
| **Offline replay** | Capture v1 traffic, replay against v2 later. | No user impact, and no live comparison. |
| **Diff in a batch job** | Log both, compare nightly. | Cheap and slow. A regression lives a day. |
| **Trust the changelog** | Read the migration guide, flip. | Free, and the reason this scenario exists. |
| **Sample a small percentage** | Shadow 1–5% of reads. | The cost control that makes it viable. Needs a spelling. |
## What StitchAPI does [#what-stitchapi-does]
### The safe dual-run is 69 lines across 5 seams [#the-safe-dual-run-is-69-lines-across-5-seams]
Replayed against the same vendor and the same flaky v2, the naive construction and the safe one
differ **0-of-4 versus 4-of-4** user-facing calls succeeding. The naive version —
`all([v1, v2])` under one seam, both versions on `/customers` — propagated v2's 500 to the caller
twice, then fast-failed v1 on **v2's** breaker twice, and along the way put a 90 ms shadow on a
10 ms call's critical path and sent v2's parameter name to v1.
The safe version needed no fork and no new config key:
* **`readsOnly` on the shadow's adapter only** — an 8-line wrapper. Three shadow write attempts
(a plain POST, an `llm`-surface call, a `.with()`-bound handle) reached the wire **0 times**,
while the primary's own POST still succeeded.
* **A seam-level `throttle`** — one bucket by default, because `seamBucket` re-keys every acquire
onto the seam id, so the meter adds up across both versions.
* **Distinct `circuit.key` strings** — the shadow tripped its own breaker and the primary never
noticed.
* **A hand-written normalizer** — 7 raw diff ops per call became exactly 1, the planted
regression, reported with both values.
* **A floated `.safe()` at the call site** — the one spelling that is simultaneously off the
critical path, unable to reject, unable to cancel the primary, and eager enough to actually
run.
### A `url` thunk moves more than the base URL [#a-url-thunk-moves-more-than-the-base-url]
`url` is `string | (() => string)`, it carries the **complete endpoint**, and `{param}`
interpolation still applies to a thunk-supplied URL. One flag moved `/v1/customers/{id}` to
`/v2/customers` between calls with no redeploy.
## What StitchAPI does not solve [#what-stitchapi-does-not-solve]
### One of five isolation channels is safe by default [#one-of-five-isolation-channels-is-safe-by-default]
| channel | safe by default? | what it takes |
| ---------------- | --------------------------------------------- | ---------------------------------------------------- |
| **retry budget** | **yes** — a per-call loop counter | — |
| **latency** | no — `all()` cost **+109 ms** on a 13 ms call | don't await the shadow; no combinator does this |
| **thrown error** | no — `all()` threw `StitchError` | `void v2.safe(input)` |
| **cancellation** | no — the primary measured `aborted: true` | never put the shadow in `all()` |
| **circuit** | no — see below | distinct `circuit.key`; never unkeyed `pool: 'host'` |
The cancellation channel was not on our list of things to check, and it is the most dangerous:
`all()` aborts the in-flight **primary** when the shadow settles first.
### The circuit is a key collision, not a shared default [#the-circuit-is-a-key-collision-not-a-shared-default]
More precise than "resilience state is shared." Identity is
`(store) × ('circuit:' + (circuit.key ?? name ?? path ?? 'stitch'))`. Across five configurations
the primary got `1, 1, 0, 0, 1` requests — isolated standalone and under a seam with distinct
paths, **broken** with a seam and the same path, and **broken** under `pool: 'host'`.
Both failing cases are ordinary dual-run shapes: v1 and v2 usually share a path and differ by
base URL, and they are usually on the same host.
**The `pool: 'host'` trap.** It is the setting that makes cost accounting
correct across two stitches — and it silently re-keys the **breaker** onto
the host too, which is exactly the configuration measured fast-failing the
primary on the shadow's breaker. Use a seam-level throttle instead: it pools
correctly and leaves the circuit keyed per path.
### The rest [#the-rest]
1. **The combinators broadcast one input.** `all`/`any` build every member's input from the one
group input, so the shadow received `/v2/customers` — **no id**. Adding the id for v2 made v1
send `/v1/customers/cus_7Q2?customer_id=cus_7Q2`. This is
[#643](https://github.com/rejifald/StitchAPI/issues/643), and a dual-run is the case that
needs the opposite.
2. **`.with()` survives the broadcast but binds a constant.** A group built once and called twice
left the shadow pinned to `cus_7Q2` while the primary followed `cus_ZZZ`.
3. **A bare `void v2(input)` sends zero requests.** `StitchResult` extends `PromiseLike`
(`types.ts:2007`), so nothing runs until `.then`. No request, no rejection, no
`unhandledRejection` — the dual-run silently compares nothing. Same root cause as
[#660](https://github.com/rejifald/StitchAPI/issues/660); `void v2.safe(input)` is the spelling
that works.
4. **No response-vs-response comparator is reachable.** Of 33 root exports the only
comparison-shaped one is `drift()`, which takes a *schema*. The real primitives exist —
`diff(before, after)` and `classifyDiff(a, b, opts)` — and neither is exported from any of the
17 subpaths. A hand-written replacement is 23 lines, and is **better** for this: `classifyDiff`
renders the planted regression as `"number -> number"`, a type delta with no numbers in it.
5. **`DriftOptions.ignore` is suppression, not relevancy.** Four clauses take 7 diff ops to 1 —
and measured, the clause silencing a benign tag reorder also silences a real tag change, and
the one silencing the rename also silences a v2 reporting the wrong instant. There is no
aliasing, no unordered-array comparison, no coercion hook and no tolerance anywhere in the
tree, so a filter that still catches what it should is 24 lines of user code.
6. **Sampling is not expressible.** No sample/ratio/percent slot exists on any subpath. The
shadow doubles consumption exactly — 20 logical calls became 40 vendor requests, **2.000×** —
and 5 lines of user code took that to 1.05×.
7. **No write guard exists.** An unguarded dual-run of `POST /charges` sent **2 charges** with no
config key, type error or runtime nudge objecting. The only method-shaped option is
`cache.methods`, which gates cacheability and let the POST through. A construction-time gate on
`__config.method` is also not enough: the `llm` surface reports `method === undefined`, passes
the gate, and POSTs. The guard has to sit at the `Adapter`, below every authoring surface.
8. **Cutover cannot be one flag.** A thunk moves the URL, but the input mapping is caller-side
(after the flag, a v1-shaped input against the v2 URL silently produced `/v2/customers` with no
id and nothing threw) and `output` is resolved once at construction (the same stitch went from
`ok: true` to `ok: false` against a v1-shaped schema). The real cutover is a 4-line selector
over two whole stitches.
## See also [#see-also]
* [Scenario: the vendor told you for six months, in a header](/docs/scenarios/deprecation-headers)
— how you learn the migration is coming
* [Scenario: failing over to the backup provider](/docs/scenarios/provider-failover) — where the
one-input broadcast was first measured
* [Scenario: one tenant's revoked token, everyone's outage](/docs/scenarios/multi-tenant-blast-radius)
— the keying problem this scenario runs into again
# The signature that expired in your own queue (/docs/scenarios/expiring-signatures)
## The problem [#the-problem]
You call a service that requires **signed requests** — S3 or any AWS API over SigV4. The
signature covers a timestamp, and the server rejects anything more than **five minutes** off
its own clock. That window exists to stop replay attacks and is not negotiable.
There are three ways to fall outside it, and only one is your clock:
1. **The clock drifts.** Containers inherit the host's time at start and never re-sync. Retry
makes it *worse* — the same stale clock produces the same invalid timestamp every attempt.
2. **The signature ages in a queue — yours.** Sign, then hold: behind a rate limiter, a
concurrency cap, a backoff. AWS's own answer: *"The SDK signs the request, then puts it in a
queue. If the request is pending for more than 5 minutes, the signature expires."* The fix
filed against botocore ([#149](https://github.com/boto/botocore/issues/149)) is to generate
the timestamp per signing operation.
3. **The retry replays a stale signature**, if signing happens once per call rather than per
attempt.
And the compounding detail: `RequestTimeTooSkewed` is a **403**, so it reads as "auth problem,
retry it" — the failure most likely to be retried is the one retry cannot fix.
## What StitchAPI does [#what-stitchapi-does]
**Cases 2 and 3 need no user code, because the ordering is right by construction.** Inside the
attempt loop, the throttle acquire sits *above* the auth apply:
```
engine.ts:657 const { waited } = await acquireWithin(…) ← the queue wait
engine.ts:677 await cfg.auth.apply(req, …) ← signing
engine.ts:680 await cfg.hooks?.onRequest?.(…)
```
and each attempt gets fresh headers off the *unsigned* base request.
**Measured:**
| | signature age on arrival | statuses |
| ------------------------------------------------------------- | -------------------------------------- | ------------------------ |
| 4 calls behind `rate: '1/2m'`, granted at 0/2/4/6 virtual min | **0, 0, 0, 0 ms** | `200, 200, 200, 200` |
| the same calls **pre-signed** (the botocore shape) | 0, 2, 4, **6 min** | `200, 200, 200, **403**` |
| held 6 min behind `concurrency: 1` | **0 ms** | `200` |
| 3 attempts 6 min apart, long backoff | **0, 0, 0 ms** — 3 distinct signatures | |
| a 10-minute `Retry-After` park | **0 ms** | `200` |
**A StitchAPI throttle cannot expire a signature.** The circuit breaker doesn't either — it
fast-fails *before* signing, so three blocked calls produced **zero** signings.
## Clock drift is still yours — 26 lines [#clock-drift-is-still-yours--26-lines]
Per-attempt signing re-mints the *same wrong time*: measured, 4 attempts, 4 identical
600,000 ms skews. The correction AWS SDKs implement — learn the offset from the server's `Date`
header, re-sign — is reachable through `AuthStrategy.shouldRefresh`/`refresh`:
```ts
shouldRefresh: (res) => res.status === 403 && isSkew(res),
refresh: async () => { /* offset learned from the Date header, via a closure */ },
```
**Measured:** the offset was learned (600,000 ms), the *same attempt* re-signed, and the call
returned `200` — costing **no retry budget**, because a refresh re-runs the attempt rather than
consuming one. The offset persists, so the next call needed **one** request.
Assembled: **4 of 4 calls succeeded through a 10-minute host drift *and* a 6-minute rate-limited
queue**, worst signature age 0 ms, breaker never opened — 26 lines in two declarations, all of
it for the drift half. With no user code at all the same workload gave
`["403", "403", "503", "503"]`.
**`hooks.onRequest` runs *after* signing** (`:680` vs `:677`) — and it is
the only user-code seam that does. A hand-rolled pacing gate there
**re-creates botocore#149 inside a library that doesn't have it**: measured,
a 6-minute wait in `onRequest` aged the signature 6 minutes and got a `403`.
Pace with `throttle`, never with a sleep in a hook.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **Clock drift itself.** The library re-signs faithfully with whatever clock you have.
2. **Skew correction has no config vocabulary** — it is `shouldRefresh`/`refresh` plus a closure,
because `refresh` cannot see the response that triggered it. The offset has to be smuggled out
of `shouldRefresh`.
3. **A skew 403 counts as a circuit failure.** A fault *inside your own process* opens the
dependency's breaker, and the half-open probe then reports `RequestTimeTooSkewed` rather than
the fault that opened it.
4. **A breaker does not shed a burst already queued behind a throttle** — the circuit phase is
read before the throttle wait. Measured: 4 concurrent calls all reached the wire over 6
minutes *after* the breaker opened; the same 4 issued sequentially stopped after 2.
A fifth gap this audit found is gone. `awsSigV4` used to stamp `x-amz-date` from `new Date()`
rather than the injected clock, so a skew test on a `manualClock` was impossible: 600 virtual
seconds moved the shipped stamp **0 seconds**, and under a default `manualClock()` **0 of 3**
calls were accepted. Filed as [#658](https://github.com/rejifald/StitchAPI/issues/658), fixed by
[#667](https://github.com/rejifald/StitchAPI/pull/667): the signer now stamps from the stitch's
[injected clock](/docs/guides/testing/mocking) — the same seam that drives retry, throttle and
token freshness — so the same measurement moves **600 seconds** and a SigV4 stitch is testable
on a `manualClock`, **3 of 3** accepted. One residual: a default `manualClock()` starts at epoch
0 and signs `19700101T000000Z`, so seed it (`manualClock(Date.now())`) when the stamp must be
plausible to a real endpoint.
**`verdict: { accept: [403], flag: 'ok' }` swallows the skew error.** The recipe that works
for a 401 in [the multi-tenant scenario](/docs/scenarios/multi-tenant-blast-radius) does not
transfer: `verdict.flag` is three-state, an **absent** flag means "no signal", and AWS error
bodies carry no flag. Measured: `ok: true`, with `RequestTimeTooSkewed` handed to the caller
as data. Six lines of `Surface.interpret` fix both this and finding 3 above.
Also: **`backoff.max` defaults to 10 s**, so `base: '6m'` silently waits 10 seconds.
Protective here — it cost this scenario's proofs a false negative.
## See also [#see-also]
# Scenarios (/docs/scenarios)
A scenario is a problem people actually hit against a real API, picked because the
honest answer is *not* a one-liner in any library. Each page states the problem,
lays out the solutions the ecosystem has converged on and what each one costs,
shows the StitchAPI shape next to them — and then says plainly what StitchAPI
does **not** solve, so you can see the remaining work before you commit to it.
Where a [recipe](/docs/recipes) shows a task with a known good answer, a scenario
shows a problem where the answer is a trade-off.
Every claim on these pages is backed by a script that was run offline against the
real runtime, not by reading the source.
# The vendor changed the shape for 5% of responses (/docs/scenarios/intermittent-drift)
## The problem [#the-problem]
A vendor ships a response-shape change — but not all at once. A canary at 5% of traffic, then
25, then 50. Or not a rollout at all: the shape varies *by data*, like a geocoder that returns
`null` for `formatted_address` only on ambiguous queries.
**Intermittent breakage is harder than total breakage.** A change that breaks every call is
found in minutes and rolled back. A change that breaks 5% looks like flakiness, sits in the
backlog for a week, and gets fixed once someone spots the pattern.
Four change classes, and treating them alike is the mistake — addition is non-breaking by every
published policy, removal and type change are breaking, and a field becoming nullable is
warning-level and the most intermittent of all.
The dangerous one is the type change, because a naive cast produces a **plausible** value: a
payment provider moves `transaction_id` from `12345` to `"12345"`, your code casts to int, gets
`0`, and processes a **$0 transaction**.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| ------------------------------- | --------------------------------------------------------------------------------- |
| Strict schema validation | Catches everything — including the added field that broke nothing. Alarm fatigue. |
| Parse loosely, cast defensively | Never alarms, and manufactures the `$0` transaction. |
| Contract tests in CI | Can't see a canary that started after your deploy. |
| Level the findings | The right model — needs a vocabulary most validators lack. |
| Log and aggregate | The only way to see 5% → 25%. Needs the finding to name the field. |
| Pin a vendor API version | The real fix where offered; useless against data-dependent nulls. |
## What StitchAPI does [#what-stitchapi-does]
**The default is safe, and that is the headline.** Against `z.number()`, a `transaction_id` that
arrives as `"12345"` produces `error | invalid | transaction_id | Invalid input: expected
number, received string` and the **call fails** with `data: null`. Even `z.coerce.number()` on
`"abc"` fails — Zod rejects `NaN`. StitchAPI does not manufacture a $0 charge on its own.
**The precision is excellent.** Over 100 calls where a geocoder returned `null` on 5 of them,
drift fired on **exactly** calls `[20, 40, 60, 80, 100]` — matching the vendor's own ledger,
zero false positives on the other 95, each finding naming `formatted_address` with
`null -> string`.
**And aggregation is a real seam**, which is what makes an intermittent change actionable. A
`TraceSink` sees every event of every call, and `ctx.spanId` lets you hold per-call state
honestly:
```ts
const charges = stitch({
url: 'https://api.vendor.com/charges/{id}',
output: drift(StrictCharge, { level: { undeclared: 'verbose' } }),
trace: new DriftRate({ window: '15m' }),
});
```
**Measured:** `5.0% of calls: warn|coerced|transaction_id|null -> number (5/100, 5 landed 0)`,
and on a rolling window the same field widening **5.0% → 25.0%** as the canary expanded. Across
six workloads at 100 calls each: silent on a 100% *addition* rollout, one alert line per
breaking class at 5%, and **zero $0 charges**. The same six workloads against the soft schema
teams write for availability kept 100% of calls and produced **ten** $0 charges.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **A `coerced` finding cannot say whether the coercion was destructive.** `"12345" → 12345`
and `"abc" → 0` emit **byte-identical** findings — `warn | coerced | transaction_id | string
-> number` — because `detail` is `kindOf(old) -> kindOf(new)` and values never appear in a
finding. Only joining `drift` to `result` on `ctx.spanId` in a sink separates them.
2. **The nullable class is inexpressible.** "Nullable is a warning, value intact" — the fourth
industry class — has no spelling. `.nullable()` produces **nothing at all**, so the 5%
rollout is invisible; the strict schema turns it into a 5% error rate that also discards the
four fields that were fine. A hand-rolled classifier beats `DriftOptions` on exactly this row.
3. **`level` is keyed by mechanism, not by change class.** It takes
`undeclared`/`coerced`/`defaulted` — of which only *addition* maps 1:1 to an industry class.
Removal, type change and nullability each land on a kind decided by **your schema**, so their
loudness is a schema decision rather than a severity one.
4. **No per-path severity.** `ignore` is the only path-aware lever and it is on/off. "Coercion on
`transaction_id` pages, coercion on `description` doesn't" has no spelling.
5. **A soft finding cannot be promoted to fatal through the type.** `error` isn't in
the soft half of `DriftLevel` — `Exclude` (a cast past it does work at
runtime).
6. **Soft findings are invisible on the awaited path.** `await` and `.safe()` carry nothing —
`StitchError` has no `findings`, and a hard failure gives only a generic `contract violation
(drift)`. The trace sink for the *same* run named the field and both types. `drift()` with
only `.safe()` does nothing for you.
7. **A schema strips what it doesn't declare.** The added field in the addition case is
`undefined` on `data`; reading it needs `.inspect().raw`, which is a fresh request.
**Two ordinary spellings manufacture the $0 charge.**
`z.coerce.number()` maps **`null` → `0`** with no `.catch()` involved, because
`Number(null) === 0`. Measured: `null`, `""`, `" "`, `false` and `[]` all coerce to exactly
`0`; only `"abc"` rejects. And `.catch(0)` hands the caller `0` for *anything*.
`.default('usd')` on a **removed** field is the same shape — it fabricates a value the vendor
never sent, at `verbose`. On money, prefer a failed call to a plausible number.
**A cache hit emits no drift, so caching divides your drift rate by the miss ratio.**
Measured: 5 calls against a vendor drifting on **100%** of responses reported **20%**.
Two smaller counting traps: findings are not calls (2 findings on one response reads as 200%
unless you collapse on `ctx.spanId`), and `.report()`/`.inspect()` are **fresh probes** —
they cost a request, tick your denominator, and answer about a different response. `.report()`
called right after a drifting call reported **zero** findings.
## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution]
**A wash on size — 93 lines against 92.** But the halves are not alike: detection is **9
declarative lines against \~35**, while aggregation is \~84 lines of user code either way, because
the library counts nothing.
What the hand-rolled 92 lack is the resilience stack, measured here as one `retry` line
absorbing 8 × `503` across the canary while the rate still counted **100 logical calls out of
108 wire requests**.
## See also [#see-also]
# The export that eats the heap (/docs/scenarios/large-response-memory)
## The problem [#the-problem]
You call an export endpoint. The vendor hands back one JSON array with tens of thousands of
rows. You `await` it, parse it, iterate. Then the catalog grows and the process dies.
**Parsing costs several times the wire size**, because `JSON.parse` holds the whole string *and*
builds the object tree. A documented sync of 22,000 products in an **84 MB response exhausted
\~2.1 GB** on a 4 GB VPS; the streaming rewrite ran at **180 MB peak**.
This failure mode is unlike every other scenario here. Nothing returns wrong data — the process
just dies, usually after a dataset crossed a threshold nobody was watching.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `await` the JSON | Dies above a size you can't predict, and a chunked response has no `Content-Length`. |
| Ask the vendor for NDJSON | The correct fix where offered. Most REST endpoints don't. |
| Structural streaming parser | The real answer for one giant array. A dependency, and fiddly to assemble. |
| Paginate instead of exporting | Bounded — and inherits [every pagination problem](/docs/scenarios/unstable-pagination), plus N× the requests. |
| Raise `--max-old-space-size` | Moves the cliff toward you as data grows. |
| Batch and release references | Necessary alongside streaming; useless if the parse already buffered. |
## What StitchAPI does [#what-stitchapi-does]
**It ships a genuinely O(1) NDJSON decoder.** Driven directly, `decode: 'ndjson'` held **0.8 MB
of retained heap for 1,000,000 rows and 214 MB of wire**, moving less than 15% across a 1000×
change in workload. That is the real thing.
**And the engine retains every chunk one line later.** Through `runStreaming` the same decoder
is linear — 3.5 MB → 30.2 MB from 10k to 100k rows — because every delta is pushed onto an
accumulator so the terminal `result` can mirror the whole spine.
**`.stream()` is not a memory fix.** Measured: **30.2 MB iterating vs 33.5
MB awaiting** — the same number twice. The accumulator is *inside* the
generator both accessors drain, and no option disables it. The engine's own
code comment recommends reading incrementally via `.stream()` for exactly
this reason; the measurement says that mitigation doesn't work.
So the seam that does work is `Surface.stream` — not to replace the decoding, which is already
O(1), but because the engine retains whatever that hook *yields*. Yield one small receipt per
batch instead of one row per row:
```ts
const rows = stitch({
url: 'https://api.vendor.com/export.ndjson',
kind: batchedExport({ size: 1_000, onBatch: writeToDb }), // yields a receipt, not rows
stream: 'ndjson',
});
```
**Measured:** **1.3 MB retained for 100,000 rows against the buffered baseline's 53.8 MB — a 40×
cut — and flat**: 1.3 MB at 1,000 rows, 1.4 MB at 100,000. Under a 96 MB heap ceiling, 400,000
rows that killed the buffered path outright processed completely at 1.3 MB.
Fairness on the baseline: the buffered multiplier measured **2.5×**, and it is `JSON.parse`'s,
not the library's — StitchAPI's overhead over a bare `JSON.parse` of the same bytes was **0.2
MB**. It adds no copy. It also removes none.
## `decode: 'json'` on one array: right answer — and, since #665, right memory [#decode-json-on-one-array-right-answer--and-since-665-right-memory]
The structural decoder's **emission was always correct**, and impressively so — one delta per
element, holding up under `,` `]` `}` inside string values, escaped quotes, embedded newlines,
pretty-printed multi-line records, deep nesting, and 1-character chunk boundaries.
Its memory was not. The decoder retained the whole array text at \~0.9× the wire in quadratic
time, and **tripped its own default cap mid-export**: a 60,000-row array delivered 37,312 rows
and then `error`/`done(ok: false)` under *"a malformed or never-closing value was streamed"* —
which the vendor did not do. That defect was measured here, filed as
[#659 §2](https://github.com/rejifald/StitchAPI/issues/659), and **fixed by
[#665](https://github.com/rejifald/StitchAPI/issues/665)**: a top-level array records no
compaction floor of its own, so emitted elements are released as they close.
**Re-measured after the fix:** a 100,000-row single array decodes **flat —
0.6 MB retained, under 3% of the 21.4 MB wire, in linear time — and finishes
on the default cap**; the same records as concatenated top-level values
measure alike. The cap no longer trips on a well-formed array of any length:
it bounds **one element**, so an array is capped by its largest element, not
its row count. (Node v24.18.1, arm64 macOS — absolute numbers differ
elsewhere; the shapes don't.)
What the fix does not change: through the engine, `decode: 'json'` now costs exactly what
`ndjson` costs — 30.2 MB at 100,000 rows either way — because the accumulator above it still
retains every element. The decoder stopped buffering; the engine hasn't. What it does change:
the batched seam above no longer depends on being offered NDJSON — over one 100,000-row array
the same surface runs at **1.4 MB**, where it used to pay 19 MB for the decoder's buffer
sitting upstream of the seam.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **No memory-bounded path is reachable from config alone.** Every decoder × every accessor is
O(N) through the engine.
2. **No size guard, threshold, warning or event on the buffered path.** A 21 MB response emits
the same four events a 40-byte one does. When the wall arrives it is V8's: 400,000 rows under
a 96 MB ceiling gave `FATAL ERROR: … JavaScript heap out of memory`, **exit 134** — no
catchable error, no `error` event, no `finally`.
3. **`stream.buffer.chars` is a malformed-input guard, not a budget.** It bounds one line or one
un-closed value — 20,000 well-formed rows streamed cleanly through a **1,000-character** cap
while the engine accumulated all 20,000. On a buffered stitch it typechecks and is **inert**.
4. **`pick` and `transform` are silently inert on a stream** — `transform` called **zero** times
over 200 deltas. No `info`, no drift finding, no throw. A stitch that carries them and is
later switched to `kind: stream` keeps compiling and quietly stops reshaping.
5. **`output` on a stream validates without transforming.** The buffered path serves the
validated value; the streaming path keeps only the errors. The same coercing schema reshapes
your data on `await` and silently does not on `.stream()`.
6. **A failing row is a circuit breaker, not a filter** — `contract violation (drift)`, the
stream ends, and the rest is never read.
7. **No batching primitive.** There's no `paginate`-style chunking for streams; the 75 lines
above are yours.
**`stream({ kind: mySurface })` silently drops your surface** — `stream()` overwrites `kind`
after spreading your config. Measured: 1,000 raw rows, **zero** through the surface, no error.
Spell it `stitch({ kind })`.
And a backlogged socket is **invisible to `heapUsed`** — a producer that ignores
`desiredSize` puts the body in the stream's internal queue, which is external memory. Watch
`arrayBuffers`.
## See also [#see-also]
# A stream that fails after 800 tokens (/docs/scenarios/mid-stream-failure)
## The problem [#the-problem]
You stream a completion to a user. Eight hundred tokens in, it stops. The request did not
fail — `200 OK` went out with the *first* token, and the status line was spent before anything
went wrong. Every later failure has to arrive **in-band**, as an SSE frame, or as nothing at
all when the socket simply drops.
Three consequences, all awkward:
* **Retrying is not neutral.** Replaying re-runs the model — you pay for the first 800 tokens
*and* their replacement — and it duplicates content the consumer has already accumulated.
The user watches the answer restart.
* **"Ended" and "ended early" are the same shape.** A complete OpenAI stream is terminated by
`[DONE]`. A truncated one just stops. Nothing else distinguishes them.
* **Resumption mostly isn't offered.** SSE's `Last-Event-ID` needs the server to put `id:` on
every frame. OpenAI-style completion chunks carry none, so the standard mechanism does not
apply to the most common streaming API there is.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| ----------------------------------- | ------------------------------------------------------------------------------------------ |
| Retry the whole request | Pays twice, re-runs the model, duplicates content in any accumulator. The answer restarts. |
| Never retry a stream | Safe and common. Turns every transient blip into a visible failure. |
| Resume via `Last-Event-ID` | The correct mechanism — and unavailable on LLM APIs, which emit no `id:`. |
| Continuation prompt | The pragmatic LLM answer. Costs another call, and the seam shows in the output. |
| Buffer everything, emit at `[DONE]` | Makes truncation detectable and retry safe — and discards the entire point of streaming. |
| Check the sentinel | Necessary in all of the above. Cheap, and routinely forgotten. |
## What StitchAPI does [#what-stitchapi-does]
**If your stream is resumable, this is one flag and it is correct.** A feed that emits `id:`
on every frame, dropped after 2 of 5 tokens, with `sse: { reconnect: true }`:
```ts
const feed = sse({
url: 'https://api.example.com/events',
sse: { reconnect: true },
});
```
Measured: the consumer saw `ABCDE` — five deltas, **zero duplication** — and the reopened
request carried exactly the right header, `Last-Event-ID` moving `(none) → t2`, where `t2` was
the last id delivered before the drop. Server pacing is honoured too: a `retry: 9000` frame
produced 9000 ms gaps, overriding the authored `reconnect.delay`.
**`reconnect` cannot help an LLM stream — resuming needs `id:`.** With no
`id:` on any frame there is nothing to replay as `Last-Event-ID`, so the
engine does not reopen. Measured on an OpenAI-shaped stream with
`sse: { reconnect: true }`: a completed stream is 1 open, `ABCDE` once,
one `[DONE]`, and a mid-body drop surfaces its error with the partial
kept (`ABC`, `done(ok: false)`) instead of being reopened. An earlier
build replayed the whole completion here — measured by this audit, filed
as #640, fixed in #647 — so the flag on an id-less stream is now a no-op,
not a replay. `sse: true` is the same flag. What the LLM case actually
needs is below.
For the LLM case the answer is **two seams**: `Surface.execute` for connect-only retry, and
the surface's `stream` hook to require the sentinel and reject in-band error frames.
```ts
const TRANSIENT = [429, 502, 503, 504]; // the engine's own default retry.on
// Retry the CONNECT phase only — the one replay that is unambiguously safe.
// `transport` is the injected adapter (a surface with `execute` replaces
// `config.adapter`), and adapters resolve on every status — a 503 is a value,
// not a throw — so the loop tests `res.status`; a catch would never see it.
execute: async (req) => {
let res = await transport(req);
for (let i = 1; i < connectAttempts && TRANSIENT.includes(res.status); i++)
res = await transport(req); // no bytes have flowed yet
return res;
},
// Require the sentinel: truncation is the ABSENCE of a frame, so nothing
// per-frame can catch it.
stream: async function* (res, cfg) {
let sawDone = false;
for await (const frame of sseSurface.stream(res, cfg)) {
if (isErrorFrame(frame)) throw new Error(`provider error frame: ${messageOf(frame)}`);
if (isDone(frame)) { sawDone = true; continue; }
yield frame;
}
if (!sawDone) throw new Error('stream truncated: no [DONE] sentinel');
},
```
**Measured across six shapes:** a complete answer delivered once; a healed connect retried at
the connect phase only (3 opens, `ABCDE` once); a mid-body drop keeping `ABC`; a truncation
caught as `stream truncated: no [DONE] sentinel` with the partial kept; an in-band error frame
surfaced with the bad frame **withheld** from the consumer; and a dead server failing after 4
connect attempts rather than resolving empty.
## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution]
Unusually for this section, **the StitchAPI version is smaller** — 62 lines against 83 for the
hand-rolled twin, which has to bring its own SSE parser. Both produce byte-identical results
and identical open counts on all six shapes, so the machinery isn't buying behaviour; it's
buying the spine: one `start` / `delta`×N / `error` / `done` trace under one traceId, and
`auth`, `headers`, `throttle` and `timeout` staying configuration instead of growing the
hand-rolled file.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **`retry` does not run on a streaming stitch at all.** Measured: `retry: { attempts: 4 }`
against an always-503 server made **4 requests on a buffered stitch and 1 on an `sse` one**,
with `error.attempts: 1`. The safe replay — the connect phase, before any byte — is the one
case `retry` cannot cover.
2. **`retry.attempts` is inert but `retry.backoff` is live**, as the *reconnect* curve. One
name, two fates: on a resumable feed that kept dropping, `retry: { attempts: 1 }` still
produced 4 opens — the cap that binds is `reconnect.attempts`.
3. **Connect-phase and body-phase policy are not separately addressable.**
`reconnect.onlyOnDrop`, `reconnect.requireToken` and `retry.phase` are all absent
(machine-checked) — the engine now behaves as the first two would have configured, but the
phase split itself still has no config spelling.
4. **`interpret` and `verdict.flag` are dead code on streaming surfaces.** A custom surface's
`interpret` ran **zero** times — `runStreaming` calls only `classifyStatus`. They typecheck
and do nothing.
5. **`hooks.onError` never fires for a post-200 stream failure.** Measured hook sequence on a
mid-body drop: `[onRequest, onResponse]`, while the run failed. Any hook-based error
pipeline is blind to this entire scenario. `onResponse` also fires *before* a single frame
is parsed — `ctx.res.body` is a live `ReadableStream`.
6. **Truncation is undetectable by any built-in.** A truncated stream and a complete one
produce the same terminal spine — `result, done(ok: true)`. `output` can't express it
either: a schema demanding the sentinel rejects delta 1 instead.
7. **The partial survives only on `.stream()`.** `.safe().data` is `null`; the thrown
`StitchError` has `body`, `data`, `partial` and `chunks` all `undefined`; and `.inspect()` —
whose whole job is "what did the server actually send?" — returns `data: null`, `raw: null`,
`status: 0`. The engine is holding the answer one line above the `return` that discards it.
**Default reconnect backoff is roughly 50 ms** (`expo-jitter` off base 100), so a dropped
stream reopens within a tenth of a second unless you author a `retry.backoff`. And
`verdict: { accept: [503] }` combined with `reconnect` resolves a permanently-failing
server **successfully** with `data: []`.
## See also [#see-also]
# One customer's revoked token, everyone's outage (/docs/scenarios/multi-tenant-blast-radius)
## The problem [#the-problem]
You integrate a vendor API on behalf of each of your customers. Every call carries that
customer's credential. At 500 customers with 8 connections each, that's
[4,000 token lifecycles](https://truto.one/blog/how-to-architect-a-scalable-oauth-token-management-system-for-saas-integrations/).
**The unit of failure is the tenant. The unit of protection usually isn't.** Rate budgets and
circuit breakers are scoped to a *dependency*, but what goes wrong is scoped to a *customer* —
their token was revoked, their admin changed a permission, their batch job went rogue. When the
protection is broader than the failure, one customer's problem becomes everyone's.
**Measured, on a shared seam with `circuit: { failures: 3, cooldown: '30s' }`:** one customer
with a revoked token failed **9 of 9 healthy customers**, and **zero** of their requests ever
reached the vendor — they fast-failed in-process with `503 circuit open`.
**And the outage does not end on its own.** Half-open admits exactly one trial call, and the
broken tenant is the one retrying hardest — so across four full cooldown windows the healthy
tenant measured `503, 503, 503, 503`. Recovery happens when a healthy tenant *happens* to win
the probe. That's a race, not a policy.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| -------------------------------------- | ----------------------------------------------------------------------------------------- |
| One client instance per tenant | Correct by construction — and usually assumed not to scale. |
| Partitioned limiter | The right shape, if your client offers one. Most don't. |
| Per-tenant circuit breaker | The named mitigation. Needs the breaker to accept a per-tenant key. |
| Global breaker, tuned high | Trades one failure mode for another — a real outage now takes far longer to trip. |
| Exclude auth failures from the breaker | Genuinely correct and usually forgotten. A `401` says the credential is bad, not the API. |
| Sharded workers by tenant | Real isolation, at the cost of a routing tier. |
## What StitchAPI does [#what-stitchapi-does]
The library already carries a tenancy axis — and it reaches exactly half of what you need.
`AuthContext.principal` is visible to the auth strategies and the cache-key builder, **and
nothing else**. So the split falls on the auth/resilience line:
| resource | isolated by | fails |
| ------------------- | -------------------------------------------------- | -------------------------------------------- |
| **Token** | `oauth2({ tenancy: 'principal' })` + `seam.as(id)` | **closed** — errors if no principal is bound |
| **Cache** | `tenancy`, defaults to `'principal'` | **closed** |
| **Rate budget** | a per-tenant limiter **key** you write | **open, silently** |
| **Circuit breaker** | a per-tenant `circuit.key` you write | **open, silently** |
The two whose isolation is a *security* property fail closed. The two whose isolation is an
*availability* property fail open. That is the whole scenario in one table.
The working construction is one shared seam plus a per-tenant member:
```ts
const vendor = seam({
baseUrl: 'https://api.vendor.com',
auth: oauth2({ tenancy: 'principal' /* … */ }),
store: redisStore(/* … */),
});
const itemsFor = (tenant: string) =>
vendor.as(tenant).stitch({
path: '/v1/items',
name: `items:${tenant}`, // partitions the rate budget
circuit: { failures: 3, cooldown: '30s', key: `items:${tenant}` }, // and the breaker
// A 401 means the CREDENTIAL is bad, not the vendor — don't let it trip the breaker,
// but don't swallow it either.
verdict: { accept: [401], flag: 'ok' },
});
```
**Measured:** the same revoked credential that took down 9 of 9 in the callout above took down
**0 of 9** here. The broken tenant still received a real `401`. A genuine `500` still opened
**that tenant's own** breaker (`500, 500, 500, 503`) with zero effect on the others. And the
20-call burst that pushed a quiet customer from t=0 to **t=2000 ms** arrived at **t=0**.
**`flag: 'ok'` has a precondition, and missing it re-creates the swallowing.** The flag is
three-state, and only a **present, falsy** value fails the call — absence is silence, not
failure. The vendor measured above answers `401` with
`{ "ok": false, "error": "invalid_token" }`. Against a vendor whose error body has no `ok` key
at all — a plain `{ "error": "invalid_token" }` is typical — `accept: [401]` stands alone and
the call resolves `ok: true` **with the error envelope as its data**. For that vendor, keep
`accept` and restore the failure in five lines of `Surface.interpret` composing `verdictOf`
(measured identically: a real `401`, 0 of 9 healthy failed, 0 circuit failures):
```ts
const credentialAware: Surface = {
id: 'http',
interpret: (res, cfg) => {
if (res.status === 401 || res.status === 403)
return {
ok: false,
message: `credential rejected (HTTP ${res.status})`,
status: res.status,
};
return verdictOf(res, cfg) ?? { ok: true, data: res.body };
},
};
```
Pass it as the stitch's `kind`, and keep `verdict: { accept: [401, 403] }` — `accept` is what
stops the engine throwing on the status before the surface is ever consulted.
## Isolation is a property of the key string, never of the object graph [#isolation-is-a-property-of-the-key-string-never-of-the-object-graph]
This is the part worth internalising, because the intuitive constructions don't work:
* **10 separate `.as()`-bound stitch objects** resolving to the same `path` → **1** breaker key,
9 of 9 healthy tenants down.
* **10 separate seams** sharing one store → same result.
* A **`url`-only stitch** keys its breaker on the literal string `'stitch'` — so every such
stitch sharing a store shares one process-wide breaker, across tenants *and* endpoints.
Conversely the correct partition is cheap: 100 keyed stitches built in under 100 ms with zero
timers armed, and 100 per-tenant seams cost well under 40 kb each with **zero** connection pools
— a seam owns no transport, so the "4,000 pools" the literature warns about isn't a cost this
construction has.
**A per-tenant seam isolates the rate budget and *not* the breaker.** Measured in one run:
the quiet tenant left at t=0 (rate isolated) while 3 of 3 healthy tenants got `503` (breaker
shared). The most isolated-looking construction is half a fix, and the missing half is the
one that causes outages.
**And `throttle: { pool: 'host' }` silently re-keys the *circuit* onto the host.** A
per-tenant `name` partition evaporates, and an unrelated endpoint for an unrelated tenant
measured `503`.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **No per-tenant declaration exists for either resource.** `ThrottleOptions.pool` is
`'stitch' | 'host'`; `CircuitOptions` is `{ failures, cooldown, key }`. `pool: 'principal'`,
`throttle.key`, `throttle.tenancy` and `circuit.tenancy` are all compile errors. You smuggle
tenancy through key strings.
2. **`oauth2` defaults to `tenancy: 'app'`** — three different customers measured **one** token
fetch and one shared `Authorization` header. Nothing at the call site hints at it.
3. **`tenancy` partitions the token *cache*, not the *credential*.** All tenants' tokens are
minted from one `client_id`, because `Secret` is a niladic thunk with no context. Per-customer
credentials need a custom `AuthStrategy.apply(req, ctx)` reading `ctx.principal` — the only
user-reachable hook that sees the bound principal at call time.
4. **Global quota *and* per-tenant fairness is not expressible in one construction.** A member
throttle stacks tighten-only on the seam bucket, so declaring "1000/m to the vendor" puts the
noisy neighbour straight back (quiet tenant returns to t=2000).
5. **Breaker records have no TTL.** A churned tenant's key was still resident after a virtual
*year*, and nothing sweeps them — while the rate counter beside it does expire.
6. **`seam.stitch()` pins every stitch it creates.** Measured with `WeakRef` after a forced GC:
200/200 root-created still reachable, versus **0/200** created through `seam.as(p).stitch()`.
The per-request shape is the one that doesn't leak; the only release for the other is
`seam.close()`, which also closes the store.
7. **Seam ids are a creation-order counter**, so per-tenant seams over a shared durable store
collide across worker processes non-deterministically.
8. **`CircuitOpenError` names nothing about who tripped it.** When the breaker is shared, you
cannot tell from the error which tenant caused the outage.
## See also [#see-also]
# The upload you must clean up after (/docs/scenarios/multipart-upload)
## The problem [#the-problem]
A 5 GB upload can't go in one request, so you use S3-style multipart:
1. `POST ?uploads` → an `UploadId`
2. `PUT ?partNumber=N&uploadId=…` × N → each returns an **`ETag` response header**
3. `POST ?uploadId=…` with the **ordered** `{ PartNumber, ETag }` list → the object
4. …and on **any** failure, `DELETE ?uploadId=…`
**Step 4 is the one nobody models.** Abandon an upload and every part already sent stays in
the bucket and bills as storage indefinitely — while being invisible to `aws s3 ls` and to the
console's objects tab. AWS's own FinOps guidance puts incomplete multipart uploads at [up to
20% of an S3 bill](https://aws.amazon.com/blogs/aws-cloud-financial-management/discovering-and-deleting-incomplete-multipart-uploads-to-lower-amazon-s3-costs/),
and there is a [lifecycle rule](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpu-abort-incomplete-mpu-lifecycle-config.html)
that exists purely to clean up after clients that didn't.
That's a **compensating action**: a failure in step 2 or 3 obliges you to make a *different*
API call. No HTTP client models it. The rest is ordinary orchestration made fiddly — the part
result is a header, the list must be in part order not completion order, concurrency needs a
bound, and progress needs XHR because `fetch` cannot report bytes sent.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Single `PUT` of the whole file | One blink and 5 GB is gone; above 5 GB S3 refuses outright. |
| Vendor SDK (`@aws-sdk/lib-storage`) | Correct, and right if you're on AWS. Large dependency, and the shape recurs on every non-AWS API. |
| Hand-rolled loop + `try/finally` | What most teams write. The abort is one early `return` from being skipped, and nothing tells you when it was. |
| tus / resumable protocol | Better where you control the server. Not an option against S3's own API. |
| Lifecycle rule as the safety net | Necessary, not a fix — you still pay for N days of orphans on every failed upload. |
## What StitchAPI does [#what-stitchapi-does]
Three things become configuration, and they're worth having:
```ts
const putPart = stitch({
method: 'PUT',
// `{?…}` is the RFC 6570 query operator, filled from `params`. A literal
// `?partNumber={part}` suffix is split off BEFORE templating and ships
// its braces percent-encoded.
url: 'https://s3.example.com/{key}{?partNumber,uploadId}',
// S3's own transient error is 500 InternalError — NOT in the default set.
retry: { attempts: 3, on: [429, 500, 502, 503, 504] },
// One stitch, called N times. `pool: 'host'` because the default is per-stitch.
throttle: { concurrency: 4, pool: 'host' },
adapter: xhrAdapter(), // fetch cannot report bytes sent
kind: {
id: 'http',
// The part's result is a HEADER. Without a surface it is unrecoverable —
// `.inspect()` carries no headers at all.
interpret: (res, cfg) =>
verdictOf(res, cfg) ?? { ok: true, data: res.headers['etag'] },
},
});
```
**Measured:** per-part retry re-sent only the failing part (arrival order `[1,2,3,4,3]`, one
initiate, zero orphans). `throttle: { concurrency: 3, pool: 'host' }` held peak in-flight at
**3** across 8 parts. `xhrAdapter` reported 4 upload ticks per part *before the response
existed*; the same call through `fetchAdapter` reported **zero**.
## What it does not do — and this is the point of the page [#what-it-does-not-do--and-this-is-the-point-of-the-page]
**There is no compensation seam.** `Hooks` is exactly `{ onRequest, onResponse, onError,
onRetry }`, and `onError` is not a failure hook — it's the `catch` around the transport. On an
HTTP 500 the measured hook sequence was `[onRequest, onResponse]` with **zero** `onError`
calls. `HookContext` has no run-scoped slot to hold an `UploadId`, and `linked()` has no
`finally`.
So the cleanup is a plain `try/finally` in your own orchestration function — and it has to be,
because the `UploadId` only exists there. Measured, with a part failing and no user cleanup:
| | orphaned parts | DELETEs |
| --------------------------- | ----------------------------------- | ------- |
| no cleanup | **3 (15 MiB), 1 dangling UploadId** | 0 |
| cancelled via `AbortSignal` | **2** | 0 |
| `timeout.total` expiry | **3** | 0 |
| user-written `try/finally` | 0 | 1 |
Cancellation is not cleanup: the engine cancels in-flight work and forgets the work that
landed.
## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution]
**141 lines against 163** hand-rolled — 22 shorter, and the difference attributes exactly to
the retry loop with backoff, the concurrency pool, the retryable-status set and URL assembly,
all of which became config.
What did *not* shrink is the half the scenario exists for: the `try/finally`, the
loud-cleanup rule, the per-part high-water progress map and the input-order assembly are
byte-for-byte identical on both sides. The library is a bystander for the compensation.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **The compensating call.** No hook, no config key, no surface position runs on failure.
2. **`hooks.onError` is not a failure hook** — zero calls on an HTTP 500.
3. **Cancellation ≠ cleanup.** `AbortSignal` and `timeout` both leave orphans.
4. **`all()` bounds nothing** — measured peak **8** over 8 members — and it hands every member
the *same* input, so one stitch × 8 members produced 8 PUTs all carrying `partNumber=1`.
5. **`throttle.concurrency` defaults to `pool: 'stitch'`.** Eight stitches at `concurrency: 3`
each measured a peak of **8**. Use `pool: 'host'` or a seam bucket.
6. **`all()` discards partial results on fail-fast** — 2 parts stored, **0 nameable** for the
abort. There is no `allSettled`; only an `onResponse` side channel recovers them.
7. **The default `retry.on` excludes 500**, which is S3's own transient error. Measured: 4 PUTs,
1 failed part, 3 orphans with the default set.
8. **Whole-upload retry is neither flagged nor prevented** — `retry` on the outer orchestration
measured **3 initiates, 9 orphaned parts, 45 MiB**.
9. **A progress tick has no identity** — `{ direction, loaded, total }` only, so a shared
`onProgress` across a fan is unattributable. And ticks are cumulative *within* a part, so
summing them overshoots: naive `Σ loaded` measured **400** against a real 160.
10. **Response headers are absent from `.inspect()`**, so without a surface the `ETag` is
unrecoverable on the awaited path.
**Two ways cleanup lies to you, both measured.**
**`.safe()` on the abort cannot throw.** Inside a correct-looking `try/finally`, pointed at
a wrong `UploadId`: **0 accepted DELETEs, 3 orphaned parts, and nothing thrown anywhere**.
The `finally` ran. That is a permanent invisible bill with a clean code review — make the
cleanup failure loud.
**Cleanup inside `Surface.execute` runs after the caller returns.** Measured at the instant
the caller's promise settled: **3 orphans, 0 DELETEs**; the DELETE landed several turns
later. In a lambda, or any process that exits on the error, the later half never happens —
and the code reads as though the engine owns the lifecycle.
## See also [#see-also]
# One list, a hundred follow-up calls (/docs/scenarios/n-plus-one-fanout)
## The problem [#the-problem]
`GET /orders` returns 100. Each carries a `customerId`, so you make 100 more calls. It is the
most common composition shape in API integration, and every part of it is a decision.
**N is unknown until runtime**, and every call needs a *different* input. Then:
* **Concurrency** — all 100 at once trips a rate limit; one at a time wastes the afternoon. And
vendor governance is often concurrency-based, so a requests-per-second cap doesn't protect you.
* **Partial failure** — `Promise.all` rejects on the first error **and discards the successes**.
* **The thundering herd** — 100 calls that 429 together and back off by the same amount retry
together. Deterministic backoff *re-clusters* the burst.
* **Duplicates** — 100 orders commonly reference far fewer customers.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `Promise.all(ids.map(fetch))` | Unbounded concurrency, and one failure discards every success. |
| `Promise.allSettled` + `p-limit` | Correct — two dependencies and a hand-rolled join. |
| Sequential loop | Safe, N× the latency. |
| A batch endpoint | Best where offered, and then you inherit [partial-failure semantics](/docs/scenarios/batch-partial-failure). |
| Cache / dedupe by id | Free quota — only if **in-flight** duplicates collapse too. |
| `?expand=` on the list | The real fix. Rarely offered. |
## What StitchAPI does [#what-stitchapi-does]
**`cache.coalesce` collapses in-flight duplicates, and that is the headline.** 100 concurrent
calls over 30 distinct ids made **30 requests** — one per id, exactly the floor — with every
call in flight and **not one response landed**. Seventy callers were served without a request of
their own, from a single `cache: { ttl }` block and no user code. The same cache with
`coalesce: false` made **100**.
Three more things are configuration:
```ts
const customer = stitch({
url: 'https://api.vendor.com/customers/{id}',
throttle: { concurrency: 8 }, // measured: peak 8 exactly, against 100 unbounded
cache: { ttl: '60s' }, // the dedupe
retry: { attempts: 3 }, // jittered by default
});
```
**Measured:** bounded concurrency held **peak 8** exactly. And the retry default genuinely
de-clusters a herd — 100 calls 429'd in the same instant retried across **\~98 distinct
milliseconds** under `expo-jitter`, where `'fixed'` **and** `'expo'` both put all 100 into **one
millisecond** (doubling a constant is still a constant on attempt 2).
`linked` gives the fan-out **one trace** — 1 traceId, 1 root, 101 spans covering the list and
every lookup, with per-call inputs. Assembled: **45 executable lines against 87** hand-rolled,
the difference being the FIFO pool, the retry loop, the retryable-status set and URL assembly,
all of which became config.
## Where it loses: a coalesced failure is not shared [#where-it-loses-a-coalesced-failure-is-not-shared]
The leader's *failure* releases its joiners to re-run. Measured: 100 concurrent calls for one
id that 404s made **100 requests in two waves** — 1 leader, then 99 followers each re-running
the whole chain.
Every joiner gets its own honest `HTTP 404`, never a leader artefact — the right *error* at the
wrong *price*. End to end this is the one place the hand-rolled version wins: **44 customer
requests against 32**, and a deleted customer cost **4 requests against 1**. A `Map`
shares the rejection; the library spends a wasted request per duplicate reference to a broken
id — which is exactly the shape a dead foreign key takes.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **The combinators cannot express this** — and not for the reason you'd guess. The runtime
length is fine (`all(ids.map(…))` compiles). **The input is the wall**: `all()` spreads one
`StitchInput` into every member, so 100 members made **100 requests for one distinct id**.
It also bounded nothing (peak 100) and fail-fast **discarded 99 successful fetches** while
still paying for them — the auto-cancel prevented **zero** requests.
2. **`allSettled` semantics are absent**, and the documented workaround — compose `.safe()`
members by hand — does not typecheck, because `Member` is brand-gated.
3. **In-flight dedupe of failures**, above.
4. **A fan-shaped trace over per-call inputs.** You get a fan (`all()`, wrong inputs) or per-call
inputs (`linked`, which chains each call under the previous — measured **depth 101, fan-out
1** for calls that ran at peak 100 concurrently). Not both.
5. **Deep-copying joined results** — see the aliasing callout.
6. **A batch endpoint.** Nothing can invent one.
**A declared concurrency budget silently multiplies across stitch objects.** One stitch called
100 times at `concurrency: 8` → peak **8**. One hundred *separate* stitches each declaring 8 →
peak **100**, because the limiter is per stitch. `pool: 'host'` repairs it, and a lease-capable
`store` keeps the repair — measured peak **8** — and makes it fleet-wide: the store holds
**one** budget under the same host key, so eight in flight means eight across every worker on
that store. The remaining trap is a store *without* the lease verbs (an eventually-consistent
KV, a minimal custom store): concurrency falls back to per-process there, and the multiplied
budget returns with the config unchanged. A seam-level `concurrency` survives the
stitch-per-id shape on its own.
Related: **a backing-off call holds its slot.** With a bound of 4 and a 1 s backoff, \~95% of
the budget sat idle on sleeping calls, and the retry re-queued at the **back** of the FIFO.
**`cache: { ttl: 0 }` caches forever.** It is the obvious spelling for "dedupe but don't
cache", and `expires === 0` reads as live — a later fan-out added **0 requests**. There is no
coalesce-only spelling.
**`Retry-After` defeats the jitter by default.** `retry.respect` is on, so a 429 carrying
`Retry-After: 2` put all 100 retries back into **one millisecond** with `expo-jitter` still
declared. `respect: false` restores the spread and is all-or-nothing.
**Coalesced and cached callers share one object by reference.** 20 rows over 5 customers gave
**5 distinct objects** — mutating row 0 changed row 5. The aliasing arrives with the
optimisation.
Two smaller ones: **`sensitive: true` silently disables coalescing**, and coalescing is
GET/HEAD only until you name `methods`.
## See also [#see-also]
# OAuth2 refresh tokens that rotate (/docs/scenarios/oauth2-refresh-token-rotation)
## The problem [#the-problem]
Your backend holds a per-account **refresh token** for a third-party API —
Atlassian, Asana, Xero, Slack — and exchanges it for short-lived access tokens.
The provider implements
[RFC 6819 §5.2.2.3](https://datatracker.ietf.org/doc/html/rfc6819#section-5.2.2.3)
replay detection: redeeming a refresh token returns **a new one** and invalidates
the old, and presenting an already-redeemed token is read as evidence of theft.
So the provider does not reject one call — it **revokes the entire token family**.
The user is silently disconnected and has to re-authorize in a browser.
Now add ordinary concurrency. A sync job, a webhook handler, and a user request
all hit `401` at the same instant. Each independently decides to refresh. The
first redemption rotates the token; every other one presents a consumed token and
trips replay detection.
The failure mode is not "a request failed." It is "the integration lost the
account," and it only appears under load.
This is not a beginner's mistake. It has been filed against [OpenAI
Codex](https://github.com/openai/codex/issues/10332), the [MCP TypeScript
SDK](https://github.com/modelcontextprotocol/typescript-sdk/issues/1760),
and [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy/issues/1992)
— and it was a CVE in an auth library whose whole job is this
([GHSA-392p-2q2v-4372](https://github.com/better-auth/better-auth/security/advisories/GHSA-392p-2q2v-4372)).
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Refresh-on-401 interceptor | The baseline bug. N concurrent 401s ⇒ N redemptions ⇒ family revoked. |
| In-process promise memo | Correct for **one** process. Silently insufficient at two workers — and it looks fixed in dev, where there is one. |
| Distributed lock (Redis `SETNX`) | Works, and makes refresh a distributed-systems problem: lock TTL vs latency, crash-while-holding, fencing. |
| Proactive refresh before expiry | Shrinks the window; does not close it. The skew boundary is a moment every worker crosses together. |
| Provider grace period | Not yours to choose. Auth0 and Okta offer one; Atlassian and Asana do not. |
| Dedicated refresh worker | Genuinely correct. Costs a deployable, and a cold access token now waits on a queue round-trip. |
There is no one-liner. The honest minimum is **coordination scoped to the account
and spanning processes**, plus **durable persistence of the rotated token before
the old one is treated as spent**.
## What StitchAPI does [#what-stitchapi-does]
Not `oauth2()`. That strategy implements the `client_credentials` grant, which has
no refresh token by design — it reads only `access_token` and `expires_in` from
the token response, so a rotated `refresh_token` is discarded.
**`params` will let you look like you configured rotation.** It merges into the
token-request body and can override `grant_type`, so
`params: { grant_type: 'refresh_token', refresh_token: rt }` compiles, and
the **first redemption returns 200**. The second sends the same consumed token
and the provider revokes the family. There is no error and no type friction at
the moment you write it. Don't.
What StitchAPI gives you is the **seam**: `AuthStrategy` is a public exported type,
and a strategy receives an `AuthContext` with an async `vault` it owns, plus a
`shouldRefresh`/`refresh` pair wired into the call. You write the rotation policy;
the runtime owns where it runs.
```ts
import type { AuthContext, AuthStrategy } from 'stitchapi';
/** What the vault holds for the access token. `expiresAt: 0` = no known expiry. */
type CachedAccess = { token: string; expiresAt: number };
export function rotatingRefresh(opts: RotatingRefreshOptions): AuthStrategy {
const skew = opts.skewMs ?? 30_000; // treat the token as stale this early
const accessKey = `rr:${opts.key}:access`;
const refreshKey = `rr:${opts.key}:refresh`;
let inFlight: Promise | undefined;
const redeem = async (ctx: AuthContext): Promise => {
const stored = (await ctx.vault.get(refreshKey)) as string | undefined;
const res = await opts.adapter({
url: opts.tokenUrl,
method: 'POST',
headers: { accept: 'application/json' },
body: {
grant_type: 'refresh_token',
refresh_token: stored ?? opts.seedRefreshToken,
client_id: opts.client.id,
client_secret: opts.client.secret,
},
bodyType: 'form',
});
const body = (res.body ?? {}) as TokenResponse;
if (res.status >= 400 || !body.access_token)
throw new Error(`refresh_token grant failed: HTTP ${res.status}`);
// Persist the ROTATED token first — the old one is spent server-side the
// moment the provider answered, so this write must land before use.
if (body.refresh_token)
await ctx.vault.set(refreshKey, body.refresh_token);
const ttl = body.expires_in ? body.expires_in * 1000 : undefined;
await ctx.vault.set(
accessKey,
{ token: body.access_token, expiresAt: ttl ? Date.now() + ttl : 0 },
ttl,
);
return body.access_token;
};
// Coalesce concurrent redemptions; clear on settle so a failure never sticks.
const redeemOnce = (ctx: AuthContext): Promise =>
(inFlight ??= redeem(ctx).finally(() => {
inFlight = undefined;
}));
return {
name: 'rotatingRefresh',
async apply(req, ctx) {
// `vault.get` returns `Promise` — type the read. Freshness
// is the STORED expiry minus a skew, so a token about to lapse
// mid-request is already treated as stale.
const cached = (await ctx.vault.get(accessKey)) as
CachedAccess | undefined;
const fresh =
cached &&
(cached.expiresAt === 0 ||
Date.now() < cached.expiresAt - skew);
req.headers['authorization'] =
`Bearer ${fresh ? cached.token : await redeemOnce(ctx)}`;
},
shouldRefresh: (res) => res.status === 401,
async refresh(ctx) {
await redeemOnce(ctx);
},
};
}
```
Attach it like any other strategy, to any stitch:
```ts
const issues = stitch({
baseUrl: 'https://api.atlassian.com',
path: '/ex/jira/{cloudId}/rest/api/3/search',
auth: rotatingRefresh({ key: `jira:${accountId}` /* … */ }),
store: redisStore(/* … */),
});
```
**Measured:** 20 concurrent cold callers produce **1** redemption and **0**
replays. Cross-worker exclusion — built on `vault.increment(key, ttl) === 1` to
acquire and `vault.set(key, undefined)` to release — collapses **3 workers × 10
callers to 1** redemption, and costs **42 more lines**.
## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution]
The line count is not the win. A correct axios interceptor with a Redis lock is
about as long. Three things change:
* **The policy is one object, not a call-site convention.** Every stitch that
names this strategy gets it — the CLI, an HTTP route, and an agent tool
included. Nothing can call the API and miss the interceptor.
* **The token never reaches the caller.** It lives in the vault and the outgoing
header. A caller, an agent included, receives data — never the credential. See
[Capability, not credential](/docs/concepts/capability-not-credential).
* **The store is already there.** `vault` is scoped, async, and shared by `key` +
`store`, so "where does the rotated token live" is answered before you start.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
Blunt, because getting this wrong costs an account:
1. **`oauth2()` does not implement the refresh-token grant.** Rotation is entirely
yours to write.
2. **`params` is a footgun for this.** It works once, then revokes the family.
3. **A shared `store` is a cache, not a lock.** Two cold workers fire one token
request *each* — measured: 2 workers ⇒ 2 requests, 2 workers × 10 callers ⇒
still 2. The store only helps once a write has landed.
4. **The built-in single-flight is per strategy instance, in-process, and
time-windowed.** 20 *simultaneous* 401s coalesce to 1 refresh; 20 staggered
8 ms apart produced **10**. Coalescing is not identity-scoped.
5. **There is no distributed-lock primitive.** `StitchStore` is
`get`/`set`/`increment`/`close` — no compare-and-set, no fencing token, no
blocking wait. A lock built on `increment` leans on cross-process atomicity
that the store contract only guarantees *within* a process; confirm your
backend before relying on it.
6. **Nothing enforces write-before-use ordering.** The vault is a plain KV. The
"persist before the old token is spent" discipline is yours.
7. **`cookieSession` is not an alternative path.** Its refresh hook receives
`{ ok, status }` — the login response body never reaches you, so it cannot
carry a rotated token.
8. **One refresh per call.** If the retry after a refresh also 401s, the call
fails. A lock timeout surfaces to the caller as a 401.
## See also [#see-also]
# The customer data you didn't mean to log (/docs/scenarios/pii-in-the-logs)
## The problem [#the-problem]
You integrate a vendor API that returns customer records — names, emails, addresses, health or
legal detail. You add tracing, because you are responsible about observability. Six months later
someone greps the log aggregator and finds all of it in plain text.
Nobody logs PII on purpose. **Middleware does** — the two most-cited causes in the field are a
debug endpoint returning full user objects and
[a logging middleware that captures raw request bodies](https://hoop.dev/blog/why-pii-leakage-happens-in-apis/),
and the second is exactly what a well-instrumented client library is.
Three things make it structural rather than careless. **You cannot enumerate what is sensitive in
advance** — a denylist of field names misses `primaryContactMail`, `profile.contact.mail`, and
an address inside a free-text note. **The vendor adds a field and it starts flowing**, which makes
this a drift problem, not a configuration one. And **the regulation is about defaults**: GDPR
Article 25 mandates protection by design *and by default*, so "we can turn redaction on" is not
the same thing.
## The common solutions [#the-common-solutions]
| Approach | What it is | Where it breaks |
| ----------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------- |
| **Denylist of field names** | Scrub `email`, `ssn`, `card`. | Misses renamed, nested and free-text fields. Silently incomplete the day the vendor adds one. |
| **Allowlist of safe fields** | Log only what you named. | Correct by construction; needs the whole response shape — the thing that drifts. |
| **Don't log bodies at all** | Metadata only. | Safe and often unusable: the body is what you need when debugging an integration. |
| **Scrub at the aggregator** | Filter on ingest. | The data already left your process and crossed a network. |
| **Gateway/sidecar redaction** | Strip in a proxy. | Another hop, and it cannot know which field is sensitive in *your* domain. |
## What StitchAPI does [#what-stitchapi-does]
### The exposure is binary, which makes it auditable [#the-exposure-is-binary-which-makes-it-auditable]
Measured with seven distinct sentinels — a name, an email, an SSN, a nested
`profile.contact.mail`, an email inside a prose `note`, one in `contacts[1].email`, and a renamed
`primaryContactMail`. **Twelve destinations carry all seven. Twelve carry zero. Nothing is
partially redacted.**
| carries the whole body | carries none of it |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| the `result` event · `fileSink` at any non-zero cap · `.inspect().raw` · `.inspect().data` · `JSON.stringify(inspect())` · `.report()` · `StitchError.body` · the cache entry | `start` / `progress` / `done` / `error` events · `consoleSink` · `loggerSink` · `otlp.sink` · `StitchError.message` · `String(err)` · `err.stack` · `JSON.stringify(err)` |
Exactly **one event** carries the response body — `result`, on `data`. "The event spine leaks" is
really "one event leaks", which is a much smaller thing to reason about.
### An `output` allowlist genuinely filters — 9 lines [#an-output-allowlist-genuinely-filters--9-lines]
This is the answer, and it works because the engine serves the **validated** value —
[measured in scenario 20](/docs/scenarios/precision-loss), and since
[#663](https://github.com/rejifald/StitchAPI/pull/663) no longer an asymmetry: an `input` schema
now shapes the request the same way (scenario 20 caught it merely checking;
[#648](https://github.com/rejifald/StitchAPI/issues/648) filed the gap).
Either direction filters the slots you **declare** — a slot with no schema on it still flows
through untouched, which is why the nine lines below have to be written.
```ts
const SAFE = z.object({
id: z.string(),
status: z.string(),
total: z.number(),
});
const getCustomer = stitch({ url: '…', output: drift(SAFE) });
```
A four-field schema took the JSONL sink, the `result` event, the console line, the cache entry
and the whole `.inspect()` wrapper from **7 sentinels to 0** — at depth (`profile.contact` gone)
and inside array elements (`contacts[].email` gone), **without naming a single PII field
anywhere**. That is the allowlist property: it survives the vendor adding a field.
### `drift()` gives you the inventory without the data [#drift-gives-you-the-inventory-without-the-data]
Wrapped in `drift()`, the same allowlist emits a value-free record of everything it stripped —
seven `undeclared` findings whose paths name the fields and whose details are **kinds only**
(`undeclared field (string)`), with zero sentinels across every finding, every drift event and
every sink. That is exactly the *log the detection, not the data* shape the guidance recommends.
When the vendor adds `taxId` at three levels, you get exactly three new `undeclared` findings.
### Declarative credentials never enter the spine [#declarative-credentials-never-enter-the-spine]
`auth.apply` runs on a request clone inside the attempt loop, while the `start` event was built
from the pre-auth request — so a `bearer` token or `apiKey` in query or cookie is **0 of 3** even
for a naive custom sink. Hand-rolled request credentials are scrubbed by every built-in sink:
`"authorization":"[REDACTED]"`, a scrubbed `url`, and no headers at all on console, logger or
OTLP.
## What StitchAPI does not solve [#what-stitchapi-does-not-solve]
1. **`sensitive: true` does not mean "do not log".** It changed the leak at **1 of 11**
destinations — the cache. Across all of `packages/core/src` there is exactly **one** read of
the value, `engine.ts:1051`, inside `ensureCache`. No sink, no trace module and no event
builder mentions it. It means *do not persist this to the cache*, and while it is set the
JSONL sink still writes the full body to disk. It also survives onto the public `__config`, so
`.report()` prints `"sensitive":true` beside the record it did not protect.
2. **`.inspect({ redact: true })` removes 0 of 7.** The shared denylist is the *credential* list
(`token`, `secret`, `password`, `apikey`, `signature`, …) reused — and no PII field name is on
it. `redact: ['mail', 'email']` does work, at depth and across array elements, but only for
names you enumerate; a renamed key and an address in free text are unreachable by
construction. There is no stitch-level or process-level default — ADR 0018's `defaultInspect`
was never implemented.
3. **`err.body` still holds the whole record; only `JSON.stringify` leaves it out.**
`StitchError.toJSON()` emits `{ name, message, status, attempts, url }` (since
[#873](https://github.com/rejifald/StitchAPI/issues/873)), so `JSON.stringify(err)` and a logger
that serializes through it are clean. `body` is still an own enumerable property, so a
serializer that copies the error's fields itself — pino's `err` serializer does — still writes
it: under pino, **`logger.error({ err })` is the leak.** The same shape repeats on success:
ADR 0016's non-enumerability protects `.inspect().raw` and nothing else, because
`.inspect().data` holds the same record enumerably.
4. **A validation error can carry the value into a log — when the validator's message echoes
it.** `validationErrors` (`drift.ts:50-56`) copies the validator's own message into `detail`
verbatim, so what escapes is decided by the message's wording, not by anything in this
library. Stock Zod 4 (the workspace's validator since
[#589](https://github.com/rejifald/StitchAPI/pull/589)) no longer echoes the received value —
its enum error reads `Invalid option: expected one of "enterprise"|"free"`, and every sink
measures clean. A message that does embed the input — a custom `refine`/`check` message, or
Zod 3's enum wording — is measured reaching the JSONL file, `consoleSink` **and** `loggerSink`
— the two sinks that are otherwise 0 of 7. Only OTLP stays clean, because it drops `detail`.
5. **The `fileSink` body cap is a size control, not a privacy control.** On a 2.9 KB body all
seven sentinels still persisted into the `preview`; only an eighth planted past character 2048
was absent. It keeps a *prefix*, so reordering the vendor's JSON changes which fields leak.
6. **Only `hooks.onResponse` covers the failure path.** On a 200, `interpret`, `transform` and the
hook are equivalent. On a 500 the engine throws carrying the untouched response, so
`StitchError.body` stays at 7/7 under `transform` and under a stripping `interpret` — the hook
holds at 0/7 only because it *mutated* `ctx.res.body` in place. Its type is `(ctx) => void`,
and mutating the body there is nowhere described as a privacy mechanism.
7. **The boundary and the drift signal are mutually exclusive.** Stripping in the hook takes
`drift()` to **0 findings**, because drift diffs exactly the bytes the boundary removed.
Having both means re-implementing `drift.ts`'s walker — 23 lines, and not exported.
8. **Nothing here is upstream of the `Adapter`**, which read the bytes first. A spy inside the
transport still sees all seven.
**A credential that rides the payload is scrubbed by name; PII is not.** The
JSONL sink runs the shared secret-key denylist over every payload it
persists (since [#873](https://github.com/rejifald/StitchAPI/issues/873)),
so an `access_token` in a *response* body or a `client_secret` in a
*request* body reaches disk as `[REDACTED]`, before the cap cuts a
`preview`. None of the seven PII names above is on that credential list, so
the file sink's seven of seven stands — `secrets.register` widens it one
exact name at a time, which is the denylist approach the table at the top
rules out.
## The assembled setup [#the-assembled-setup]
**42 executable lines across 2 seams** — `hooks.onResponse` plus `output: drift(SAFE)` — reaches
**0 of 9 destinations on both the success and the failure path**, and still recovers a names-only
inventory of every undeclared field. Of those 42 lines, 23 re-implement the walker `drift.ts`
already contains.
The cheaper variants are honest about their edges: the allowlist alone is 9 lines and 1 of 10 on
a 200 (only `.inspect().raw`, by design) but leaves the 500 path untouched; the boundary alone is
7 lines and 0 of 10 on both paths, at the cost of the entire drift signal.
## See also [#see-also]
* [Trace sinks](/docs/guides/observability/trace-sinks) — the sinks measured above
* [Drift](/docs/guides/validation/drift) — the inventory channel
* [Scenario: the ID that changed on the way in](/docs/scenarios/precision-loss) — where
`output`'s serve-the-parsed-value behaviour was measured
* [Scenario: the agent picks the arguments](/docs/scenarios/agent-holds-the-tool) — the other
scenario about data reaching somewhere it should not
# The ID that changed on the way in (/docs/scenarios/precision-loss)
## The problem [#the-problem]
You integrate an API whose IDs are 64-bit integers — Discord and Twitter/X snowflakes, database
primary keys. The vendor sends them as JSON numbers. Measured through StitchAPI's real default
transport:
| sent | received | |
| --------------------- | --------------------- | --------------- |
| `1234567890123456789` | `1234567890123456768` | off by 21 |
| `9007199254740993` | `9007199254740992` | 2⁵³+1 |
| `9223372036854775807` | `9223372036854775808` | int64 max |
| `9007199254740991` | `9007199254740991` | control, intact |
`JSON.parse` produces IEEE 754 doubles, so integers above 2⁵³ are not all representable. No
throw, no warning.
Three things make it hard rather than merely annoying. **The damage happens before your code
runs** — by the time any hook or schema sees the value it is a `Number` and the digits are gone.
**It is data-dependent, so it arrives on a date**: IDs below 2⁵³ round-trip perfectly, and
snowflakes are time-ordered, so the failure lands fleet-wide at once. And **the fix is not
local** — not using `JSON.parse` means values become `BigInt` or `string`, which breaks
`JSON.stringify`, cache serialisers, arithmetic and every validator expecting `number`.
It is also a cross-language interop bug specifically: Python and Java parse the same payload
exactly, so the vendor's tests pass and it is entirely on your side of the wire.
**Money is a different problem wearing the same hat.** `19.99` round-trips
through the wire fine, because the nearest double's shortest form *is*
`"19.99"`. The value is still inexact (`19.98999999999999843681`), so
decimals fail in **arithmetic**, not in **transport**. Only integers above
2⁵³ are a wire-fidelity bug.
## The common solutions [#the-common-solutions]
| Approach | What it is | Where it breaks |
| --------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------- |
| **Use the vendor's string field** | Read `id_str` instead of `id`. | Correct and free — when the vendor provides one. Most do not. |
| **`json-bigint` / custom parser** | Replace `JSON.parse` wholesale. | Correct at the boundary; now every consumer must handle `BigInt`. |
| **Reviver on `JSON.parse`** | `JSON.parse(text, reviver)`. | **Does not work** — the reviver receives the already-parsed `Number`. |
| **Regex the raw text** | Quote big integers before parsing. | Works, and is a JSON parser written in regex. Breaks on numbers inside strings. |
| **Keep everything as strings** | Treat IDs as opaque text. | The most robust answer, enforced by convention across every layer. |
## What StitchAPI does [#what-stitchapi-does]
### The repair is config, not a custom adapter — 16 lines [#the-repair-is-config-not-a-custom-adapter--16-lines]
`wire: { response: 'text' }` is an `else if` branch at `http-adapter.ts:123` that returns
**before** the JSON branch at `:135`. So the stock `fetchAdapter` hands you the verbatim bytes
and `transform` runs pre-parse:
```ts
const getThing = stitch({
url: 'https://api.vendor.test/v1/things/{id}',
wire: { response: 'text' }, // stop the transport parsing
transform: parseBigIntsAsStrings, // parse it yourself
output: z.object({ id: z.coerce.string() }),
});
```
Measured end to end, this delivered the **exact sent digits** `1234567890123456789`, with zero
findings and a result that `JSON.stringify`s with no replacer. The `transform` is a
single-pass scanner — correct where a regex is not, leaving
`"order 1234567890123456789 shipped"` and escaped-quote strings untouched.
### A detector that stays loud without failing the call — 15 lines [#a-detector-that-stays-loud-without-failing-the-call--15-lines]
```ts
output: drift(
z.object({
id: z
.number()
.transform((n) => (Number.isSafeInteger(n) ? n : String(n))),
}),
);
```
produces `warn|coerced|id|number -> string`, inserts a `drift` event into the spine, and the call
still resolves `ok: true`. So it reaches `.inspect().findings`, `.report()`, any `TraceSink`, and
`loggerSink` at warn level — because a finding's level *is* its log level.
Over 20,000 random Discord-range snowflakes: **zero false negatives**, 0.535% false positives
(roughly the spacing of representable doubles at 1.1×10¹⁸). Walking the boundary shows
`lossless=false` never co-occurs with `flagged=false`, so **false negatives are impossible**, not
merely unobserved.
### Three lossless paths, and two that are not [#three-lossless-paths-and-two-that-are-not]
Splitting by **decoder**, not by surface. `stream` with the default `decode: 'bytes'`,
`decode: 'lines'`, and `download` all bypass the JSON branch entirely and deliver verbatim bytes.
`decode: 'ndjson'`, `decode: 'json'` and `sse` all corrupt, because each calls `JSON.parse` in
its own file. Note a bare 19-digit SSE payload *is* valid JSON, so even an unstructured `data:`
line corrupts.
### Two places the library already got this right [#two-places-the-library-already-got-this-right]
`cache.ts:43` tags bigint cache keys deliberately, so `42n` cannot collide with the string
`"42n"` — two identical bigint queries coalesced to one call. And `trace.ts` ships a `bigintSafe`
replacer so tracing cannot break the call it observes, which makes a `fileSink` the one
diagnostic surface in this whole scenario that ends up holding the vendor's real digits.
## What StitchAPI does not solve [#what-stitchapi-does-not-solve]
1. **The default path corrupts, and says nothing.** The whole spine is `start`, `progress`,
`result`, `done` — **four events, zero drift, zero error, zero info** — and the sent digits
appear nowhere in it. `.report()` adds nine keys and no findings. Nothing downstream is
withholding a warning it could have given: `hooks.onResponse`, `Surface.interpret`,
`.inspect().raw`, `.report()` and a `TraceSink` all hold the already-parsed number. `raw`
means pre-**validation**, not pre-parse.
2. **A `bigint` in `params` silently vanished — this audit found it, and it is fixed.**
`expandTemplateVar` branched on `string | number | boolean`, so a bigint produced
`…/v1/things/` — no error, no event — while the sibling `query` slot handled bigint
**exactly**: the two URL positions disagreed with each other.
[#661](https://github.com/rejifald/StitchAPI/pull/661) fixed it before the issue draft was
even filed — `bigint` joined the scalar arm (`util.ts:421`) and the folded `params` slot is
typed `string | number | bigint` — so path and query now agree, measured as the exact digits
in the URL. Of four outbound positions bigint works in three and throws in one (JSON body —
loud, and correct).
3. **Reading an id and handing it straight back is eight lines and wrong.** A number in `params`
goes out as `1234567890123456800` — a **third** distinct digit string, and not the one a
debugger shows you. That is the `Unknown Channel` shape, with nothing reported.
4. **`wire.response` and `transform` are independent keys.** Set the first and forget the second
and the call silently returns a **string** instead of an object. They are independent at the
type level too: `transform` is `(body: unknown) => unknown`, so a parser written
`(text: string)` does not typecheck in the slot even though `wire.response: 'text'` guarantees
a string at runtime.
5. **`transform` is redacted from `__config`**, so "is this stitch repaired?" is only half
auditable — `wire` shows, `transform` does not.
6. **`wire.response` is an HTTP key** and does nothing for `sse` or `stream({ decode: 'ndjson' })`,
which parse in their own files.
7. **The seam that solves this has no guide page.** `wire.response` appears in the docs only in
passing, in the GraphQL guide's list of what `wire.body` does *not* do. The one config key
that recovers a corrupted ID is effectively undiscoverable.
8. **The detector buys visibility, not correctness.** The value it reports,
`1234567890123456800`, is still not the value the vendor sent.
Under a BigInt repair, `JSON.stringify(report)` throws `Do not know how to
serialize a BigInt` — and `.report()` is documented as safe to log. A
JSON-backed `store` throws on the write and the throw is **fatal** (`ok:
false`), not a silent cache miss. `memoryStore` survives, because it holds
values by reference and never encodes. Adding a bigint replacer to a JSON
store fixes the throw and introduces a subtler bug: `typeof data.id` is
`bigint` on a cache miss and `string` on a hit — a type that depends on
cache state, which a cold-cache test suite never sees.
## See also [#see-also]
* [Config types](/docs/reference/config-types) — where `wire.response` lives
* [Drift](/docs/guides/validation/drift) — the channel the detector reports through
* [Scenario: the export that eats the heap](/docs/scenarios/large-response-memory) — the other
scenario decided by which decoder runs
* [Scenario: a canary rollout of a response-shape change](/docs/scenarios/intermittent-drift) —
`drift()` used for its intended purpose
# Failing over to the backup provider (/docs/scenarios/provider-failover)
## The problem [#the-problem]
You depend on a provider that will eventually be down — an LLM API, an SMS gateway, a payment
processor — so you line up a second one of the same shape.
Two techniques wear similar clothes:
* **Failover** — try the primary; on failure, try the backup. **One** call on the happy path.
* **Hedging** — fire both immediately, take the first. **Two** calls, always, for better tail
latency.
Picking the wrong one is a bill, not a bug report.
And the trigger has to **classify**, not just notice. The industry consensus is consistent:
timeouts (`408`), `429` and the whole `5xx` family are availability errors — and so is no
status at all, the connection that never got an answer. Try the next provider. A `400` is a
bad request — **stop**, because your payload is malformed and the backup will reject it
identically.
**`any()` is named for failover and priced as a hedge.** Its docstring says *"failover across
interchangeable sources — a primary and a mirror, two regions, two providers"*, over an
implementation that starts **every** member eagerly.
Measured: ten calls where the primary succeeded every time cost **10 primary + 10 backup
requests — 20 requests for 10 answers**, 100% amplification against a provider that never
failed. The same providers under `try`/`catch`: `[10, 0]`.
**"The losers are auto-cancelled" is not "the losers are free."** The abort runs in a
`finally` *after* the winner settles, so the loser's request always arrives: 10/10 backup
requests completed, **zero** aborted. And `any` has **no preferred member** — a healthy
primary that was merely 10 ms slower *lost*, and was aborted mid-flight.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| -------------------------- | ---------------------------------------------------------------------------------------------------- |
| Sequential fallback | The correct default — one call in the happy path. Adds the primary's timeout to the failure path. |
| Concurrent "first success" | Best latency, **double spend on every call** — including the 99% that didn't need it. |
| Hedge after a delay | The nuanced answer. Needs a threshold, and amplifies an outage exactly when you can least afford it. |
| Gateway / router | Complete, and a third party in the path plus a bill. |
| Retry, not failover | Right for a `429`; useless when the provider is genuinely down. |
| Classify then route | What everyone converges on, and what hand-rolled failover usually skips. |
## What StitchAPI does [#what-stitchapi-does]
**Everything per-provider is free and declarative — and that's most of the work.** Two
providers with different origins, paths, auth strategies, retry policies, breakers, timeouts
and response shapes compose with zero glue:
```ts
const primary = stitch({
url: 'https://primary.example.com/v1/complete',
auth: bearer(env('PRIMARY_KEY')),
retry: { attempts: 2, on: [429, 503] },
circuit: { failures: 3, cooldown: '30s', key: 'llm:primary' },
pick: 'choices.0.text',
transform: (v) => ({ provider: 'primary', value: v }), // attribution
});
// …and a `backup` with a different path, `x-api-key` auth, and `pick: 'output'`.
```
Measured: neither credential appeared on the other provider, and per-stitch `pick` normalised
two different response envelopes into one string.
`linked` comes from `stitchapi/pipe`, which may change shape or behaviour in
a minor release, with each change and its migration listed in the changelog.
[Stability](/docs/reference/stability) says what that means.
**The routing between them is yours** — about 30 lines, on `linked`:
```ts
import type { StitchError } from 'stitchapi';
import { linked } from 'stitchapi/pipe';
// `0` stands in for "no status at all": `StitchError.status` is `undefined`
// when the transport never answered — DNS, refused connection, timeout — which
// is exactly the "provider is down" this page is about. `?? 0` routes it here.
const FAILOVER_ON = new Set([0, 408, 425, 429, 500, 502, 503, 504]);
const answer = await linked(async (run) => {
try {
return await run(primary, input);
} catch (err) {
// A 400 means the REQUEST is broken — the backup will reject it too.
// (A breaker-open fast-fail carries status 503, so it does fail over.)
if (!FAILOVER_ON.has((err as StitchError).status ?? 0)) throw err;
return run(backup, input);
}
});
```
**Measured:** 10 successful calls sent `[10, 0]`, every one credited to the primary. A `400`
stopped the chain at `[1, 0]` with a real `StitchError` 400 and the provider's `invalid_request`
body intact. A `503` retried the primary twice, then failed over — `[2, 1]` — in **one trace
tree** with the spine `primary ← root, backup ← primary`. With both providers down the caller
got the **last real error**, not an aggregate.
**30 lines against 104** hand-rolled for the same feature set: the library carries \~71%, all of
it per-member, and **zero** of the routing.
Use `linked` rather than a bare `try`/`catch`. Both produce the same request
counts, but `linked` measured **one** traceId with a `primary → backup`
spine, while the bare version produced **two unrelated root traces** — the
failover becomes invisible to an on-call engineer at exactly the moment it
matters.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **No sequential-fallback combinator.** `all`, `any` and `race` are one eager implementation
with three different joins — each measured `[1, 1]` on a single call. `linked` is sequential
but returns a Promise, not a node, so the flow can't be nested, handed to a seam, or
introspected.
2. **No failure classification for routing.** `retry.on` is exactly the right vocabulary, scoped
to the wrong target — it re-hits the *same* endpoint.
3. **`AggregateError` drops everything you'd route on.** `status` and `body` are both
`undefined`; the actionable `400` survives only inside `.errors[0]`, which no `StitchError`
API points at.
4. **No winner identity.** The result is the winner's raw body, and the `pick` that normalises
two envelopes destroys the only attribution. The group emits **zero** events of its own —
a composition is not a span — so the trace can't break the tie either. Fix it with
`transform`, one line per member.
5. **A cancelled member emits nothing terminal** — `start`, `progress`, then silence. Zero
`error`, zero `done`. A span-based backend reads that as a leak.
6. **No hedge delay or threshold anywhere.** `race` measured **2.00×** amplification healthy
*and* degraded — the doubling is the steady state, not an outage behaviour. The delayed
hedge everyone actually recommends took \~11 lines of raw `AbortController` and got the right
profile: `[10, 0]` healthy, `[10, 10]` degraded.
7. **A breaker is a health gate, not a budget gate** — 10 healthy calls with `circuit` on both
members still measured `[10, 10]`.
8. **`Composable` is not user-authorable.** `makeComposable` is unexported and the member gate
checks only the brand, so a hand-branded node **compiles** and then throws `TypeError`.
**A per-call header reaches every provider.** Config headers merge *under* input headers, and
each auth strategy only overwrites its own header name — so a per-call
`headers: { authorization: 'Bearer …' }` intended for the primary was measured **arriving at
the backup verbatim**. One vendor is handed another vendor's credential, silently, with no
type error. Keep per-provider credentials in each stitch's `auth`, never in the call input.
**And two `url`-only stitches share one breaker.** With neither `name` nor `path` they both
key on the literal string `'stitch'` — measured, the primary's outage opened the *backup's*
breaker and fast-failed 3 of 5 healthy calls. Setting `name` fixes it completely, which
makes a diagnostic label load-bearing.
## See also [#see-also]
# The mock that passed for six months (/docs/scenarios/stale-fixture)
## The problem [#the-problem]
You integrate a vendor API. You write tests. You cannot call the real API on every CI run — it
is slow, rate-limited, costs money and mutates state — so you test against something fake.
Then the vendor changes the API, and **your tests keep passing.**
This is the one scenario in this pass where the failure mode is the test suite actively lying to
you. Every approach trades one wrongness for another: a recording goes stale silently (the
classic shape is a cassette holding a session token that later expires, so the failure surfaces
somewhere unrelated); a hand-written mock encodes your misunderstanding faithfully, because the
same person wrote the mock and the code from the same reading of the docs; a vendor sandbox
exists for initial development rather than regression and lags production. The consensus is that
no single approach suffices — you need virtualisation for volume *and* live verification for
accuracy.
Which narrows the question for a client library to: **can it tell you your fake has drifted from
the real thing?**
## The common solutions [#the-common-solutions]
| Approach | What it is | Where it breaks |
| ---------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------- |
| **Record/replay cassettes** | Record real traffic once, replay forever. | Goes stale silently. Re-recording is a process, not a check. |
| **Hand-written mocks** | Fixtures you write from the docs. | Encode your misunderstanding faithfully. Cannot catch what you got wrong. |
| **Vendor sandbox** | The vendor's test environment. | Lags production, misses edge cases, slow, rate-limited. Not built for regression runs. |
| **Contract testing (Pact)** | Both sides verify a shared contract. | Needs vendor participation. Not available for a third party. |
| **Hit production in CI** | The only truly accurate option. | Slow, costly, mutating, and flaky for reasons unrelated to your code. |
| **Schema/contract snapshot** | Validate responses against a schema. | The honest middle ground — but only if the *same* schema guards prod and the fixtures. |
## What StitchAPI does [#what-stitchapi-does]
### Resilience tests need no vendor and no waiting [#resilience-tests-need-no-vendor-and-no-waiting]
This is the strongest result. With `mockAdapter` + `manualClock` + `collectStitchEvents`, retry,
throttle and circuit behaviour is assertable to the millisecond with zero real time elapsed:
* **Attempt counts**, three independent ways: `mockAdapter.callCount()` → `3`,
`result.attempts` → `3`, and five `progress` events phased
`request, retry, request, retry, request`.
* **Circuit transitions** — the whole closed → open → half-open → closed trace reads off
`callCount()` as `1, 2, 2 (blocked), 3, 4`, because an open circuit doesn't move the transport
count.
* **Throttle spacing**, exact and self-reporting: requests at virtual `0 / 500 / 1000` for
`'2/s'`, with `progress{phase:"throttled"}.waited` reading `500` then `1000`.
* **Backoff curves** are exact under the clock — requests at virtual `0 / 1000 / 3000` for an
`expo` base-1000 curve.
### Streams are fully deterministic [#streams-are-fully-deterministic]
`streamThenError` over an `sse` stitch produced `start, progress, delta, delta, delta, error,
done` with all three deltas intact, `error.message: "socket reset"`, `done.ok: false` — and
**five repeat runs produced one distinct outcome, byte-identical.** `gatedStream` holds a
connection open on a promise you resolve; `sseStream` writes well-formed frames.
### A shared schema catches a fixture that drifts from the contract [#a-shared-schema-catches-a-fixture-that-drifts-from-the-contract]
Point the same `output` schema at production and at the fixture, and all four mutations fail the
call — a field removed, renamed, retyped, and nulled — with `drift()` naming each:
`error|invalid|paid|Expected boolean, received string`.
### Targeting three environments is one config slot [#targeting-three-environments-is-one-config-slot]
`extends: { baseUrl, adapter }` aims one endpoint object at fixture, sandbox and production, and
the difference is visible through the same schema: `ok`, `ok`, `contract violation (drift)`. A
`baseUrl` thunk (`string | (() => string)`) retargets between calls without rebuilding.
## What StitchAPI does not solve [#what-stitchapi-does-not-solve]
1. **The scenario's actual direction is invisible offline.** Everything above catches the
*fixture* drifting from the schema. The scenario is the *vendor* drifting while the fixture
sits still — measured, that run is **test `ok: true` with zero findings, production
`ok: false`, five keys different**. Nothing offline closes it, because offline the only bytes
are the fixture's. The comparison that catches it is 8 lines and every seam it needs already
exists; what is missing is a place to put a call you are only allowed to make sometimes.
2. **`manualClock` drives eight time-driven features and not the other four.** The table below is
the one to keep. A test written against a wall-clock row **passes without asserting
anything**.
3. **`mockAdapter` validates almost nothing.** It defaults `status` to 200 and lowercases header
names; beyond that it served statuses `999, -1, 0, 1.5, 200.7` and bodies of type `Date`,
`Map`, class instance, `undefined`, `function`, `bigint` and Symbol-keyed — all verbatim,
through the full engine. The consequence is a green test for code that cannot work: a fixture
built from `new Invoice(...)` gives the caller `data.total === 42` from a **prototype
getter**, where the same object over a JSON wire is `{"id":"inv_1"}` and `data.total` is
`undefined`.
4. **`stubStitch` runs none of the `input` schemas.** One line of calling code passing `42` where
the schema says `z.string()`: the real stitch errors and no request leaves the process; the
stub **resolves** and records `{"params":{"id":42}}`. There is no slot to hand it the
contract — `StubStitchOptions` is `{name,status,config,events}`, and `config` is the redacted
read-out shape, which carries no schema.
5. **A fixture cannot say when it was recorded.** Of all exported names across the main entry and
`stitchapi/testing`, exactly one matches `/fixture|cassette|record|snapshot|stale|fresh|expire/`
— `conformance.fixture`, the transport echo contract for plugin authors. `__config` has no
metadata slot, so "recorded on 2026-02-04" is not expressible.
6. **Retry backoff delays are absent from the event stream.** `progress{phase:"retry"}` carries
`detail: "status 503"` and `waited: undefined`, where the throttle and reconnect paths both
set `waited`. You can assert the backoff, but only by reading `clock.now()` yourself.
### Which features `manualClock` actually drives [#which-features-manualclock-actually-drives]
| Feature | Driven by | Measured |
| ------------------------------- | ----------------- | -------------------------------------------------------------------------------- |
| `retry` backoff | **`manualClock`** | `advance(5000)` → 3 calls; `advance(0)` → 1 call, pending |
| `throttle` rate | **`manualClock`** | `advance(3000)` → 3 calls |
| `throttle` concurrency | **`manualClock`** | holder releases on virtual time → queued callers proceed |
| `circuit.cooldown` | **`manualClock`** | `advance(60_000)` past a 30s cooldown → half-open probe reaches the vendor |
| `timeout` (per-attempt) | **`manualClock`** | `advance(2000)` past a 1s timeout → error |
| `Retry-After` | **`manualClock`** | reads the injected clock — **and that is the trap**, see below |
| OAuth2 token expiry | **`manualClock`** | `advance(600_000)` past a 60s `expires_in` → a fresh token fetch |
| AWS SigV4 signing date | **`manualClock`** | `x-amz-date` reads the clock; a default `manualClock()` signs `19700101T000000Z` |
| **`timeout.total`** | wall clock | a 1000ms budget survived **2700 virtual ms** across 3 attempts, `ok: true` |
| **`cache.ttl`** | wall clock | `advance(600_000)` past a 60s TTL still served the cached entry |
| **`memoryStore` TTL** | wall clock | a 1s entry survived 60,000 virtual ms |
| **event `at` / `done.elapsed`** | wall clock | `at = 1785941469178` while `clock.now() = 0`; `done.elapsed` reads `0` |
| `paginate` | no time | no inter-page delay knob; 3 pages fetched at `clock.now() = 0` |
The OAuth2 and SigV4 rows are new arrivals in the driven half: both read the wall clock when this
audit measured them — 600,000 virtual ms past a 60s `expires_in` refetched nothing, and no clock
reached the signer. Filed as [#650](https://github.com/rejifald/StitchAPI/issues/650) and
[#658](https://github.com/rejifald/StitchAPI/issues/658), fixed by
[#664](https://github.com/rejifald/StitchAPI/pull/664) and
[#667](https://github.com/rejifald/StitchAPI/pull/667): token freshness and `x-amz-date` now read
the same injected clock that drives retry and throttle. The four rows still on the wall are
deliberate scope — the clock owns control-flow time, not bookkeeping.
**`Retry-After` is a trap precisely because it honours the clock.**
`parseRetryAfter` computes `httpDateEpoch - clock.now()`, and
`manualClock()` starts at `0` — so an HTTP-date header meaning "5 seconds"
becomes a wait of roughly **20,000 days**. Start the clock at a realistic
epoch if a fixture carries a dated `Retry-After`.
**`timeout.total` does not "ignore" the clock — it resets.** The per-attempt
clamp *does* fire on virtual time; the wall-anchored part is the deadline
(`wallT0 + total`). Virtual sleeps never drain it, so each attempt gets the
full budget back. On a real clock it behaves correctly. This is specifically
a testing unsoundness, and it was the pass's own prediction that got the
mechanism wrong.
## The best available setup [#the-best-available-setup]
**98 executable lines across 5 seams**, closing four of the five gaps: a wire-shape guard
(rejects the class-instance fixture with `fixture is not a wire shape ($: LiveInvoice)`), a
`contractStub` that runs the input schema, a config lint that refuses to pair a `manualClock`
with `cache` or `timeout.total`, and a fixture datestamp
(`getInvoice recorded 2026-02-04 (182d old)`).
The fifth does not close offline. Dating a fixture fails your suite on the **calendar**, not on
the drift. The only thing that actually detects the drift is a live comparison — measured
returning `DISAGREE live=contract violation (drift) fake=ok` — and it needs a real call.
## See also [#see-also]
* [Mocking guide](/docs/guides/testing/mocking) — `mockAdapter`, `stubStitch`, `manualClock`
* [Drift](/docs/guides/validation/drift) — what `output` findings mean
* [Scenario: a canary rollout of a response-shape change](/docs/scenarios/intermittent-drift) —
detecting vendor drift in production, which is the half this scenario cannot do offline
* [Scenario: a stream that fails after 800 tokens](/docs/scenarios/mid-stream-failure) — the
behaviour `streamThenError` reproduces deterministically
# The charge you can't confirm (/docs/scenarios/unconfirmed-write)
## The problem [#the-problem]
You POST a charge. The connection times out. **You have no idea whether the money moved.**
A timeout does not distinguish "the request never arrived" from "it was processed and the
response was lost". No client-side care removes that — you can only make the *retry* safe. And
idempotency keys, which do, bring their own edges: the replay returns the **original** outcome
including a failure, the key has a **TTL shorter than your job queue**, and the same key with a
different body is an **error**.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| ---------------------------------- | ------------------------------------------------------------------------------------- |
| Retry blindly | Double-charges. The failure the whole scenario exists to prevent. |
| Never retry a write | Safe and expensive — every blip becomes a support ticket. |
| Key minted per call | Correct for retries *inside* the call; useless across a restart. |
| Key derived from the business fact | The right answer — stable across processes, restarts and queues. |
| Query-then-decide | The standard recovery, and it must run **first** — after the write it can't un-write. |
| Persist intent first | Durable, and now you own a two-phase workflow. |
## What StitchAPI does [#what-stitchapi-does]
**`idempotency.keyOf` fixes the restart *and* the concurrency race in configuration alone.**
```ts
const charge = stitch({
method: 'POST',
url: 'https://api.vendor.com/charges',
// Derived from the business fact — stable across processes, restarts and queues.
idempotency: {
keyOf: (input) => `charge:${(input.body as Charge).invoiceRef}`,
},
retry: { attempts: 3 },
});
```
**Measured**, across six workloads including a crash-and-re-drive, a lost response, a TTL
expiry and two concurrent runs:
| | charges created | intended |
| ---------------------------------------------- | -------------------------- | -------- |
| `idempotency: true` (the default), no recovery | **8** — two duplicates | 6 |
| derived `keyOf` + a query-first recovery | **5** (the sixth declined) | 6 |
Two things work well and are worth naming. The **same key is carried on every attempt** of a
call — 3 attempts, 1 key, 1 charge — so a response lost *after* the charge was processed is
**recovered** by the retry replaying the stored `200`. And a **cached failure is not retried by
default**: a stored `500` produced exactly 1 request under `attempts: 4`, because 500 isn't in
the default `retry.on`.
**`idempotency: true` is per call, not per intent.** The default key is a `randomUUID()`
minted when the request is built, so a queue re-driving a job after a crash mints a *new*
key. Measured: **2 distinct keys, 2 charges, for 1 intended payment.**
And the library warns about exactly this — unless you follow the advice. The nudge fires only
when there is a random key *and* no `retry`, on the reasoning that a retry is the thing a
random key does protect. True, and narrower than most readers will assume: **adding `retry`
silences the warning while leaving the restart case wide open.**
## The recovery has to run first [#the-recovery-has-to-run-first]
A query-then-decide recovery only works **before** the write. Running it after a failure still
double-charged on the TTL workload — because a recovery that runs after the write cannot
un-write it. That is 19 of the 43 lines, and no hook can do it: a hook cannot change the
outcome.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **Restart safety by default** — see the callout. `keyOf` is the fix and it is opt-in.
2. **TTL expiry.** A prune turns a correct, stable key into a second charge — measured **2
charges at a 25 h delay against a 24 h TTL**, 1 charge at 23 h. The duplicate arrives as a
clean `200` with **no replay marker**, so nothing client-side can notice. `timeout.total` is
per call and cannot bound the gap.
3. **Timeout disambiguation.** A dropped request (**0 charges**) and a lost response (**1
charge**) produced **field-for-field identical** errors: `StitchError`, `status: undefined`,
`attempts: 1`, `timed out after 5000ms`, `body: undefined` — and the same live `TimeoutError`
on `error.cause`. The class stays unexported, but the instance rides `cause`, so "was this a
timeout?" is the structural check `err.cause?.constructor.name === 'TimeoutError'`, not a
message match. What it cannot tell you is whether the money moved.
4. **"Don't retry into the unknown."** `retry.on` gates statuses only — a transport failure is
retried even with `on: []`. The only lever is `attempts: 1`.
5. **"Don't retry a replayed failure."** Inexpressible declaratively: `interpret` runs *after*
the retry check, and `retry.on`'s predicate sees only the status. The vendor's replay marker
is visible to `hooks.onResponse` and absent from `StitchError`, which carries no headers.
6. **No event carries the idempotency key** — so with the random default, a query-by-key
recovery is impossible: you cannot ask about a key you never learned.
7. **Nothing expresses "sticky for a decline, fresh for a blip."** A stable key makes a recorded
failure sticky for the whole TTL — measured, a declined card stayed declined — while the
random key never reaches the record and simply charges again on the second run. Both
behaviours are defensible; neither is selectable.
**`keyOf: (i) => JSON.stringify(i.body)` fails by charging twice, not by erroring.** Key
order alone moved the hash — measured **2 keys, 2 charges, statuses `[200, 200, 200]`**, no
`409` anywhere, because the vendor never saw the same key twice. Derive from a business
reference, canonicalise before hashing — or declare a coercing input **schema**: `keyOf` now
reads the **validated** body (#648, closed by #663), so a schema that normalises the body
stabilises the key. Measured: the same three renderings under one → 1 key, 1 charge.
Two smaller ones: **pagination mints a key per page** (3 pages, 3 keys), and
**`verdict: { accept: [409], flag: 'ok' }` swallows an idempotency conflict**, returning the
error payload as data — the same trap as
[the signature scenario](/docs/scenarios/expiring-signatures).
## See also [#see-also]
# The page that moved while you were reading it (/docs/scenarios/unstable-pagination)
## The problem [#the-problem]
You page through a live collection — `?offset=0&limit=100`, then `100`, then `200` — while
other people are inserting and deleting rows. You end up with a list, and it is quietly wrong.
**Offset is a position in a result set, not a position in the data.** The two cases go in
opposite directions, and it's worth getting them the right way round:
* **An insert behind your cursor** pushes rows to *higher* indices, so your next fixed offset
lands on a row you already read. Measured: `skipped []`, **`duplicated ["r04"]`** — 11 items
for 10 distinct rows. An offset insert can never cause a skip.
* **A delete behind your cursor** pulls rows to *lower* indices, so your next offset jumps past
one. Measured: **`skipped ["r05"]`**, `duplicated []`.
And the case people don't believe until they see it: **a non-unique sort key breaks it with no
writes at all.** Ten rows in, ten rows out, nothing created or destroyed — measured
`skipped ["r05"]` *and* `duplicated ["r03"]`, because the server returned tied `created_at`
values in a different order on each query.
The fix is **keyset (seek) pagination** on a composite `(sort, id)` cursor. That is a *server*
capability — no client can make an offset API consistent. What a client can do is **notice**.
**Every damaged run above reported success.** `ok: true`, `error: null`,
`findings: []`, `status: 200`, `attempts: 1` — byte-identical to a clean
run. Measured on the insert, the delete, the tie case, and two more below.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| ------------------------- | ----------------------------------------------------------------------------- |
| Offset/limit, as offered | Duplicates on insert, skips on delete, both under ties with no writes at all. |
| Keyset / seek pagination | The correct fix — if the vendor implemented it. |
| Snapshot / point-in-time | Ideal where offered. Rare in REST. |
| Sort by an immutable key | Removes the mutable-sort case, not the insert/delete cases. |
| Client-side dedupe by id | Fixes duplicates, leaves skips invisible — **and see the callout below**. |
| Reconcile against `total` | The obvious check, and it misses the case that matters. |
## What StitchAPI does [#what-stitchapi-does]
**Keyset is four lines, and it works.** `next` receives the previous page's raw body, so a
composite cursor is straightforward:
```ts
paginate: {
next: (body) => {
const rows = (body as Page).rows;
const last = rows.at(-1);
return last
? { query: { after_ts: last.created_at, after_id: last.id } }
: undefined;
},
items: (v) => (v as Page).rows,
}
```
**Measured:** against a real seek endpoint, every workload that broke offset came back complete
and clean — the insert (which had cost a duplicate), the delete (a skip), and the ties (both) —
`skipped []`, `duplicated []`, 10 rows in cursor order.
**Sending a composite cursor does not make an endpoint a seek endpoint.**
The same four lines against a vendor that *accepts* `(after_ts, after_id)`
but orders by `created_at` alone lost `["r03"]` on one collection and
duplicated `["r13"]` on another — with zero writes. The client half of
keyset is four lines and it is not the half that decides.
**Detection, when only offset is on offer, is yours to write.** The library ships no comparison
of any page to any other. The seam that works is `output` — a validator over the *aggregated*
array, which runs after the loop:
```ts
output: reconcile, // dedupes, or fails the run with the ids named
```
Measured over 8 workloads: **zero false negatives** — every run that lost rows was flagged —
and 3 false alarms on undamaged runs, which is the right trade for a sync job.
**The signal that carries the delete case is not the one anyone writes.** It is that the
**declared `total` moved** (10 → 9). A plain length-vs-total check fired **0 of 4** times; the
deduped variant missed the delete entirely — because the delete removed a row from `total` at
the same instant it removed one from your result, so the arithmetic balances perfectly while
`r05` is gone.
## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution]
**84 lines against 74 hand-rolled — the library version is longer.** The detection is identical
in both, and a raw paging loop is cheaper to write than the declarative equivalent. The two
agreed on rows and verdict across all 8 workloads.
What the 74 lines don't have is the resilience stack: one `retry` line recovered a page that
answered `500` mid-run — 4 wire requests, 10 rows, nothing skipped. Every page runs inside its
own attempt loop, so retry, auth, throttle and the circuit breaker apply per page for free.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **No dedupe, no reconciliation, no page-to-page comparison.** Nothing in `paginate`'s three
fields (`next`, `items`, `pages`) looks at what the last page contained.
2. **Four different endings share one `break` and one successful result** — the collection
ended, a page came back empty, the page cap was hit, or drift emptied a page mid-run. Measured
`pages: 50` silently returning **200 of 220 rows**, `ok`, with no event distinguishing cap
from end.
3. **`total` is not surfaced anywhere structured** — it's a field in a body you have to catch
yourself, in `transform` or `hooks.onResponse` (`next` never sees the terminal page's).
4. **No per-page state.** Every cross-page fact is a closure you own — with the reuse hazard
below.
5. **You cannot both succeed and warn.** `output` either replaces the value or fails the run.
6. **`drift()` cannot express this.** Deduping an array re-indexes it, so the findings come back
as `coerced`/`undeclared` on element paths — none of them says "duplicate".
7. **`.report()` is a fresh probe.** It re-paginated the collection and did not reproduce the
duplicate at all — it describes a run it just made, never the run you made.
**Deduping inside the loop can cause the data loss it was meant to prevent.** `items` and
`transform` run *per page*, above the break. On a workload where page 2 repeated page 1
verbatim, the deduper emptied that page, the loop treated zero items as the end, and the run
finished `ok` having **skipped `["r05"…"r10"]` — 6 rows lost by the fix**, against a declared
total of 14. Dedupe in `output`, after the loop, not in `items`.
**And a deduping `items` on a reused stitch returns `[]` — successfully — on every call after
the first**, because the `seen` set outlives the call. The natural way to write it is defined
once and called many times.
## See also [#see-also]
# Receiving a signed webhook (/docs/scenarios/webhook-receipt)
## The problem [#the-problem]
Stripe, GitHub, Slack push events at an endpoint you host. You verify the signature, decide
it's genuine, and act. This is the only scenario in this section where **someone is calling
you** — and the [stitch is a per-call primitive, not a
server](/docs/concepts/the-stitch): inbound webhooks are your application's job.
So this page is about **where the line falls**, and what's on each side of it.
What makes the receipt half hard:
* **Signature verification needs the exact bytes.** The provider signs the raw payload; any
JSON round-trip yields logically identical bytes that don't match. This is the famous
`express.raw()`-before-`express.json()` rule, and the most-reported webhook bug there is.
* **At-least-once means duplicates are normal.** Dedup on the event id, with a TTL longer than
the provider's retry window (Stripe: 3 days).
* **Order cannot be trusted.** `subscription.updated` can arrive before `.created`.
* **Ack fast.** Providers time out in seconds and retry on slowness.
## The common solutions [#the-common-solutions]
| Approach | Where it breaks |
| ---------------------------------------- | ----------------------------------------------------------------------------------- |
| Provider SDK verifier (`constructEvent`) | Correct, and the right answer per vendor. One per provider. |
| Framework raw-body middleware | The standard fix, and entirely about ordering — get it wrong and it fails silently. |
| Hand-rolled HMAC | Fine, and easy to get subtly wrong: constant-time compare, timestamp tolerance. |
| Webhook gateway (Svix, Hookdeck) | Most complete at scale. A third party in the path, and a bill. |
| Dedup on the event id | Necessary. The TTL is the trap — it must outlive the retry window. |
| Fetch-on-receipt | Makes payload order irrelevant. Costs an API call per event. |
## Where the line falls [#where-the-line-falls]
**`serve` is not a webhook endpoint**, and the measurements are unambiguous. A real `serve()`
process answered **404** to a correctly signed Stripe POST at `/webhooks/stripe`, `/webhook`,
`/`, `/stitch` and `/hooks/v1/billing` — the route table is exactly `GET /` and
`POST /stitch/:name`. On the one route that reaches user code:
* the body has already been through `JSON.parse`, and the deepest user-reachable seam got a
parsed object with **no raw string anywhere**;
* **the inbound headers are dropped entirely** — `stripe-signature` reaches nothing, so there
is no signature to verify even if you had the bytes;
* a form-encoded provider (Slack) is rejected `400 invalid JSON body` before any of it.
The byte gap is real and measured: the provider signed **162 bytes**; a parse→stringify
round-trip produces **153** logically identical bytes, and verification returns
`bad-signature`.
There is also **no inbound-signature primitive anywhere** in the packages. Enumerating all 89
runtime exports — one level into namespace exports, so nothing hides behind one — **none** match
`/verif|hmac|…/`; the only near-misses are the BYO-plugin conformance kit. The
one HMAC in the repo is `@stitchapi/aws-sigv4`, whose key is imported with
`usages: ['sign']` — it structurally cannot verify.
## What StitchAPI does own [#what-stitchapi-does-own]
Everything after the ack, and it is worth having:
```ts
const billing = seam({
baseUrl: 'https://api.example.com',
auth: bearer(env('API_KEY')),
throttle: { rate: '10/s' },
});
// Fetch-on-receipt: the payload is a hint; the API is the truth.
const getSubscription = billing.stitch({
path: '/subscriptions/{id}',
retry: { attempts: 3 },
timeout: { total: '10s' },
});
```
**Measured:** acting on payload order with a reversed pair left the app believing
`trialing/free` while the server said `active/pro` — a silent, self-inflicted downgrade.
Fetch-on-receipt converged both events on `active/pro` at a cost of 2 calls for 2 events, and
the call survived two 503s in 3 attempts with the retry, backoff, auth and deadline all as
config rather than handler code.
The **dedup ledger** is also first-class, and the store is yours to supply — `redisStore`,
`cloudflareKvStore` and `denoKvStore` all implement the same interface, so durability is a
one-line change and `conformance.store` proves a BYO one conforms (measured: all 11 rules, 0
violations).
**Use `increment`, not `get`-then-`set`, to claim an event id.** Measured
with 3 concurrent deliveries of one id: `get`+`set` returned `[true, true,
true]` — three charges — while `increment(key, ttl)` returned `[true, false,
false]`, exactly one. The default `memoryStore` is also not durable:
`close()` clears it, so a deploy inside the retry window re-processes
everything still in flight.
## The boundary, as a number [#the-boundary-as-a-number]
A complete, honest implementation — a `node:http` server you own, plus StitchAPI downstream —
rejects forged signatures (`400 bad-signature`), rejects a genuine MAC on a 10-minute-old
timestamp (`400 stale`, zero side effects), converges a reversed pair, and acks a duplicate at
**zero API calls**.
| half | lines | what it is |
| ------------ | -------------------------------------- | ----------------------------------------------------------------- |
| **Receipt** | **154** (96 server + 58 `node:crypto`) | imports *nothing* from `stitchapi` at runtime — one `import type` |
| **Reaction** | **63** | almost all config: one seam, two stitches, a 4-line version guard |
**71% of the code by line, 100% by concern, is the half StitchAPI does not participate in.**
That is the honest shape of this scenario, and it is by design.
## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here]
1. **Route ownership, raw bytes, and the inbound headers.** All three are absent from `serve`.
2. **HMAC verification and constant-time comparison.** No primitive exists. `xxh128` is
**unkeyed** and non-cryptographic — it will produce a plausible digest that authenticates
nobody.
3. **Replay/timestamp tolerance.** Yours.
4. **Non-JSON bodies.** `serve` rejects them before any user code.
5. **Any queue, outbox, dead-letter or backpressure.** `pipelineStages` on a maximally
configured stitch shows no stage matching queue/detach/defer — `result` is last, and the
pipeline never releases a caller early.
6. **The ack/work split.** Worse: `serve` consumes the run to completion before writing a
byte, so with `retry: { attempts: 3 }` and a 5 s backoff the ack went out only at attempt 3,
after **exactly 10 virtual seconds** — Stripe's timeout to the second. The retry policy
manufactures the duplicate it was meant to survive.
7. **Write-order conflict.** Fetch-on-receipt fixes *payload* order, not *write* order: two
concurrent handlers with snapshots v2 and v3 landed on v2 under last-write-wins. A version
guard recovered it — four lines the library does not express.
8. **`.deleted` events.** The fetch returns 404, indistinguishable from "never existed", so the
event type stays load-bearing.
**`void call(input)` is a no-op, not fire-and-forget.** It is the spelling anyone reaches
for to ack-then-continue, and it made **0 HTTP calls and raised 0 errors** — a stitch call
is a lazy thenable that starts on `.then`, so the work is silently dropped *after* the
provider was told 200. `void call(input).then(…)` does run it, and is then unsupervised:
unhandled it surfaced as an `unhandledRejection`, and `.safe()` reported the failure
nowhere.
**And `serve` is unauthenticated by design.** An unsigned, forged body under the size cap
ran the stitch and returned 200. Anyone who can reach the port can run your stitches — it
is a local front door, not an internet-facing endpoint.
## See also [#see-also]
# CLI (/docs/surfaces/cli)
Reach for the `stitch` CLI when you want to run a stitch and read its trace from
the shell — no app boot. `stitch run ` streams a named stitch's events to
stdout as JSONL, and `stitch trace` summarizes the run log those events leave
behind.
## Example [#example]
Author your stitches in a module that exports each one by name; the CLI
discovers them there (default `./stitches.ts`, or pass `--module`).
```ts twoslash
// stitches.ts
import { stitch } from 'stitchapi';
export const getUser = stitch({
name: 'getUser',
baseUrl: 'https://api.example.com',
path: '/users/{id}',
});
```
Run it by its export name. A bare `--id` routes to the `{id}` path param, and
every event is printed as one line of JSON, so the stream pipes straight into
`jq`:
```bash
stitch run getUser --id 1
```
Then summarize what those runs recorded:
```bash
stitch trace --since 1h
```
## Options [#options]
**`stitch run [--module ] [--trace[=console|]] [--flags…]`**
resolves the stitch named `` from the module and streams its event stream
to stdout as JSONL — one JSON object per line, exit code `1` if an `error` event
is seen. Flags map onto the stitch's single input object: a bare `--` becomes
a path param when `` is a `{param}` in the path and a query param otherwise,
while `--params.`, `--query.`, `--headers.`, and `--body ''` (or
`--body. `) target a bucket explicitly. Secrets you pass this way reach
the request but not the output: like `stitch serve`, each `start` line has its
URL credentials, secret query values, secret header values
(`--headers.authorization`) and secret-named body fields (`--body.password`)
redacted. The response itself — the `result` line — is printed as the server
sent it. `--module, -m` points at the
stitches module (default `./stitches.ts` and friends). `--trace` records the run
(off by default): bare writes the default JSONL file for `stitch trace`,
`--trace=console` streams events to stderr, and `--trace=` writes JSONL to
that path.
**`stitch trace [--file ] [--since 1h] [--name ] [--json]`** folds the
JSONL run log into per-stitch stats (runs, ok/failed, retries, drift, latency
percentiles). `--file` picks the log (default `~/.stitch/runs/proto.jsonl`),
`--since` keeps only records inside a window like `1h`/`30m`/`45s`/`2d`,
`--name` filters to one stitch, and `--json` emits the raw summary instead of
the aligned table.
**Anti-pattern:** don't lean on `stitch trace` and the default
`~/.stitch/runs/proto.jsonl` log as your production observability — that
file is process-local (the `proto` name is a clue: it's a prototyping
convenience), so its percentiles and ok/failed counts only ever cover the
runs from this one shell and never roll up across machines, CI jobs, or
workers. For durable, shared telemetry, fan the same event stream into the
observability stack you already run with OTLP export instead. See [OTLP
export](/docs/guides/observability/otlp).
The CLI runs the very same stitch definition as the in-process
[function](/docs/surfaces/function) — one source of truth, just a different
front door. Like the library, the CLI traces nothing by default; pass `--trace`
to `stitch run` to record the JSONL
[trace sink](/docs/guides/observability/trace-sinks) at
`~/.stitch/runs/proto.jsonl` (or `STITCH_TRACE_FILE`) so `stitch trace` has a
log to read — `--trace=console` streams to stderr, `--trace=` writes JSONL
there.
A `.ts` module loads only when the host has a TypeScript loader registered
(run under `tsx`); otherwise point `--module` at compiled JS.
## Diagram [#diagram]
**`stitch diagram [--module ] [--name ]`** prints a
[Mermaid](https://mermaid.js.org) flowchart of each stitch's configured pipeline
— throttle, request, retry, the request [surface](/docs/reference/surfaces),
pagination, validation, transform, pick, and cache — read straight from the
definition, with no run. Pass `--name` to diagram a single stitch by its export
name; a configured `name` does not select it (a module's `default` export, which
has no export name, is keyed by its configured `name`). Auth is redacted from a stitch's
public config, so the credential never appears in the diagram.
```bash
stitch diagram --name getUser # paste the Mermaid into any renderer
```
## Export [#export]
**`stitch export --openapi [--module ] [--title ] [--api-version ] [--schema-module ]`**
emits an [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) document (JSON) for
the whole module to stdout — the emit half of "reversible": a stitch declaration
becomes a spec.
```bash
stitch export --openapi --title 'My API' --api-version 1.0.0 > openapi.json
```
It is **structural** by default — paths, methods, `operationId`s, and the
path/query parameters parsed from each
[URL template](/docs/guides/data/url-shaping), plus the presence of a request
body and response. Three things make it richer:
* **Body and per-parameter schemas.** Pass `--schema-module ` at a module
that exports a `toJsonSchema(source, info)` converter (its default export or a
named `toJsonSchema`). Core stays zero-dependency — you bring the converter
(e.g. one wrapping `zod-to-json-schema`), the way an adapter brings your
`axios`. Without it, bodies and parameters stay `{}`.
* **Security schemes.** Each stitch's `auth` contributes a
`components.securitySchemes` entry and a per-operation `security` requirement —
`bearer`/`basic` become HTTP schemes, `apiKey` its declared header, `oauth2`
its token endpoint and scopes. Only the **scheme** is emitted; the credential
is never read or written.
* **Warnings, not silent drops.** A stitch whose endpoint is a thunk (resolved
at call time) can't be exported statically, so it is reported on stderr as a
`warning:` and skipped — never dropped silently.
`toOpenApi` is also exported from `stitchapi/registry` for in-process use — pass
your own `toJsonSchema` converter to fill body and parameter schemas:
```ts twoslash
import { stitch } from 'stitchapi';
import { toOpenApi } from 'stitchapi/registry';
const getUser = stitch({
baseUrl: 'https://api.example.com',
path: '/users/{id}',
});
const { document, warnings } = toOpenApi({ getUser });
```
## See also [#see-also]
# In-process function (/docs/surfaces/function)
A stitch is a typed async function — no server, no transport. `await` it for the
final picked, validated value, or iterate its event stream for everything that
happened on the way there. This is the closest of the four surfaces: the same
definition you call here also runs from the CLI, over HTTP, and through MCP.
## Example [#example]
```ts twoslash
import { stitch } from 'stitchapi';
const getUser = stitch<{ id: number; name: string }>({
baseUrl: 'https://api.example.com',
path: '/users/{id}',
});
// Await it for just the value…
const user = await getUser({ params: { id: 1 } });
// …or iterate the typed event stream for everything that happened.
for await (const event of getUser.stream({ params: { id: 1 } })) {
if (event.type === 'result') console.log(event.data);
}
```
## Options [#options]
**Awaited vs streamed.** `await getUser(input)` resolves to the final value,
already picked and validated. `getUser.stream(input)` yields the typed
[event stream](/docs/concepts/event-stream) — `start → progress → drift →
result → done` — so you can observe retries, throttling, and drift as they
happen. Same call, two altitudes.
**Pre-binding input.** `getUser.with(partial)` returns a new stitch with part of
the input baked in, so a shared header or a fixed path param is set once and
reused across calls. See [`.with()` partial application](/docs/guides/authoring/with).
**One definition, four surfaces.** The in-process function is one surface over a
single definition. The same stitch is reachable from the [CLI](/docs/surfaces/cli),
served over [HTTP](/docs/surfaces/http-serve), and exposed to agents through
[MCP](/docs/surfaces/mcp) — nothing about the definition changes.
Calling the stitch returns a result you can both `await` and `.stream()`;
`getUser.stream(input)` is shorthand for streaming that same result.
## See also [#see-also]
# HTTP serve (/docs/surfaces/http-serve)
Reach for `stitch serve` when a caller can't import your stitches in-process —
a remote service, another language, a shell script — and you want to expose the
registry of named stitches over a thin local HTTP front door instead.
## Example [#example]
Start the server, then call a registered stitch by name with a JSON body:
```bash
# Serves the registry on http://127.0.0.1:8787 by default.
stitch serve
# List the available stitch names.
curl http://127.0.0.1:8787/
# Run the `me` stitch; the request body is its input.
curl -X POST http://127.0.0.1:8787/stitch/me \
-H 'content-type: application/json' \
-d '{}'
```
The server runs the named stitch through the same engine as an in-process call
and returns the final validated result as JSON.
## Options [#options]
Two routes serve the registry of named stitches:
* `GET /` lists the available stitch names.
* `POST /stitch/:name` runs that stitch; the JSON request body is its input.
Any other method returns `405`, and an unknown name returns `404`.
`:name` is one of the names `GET /` lists: a registry key, which for
`stitch serve` is the export name. A stitch's configured `name` labels its
traces and does not route to it; a module's `default` export has no export name,
so it is keyed by its configured `name`.
The server binds to `127.0.0.1:8787` by default. Responses are the final
validated result as JSON; send `Accept: text/event-stream` (or add `?stream=1`)
to receive the live event stream as SSE instead.
This front door runs stitches you name; it is not a webhook endpoint, and
StitchAPI does not receive webhooks at all — that is your server's job.
[Receiving a signed webhook](/docs/scenarios/webhook-receipt) draws exactly where
the line falls and what the library still owns on the far side of it.
**Anti-pattern:** don't move the bind off the `127.0.0.1` loopback default
to a public interface so a remote caller can reach it directly — the front
door has no auth of its own, so a public bind turns the registry into an
open proxy that runs every stitch for anyone who can reach the port. Keep it
on loopback and put a fronting layer (a reverse proxy or gateway that
authenticates the caller) in front of it; the stitch still holds its own
credential and resolves it server-side, never handing it to the caller. See
[Capability, not credential](/docs/concepts/capability-not-credential).
## Failures [#failures]
A failed run answers with a non-2xx status and a JSON body of
`{ error, status }`. An upstream error status passes through (`401`, `429`,
`503`); a failure with no status, such as a timeout or a transport error,
answers `502`. `status` in the body is the upstream's own status, when there
was one.
`error` is the standard reason phrase for the response status, not the
failure's own message:
```json
{ "error": "Bad Gateway" }
```
The raw message can name an internal host (`getaddrinfo ENOTFOUND
payments.internal.corp`) or repeat an upstream's wording to whoever called the
server. Every `@stitchapi/*` host adapter withholds it by default, and `serve`
does the same.
SSE mode withholds the same detail. The frames and their event types stay, and
these fields are dropped:
* an `event: error` frame's `message` is the reason phrase, and so is the
frame sent when the stream throws;
* an `event: start` frame has no `url` and no `template`, which name the
upstream host and its route (`/internal/ledger/{id}`);
* an `event: progress` or `event: info` frame has no `detail`. On a retry
`progress` frame that field is the raw transport error text;
* a frame's `name`, on `start` and `error`, is the name you addressed the stitch
by in `/stitch/:name`. A stitch's own name defaults to its `path`, so
`/internal/ledger/{id}` would otherwise appear there;
* an `event: drift` frame's `finding` has no `detail`. For a failed output
validation that field is the validator's issue message, which can repeat the
value the upstream sent. The finding keeps its `level`, `path`, `change` and
`sample`.
Anything else a stream carries is unchanged: `delta` and `result` payloads, the
request input echoed on `start`, span ids, `surface` and `transport`, and a
`progress` frame's `status` and `errorType`.
When the callers are trusted, such as in local development or on an internal
network, turn the detail on with `stitch serve --disclose`, or
`serve(registry, { disclose: true })` in code. It restores the raw message and
the dropped SSE fields together.
A delegate-backoff rate limit (a `RateLimitError` from
[`throttle.delegate`](/docs/guides/resilience/delegate-backoff)) also sends a
`Retry-After` header, in whole seconds, when the upstream gave one. The value
is capped at one day. A caller can then wait as long as the upstream asked.
## Programmatic API [#programmatic-api]
The CLI is a thin wrapper. To embed the same server in your own process, import
`serve` from the `stitchapi/serve` subpath and hand it a registry of named
stitches:
```ts
import { me } from './stitches';
import { serve } from 'stitchapi/serve';
const handle = await serve({ me });
console.log(`listening on ${handle.url}`);
// later
await handle.close();
```
`serve(registry, options?)` returns a handle with the bound `url`, `port`, the
underlying `server`, and a `close()` method. It binds `127.0.0.1:8787` by
default; pass `{ port: 0 }` to let the OS pick an ephemeral port, and
`{ disclose: true }` to send failure detail as described in
[Failures](#failures).
A served stitch is the same definition as the in-process
[function surface](/docs/surfaces/function) and the
[`stitch` CLI](/docs/surfaces/cli) — one source of truth, three ways to call it.
## See also [#see-also]
# MCP (/docs/surfaces/mcp)
Reach for the MCP surface when an agent should call your stitches: it exposes a
single code-mode `run_stitch` tool over MCP, plus a `list_stitches` discovery
tool — so the agent's tool list stays tiny no matter how many stitches you
register, and never touches a credential.
## Example [#example]
Start the server with the `stitch` CLI. It loads your stitches module and speaks
JSON-RPC over stdio (newline-delimited), with a startup notice on stderr:
```bash
stitch mcp --module ./stitches.ts
```
The agent then calls one tool — `run_stitch` — passing the stitch name and its
input. The tool call looks like this:
```json
{
"name": "run_stitch",
"input": {
"name": "getUser",
"input": { "params": { "id": "42" }, "query": { "expand": "owner" } }
}
}
```
`input` carries `{ params, query, body, headers }`; `run_stitch` runs the named
stitch behind the [capability boundary](/docs/concepts/capability-not-credential)
and returns its validated result.
The agent's object is the *call* input, not the request: a slot with a declared
[`input` schema](/docs/guides/validation/validation) is rebuilt from what that
schema returned, so a key the model invented is stripped before the request. A
slot with no schema stays a passthrough — declare the slots an agent may fill.
## Options [#options]
`run_stitch` takes `{ name, input }` and covers **every** registered stitch with
one tool — `name` selects the stitch, `input` is its `{ params, query, body,
headers }` object. This is code-mode: one tool, not one tool per path.
`list_stitches` is the discovery tool. It takes no arguments and returns each
stitch's `name`, `method`, and `path`, so the agent knows what to pass to
`run_stitch`. The `path` is the route template: a literal query written into it
(`/v1/{id}?sig=…`) is the operator's pinned default, and can be a credential, so
it is cut. A `{?limit}` template operator is part of the template and stays.
### Which names a call resolves [#which-names-a-call-resolves]
A tool call resolves `name` against the registry's **own keys** and nothing
else. For `stitch mcp --module`, a key is the export name. The names
`list_stitches` returns are exactly the names `run_stitch` and
`describe_stitch` accept:
* A stitch's configured `name` is a trace label, not an address. Expose
`export const approvedRefund = stitch({ name: 'issueRefund', … })` and the
agent can call `approvedRefund` but not `issueRefund`. Renaming a stitch in
the registry you hand over really hides the old name. The one exception is a
module's `default` export: it has no export name, so `stitch mcp --module`
keys it by its configured `name` (or `default` when it has none), because that
is the only name it has.
* Inherited object keys are not stitches. `constructor` or `toString` is an
`unknown stitch` error like any other missing name.
`stitch run` and `stitch serve` resolve names the same way.
`stitch mcp --module ./stitches.ts` exposes **every** stitch the module
exports, including a login stitch or a write you only meant to call from
code. To expose fewer, build the registry yourself and pass it to
`serveStdio` (see [Programmatic API](#programmatic-api)). The object you
hand over is the allow-list.
### Errors the agent sees [#errors-the-agent-sees]
A failed call returns an `isError` tool result whose text is the error message.
Every URL in that message — absolute, schemeless or relative — has lost its userinfo and the
values of its secret-bearing query parameters. A transport error often quotes the
request URL,
and with `apiKey({ in: 'query' })` that URL carries the key. The model sees
`Failed to parse URL from http://api.vendor.test:99999/v1/metrics?api_key=REDACTED`,
never the key itself. The scrub happens in core, where the engine turns the
transport's throw into the stitch's error, so the same message reaches every
other surface too (see [Errors & pitfalls](/docs/errors)); the MCP server scrubs
once more at its own boundary as a second line. The scrubber matches the same
parameter names as the trace sinks, including any name
`apiKey({ in: 'query', name })` registers. `describe_stitch` scrubs the endpoint
it reports the same way, so a credential written into a `baseUrl` does not reach
the model either. A key in a header is still the better
choice: it is never part of a URL.
### Tool annotations [#tool-annotations]
Each tool carries MCP `annotations`, so a host can decide whether to ask a human
before a call:
| Tool | `readOnlyHint` | `destructiveHint` | `openWorldHint` |
| ----------------- | -------------- | ----------------- | --------------- |
| `list_stitches` | `true` | — | `false` |
| `describe_stitch` | `true` | — | `false` |
| `run_stitch` | `false` | `true` | `true` |
`run_stitch` is one tool for every stitch you register, a write included, so it
claims nothing that does not hold for all of them. A host reading these hints
still cannot tell a read from a refund. To make that distinction, register only
the stitches the agent may call.
Why one tool beats one-per-path: a tool per registered stitch floods the agent's
context — every name, description, and input schema is re-sent on each turn. Code
mode keeps the tool list at two entries however many stitches you register, so
the agent spends its context on the task, not on a tool catalog. The full design
of the tool lives in the agents section; see
[run\_stitch & code mode](/docs/agents/run-stitch-tool).
**Anti-pattern:** don't register one MCP tool per stitch or path — say, a
separate tool for each route under `api.example.com` — to make the agent's
calls "more explicit"; that re-sends every tool's name, description, and
input schema on each turn and floods the model's context. Expose the single
code-mode `run_stitch` tool (with `list_stitches` for discovery) and let the
agent select the stitch by name instead. See [run\_stitch & code
mode](/docs/agents/run-stitch-tool).
## Programmatic API [#programmatic-api]
The CLI is a thin wrapper. To run the MCP server in your own process, import
`serveStdio` from the `stitchapi/mcp` subpath and hand it a registry of named
stitches:
```ts
import * as stitches from './stitches';
import { serveStdio } from 'stitchapi/mcp';
// Reads newline-delimited JSON-RPC from stdin, writes responses to stdout.
const { close } = serveStdio(stitches);
// later
await close();
```
`serveStdio(registry, options?)` wires the `run_stitch` / `list_stitches` server
to stdio and returns a handle whose `close()` detaches the transport. Pass
`{ stdin, stdout }` to bind a different stream pair instead of stdio, and
`server` to set the identity reported in the MCP `initialize` result — a bare
string names it (`server: 'orders-api'` ≡ `server: { name: 'orders-api' }`).
The `version` defaults to the package version baked in at build time, so it can
never drift from the release you installed; set `server: { name, version }` only
when you want to report your own.
The capability boundary holds here: the agent calls `run_stitch` by name and
never sees the token, cookie, or signing key the stitch holds — it invokes a
capability, not a credential.
The transport writes JSON-RPC to stdout and its startup notice to stderr, so
keep stdout clean — don't log to it or pipe anything else through it, or the
framing breaks.
## See also [#see-also]
|