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.
On this page
The ctx Object
Every backend function receives a ctx object and a params object. The ctx object provides access to all platform features:
| Property | Description |
|---|---|
| ctx.tables | Database operations (respects access rules) |
| ctx.service.tables | Database operations (bypasses access rules) |
| ctx.files | File operations (respects access rules) |
| ctx.service.files | File operations (bypasses access rules) |
| ctx.fetch | HTTPS requests to external APIs |
| ctx.env | Access environment variables |
| ctx.user | Calling user's { id, email, name, role, roleIds, roleNames, profileData } or null. Branch on roleNames — role is only the built-in user/admin flag |
| ctx.email | Send transactional email via the project's sender identity |
| ctx.entitlements | Grant/extend/suspend/revoke paid-access entitlements — the only write path onto the entitlement ledger |
| ctx.realtime.publish | Push an event to connected clients on a channel |
| ctx.agents.enqueue | Hand 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
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: { ... } }| Option | Default | Description |
|---|---|---|
| method | GET | HTTP method |
| headers | {} | Request headers (supports {{ENV_VAR}} placeholders) |
| body | - | Request body (auto-stringified if object) |
| timeout | 10000 | Timeout 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 Type | Who Can Execute |
|---|---|
| public | Anyone (no authentication required) |
| authenticated | Any logged-in user |
| has_role | Users 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
| Limit | Value |
|---|---|
| Plan requirement | Paid plans only — every execution path rejects free-plan accounts |
| Default timeout | 10 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 quota | Plan-based, but the counter is per account — shared across every project you own, not per-function or per-site |
| Bridge calls per execution | 100 (database/file operations) |
| Request body size | 5MB |
| Response size (ctx.fetch) | 5MB |
| Memory | 128MB, fixed on every paid plan |
| Filesystem access | Blocked (read/write denied) |
| Environment access | Blocked (use ctx.env instead) |
| Subprocess spawning | Blocked |
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.