Guides
GuideBackend Functions

Backend Functions

Server-side JavaScript functions that run in isolated Deno sandboxes. Access your database, files, external APIs, and environment variables securely.

Write JavaScript code that runs on the server. Each function gets a ctx object with methods to interact with your app's data.

Call functions from your published app using: await functions.call('myFunction', params)

Scope: a backend function belongs to one site. For logic that spans several of your sites, or that an external service calls by URL, use an account function & webhook instead.

The ctx Object

Every backend function receives a ctx object and a params object. The ctx object provides access to all platform features:

PropertyDescription
ctx.tablesDatabase operations (respects access rules)
ctx.service.tablesDatabase operations (bypasses access rules)
ctx.filesFile operations (respects access rules)
ctx.service.filesFile operations (bypasses access rules)
ctx.fetchHTTPS requests to external APIs
ctx.envAccess environment variables
ctx.userCalling user's { id, email, name, role, roleIds, roleNames, profileData } or null. Branch on roleNamesrole is only the built-in user/admin flag
ctx.emailSend transactional email via the project's sender identity
ctx.entitlementsGrant/extend/suspend/revoke paid-access entitlements — the only write path onto the entitlement ledger
ctx.realtime.publishPush an event to connected clients on a channel
ctx.agents.enqueueHand work to one of the owner's AI agents (fire-and-forget; the agent must be granted to this site)

ctx.tables — Database Operations

All 13 methods available on ctx.tables. These respect the table's access rules based on the calling user.

Read Operations

await ctx.tables.getRows('Orders', { limit: 10, offset: 0, sort: 'createdAt', order: 'desc', filters: { Status: { $eq: 'active' } } })

Fetch rows with optional filters, sorting, and pagination.

await ctx.tables.getRow('Orders', 'row-id-123')

Get a single row by ID.

await ctx.tables.searchRows('Orders', 'search term')

Full-text search across text fields.

await ctx.tables.filterRows('Orders', { filters: { Status: { $eq: 'active' } } })

Structured filter query, separate from getRows' inline filters.

await ctx.tables.getRowCount('Orders')

Get total row count for a table.

await ctx.tables.listTables()

List all tables in your project.

await ctx.tables.getTableSchema('Orders')

Get field definitions for a table.

Write Operations

Every key must be a real field on the table, spelled exactly. If you write a key the table doesn't have, the write is rejected — nothing is saved, and the error lists the fields the table does have. It is never quietly skipped. A form with a Phone box needs a Phone field, or that answer has nowhere to go.

Field names are case-sensitive: email does not reach an Email field — it is an unknown field. A table can hold two fields whose names differ only by case, so matching loosely could put your value in the wrong one. For the same reason we never auto-correct to a similar name: only you know whether a key was a typo or a field you still need to create.
await ctx.tables.createRow('Orders', { Name: 'Alice', Status: 'active' })

Create a new row.

await ctx.tables.updateRow('Orders', 'row-id', { Status: 'shipped' })

Update a single row.

await ctx.tables.deleteRow('Orders', 'row-id')

Delete a single row.

await ctx.tables.upsertRow('Users', { matchField: 'Email', matchValue: 'alice@example.com', data: { Name: 'Alice' } })

Create or update based on a matching field.

Bulk Operations

await ctx.tables.bulkUpdateRows('Orders', [{ rowId: 'r1', data: { Status: 'shipped' } }, ...])

Update up to 100 rows in a single call.

await ctx.tables.bulkDeleteRows('Orders', ['row-1', 'row-2'])

Delete up to 100 rows in a single call.

await ctx.tables.query('SELECT c."Name", SUM(o."Amount") AS total FROM "Customers" c JOIN "Orders" o ON c."Orders" @> to_jsonb(o.id) WHERE LOWER(c."Email") = LOWER($1) GROUP BY c."Name"', [email])

Raw SQL (read-only) — the most powerful read: one SELECT over your base's tables by name with joins, OR, LOWER(), GROUP BY, aggregates, sorting, and computed lookup/rollup columns. Sandboxed to your base only (platform and system tables don't exist to it), read-only, 4s timeout, 2,000-row cap. Always pass values as $1..$n params — never build SQL strings from user input. Owner-level: no per-member scoping — your function decides what the caller may see, so treat it like a stored procedure: fixed query, member-supplied values only. Pages can't run SQL directly (page code runs in the visitor's browser) — they call a function like this via functions.invoke().

