Triggers

Bind a function to an event in your project. The function fires automatically when a matching event happens — no polling, no glue code. Triggers can listen to database mutations, storage object lifecycle, or user registration.

Event families

Events come from three families. Each family binds to a different kind of target — collections for database, buckets for storage, nothing at all for auth (the event fires globally).

Database events

Each trigger is bound to a specific collection. Fires after the matching mutation commits.

db.record.createdA new record was inserted into the collection
db.record.updatedAn existing record was updated
db.record.deletedA record was deleted

Storage events

Each trigger is bound to a specific bucket. Fires after the object lifecycle change commits.

storage.object.createdA new object was uploaded and confirmed
storage.object.updatedAn object's metadata was edited, or the object was overwritten
storage.object.deletedAn object was deleted

Auth events

Auth triggers are not bound to a target. They fire globally on the matching event.

auth.user.registeredA new user account was created
auth.user.deletedA user account was permanently deleted (self-service via the SDK's deleteAccount, or admin action)

auth.user.deleted is where your cleanup goes — nothing else does it

Deleting an account removes the user's sessions and their auth record. It does not touch your collections or your storage: Koolbase cannot know whether a record should be deleted, anonymised, or kept for a statutory retention period, so it does not decide for you. Records keep their created_by, now pointing at a user that no longer exists.

That makes this trigger the contract. Subscribe a Function to it and clean up with deleteWhere(collection, { created_by: userId })— one call per collection, and it runs server-side, so a user closing the tab mid-flow cannot strand anything.

If you subscribe nothing, your users' records stay indefinitely. Where deletion is a legal obligation rather than a nicety — GDPR erasure, Apple's account-deletion requirement — that is your obligation to meet, and this trigger is how.

auth.user.registered fires once per user, regardless of method

Whether a user signs up with email + password, OAuth (Google, GitHub), or phone + OTP, the same canonical event fires once after the user record is created. You only need one trigger to react to every signup; future signup methods route through the same emitter.

Creating a trigger

In the dashboard, go to Functions → pick a function → Triggers tab → Add Trigger. Pick the event type — the form adjusts to ask for the right target (collection for database events, bucket for storage events, nothing for auth events).

One example per family:

Function

send-welcome-email

Event

db.record.created

Collection

users

Function

process-image

Event

storage.object.created

Bucket

avatars

Function

send-welcome-email

Event

auth.user.registered

Target

(none — fires for every signup)

Replaying existing records

A trigger fires for changes that happen after it exists. Records already in the collection, seeded or imported before you bound the trigger, never passed through it. Replay runs the trigger's function once for each of them, oldest first, as if each had just been created or updated.

koolbase triggers replay <trigger-id> --project <project-id> --dry-run   # counts only
koolbase triggers replay <trigger-id> --project <project-id>
  • Only db.record.created and db.record.updated triggers can be replayed, and only while they are enabled.
  • Up to 1,000 records per run by default; --limit goes up to 5,000. Records beyond the limit are counted and reported, not queued.
  • Deliveries go through the retry queue, at most 25 every 30 seconds, so real events are never held up behind a replay. A failed delivery is retried and dead-lettered like any other.
  • Each delivery gets the normal database event payload, plus replay: true and an event_id that is the same every time that record is replayed for that trigger.
replayed payload
JSON
{
  "record": { "$id": "rec_123", "title": "..." },
  "replay": true,
  "event_id": "replay:<trigger-id>:rec_123"
}

Make the function safe to run twice

Running a replay again queues every record again, with the same event_id. If the function has side effects, such as sending an email or inserting rows, check event_id or look for work it already did, so a repeat does no harm.

The same is available over the API: POST /v1/projects/{project_id}/triggers/{trigger_id}/replay?dry_run=true&limit=N.

Accessing trigger data

When a trigger fires, ctx.request carries the event name and the matching payload. The payload shape depends on the family.

Database event payload

db-trigger
TypeScript
export async function handler(ctx) {
              // ctx.request.event      — e.g. 'db.record.created'
              // ctx.request.collection — the collection that was mutated, e.g. 'users'
              // ctx.request.payload    — the event payload (shape varies by event, see below)

              const { event, collection, payload } = ctx.request;

              // created / updated — payload.record is the record, flat, with
              // $-prefixed system fields alongside your own fields:
              // payload.record: { $id, $collection, $createdAt, $updatedAt, ...yourFields }
              if (event === 'db.record.created' && collection === 'users') {
                const record = payload.record;
                const email = record.email;      // your fields are top-level
                const recordId = record.$id;     // system fields are $-prefixed
                // ... do something with the new user
              }

              // deleted — the record is gone; you get its id only:
              // payload: { record_id }
              if (event === 'db.record.deleted') {
                const deletedId = payload.record_id;
              }

              return { ok: true };
            }

Note the two levels: payload.record

For created and updated events the record is nested at payload.record — reading fields off payload directly will give you undefined. The record itself is flat: your fields at top level, system fields prefixed with $ ($id, $collection, $createdAt, $updatedAt).

Storage event payload

storage-trigger
TypeScript
export async function handler(ctx) {
  // ctx.request.event   — e.g. 'storage.object.created'
  // ctx.request.bucket  — the bucket that fired, e.g. 'avatars'
  // ctx.request.payload — the object record

  const { event, bucket, payload } = ctx.request;
  // payload: {
  //   id, bucket, path, size, content_type, etag,
  //   metadata: { ... },
  //   created_at, updated_at
  // }

  if (event === 'storage.object.created') {
    console.log(`New upload in ${bucket}: ${payload.path} (${payload.size} bytes)`);
    console.log('Custom metadata:', payload.metadata);
  }

  return { ok: true };
}

Auth event payload

auth-trigger
TypeScript
export async function handler(ctx) {
  // ctx.request.event   — 'auth.user.registered'
  // ctx.request.payload — the new user record

  const { event, payload } = ctx.request;
  // auth.user.registered → payload: { id, email, name, providers, created_at }
  // auth.user.deleted    → payload: { reason: 'self_service', user: { email, id } }
  // payload: { id, email, name, providers, created_at }
  // For auth.user.deleted the payload is:
  // { reason: 'self_service', user: { email, id } }

  await sendEmail({
    to: payload.email,
    subject: 'Welcome!',
    body: `Hi ${payload.name ?? 'there'}, welcome to MyApp.`,
  });

  return { ok: true };
}

Handling multiple event types in one function

A single function can be bound to several triggers across families. Branch on ctx.request.event:

multi-event
TypeScript
export async function handler(ctx) {
  const { event, payload } = ctx.request;

  switch (event) {
    case 'auth.user.registered':
      await sendEmail({ to: payload.email, subject: 'Welcome!' });
      break;

    case 'storage.object.created':
      if (payload.content_type?.startsWith('image/')) {
        await enqueueThumbnail(payload.bucket, payload.path);
      }
      break;

    case 'db.record.deleted':
      await invalidateCache(`${ctx.request.collection}:${payload.record_id}`);
      break;
  }

  return { ok: true };
}

Trigger observability

Each trigger row in the dashboard shows a 24-hour success rate with a colour-coded progress bar — green (≥80%), amber (50–80%), red (<50%). Triggers with a failure rate above 50% show a "Failing frequently" warning badge.

Success ratePercentage of executions that returned without error in the last 24h
Run countTotal number of trigger executions in the last 24h
Failure countNumber of failed executions in the last 24h

Trigger execution is asynchronous

Triggers fire in a background goroutine — they never block the database, storage, or auth operation that caused them. If the trigger handler fails, the platform retries automatically. See Retries & DLQ for the retry policy and the dead-letter behavior after the final attempt.