For AI agents: the complete documentation index is available at https://dotpress.dev/llms.txt, the full documentation bundle is available at https://dotpress.dev/llms-full.txt, and this page is available as Markdown at https://dotpress.dev/reference/responses.md.

Responses and errors

What a handler returns: values, response helpers and errors. For a guide, see Responses and Errors.

What a handler returns

ValueResponse
A value (object, array, string, number…)JSON, with the status of the route (200 by default). Validated by schema.response, then filtered.
undefined204 No Content, or the status set with res.setStatus() or the route status.
json(data, init)JSON with an explicit status or headers. Validated and filtered like a value.
text(), redirect(), stream(), sse(), file(), empty()Sent as is: no response validation, no filters.
An HttpErrorThe error response. Returning it or throwing it gives the same response.
Anything thrown, except an HttpError500 INTERNAL_ERROR, logged and passed to onError.

Response helpers

All of them are exported by @dotpress/core. init accepts status and headers; headers set on ctx.res are merged.

HelperResponse
json(data, init?)JSON.
text(body, init?)Plain text, text/plain; charset=utf-8 unless a content-type header is given.
redirect(location, status?)A redirect, 302 by default: 301, 302, 303, 307 or 308.
stream(source, init?)A streamed body. init.contentType defaults to application/octet-stream.
sse(source, init?)Server-Sent Events. init.heartbeat in milliseconds, 15000 by default, or false.
file(body, init?)A download: filename, contentType, disposition (attachment by default, or inline).
empty(init?)No body, 204 by default.
handler: ({ req, res }) => {
  res.setHeader('Cache-Control', 'no-store')
  return json(created, { status: 201, headers: { Location: `/tasks/${created.id}` } })
}

redirect() sends the location as given: behind a proxy, build it with ctx.publicPath('/login').

stream()

The source is a ReadableStream, an iterable, an async iterable, or a function returning one. A function receives { signal }, aborted when the client disconnects. Chunks are strings, encoded as UTF-8, or Uint8Array. A string or a Uint8Array given as the source is one chunk.

return stream(async function* ({ signal }) {
  for await (const row of db.export({ signal })) yield `${row.csv}\n`
}, { contentType: 'text/csv' })

sse()

The source yields events, or strings sent as data:

FieldDescription
dataThe payload. Strings are sent as is, other values as JSON.
eventThe type of the event, message on the client by default.
idSent back by the browser as Last-Event-ID when it reconnects.
retryReconnection delay for the browser, in milliseconds.
commentA comment line, ignored by the browser.

sse() sets Content-Type: text/event-stream, Cache-Control: no-cache and X-Accel-Buffering: no (no buffering in nginx).

file()

body is a Uint8Array, an ArrayBuffer, a Blob or a ReadableStream. The content type defaults to the type of the Blob, else application/octet-stream. Content-Length is set when the size is known, and filename is encoded for non-ASCII names.

return file(pdf, { filename: 'invoice-2026-001.pdf', contentType: 'application/pdf' })

Error helpers

Each one returns an HttpError: return notFoundError() or throw notFoundError(). message replaces the default message; data adds details to the body.

HelperStatuserror codeDefault message
badRequestError(message?, data?)400BAD_REQUESTBad Request
unauthorizedError(message?, data?)401UNAUTHORIZEDUnauthorized
forbiddenError(message?, data?)403FORBIDDENForbidden
notFoundError(message?, data?)404NOT_FOUNDNot Found
conflictError(message?, data?)409CONFLICTConflict
unprocessableError(message?, data?)422UNPROCESSABLE_ENTITYUnprocessable Entity
tooManyRequestsError(message?, data?)429TOO_MANY_REQUESTSToo Many Requests
internalError(message?, data?)500INTERNAL_ERRORInternal Server Error
errorResponse(status, code, message, data?)anyany—

Errors produced by Dotpress

Statuserror codeWhen
400VALIDATION_FAILEDThe request does not match the schema of the route. data.issues lists every issue.
400MALFORMED_BODYThe body cannot be parsed: invalid JSON, UTF-8 or multipart.
404NOT_FOUNDNo route matches the path (Route not found).
405METHOD_NOT_ALLOWEDThe path exists for other methods. The Allow header lists them.
413PAYLOAD_TOO_LARGEThe body exceeds the limit of the route or of the adapter.
415UNSUPPORTED_MEDIA_TYPEA Content-Encoding other than identity, or an unknown charset.
500INTERNAL_ERRORAn unexpected error, or a response that does not match schema.response.

Validation issues have this shape:

interface ValidationIssue {
  source: 'params' | 'query' | 'body' | 'files'
  path: (string | number)[]
  code: string // the Zod issue code: 'invalid_type', 'too_small'…
  message: string
}

Error body

Every error response has the same JSON body:

interface ErrorBody {
  status: number
  error: string
  message: string
  data?: Record<string, unknown>
}
{ "status": 404, "error": "NOT_FOUND", "message": "Task not found" }

With exposeErrors: true, the data of a 500 holds the name, message and stack of the error, and the issues of an invalid response. Never in production.

HttpError

class HttpError extends Error {
  constructor(
    status: number, // 400 to 599
    code: string,
    message: string,
    data?: Record<string, unknown>,
    options?: { cause?: unknown }
  )
  readonly status: number
  readonly code: string
  readonly data: Record<string, unknown> | undefined
  toJSON(): ErrorBody
}

Subclass it, or wrap errorResponse(), for the errors of your domain:

export const quotaExceededError = (limit: number) =>
  errorResponse(402, 'QUOTA_EXCEEDED', 'Quota exceeded', { limit })

isHttpError(value) recognizes an HttpError, even one created by another copy of @dotpress/core. toErrorBody(error) returns its body.

Headers set on ctx.res are kept on the response of an HttpError, not on the 500 of an unexpected error. See Custom errors and Unexpected errors.