Skip to main content

Server Routes

Looking for the product view?
The Help Center explains this surface as users see it: API Routes & Webhooks.

Apps can define server-side route handlers that run in a sandboxed V8 isolate. Handlers have access to the app's Postgres workspace via query() and authenticated Informer API access via fetch().

Architecture​

Server routes use file-convention routing: files in the server/ directory map to URL paths. Each file exports named HTTP method handlers (GET, POST, etc.) that receive a single context bag — request, the typed context, and the service helpers (query, fetch, respond, emit, notify, email, crypto, markdown, extractText, uploads, downloads, log, env). See the Context Object table for the full list.

Handlers are:

  • Bundled with esbuild during deploy (IIFE format)
  • Executed in an isolated-vm isolate per request (no shared state between requests)
  • Sandboxed with no access to Node.js APIs, filesystem, or network — only the injected bag helpers (query, fetch, respond, emit, notify, email, crypto, markdown, extractText, uploads, downloads, log) and the typed context

Routing Convention​

Files in server/ map to routes based on their path. Dynamic segments use bracket syntax [param]:

File PathRoute PathURL
server/index.js//api/apps/{id}/view/api/_server/
server/orders/index.js/orders/api/apps/{id}/view/api/_server/orders
server/orders/[id].js/orders/:id/api/apps/{id}/view/api/_server/orders/123
server/orders/[id]/approve.js/orders/:id/approve/api/apps/{id}/view/api/_server/orders/123/approve
server/health.js/health/api/apps/{id}/view/api/_server/health

Handler Structure​

Each file exports named functions for the HTTP methods it handles:

// server/orders/index.js

export async function GET({ query, request }) {
const rows = await query('SELECT * FROM orders ORDER BY created_at DESC');
return rows;
}

export async function POST({ query, request }) {
const { name, quantity } = request.body;
const rows = await query(
'INSERT INTO orders (name, quantity) VALUES ($1, $2) RETURNING *',
[name, quantity]
);
return { status: 201, body: rows[0] };
}

Context Object​

Every handler receives a single context object with these properties:

PropertyTypeDescription
query(sql, params?) => Promise<Row[]>Execute SQL against the app's workspace
fetch(path, opts?) => Promise<{status, body}>Make authenticated API calls to Informer
contextobjectTyped bound dependencies — context.<slot>.<method>(...) (see Dependencies)
respond(body) => Promise<void>Send early HTTP response (handler continues running)
emit(event, payload) => Promise<void>Create an app event (can trigger agents)
broadcast(channel, event, payload, options?) => Promise<{ok, seq}>Push a live frame to open pages subscribed to a channel; at most once, no row. seq is the frame's per-channel sequence number; { replay: false } keeps the frame out of the channel's replay buffer (see the Channels guide)
notify(username, message) => Promise<{id}>Enqueue a push notification (see below)
email(to, message) => Promise<{id}>Enqueue an email (see below)
cryptoobjectCryptographic helpers (see below)
markdownasync (text) => stringConvert markdown text to HTML using marked
extractTextasync (data, contentType) => stringExtract plain text from a base64 file (PDF, Excel, Word, text) (see below)
uploadsobjectResolve a file the browser staged — uploads.get(id) (see below)
downloadsobjectStage bytes for the browser — downloads.create(opts) (see below)
logfunctionStructured logging (see below)
requestobjectThe incoming request (see below)
envobjectApp environment variables
platform{ version, originMode, capabilities }What the platform offers: the Informer build version and the app type's capability flags (serverRoutes, webhooks, mcp, storage, embeddings, …). Feature-detect with platform.capabilities.<name> rather than probing for a helper or comparing versions. originMode is true when Apps serve from their own origins, which live channels need. The same object is window.__INFORMER__.platform in the browser.

Request Object​

PropertyTypeDescription
request.methodstringHTTP method (GET, POST, etc.)
request.pathstringRoute path (e.g., /orders/:id)
request.paramsobjectPath parameters (e.g., { id: '123' })
request.queryobjectQuery string parameters
request.bodyanyParsed request body (JSON)
request.headersobjectRequest headers
request.rolesstring[]Current user's resolved roles
request.userobjectCurrent user identity (see below)
request.apiKeyobject | null{ id, name } when an App API key authenticated the call, else null
request.appobjectThis App's identity and addresses (see below)

User Object​

PropertyTypeDescription
request.user.usernamestringLogin username
request.user.displayNamestringUser's display name
request.user.emailstring | nullEmail address
request.user.timezonestring | nullTimezone (e.g., America/New_York)

App Object​

PropertyTypeDescription
request.app.idstringApp UUID
request.app.naturalIdstringApp natural ID (ownerId:slug)
request.app.namestringApp name
request.app.baseUrlstringThe App's view URL on the Informer origin (https://<informer>/api/apps/<naturalId>/view), for links a person opens: scheduled emails, notifications, share messages
request.app.originstring | nullThe App's own origin (https://<label>.<apps-domain>), or null when the server serves Apps path-mounted or the App has no origin yet. A draft reports its own -draft origin, where its preview runs
request.app.apiUrlstringThe base an outside caller appends a route to: <origin>/api on an App origin, else the App's own-routes base on the Informer origin (https://<informer>/api/apps/<naturalId>/view/_). The same value as servers[0].url in the App's openapi.json