Filter operators: $eq, $ne, $gt, $gte, $lt, $lte, $contains, $in, $isEmpty, $isNotEmpty

ctx.service — Admin Access

ctx.service.tables and ctx.service.files have the same methods as their regular counterparts but bypass all access rules. Use these for admin operations that need unrestricted access.

Best Practice

Use ctx.service only in functions that require authentication (e.g., authenticated or has_role access rules) to ensure only authorized users can trigger admin-level operations.

// Example: admin function to get all orders regardless of access rules
const allOrders = await ctx.service.tables.getRows('Orders');
return allOrders;

ctx.fetch — External APIs

Make HTTPS requests to external services. Supports environment variable placeholders using {{VAR_NAME}} syntax.

// Call an external API with an env var for the API key
const result = await ctx.fetch('https://api.example.com/data', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer {{API_KEY}}' },
  body: JSON.stringify({ query: params.query }),
  timeout: 10000,
});

// result = { status: 200, data: { ... } }
OptionDefaultDescription
methodGETHTTP method
headers{}Request headers (supports {{ENV_VAR}} placeholders)
body-Request body (auto-stringified if object)
timeout10000Timeout in ms for this one request (max 30,000 = 30 sec)

Security: Only HTTPS requests to public endpoints are allowed. Response size limited to 5MB.

ctx.files — File Operations

await ctx.files.list()

List files accessible to the current user.

await ctx.files.getUrl('file-id')

Get a temporary download URL for a file.

await ctx.files.upload('report.csv', csvContent, 'text/csv')

Upload a file with content and MIME type.

await ctx.files.delete('file-id')

Delete a file.

ctx.env — Environment Variables

const value = await ctx.env.get('STRIPE_SECRET_KEY');

Environment variables are encrypted at rest and only decrypted when your function executes. Set them in your app's Config tab. See Environment Variables for more details.

ctx.email — Sending Email

Send transactional email under the project's verified sender identity. Runs as the project/owner, so it isn't gated by the project's member-facing email access rules.

await ctx.email.send({
  to: 'customer@example.com',       // string or array of strings
  subject: 'Your order shipped',
  html: '<p>Order #123 is on its way.</p>',
  text: 'Order #123 is on its way.', // optional
  replyTo: 'support@yourapp.com',    // optional
  template: 'order-shipped',         // optional — use a saved email template instead of html/text
  data: { orderId: '123' },          // optional — template variables
});

Capped at 50 recipients per call and subject to your account's monthly email quota. See Email Templates.

Sending from your own mailbox

For outreach, newsletters or anything at volume, send through your own SMTP mailbox instead. Add it under Automations → Email, then send by name — no monthly platform quota applies, and mail leaves your domain and your sending reputation rather than the shared platform sender.

// which mailboxes are available (no credentials are ever returned)
const { scope, accounts } = await ctx.email.accounts();
// scope: 'member' = the signed-in member's own mailboxes (empty = THEY have none)
//        'account' = your account's mailboxes (no member signed in)
// accounts: [{ id, name, fromAddress, verified, isDefault }]
// Need your account's mailboxes while a member is signed in?
//   const { accounts } = await ctx.service.email.accounts();

await ctx.email.sendVia('outreach', {
  to: 'prospect@example.com',
  subject: 'Quick question',
  html: '<p>Hello…</p>',
  unsubscribeUrl: 'https://yoursite.com/unsubscribe?e=…', // always set for marketing
});
// → { success, messageId, skipped?, fromName, fromNameOverridden }
//   skipped = suppressed recipients

await ctx.email.sendVia(null, { to, subject, html }); // null = default mailbox

// One mailbox, several identities — set the display name per send.
// The ADDRESS never changes (it is the verified, SPF/DKIM-aligned one).
await ctx.email.sendVia('outreach', {
  to, subject, html,
  fromName: "Ken's Hardware Team",   // recipients see this name
});

