Release candidate — 1.0.0-rc.4
← Back to blog

Read Non-JSON Responses With a Typed Stitch

Oleksandr Zhuravlov

The report endpoint returns CSV. The scraping target returns HTML. The files service hands back a PDF or a zip. Your client was built for JSON, so each of these becomes a one-off: a manual fetch, a res.text() here and a res.arrayBuffer() there, a parser bolted on, and a return type of any that lies to everyone downstream. A stitch reads those bodies too — responseType picks how the body is decoded, transform reshapes it, and an output schema validates the shape you actually use.

The honest before

Here is a CSV report read by hand. The job is small, but the sharp edges are real:

async function getRevenueReport(month: string) {
    for (let attempt = 0; attempt < 3; attempt++) {
        const res = await fetch(
            `https://api.example.com/reports/${month}.csv`,
            {
                headers: { authorization: `Bearer ${process.env.API_TOKEN}` },
            },
        );
        if (res.status === 503) continue; // hope it works next time
        const text = await res.text(); // res.json() would throw on a comma
        return parseCsv(text); // returns... whatever parseCsv returns
    }
    throw new Error('report failed');
}

Nothing here is wrong, but nothing here is reusable. You chose res.text() by hand because res.json() would have thrown on a comma. The return shape is whatever parseCsv happens to give you. A flaky gateway is your problem to handle, and the bearer token is captured in the closure. Every non-JSON endpoint in the codebase grows its own copy of this.

The stitch version

The same job as a declaration. Each field replaces a piece of that glue:

import { bearer, env, stitch } from 'stitchapi';
import { z } from 'zod';

const getRevenueReport = stitch({
    url: 'https://api.example.com/reports/{month}.csv',
    responseType: 'text', // decode the body as a string, not guessed JSON
    transform: (raw) => parseCsv(raw as string), // your CSV parser
    output: z.array(z.object({ sku: z.string(), revenue: z.number() })),
    auth: bearer(env('API_TOKEN')),
    retry: 3,
});

const rows = await getRevenueReport({ params: { month: '2026-06' } });
// rows: { sku: string; revenue: number }[]

responseType: 'text' forces the body to be decoded as a string instead of guessed by content-type. transform runs your CSV parser over that raw string. output validates the parsed rows, so rows is fully typed and coerced — revenue is a number, not the string the CSV held. auth resolves the token at call time, so the caller never holds it. retry: 3 makes a transient 503 the stitch's problem, not yours. The next CSV endpoint is another five-line declaration, not another copy of the hand-rolled fetch-and-parse block.

responseType: pick the decode, or let it pick

responseType is one of four values, and the default is auto:

  • default (omitted) — JSON when the content-type says so, else text. This is the right choice for ordinary JSON APIs; you never set it.
  • 'text' — force a string. Use it for HTML, CSV, or plain text, where content-type sniffing might guess wrong or the parser you want runs on the raw string.
  • 'arrayBuffer' — force bytes as an ArrayBuffer. Use it for binary: an image, a PDF, a zip.
  • 'blob' — force a Blob, the better shape when you hand bytes to a browser API.

For binary you set responseType and skip transform if the bytes are the deliverable:

const invoicePdf = stitch({
    url: 'https://api.example.com/invoices/{id}.pdf',
    responseType: 'arrayBuffer',
    auth: bearer(env('API_TOKEN')),
});

const bytes = await invoicePdf({ params: { id: 'inv_2026_06' } });
// static type: unknown (no output schema); an ArrayBuffer at runtime

Output inference reads the output schema, never responseType, so a stitch with no output is Stitch<unknown> — the value is an ArrayBuffer at runtime, but the type system does not know that. Add an output validator or an explicit generic when you want the static type to say ArrayBuffer.

Setting responseType is the difference between a body the engine guessed at and a body decoded the way the endpoint actually answers.

transform: reshape the raw body before validation

transform runs on the raw decoded body, before the output schema sees it:

const fetchArticle = stitch({
    url: 'https://example.com/articles/{slug}',
    responseType: 'text', // an HTML string
    transform: (html) => scrapeArticle(html as string), // your scraper -> object
    output: z.object({
        title: z.string(),
        author: z.string(),
        body: z.string(),
    }),
});

const article = await fetchArticle({ params: { slug: 'stitching-apis' } });
// article: { title: string; author: string; body: string }

It is the seam where you turn a non-JSON payload into the structured value your code wants — parse a CSV string into rows, scrape an HTML string into fields, decode bytes into a record. Pair it with output and the transformed shape is what gets validated and typed, not the raw string.

The endpoint returns HTML. responseType: 'text' hands transform a string, your scraper turns it into an object, and output proves that object has the three fields you claimed. Downstream code sees a typed article, never a tag soup.

Parsing is bring-your-own — on purpose

StitchAPI does not ship an HTML-to-Markdown converter or a CSV parser. That is a design choice, not a gap. The core is zero runtime dependencies, and bundling a parser would break two of the gates every feature passes: contract-not-dependency (you bring the converter, the core defines the seam) and bundle-frugal (a CSV library does not belong in everyone's bundle).

So you call your own converter inside transform:

import { stitch } from 'stitchapi';
import Turndown from 'turndown';

// your dependency, your version

const td = new Turndown();

const fetchAsMarkdown = stitch({
    url: 'https://example.com/docs/{slug}',
    responseType: 'text',
    transform: (html) => td.turndown(html as string),
    output: z.string(),
});

The parser lives in your package.json, where you can pin it, swap it, or drop it. The stitch only knows the contract: a function from raw body to a value, validated on the way out.

What changes, axis by axis

The left column is glue you rewrite per endpoint. The right column is configuration you declare per endpoint and read the same way every time.

AxisBy handA stitch
Decoderes.text() / res.arrayBuffer() picked by handresponseType names the decode
Reshapeparser called inline, ad hoctransform is the declared seam
Return typewhatever the parser returns (any)output validates and types the result
Resiliencehand-rolled retry loopretry / timeout are fields
Credentialtoken captured in the closureauth resolves at call time
Next endpointcopy the blockanother short declaration

It is still a stitch

Reading HTML or bytes does not put you on a side path. The same configuration that surrounds a JSON call surrounds this one — auth, retry, timeout, and trace apply regardless of the body format, because the body format is one field:

const fetchPage = stitch({
    url: 'https://example.com/pages/{slug}',
    responseType: 'text',
    transform: (html) => scrapePage(html as string),
    output: PageSchema,
    auth: bearer(env('API_TOKEN')),
    retry: { attempts: 3, on: [429, 503], backoff: 'expo-jitter' },
    timeout: { total: '30s', perAttempt: '10s' },
    trace: 'console',
});

A scalar JSON call is one line — stitch({ url }), and the auto responseType returns parsed JSON. A non-JSON call is the same declaration with the decode named and a converter wired in, and it gains resilience and validation by adding fields, never by rewriting the call. The endpoint that answers CSV today and JSON tomorrow stays one declaration; you change responseType and the transform, and every caller reads it the same way.

Try it

Declare a stitch with responseType: 'text', run your own parser in transform, and validate the result with an output schema — a typed function over any non-JSON endpoint.

npm install stitchapi@rc