apiUrl is what to show a person who will call the App from a script, a scheduler, or another system with an API key:

// server/connect-info.js
export async function GET({ request }) {
return { curl: `curl ${request.app.apiUrl}/orders -H "Authorization: Bearer <key>"` };
}

Return Values​

Handlers can return values in several formats:

// Plain value → 200 JSON response
return rows;

// Explicit status + body
return { status: 201, body: { id: 1, name: 'New Order' } };

// Explicit status + headers + body
return {
status: 200,
headers: { 'x-custom': 'value' },
body: { data: rows }
};

// No return / return null → 204 No Content
return;

// A download handle → the staged bytes stream as the response body,
// with the handle's filename and content type (see uploads & downloads)
const dl = await downloads.create({ filename: 'orders.csv' });
await dl.fromQuery('SELECT * FROM orders');
return await dl.end();

The query() Callback​

Execute parameterized SQL against the app's dedicated Postgres workspace schema. Returns an array of row objects.

// Simple query
const rows = await query('SELECT * FROM orders');

// Parameterized query (prevents SQL injection)
const rows = await query(
'SELECT * FROM orders WHERE status = $1 AND amount > $2',
['pending', 100]
);

// Insert with RETURNING
const rows = await query(
'INSERT INTO orders (name, quantity) VALUES ($1, $2) RETURNING *',
['Widget', 5]
);
// rows[0] = { id: 1, name: 'Widget', quantity: 5, ... }

Important:

  • Always use parameterized queries ($1, $2) — never interpolate user input into SQL strings
  • The connection is scoped to the app's schema with SET SESSION AUTHORIZATION for tenant isolation
  • Statement timeout is enforced (default: 30s, configurable via config.timeout)
  • Requires a workspace — the app must have run migrations at least once

The fetch() Callback​

Make authenticated API calls to the Informer server. The call is validated against the app's API whitelist (from informer.yaml).

// GET request
const result = await fetch('datasets-list');
// result = { status: 200, body: [...] }

// POST request with body
const result = await fetch('datasets/admin:sales-data/_search', {
method: 'POST',
body: {
query: { match_all: {} },
size: 10
}
});

Parameters:

ParameterTypeDescription
pathstringAPI path (with or without /api/ prefix)
opts.methodstringHTTP method (default: GET)
opts.bodyobjectRequest body (for POST/PUT/PATCH)

Return value:

{
status: number, // HTTP status code
body: any // Parsed response body
}

Whitelist enforcement:

  • The path and method must match an entry in the app's whitelist
  • Requests to non-whitelisted endpoints throw an error: API endpoint not allowed: GET /api/users
  • A small set of credential-minting endpoints is blocked even when whitelisted (/api/wsToken, /api/session, the /api/tokens surface, /api/_impersonate, /api/_sync-user, /api/oauth/authorize, /api/oauth/authorize_device); these answer 403 Forbidden with API endpoint not allowed for this app. See App Runtime.

The respond() Callback​