The credential never reaches your function. You name a mailbox; the platform decrypts and sends. Function code cannot read the password — which is why SMTP credentials belong here rather than in environment variables.

Unsubscribes are honoured automatically. Recipients on the suppression list are dropped before any connection is attempted and returned in skipped. Each mailbox also has its own hourly send ceiling.

Mailboxes belong to your account, so agents, flows and automations can send too — a site isn't required. A mailbox can optionally be bound to one site.

If the display name you set isn't what recipients see

fromNameOverridden: true means the platform put your name in the From header. It does not guarantee the recipient sees it. Some SMTP providers — shared cPanel/Exim hosting in particular — rewrite the sender on authenticated submission, replacing your per-send name with whatever display name the mailbox is configured with on their server. That happens after the message leaves us, so we cannot detect or prevent it.

To confirm, open a delivered message and read the raw source (in Gmail, Show original) rather than the sender line your mail client renders — clients substitute saved contact names for addresses you have emailed before. If the raw header shows the mailbox's name, the rewrite is at your mail host. Two fixes: set the display name per mailbox there, or relay through a provider that allows per-send names (Amazon SES, Postmark, Resend, Mailgun). Retrying the send will not change the result.

ctx.entitlements — Paid Access

The entitlement ledger backs the has_entitlement access rule. ctx.entitlements is the only way to write to it — grant access after a successful payment, then gate tables/files/functions/pages behind the same key.

await ctx.entitlements.grant(userId, 'pro')

Grant a user a key, e.g. after a webhook confirms payment.

await ctx.entitlements.extend(userId, 'pro', 30)

Extend an existing grant (e.g., a recurring subscription renewal).

await ctx.entitlements.suspend(userId, 'pro')

Temporarily pause access (e.g., failed renewal) without deleting the grant.

await ctx.entitlements.revoke(userId, 'pro')

Permanently remove a grant.

await ctx.entitlements.get(userId, 'pro')

Check a single user's current status for a key.

await ctx.entitlements.listForUser(userId)

List every entitlement a user currently holds.

Access Rules

Control who can execute your function. No rules configured = only the app owner can call it.

Rule TypeWho Can Execute
publicAnyone (no authentication required)
authenticatedAny logged-in user
has_roleUsers with a specific role (e.g., admin)

See Access Control for a complete guide on all rule types (including has_entitlement, function, and relationship).

Deny-by-default is strict. A function with no accessRules configured returns "No access rules configured" to everyone except the app owner — including logged-in members calling the function's HTTP endpoint directly. Always set execute rules before relying on a function from the published app.

Limits & Security

LimitValue
Plan requirementPaid plans only — every execution path rejects free-plan accounts
Default timeout10 seconds — extendable per function up to 5 minutes for slow work (AI calls, big imports)
Retries (maxRetries)0–10, default 0 (run once). Only applies when an automation/schedule invokes the function — set >0 only if it's idempotent
Monthly execution quotaPlan-based, but the counter is per account — shared across every project you own, not per-function or per-site
Bridge calls per execution100 (database/file operations)
Request body size5MB
Response size (ctx.fetch)5MB
Memory128MB, fixed on every paid plan
Filesystem accessBlocked (read/write denied)
Environment accessBlocked (use ctx.env instead)
Subprocess spawningBlocked

Each function runs in a completely isolated Deno process. One function cannot access another function's data or memory. Crashed functions don't affect the server.

Calling from Your App

In your published app's pages, import and call backend functions using the SDK:

// In your app's page code:
import { functions } from '../api/sdk';

const result = await functions.call('processOrder', {
  item: 'Widget',
  quantity: 3,
});

// result = { success: true, data: { orderId: '...' }, executionTime: 150 }

Functions are also reachable directly over HTTP (e.g., from an external webhook). The raw JSON request body is the params object — no wrapper:

POST /api/app-builder/{projectId}/functions/processOrder
Content-Type: application/json

{ "item": "Widget", "quantity": 3 }

// Response is the same shape: { success, data, executionTime }

The same access rules apply either way — an unauthenticated or under-permissioned external caller gets the same deny response a logged-out SDK call would.