Server-Side Actions, Schedules, and Webhooks

Actions are a Custom App's server-side logic. This chapter covers how to write them, how they get triggered, and the real limits at execution time.


Why actions exist

Front-end code runs in the user's browser — which means anything you put there, the user can read and modify.

Three classes of logic therefore belong in actions:

  1. Anything needing secrets — third-party API calls, signing, encryption
  2. Anything needing privilege — triggering ERP business actions, writing sensitive data
  3. Anything that shouldn't be visible — pricing rules, risk logic, internal algorithms

Actions execute in an isolated server environment. Users cannot read the source and cannot bypass the checks inside.


Action structure

Each action is a Python file under actions/ exposing an execute(ctx) function:

# actions/confirm_orders.py
def execute(ctx):
    ids = ctx.params.get('ids', [])

    if 'sale.write' not in ctx.user_permissions:
        return ctx.response.json({'error': 'Not authorized'})

    confirmed = []
    for oid in ids:
        ctx.erp.confirm_sale_order(oid)
        confirmed.append(oid)

    ctx.response.json({'confirmed': confirmed})

actions/manifest.json

The manifest declares which actions exist and how they're configured:

{
  "actions": [
    {
      "name": "confirm_orders",
      "description": "Batch confirm sales orders",
      "meta": { "webhook": false }
    },
    {
      "name": "receive_payment_callback",
      "description": "Receive payment gateway callback",
      "meta": { "webhook": true }
    }
  ]
}

Shared code: actions/_shared/

Put logic shared between actions in actions/_shared/:

# actions/_shared/money.py — not an action; no execute(ctx) needed
def round_to_cents(value):
    return round(value + 1e-9, 2)
# actions/settle.py
from _shared.money import round_to_cents

def execute(ctx):
    total = round_to_cents(sum(x['amount'] for x in ctx.params['items']))
    ...

Note: actions cannot import each other — from confirm_orders import ... will not work. Move shared logic into _shared/ and import it from both sides.


Three ways to trigger an action

1. Front-end invocation

The most common. A user acts in the interface and the front end calls through src/action.ts:

import { callAction } from './action';
const res = await callAction('confirm_orders', { ids: [...] });

2. Schedules

Run an action at a set time with nobody online.

There are two configuration entry points with different authorization surfaces:

Entry pointWhoPermission
The Builder's Schedules tabApplication developersbuilder.app_cron_manage
Dashboard → System & Operations → App SchedulesOrganization administratorssystem.admin or settings.write

The former lets developers configure schedules while building their own app; the latter gives administrators an organization-wide view.

Typical uses:

  • Aggregate the previous day's sales overnight and produce a report
  • Check hourly for stock below the reorder point and notify
  • Batch-merge invoices monthly for account customers

Execution identity: a scheduled action has no triggering user. ctx.user_id is empty and ctx.user_permissions is an empty list. Consequences:

  • Identity-bound SDKs (like ctx.approval.list_pending) are unavailable in schedules
  • ctx.knowledge.search retrieves with the permissions of a member holding no roles
  • Approval guards still apply; the approval UI shows the requester as "API"

Automatic suspension: repeatedly failing schedules are suspended automatically so a broken task doesn't burn quota indefinitely. Re-enabling requires human review.

3. Webhooks

Add "webhook": true to an action's meta in the manifest to expose a public endpoint:

POST https://{app subdomain}/webhook/{action_name}

Multiple actions can each be declared independently, giving each its own endpoint. Effective after release.

Typical uses: payment callbacks, logistics status pushes, third-party event notifications.

Security warning: webhook endpoints are public — anyone who knows the URL can call them. Always verify the caller inside the action; the usual approach compares a signature with ctx.crypto.hmac_sign:

def execute(ctx):
    body = ctx.params.get('_raw_body', '')
    expected = ctx.crypto.hmac_sign('webhook_secret', body)
    if ctx.params.get('signature') != expected:
        return ctx.response.json({'error': 'invalid signature'})
    ...

Runtime environment and limits

Isolation

Each action runs in its own sandbox. Applications and organizations cannot see each other.

Timeouts

Actions have an execution time limit, handled in three layers:

  1. Soft timeout — at the configured limit the platform attempts to interrupt execution and report a timeout
  2. Hard timeout — if the soft interrupt fails, the runtime severs the request outright
  3. Zombie detection — if the thread cannot be reclaimed, the instance is marked unhealthy and replaced

Practical implication: actions are not suited to long computations. Split long work across scheduled runs, or hand the heavy lifting to an external service and receive the result via webhook.

Concurrency

The platform scales instances with demand, bounded by the organization's compute quota.

Execution records

Every run is recorded with a parameter summary, duration, outcome, and error message. Records are visible in the Builder, and the AI development assistant can read them to help debug.

If an outcome cannot be confirmed because of an infrastructure failure, the record is marked as indeterminate — such runs are never billed and never retried, and are overwritten automatically if the real result arrives late.


Practical guidance

Check permissions; don't assume. A hidden front-end button is not a security boundary:

if 'accounting.write' not in ctx.user_permissions:
    return ctx.response.json({'error': 'Not authorized for this operation'})

Check external call status. ctx.http.call does not raise:

resp = ctx.http.call('svc', '/path')
if resp['status'] != 200:
    return ctx.response.json({'error': resp['data'].get('fix', 'External service error')})

Avoid blocking patterns. Long synchronous waits pin an instance. The platform's static analysis warns about these.

Test before you publish. The Builder's test run genuinely executes the action against real permissions and data — far more reliable than reading the code.


Approvals intercept writes

If an administrator has configured an approval workflow for a table, an action's writes are intercepted automatically:

  • Insert — the record is held pending until every stage passes
  • Update / deletenot executed; a pending-approval exception is raised carrying a request identifier. Update payloads are held and applied by the platform once approved

Actions should not retry — retrying only creates duplicate approval requests. Catch the exception and tell the user the change was submitted for approval.

See Chapter 13.