Send an early HTTP response while the handler keeps running in the background. Useful when an external caller has a tight response deadline (e.g., Slack's 3-second limit).

export const config = { timeout: 25000 };

export async function POST({ query, fetch, respond, request }) {
const { text, response_url } = request.body;

// Send acknowledgment immediately — the HTTP response is sent now
await respond({ response_type: 'ephemeral', text: 'Processing...' });

// Everything below runs in the background
const result = await fetch('datasets/admin:sales/_search', {
method: 'POST',
body: { query: { match_all: {} }, size: 50 }
});

// Post the real answer back via response_url
await fetch('integrations/slack/request', {
method: 'POST',
body: { url: response_url, method: 'POST', data: { text: `Found ${result.body.hits.total} records` } }
});
}

Key behavior:

  • Only the first respond() call takes effect — subsequent calls are ignored
  • The response is always 200 JSON (Content-Type: application/json)
  • The isolate, database connection, and timeout remain active until the handler fully returns
  • If background work throws after respond(), the error is logged server-side but doesn't affect the already-sent response
  • If respond() is never called, the handler returns normally

The emit() Callback​

Create an app event that can trigger AI agents. Events are dispatched in near-real-time via Redis pub/sub.

export async function POST({ query, emit, request }) {
const [order] = await query(
'INSERT INTO orders (customer, total) VALUES ($1, $2) RETURNING *',
[request.body.customer, request.body.total]
);

// This triggers any agent listening for 'order_created'
await emit('order_created', { orderId: order.id, total: order.total });

return { status: 201, body: order };
}

Parameters:

ParameterTypeDescription
eventstringEvent name (must match an agent's on trigger)
payloadobjectData passed to the agent as trigger context

The notify() Callback​

Enqueue a push notification for delivery to a user's registered devices (Informer GO mobile or desktop). Messages are queued in the app_message table and delivered asynchronously via FCM. Returns immediately with the message ID.

The app's ID is automatically attached to the notification — tapping it in Informer GO opens this app (optionally at the specified sub-page).

export async function POST({ notify, request }) {
// Single notification
const { id } = await notify('jane', {
title: 'Order Shipped',
body: 'Your order #1234 has shipped!',
path: '/orders/1234' // optional deep link within this app
});

return { notificationId: id };
}

Bulk Notifications​

Pass an array to send multiple notifications in a single call:

const { ids, queued } = await notify([
{ username: 'jane', title: 'Report Ready', body: 'Your Q1 report is ready' },
{ username: 'bob', title: 'Report Ready', body: 'Your Q1 report is ready' },
]);
// ids = ['uuid-1', 'uuid-2'], queued = 2

Parameters​

Single:

ParameterTypeDescription
usernamestringInformer username to notify
message.titlestringRequired. Notification title
message.bodystringNotification body text
message.pathstringDeep link path within the app (e.g., /orders/123)
message.dataobjectAdditional custom data (values are coerced to strings)

Bulk: Array of { username, title, body, path?, data? } objects.

Delivery​

  • Messages are enqueued with status pending and processed by a background dispatcher
  • Push delivery uses FCM via the device tokens registered by Informer GO
  • Failed deliveries are retried up to 3 times, then moved to dead status
  • Stale device tokens are automatically cleaned up on failure
  • Message history is viewable via GET /apps/{id}/messages

The email() Callback​

Enqueue an email for delivery via the tenant's configured mail transport (SMTP, Gmail API, Microsoft Graph, etc.). Returns immediately with the message ID.

export async function POST({ email, request }) {
const { id } = await email('jane@acme.com', {
subject: 'Invoice #1234',
html: '<h2>Invoice</h2><p>Amount due: <strong>$1,500</strong></p>'
});

return { emailId: id };
}

Bulk Emails​

const { ids, queued } = await email([
{ to: 'jane@acme.com', subject: 'Monthly Report', html: '<p>See attached</p>' },
{ to: 'bob@acme.com', subject: 'Monthly Report', html: '<p>See attached</p>', from: 'reports@acme.com' },
]);

Parameters​

Single:

ParameterTypeDescription
tostringRequired. Recipient email address
message.subjectstringRequired. Email subject line
message.htmlstringHTML email body
message.fromstringSender address (defaults to tenant's configured defaultFromAddress)
message.icalEventobjectCalendar invite, { method?, content, filename? }. See Calendar Invites.

Bulk: Array of { to, subject, html, from?, icalEvent? } objects.

Calendar Invites​

icalEvent adds a text/calendar part to the email, so Outlook, Gmail and Apple Mail show it as a meeting request with Accept and Decline, rather than as a message with a file to download.

await email('jane@acme.com', {
subject: 'Board meeting',
html: '<p>You are invited to the board meeting.</p>',
from: 'events@acme.com',
icalEvent: { method: 'REQUEST', content: ics, filename: 'invite.ics' }
});
FieldTypeDescription
contentstringRequired. The whole iCalendar text (BEGIN:VCALENDAR ... END:VCALENDAR), at most 256KB in UTF-8. A file path or URL is not accepted.
methodstringREQUEST (default), PUBLISH, CANCEL or REPLY. Match the METHOD: line in content.
filenamestringAttachment name, up to 64 letters, digits, _, . or -. Defaults to invite.ics.

email() throws when any field breaks these rules.

For a REQUEST, set ORGANIZER in the iCalendar text to the address the email is sent from. When the two differ, Outlook shows the invite as sent "on behalf of" the organizer or drops Accept and Decline. Pass that address as from. When the tenant's mail settings have Always use the default "From:" address turned on, the email goes out from the tenant's default address instead of from, so ORGANIZER must be that default address.

Delivery​

  • Emails are queued in app_message and sent by the background message dispatcher
  • Uses the tenant's mail configuration (server.app.mail.send())
  • Failed deliveries are retried up to 3 times
  • Requires the tenant to have email configured (SMTP, Gmail, or Microsoft mail)
  • Message history is viewable via GET /apps/{id}/messages

The crypto Object​

Cryptographic helpers backed by Node's crypto on the host. Every method is async (the call crosses the isolate boundary), so await them. The same crypto object is available in server routes, webhooks, and agent tools.

Hashing & HMAC​

MethodDescription
crypto.hmac(algorithm, key, data, encoding?='hex')HMAC digest
crypto.hash(algorithm, data, encoding?='hex')Plain digest (e.g. sha256, sha512)

Random​

MethodDescription
crypto.randomUUID()RFC 4122 v4 UUID
crypto.randomBytes(length, encoding?='hex')Cryptographically random bytes (length capped at 1024)

Comparison & signature verification​

MethodDescription
crypto.timingSafeEqual(a, b)Constant-time string compare; returns false on length mismatch (never throws). Use instead of === when comparing secrets/signatures.
crypto.verifyHmac(algorithm, key, data, signature, encoding?='hex')Computes the HMAC and constant-time-compares it to signature → boolean. The safe one-liner for verifying webhook signatures.
crypto.verify(algorithm, data, signature, publicKey, signatureEncoding?='base64')Verify an asymmetric (RSA/ECDSA) signature against a PEM public key → boolean

Symmetric encryption (AES-256-GCM)​

MethodDescription
crypto.encrypt(plaintext, key)Returns a self-describing iv:tag:ciphertext string (base64 parts). key is any passphrase (hashed to a 32-byte key).
crypto.decrypt(payload, key)Reverses encrypt; throws if the key is wrong or the payload was tampered with
const sig = await crypto.hmac('sha256', secret, payload, 'hex');
const id = await crypto.randomUUID();

// Verify an inbound webhook signature without a timing leak
const ok = await crypto.verifyHmac('sha256', secret, request.rawBody, header);

// Round-trip a small secret (e.g. a signed state token)
const token = await crypto.encrypt(JSON.stringify({ userId: 7 }), env.APP_SECRET);
const { userId } = JSON.parse(await crypto.decrypt(token, env.APP_SECRET));

The extractText() Helper​

Extract plain text from a base64-encoded file on the host, using the platform's bundled parsers. The isolate has no npm at runtime, so this is the way to read a PDF or Office document into text. The same helper is available in server routes, webhooks, and agent tools, and it is async (the call crosses the isolate boundary), so await it.

const text = await extractText(base64Bytes, 'application/pdf');
ParameterTypeDescription
datastringbase64-encoded file bytes
contentTypestringIANA media type (charset params are ignored)
Content typeResult
application/pdfExtracted page text (empty for a scanned, image-only PDF)
Excel (.xlsx, .xls)One CSV block per sheet, under a ### Sheet: <name> heading
Word (.docx)Extracted document text
text/*, application/json, application/csv, application/xmlReturned unchanged

Any other content type (including images) throws. For a PDF or image you want an agent to read natively as a document rather than as text, return it from a tool with the $file convention instead (see Returning a document or image).

The uploads & downloads Objects​

Files and large result sets move through staged byte streams. The bytes never enter the isolate — a handler receives a handle, a plain metadata object whose methods ask the host to move the bytes on its behalf. A 100 MB import therefore costs nothing against the isolate's 128 MB heap, and a million-row export streams from Postgres to the browser without ever being materialized.

Staged streams live in the platform's staging store and expire after config.app.streams.ttlSeconds (1 hour by default). Every method is async.

Both kinds of handle carry:

FieldTypeDescription
idstringStream id (UUID)
filenamestring | null
contentTypestring | nullInferred from the filename when not supplied
sizenumberBytes staged
expiresAtstringISO-8601 expiry

Receiving an upload​

The browser stages the file first with __INFORMER__.upload() (see Staged Uploads & Downloads) and passes the resulting id to a server route.

export async function POST({ request, uploads }) {
const upload = await uploads.get(request.body.uploadId);
const { rowCount } = await upload.copyInto('orders', { format: 'csv', header: true });
await upload.discard();
return { status: 201, body: { imported: rowCount } };
}

uploads.get(id) resolves an upload belonging to this app and this user — anything else is a 404, so an id cannot be used to probe for streams outside the app. An upload the client has not yet sealed is a 409.

MethodReturnsDescription
copyInto(table, opts?){ rowCount }COPY … FROM STDIN directly into a workspace table
text(encoding?)stringWhole upload as text (default utf8)
json()anyWhole upload, JSON.parsed
base64()stringWhole upload as base64
extractText(contentType?)stringExtract text from a PDF, Excel or Word upload
discard()trueDrop the staged bytes now instead of at expiry

text(), json(), base64() and extractText() read the whole upload out of the staging store, so they refuse anything over config.app.streams.maxInlineBytes (10 MB by default) with a 413 carrying data.maxInlineBytes. copyInto() and query parameters never materialize the upload and are bounded only by maxUploadBytes.

copyInto() runs on the invocation's own workspace connection, so a staging table the same handler created with CREATE TEMP TABLE is visible to it — the usual shape for an upsert import:

export async function POST({ request, uploads, query }) {
const upload = await uploads.get(request.body.uploadId);
await query('CREATE TEMP TABLE staging (LIKE orders INCLUDING DEFAULTS) ON COMMIT DROP');
await upload.copyInto('staging', { header: true });
const [{ count }] = await query(`
INSERT INTO orders SELECT * FROM staging
ON CONFLICT (id) DO UPDATE SET total = EXCLUDED.total
RETURNING 1`);
return { imported: count };
}
copyInto optionDefaultDescription
formatcsvcsv or text
columnsall columnsExplicit column list
headertrueCSV only — treat the first line as a header
delimiter / quote / escapeCSV defaultsSingle characters
null—The string that represents NULL

The table name (optionally schema-qualified) and every column must be plain Postgres identifiers; anything else is rejected rather than escaped.

An upload handle is also a valid query() parameter, binding as bytea. The bytes go from the staging store to Postgres without entering the isolate:

const upload = await uploads.get(request.body.uploadId);
await query('INSERT INTO attachments (name, mime, data) VALUES ($1, $2, $3)',
[upload.filename, upload.contentType, upload]);

Producing a download​

downloads.create() stages bytes for the browser. The handle adds a url — a single-use link the page can navigate to for a save-as.

export async function GET({ downloads }) {
const dl = await downloads.create({ filename: 'orders.csv' });
await dl.fromQuery('SELECT id, customer, total FROM orders ORDER BY id');
return await dl.end(); // streams as this response
}
MethodReturnsDescription
write(chunk)trueAppend a string or byte array
writeRows(rows, opts?)trueAppend rows through a csv / json / jsonl encoder
fromQuery(sql, params?, opts?){ rowCount }Stream a workspace query straight into the download
end()handleSeal it; the returned handle is what you return or respond() with
discard()trueDrop the staged bytes

writeRows and fromQuery take format (csv, json or jsonl — default csv), columns, header, and delimiter. A download created without a contentType takes the encoder's (text/csv, application/json, application/x-ndjson).

A download is bounded by the same maxUploadBytes an upload is. Writing past it fails the call with a 413 and leaves the download unsealed, so a truncated file is never served as a complete one.

There are three ways to deliver one:

// 1. Return it — the staged bytes become the response body
return await dl.end();

// 2. respond() with it, then keep working in the background
const { rowCount } = await dl.fromQuery('SELECT * FROM orders');
await respond(await dl.end());
await query('INSERT INTO export_log (rows) VALUES ($1)', [rowCount]);

// 3. Hand the page a URL to navigate to (survives the handler returning)
return { url: dl.url };

A download's URL is single-use: the staged bytes are destroyed once served, unless the client asks for ?keep=true. A download whose contentType is text/html or image/svg+xml is always sent as an attachment, never rendered — see the download route. Downloads left open when the handler returns are sealed automatically, so background work that keeps writing after respond() still produces a complete file.

Forwarding to an integration​

A staged stream can be the body of a context.<slot>.request() call, and an integration's response can land in one — in both cases the host moves the bytes between the staging store and the upstream, and the handler holds only handles.

Outbound — the handle as the body. An upload handle in data is sent as the raw request body; the upstream sees the upload's Content-Type and a Content-Length, unless the call sets its own headers. In form, the handle becomes a file part of a multipart/form-data body and the other fields are sent beside it.

// Raw body — S3 presigned PUT, Drive media upload, GitHub release assets
await context.drive.request({
method: 'POST',
url: '/upload/drive/v3/files?uploadType=media',
data: upload
});

// Multipart — Slack files, Salesforce ContentVersion, Gmail attachments
await context.slack.request({
method: 'POST',
url: '/api/files.upload',
form: { channels: 'C123', title: upload.filename, file: upload }
});

request() returns what it always has — the parsed upstream body — and the upload is not consumed: an upstream failure throws the usual dependency error (its data.upstreamStatus says what the upstream answered) and leaves the bytes staged, so the route or the page can retry with the same id. Call discard() once the push is known to have landed.

The stream describes its own bytes, so Content-Length (and, for form, the generated multipart Content-Type with its boundary) belong to it: setting either of those on the call is a 400 rather than a silent override, because a stale length truncates the body to nothing and a boundary-less multipart type is unparseable upstream. Every other header is yours, including Content-Type for a raw data body when you want to relabel what you are sending.

Inbound — into. Without it, a binary upstream response comes back as a base64 envelope and is read whole into the isolate — there is no size cap on that path, so a large file is a memory cost you pay in the handler. With into, the host streams the upstream body into a staged stream and seals it, request() resolves with the sealed handle instead, and maxUploadBytes bounds it:

// Upstream file → download → the browser
const dl = await downloads.create({ filename: 'statement.pdf' });
await context.sf.request({ method: 'GET', url: '/services/data/v59.0/…/VersionData', into: dl });
return dl;

// Upstream CSV → a fresh upload → COPY into a table
const report = await context.qb.request({ method: 'GET', url: '/v3/…/reports/GeneralLedger?format=csv', into: 'upload' });
await report.copyInto('staging', { format: 'csv', header: true });
await report.discard();

// A body out and a file back: `data` and `into` travel together
await context.sf.request({
method: 'POST',
url: '/services/data/v59.0/analytics/reports/00O…/instances',
data: { reportMetadata: { reportFilters: [...] } },
into: dl
});
intoFillsNamed
a download handlethat download, which must be unsealed and unwritten; into is terminal — it seals itby the handle (filename, contentType), falling back to the upstream's
'upload'a new upload, returned as a normal upload handle (copyInto(), a bytea parameter, text() under the cap)after the upstream's Content-Disposition filename, else the last segment of the request path

An into fill obeys maxUploadBytes: past it the call fails with 413 (data.maxUploadBytes) and never truncates — a download is put back empty and unsealed, so nothing half-written can be served; a fresh upload is discarded. A body that ends short of the Content-Length the upstream declared is refused the same way, with 502 and data.declaredBytes / data.receivedBytes, rather than being sealed as a complete but truncated file. (An into request asks for the bytes uncompressed so that length describes what actually lands; if you set your own Accept-Encoding and the upstream compresses, more bytes may arrive than it declared, and that is not treated as a failure.)

A non-2xx upstream (or one that cannot be reached at all) throws the usual dependency error and the target is released: a download you created is left exactly as it was — empty and unsealed, still yours to end() or discard() — and a fresh 'upload' is discarded, so a failing call costs you nothing against your staging allowance.

A 3xx counts as "no body delivered": it throws 502 with data.upstreamStatus rather than resolving, because a redirect carries no file to stage. Redirects are followed as usual on a plain into pull, but not when the same call is also sending a staged body — a stream body cannot be replayed onto the redirect target — so address the final URL yourself in that case.

Forwarding works for target: integration slots. fetch() and target: app slots do not take handles.

Errors​

Stream failures arrive as real errors with the status and payload intact, so a handler can branch on them:

try {
await upload.copyInto('orders');
} catch (err) {
if (err.statusCode === 413) return { status: 413, body: { limit: err.data.maxInlineBytes } };
throw err;
}
StatusMeaning
400An into target that is neither a download handle nor 'upload', a download handle sent as the request body, or a header the stream owns set by the caller
404No such stream, or it belongs to another app or user
409The upload has not been sealed by the client yet, or an into target is already complete or already holds bytes
412Sealing found missing chunks — data.missing lists them
413Over maxUploadBytes (upload or download), maxChunkBytes, or maxInlineBytes
422A chunk's number or length disagrees with the declared geometry
429Over maxStreamsPerUser — data.maxStreamsPerUser
502An into upstream delivered a short body (data.declaredBytes / data.receivedBytes) or no body at all, such as a redirect (data.upstreamStatus)

The log() Callback​

Write structured log entries for debugging and monitoring. In production, log entries are written to the app_log table and associated with the current invocation. In dev mode, they print to the console.

export async function POST({ query, log, request }) {
log('Processing order', { orderId: request.body.id });

try {
const rows = await query('INSERT INTO orders (name) VALUES ($1) RETURNING *', [request.body.name]);
log.info('Order created', { id: rows[0].id });
return { status: 201, body: rows[0] };
} catch (err) {
log.error('Order creation failed', { error: err.message });
return { status: 500, body: { error: 'Internal error' } };
}
}

Methods​

MethodDescription
log(message, data?)Alias for log.info()
log.debug(message, data?)Debug-level entry
log.info(message, data?)Info-level entry
log.warn(message, data?)Warning-level entry
log.error(message, data?)Error-level entry
ParameterTypeDescription
messagestringLog message
dataobject | nullOptional structured data attached to the entry

Handler Config​

Export a config object to configure handler behavior:

export const config = {
timeout: 60000, // Handler timeout in ms (default: 30000)
roles: ['editor', 'admin'] // Required roles (any one suffices)
};

export async function POST({ query, request }) {
// Only runs if user has 'editor' or 'admin' role
// ...
}
FieldTypeDefaultDescription
timeoutnumber30000Max execution time in milliseconds
rolesstring[][]Required roles (OR logic — user needs any one)

Sandbox Constraints​

Server route handlers run in an isolated-vm isolate with strict limits:

  • No Node.js APIs — no fs, path, http, process, Buffer, etc.
  • No network access — only the injected bag helpers: query(), fetch(), respond(), emit(), notify(), email(), log(), crypto, markdown(), extractText(), uploads, downloads, and the typed context
  • btoa() / atob() — standard base64 encode/decode (Latin-1 only)
  • UTF-8 base64 helpers — base64Decode(), base64Encode(), base64UrlDecode(), base64UrlEncode() (async, handles multi-byte UTF-8)
  • crypto — hmac, hash, randomUUID, randomBytes, timingSafeEqual, verifyHmac, encrypt/decrypt (AES-256-GCM), verify (all async — see The crypto Object)
  • markdown(text) — convert markdown text to HTML (async, uses marked)
  • extractText(data, contentType): extract plain text from a base64 file (PDF, Excel, Word, text); throws on unsupported types (async)
  • uploads / downloads — staged byte streams that move files and large result sets without loading them into the isolate (see The uploads & downloads Objects)
  • 128 MB memory limit — isolate is disposed if exceeded
  • Timeout enforced — default 30s, configurable via config.timeout
  • Ephemeral isolates — a new isolate is created per request (no shared state)
  • Pure JavaScript — ES2022 features available, no npm packages at runtime (bundle with esbuild at deploy time)

Request Body Constraints​

Server route handlers take JSON bodies only. A multipart/form-data request to a _server/ path is refused with 415 Unsupported Media Type (Server routes do not accept multipart bodies); there is no sandbox representation for a raw multipart buffer, so it is rejected at the gateway rather than passed in. To accept a small file, base64-encode it into a JSON field and decode with base64Decode(). For anything larger — and for anything headed into a table — have the browser stage it with __INFORMER__.upload() and pass the handler only the upload id, so the bytes bypass both the JSON body and the isolate entirely (see The uploads & downloads Objects).

The same rule reaches the whitelist proxy wherever a data-access config must be applied: a filtered dataset search or a configured integration request whose body is multipart, a JSON array, or plain text answers 415. Those controls work by inspecting and rewriting the JSON body, so a body the gateway cannot inspect cannot be allowed past it. Unfiltered whitelist paths still pass any body through untouched.

Dispatch also enforces the App's compute budget: a request arriving after the budget is exhausted is refused with 402 Payment Required before the handler is loaded. A marketplace App whose paid license has lapsed past its grace period gets the same 402.


POST /api/apps/{id}/_deploy​

Bundle server routes and run pending migrations. This is typically called automatically by npm run deploy.

Authentication: Required

Permissions Required: app:write (Designer for Magic Reports, Admin for full Apps)

Path Parameters:

ParameterTypeDescription
idstringApp UUID or natural ID

Request Body: optional.

FieldTypeDefaultDescription
managedbooleanfalseClaim the App as platform-managed (origin: "deployed") on a successful deploy
stagingLibraryIdstringnoneId returned by POST /files/_stage. Presenting it authorises this deploy to promote that staged upload

Presenting stagingLibraryId is what promotes a staged upload. Without it the deploy runs against the App's live library and leaves any staged upload exactly where it is, which matters because this route is not the only way a deploy runs: lazy deploy on first view, snapshot restore, modernize and draft commit all reach the same pipeline. If any of those promoted whatever happened to be staged, a CLI deploy that died part way through its upload would have its incomplete file set published, and the complete live build discarded, by an unrelated request.

The id must still be the App's current staged upload. Staging is never taken over, so a superseded id can only come from a deploy whose staged upload was explicitly discarded (and another staged in its place) while it was uploading; presenting it gets 409 rather than promoting a library that now holds a different build. Replaying an already-consumed id gets 409 for the same reason. The claim is re-checked under a row lock once the deploy's transaction opens, so a staged upload that is superseded, emptied, or whose App had its library repointed (by a draft commit, say) during the deploy also answers 409, with nothing promoted and the live build untouched.

The capability and manifest license gates inspect the staged library when one is claimed, so a staged webhooks/ or migrations/ directory is refused on an App type that does not permit it. Gating the live library instead would let staged content past a check it never faced.

The claim is opt-in because this route is not CLI-only. The Redeploy button in the App's settings panel and the builder's Save both post here against a live App, and an unconditional claim would convert user-built Apps to managed. Since POST /_edit refuses managed Apps unconditionally, that is a one-way trip out of the builder. Callers that omit the flag leave origin untouched.

The claim applies only to an App still at origin: "ui". An App already at "deployed" stays there, and one at "marketplace" is left alone: a marketplace install stays bound to its listing for version upgrades, and the App model rejects any write that moves a row off origin: "marketplace". Deploying against either still runs the full pipeline and returns 200.

The stamp is applied after the deploy succeeds, never before. This route has no wrapping transaction and the deploy owns its own, so a claim written first would survive a failed deploy and strand an editable App as managed.

Releasing a managed App is POST /_unmanage.

Deploy an AppPOST /api/apps/admin%3Aorder-desk/_deploy
Runs pending workspace migrations, scans and bundles server/ and webhooks/ handlers, and rebuilds the route table in one pass.
Response · 200
{
"_links": {
"self": {
"href": "https://informer.example.com/api/apps/admin%3Aorder-desk/_deploy"
}
},
"migrated": [
"001-create-orders.sql"
],
"routes": [
"GET /orders"
],
"webhooks": [],
"routeShadowWarnings": [],
"areas": [],
"ownedIntegrations": [],
"refs": {
"access": {
"datasets": [],
"queries": [],
"datasources": [],
"integrations": []
},
"dependencies": [],
"warnings": []
},
"tools": [],
"mcpTools": [],
"agents": [],
"automations": [],
"environment": [],
"routeChecks": [
{
"method": "GET",
"path": "/orders",
"status": 200,
"ok": true,
"durationMs": 42
}
]
}
Captured from the API examples test suite; ids and timestamps are normalized.

What it does:

  1. Runs pending SQL migrations (see Persistence)
  2. Scans server/ directory for authenticated route handlers
  3. Scans webhooks/ directory for public webhook handlers (see Webhooks)
  4. Bundles all handlers with esbuild (IIFE format, stored in _bundles/)
  5. Rebuilds the route table in the database
  6. Invalidates route and credential caches
  7. Extracts widget definitions from informer.yaml into app.defn.widgets
  8. Extracts the areas: block from informer.yaml into app.defn.areas (unusable names are skipped and named in partialFailures, never fatal)
  9. Resolves resource references from informer.yaml (dependencies: slots and the access: section) into app_entity rows — fails with 400 if any referenced resource doesn't exist
  10. Resolves toolkit references from agent definitions into app_toolkit junction rows
  11. Scans tools/ directory for tool definitions and bundles them
  12. Extracts agent definitions from informer.yaml and upserts app_agent records (see Agents)
  13. Smoke-tests parameterless GET routes by executing each once (results attach to the response as routeChecks; these executions show up in the invocation history like real calls). Opt a route out with export const config = { smokeTest: false }

The response also carries routeShadowWarnings: one message per server route that a whitelisted access.apis entry in informer.yaml would match at bare /api, so bare calls reach the platform API and never the app route. The lint is advisory (the array is empty when nothing collides and the deploy succeeds either way); rename the route to make it reachable.

When a staged upload was promoted, the response carries stagedLibraryId: the id this deploy consumed.

When the App had a staged upload and this deploy did not claim it, the response instead carries stagingIgnored, an object with the stagingLibraryId and stagedAt of the upload that was left alone:

{
"stagingIgnored": {
"stagingLibraryId": "a1b2c3d4-5678-90ab-cdef-123456789abc",
"stagedAt": "2025-03-04T18:22:41.118Z"
}
}

The deploy itself succeeds; it just ran against the live library and published nothing from the staged upload. It is reported in the response rather than only in the server log because every non-CLI deploy path trips it, and a log line is something no author or CI job ever reads. Seeing it means an earlier deploy left an upload staged: the App is still refusing content writes, so discard it with DELETE /files/_stage or re-run the deploy that owns it.

Error Responses:

  • 400 Bad Request - The app has no associated library, or a handler failed to scan or bundle
  • 403 Forbidden - User lacks write permission, or the staged build uses a capability the app type does not permit
  • 404 Not Found - App doesn't exist
  • 409 Conflict - The presented stagingLibraryId is not the app's current staged upload, the staged upload is empty, or the app's library changed while the deploy was starting

Server Route Dispatch​

When an app makes a fetch call to a URL containing _server/, the proxy detects the prefix and dispatches to the matching server route handler instead of proxying to a real API endpoint.

How It Works​

  1. Client code calls fetch('/api/_server/orders') (dev mode) or the view proxy detects _server/ prefix
  2. Server strips the _server/ prefix and matches against registered routes
  3. If a route matches, the bundled handler is loaded and executed in an isolate
  4. The response is returned to the client

Calling from Client Code​

In development (Vite dev server):

const response = await fetch('/api/_server/orders');
const data = await response.json();

In production (running inside Informer):

// The view proxy handles _server/ routing automatically
const response = await fetch('/api/_server/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New Order', quantity: 5 })
});

The URL pattern is the same in both environments — the Vite plugin middleware and the Informer view proxy both handle _server/ dispatch.

Server-side, the canonical dispatch path is /api/apps/{id}/view/_/{path*} (all methods); the legacy /view/api/_server/{path*} alias remains supported:

Call a server routeGET /api/apps/00000000-0000-4000-8000-000000000001/view/_/orders
Dispatches to the app's server/orders.js handler. The query() callback reads the app's own workspace schema, created by the migration.
Response · 200
{
"orders": [
{
"customer": "Around the Horn",
"total": 642.5
},
{
"customer": "Berglunds snabbkop",
"total": 1208
}
]
}
Captured from the API examples test suite; ids and timestamps are normalized.

Full Example: CRUD Orders​

Migration​

-- migrations/001-create-orders.sql
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Server Routes​

// server/orders/index.js
export async function GET({ query }) {
return await query('SELECT * FROM orders ORDER BY created_at DESC');
}

export async function POST({ query, request }) {
const { name, quantity } = request.body;
const rows = await query(
'INSERT INTO orders (name, quantity) VALUES ($1, $2) RETURNING *',
[name, quantity || 0]
);
return { status: 201, body: rows[0] };
}
// server/orders/[id].js
export async function GET({ query, request }) {
const rows = await query('SELECT * FROM orders WHERE id = $1', [request.params.id]);
if (rows.length === 0) return { status: 404, body: { error: 'Not found' } };
return rows[0];
}

export async function PUT({ query, request }) {
const { name, quantity, status } = request.body;
const rows = await query(
'UPDATE orders SET name = COALESCE($1, name), quantity = COALESCE($2, quantity), status = COALESCE($3, status) WHERE id = $4 RETURNING *',
[name, quantity, status, request.params.id]
);
if (rows.length === 0) return { status: 404, body: { error: 'Not found' } };
return rows[0];
}

export async function DELETE({ query, request }) {
const rows = await query('DELETE FROM orders WHERE id = $1 RETURNING id', [request.params.id]);
if (rows.length === 0) return { status: 404, body: { error: 'Not found' } };
return { status: 204 };
}
// server/orders/[id]/approve.js
export const config = { roles: ['approver'] };

export async function POST({ query, request }) {
const rows = await query(
'UPDATE orders SET status = $1, approved_by = $2 WHERE id = $3 RETURNING *',
['approved', request.user.displayName, request.params.id]
);
if (rows.length === 0) return { status: 404, body: { error: 'Not found' } };
return rows[0];
}

Client Code​

// List orders
const orders = await fetch('/api/_server/orders').then(r => r.json());

// Create order
await fetch('/api/_server/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Widget', quantity: 10 })
});

// Approve order
await fetch('/api/_server/orders/1/approve', { method: 'POST' });