Chelabase
Functions

Handlers

The fetch and scheduled handlers, the caller header, and the payloads of database and storage events.

A function is an ES module whose default export has the handlers you use:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    return new Response("hi");
  },
  async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
    // runs on a cron schedule
  },
};

fetch serves HTTP calls and the two kinds of events (database and storage), told apart by a header. scheduled serves cron schedules. env holds the function's secrets and Chelabase's own values.

fetch: HTTP calls

The request is the caller's, with the method, path rest, query and body intact. Chelabase removes Authorization, Host, Content-Length and every chelabase-* and x-chelabase-* header (including any the client sent to forge them), and adds the headers below. The function's response goes back as it is, except that chelabase-* response headers are removed.

A request body is at most 6 MiB (413 payload_too_large above it).

Who called

chelabase-caller is JSON that says who called, as the gateway verified it:

kindWhenOther fields
public_keyA public keyrole (anon)
secret_keyA secret keyrole
app_userA public key with a valid app-user tokenrole (authenticated), user_id, claims
anonymousThe keyless /functions/v1/public/… formrole is null
internalA schedule or an eventrole is null

Two more headers let a function call the data API as the caller: chelabase-caller-key (the caller's public key, sent only for a public key or an app user, never a secret key) and chelabase-caller-token (the app user's token, only for an app user). See acting as the caller.

CORS

Functions (and services) are callable from any origin. The gateway answers a browser's preflight (OPTIONS with Access-Control-Request-Method) itself, without calling the function: Access-Control-Allow-Origin: *, the route's method, Access-Control-Allow-Headers echoing the headers the browser asked for, and Access-Control-Max-Age: 600.

On the actual response, CORS headers the function sets itself (Access-Control-Allow-Origin, Access-Control-Expose-Headers, …) are kept as they are; the gateway only adds the ones the function left out (Access-Control-Allow-Origin: * and its default exposed headers).

scheduled: cron

async scheduled(controller, env, ctx) {
  console.log(controller.cron, controller.scheduledTime); // "*/15 * * * *", a Date
}

controller.cron is the schedule's cron expression and controller.scheduledTime the tick it was due. A function with no scheduled handler, or one that throws, records scheduled handler: <outcome> as its last error. See Schedules.

Database events

A table trigger delivers each committed row change to fetch as a POST with the header chelabase-event: db and a JSON body:

{
  "id": "0123456789abcdef0123456789abcdef:app:7",
  "type": "INSERT",
  "schema": "public",
  "table": "orders",
  "record": { "id": 1, "total": "9.50" },
  "old_record": null,
  "txid": 42
}
  • type is INSERT, UPDATE or DELETE. record is the new row (null for a delete), old_record the old one (null for an insert).
  • id is <timeline>:<database>:<row id> and is the same on every attempt of the event: use it to deduplicate.
  • Columns of built-in types are JSON values; any other type is its text form.
  • Large events: when the body would pass 256 KiB, record and old_record are null, truncated is true, and primary_key holds the key's values (null for a table without a primary key). Fetch the row from the data API if you need it.
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.headers.get("chelabase-event") === "db") {
      const event = await request.json<{ id: string; type: string; record: unknown }>();
      await handleOnce(event.id, event);
      return new Response(null, { status: 204 }); // any 2xx acknowledges
    }
    return new Response("hi");
  },
};

The function's auth mode doesn't apply to events. Answering anything but 2xx makes Chelabase retry; see Delivery.

Storage events

A storage trigger delivers each matching object event to fetch as a POST with chelabase-event: storage. The body is the event alone, the same JSON that storage webhooks carry:

{
  "id": "0b7c…",
  "type": "object.created",
  "time": "2026-11-07T10:12:03.511Z",
  "database_id": "95b6…",
  "branch_id": "95b6…",
  "pg_database": "app",
  "bucket": "avatars",
  "name": "public/me.png",
  "version_id": "4c1e…",
  "size": 48213,
  "content_type": "image/png",
  "actor": { "role": "authenticated", "sub": "2b1c…" }
}

object.moved adds from: { bucket, name }, and object.deleted has no version_id. Events never carry the object's bytes: read the object with the storage API if you need it. id is stable across attempts.

Delivery

Events are delivered at least once: a function can see the same event more than once (a timeout after the work was done, a restart between delivery and acknowledgement), so deduplicate on id.

Database eventsStorage events
Acknowledged byAny 2xxAny 2xx
On failureRetried with backoff: 1 s, doubling, at most 5 minutesRetried with the same backoff as webhooks
Gives upAfter 10 attempts the event is deadAfter 24 hours the event is dropped
Counted inThe trigger's dead_count, then dropped_count when removeddropped_count

Database events that stay unacknowledged for 15 minutes (for instance because the dispatcher stopped) become due again. The limits and what is counted are in Triggers.

On this page