Palbase
Sign inGet started

Backend SDK

Event Hooks

Event hooks run your backend code when something happens inside your own stack — a user signs up, a login is attempted, a token is issued, a document is written, a file lands in a bucket. Where webhooks react to a third party's events, hooks react to this stack's, with no URL to register and no signature to manage. And a hook can do the one thing a webhook cannot: refuse the operation that raised it. @Hook blocks, @On listens, and the difference is the whole page.

Quick example

// modules/accounts/signup-gate.hook.ts
import { Database, Deny, Hook, Log, On } from "@palbase/backend";
import type { AuthHookEvent, HookMeta } from "@palbase/backend";

export class SignupGate {
  // @Hook BLOCKS. Throwing cancels the operation that raised the event, and
  // the reason reaches the person it refused.
  @Hook("before.user.create")
  async gate(event: AuthHookEvent, meta: HookMeta): Promise<void> {
    const email = event.user?.email ?? "";
    if (email.endsWith("@example-blocked.test")) {
      throw new Deny("this address is not accepted");
    }
    // Returning quietly allows the signup.
  }

  // @On only LISTENS. A throw here reaches the log and nothing else — the
  // event it watched has already happened.
  @On("after.session.revoke")
  async audit(event: AuthHookEvent, meta: HookMeta): Promise<void> {
    Log.info("session revoked");
  }
}
// modules/accounts/accounts.module.ts
import { Module } from "@palbase/backend";

import { SignupGate } from "./signup-gate.hook";

@Module({
  controllers: [],
  providers: [SignupGate],   // surfaces go in `providers`, beside the services
  exports: [],
  imports: [],
})
export class AccountsModule {}

Push, and both handlers are live. There is no URL to configure and no address to register — but there is a registration, and it is the providers list: a hook class no module names is refused at build, by name. The container builds the class, so it can take a service through its constructor like anything else. See Modules & Dependency Injection.

Blocking or listening

Two decorators, one rule:

What it doesA throw means
@Hook("before.…")Runs before the operation and can stop itThe operation is cancelled; your reason is returned to the caller
@On(…)Runs after, as a monitorLogged. The operation is not rolled back

The verdict contract is exactly that: returning quietly allows, throwing denies. Any throw denies, not only Deny — the engine is fail-closed, and a hook it cannot trust is a hook it must not obey. Deny exists so that the reason is deliberate rather than whatever a stray TypeError happened to say, because the reason reaches the person whose signup was refused.

A value you return from a blocking hook becomes the response body, serialised as JSON. That is what lets @Upload sit on this same backbone: your method answers, and the storage module hands that answer to the client unchanged. Auth hooks normally return nothing.

Hooks are fail-closed. If your backend cannot be reached, a before.* operation is refused, not allowed through — a gate built for security must not fall silently open. Keep hook bodies narrow: an accidental throw refuses a real user.

Warning: Fail-closed has a concrete and sharp shape. A hook is registered on the stack by the deploy, and the stack calls your runtime by event name. If the registration is live but the code is not — a deploy that half-landed, or a bundle that dropped the surface — the runtime answers 404 {"verdict":"deny", …} and the engine reads that as a denial. So a broken deploy of a before.user.create hook does not disable the gate: it locks signups. The shape of the answer says which case it was, which is why it is a 404 rather than a verdict, but the safe reading is the one that is taken.

Events

before.* events can block. Everything else is a monitor.

Auth — the operations you can refuse (@Hook):

before.user.create          before.token.issue           before.impersonation
before.login                before.token.refresh         before.device_grant.initiate
before.password.reset       before.transaction.approve   before.device_grant.authorize
before.mfa.verify
before.social.link

Auth — monitors (@On):

after.login.failed          after.transaction.approve    after.device_grant.authorize
after.session.revoke        after.device_grant.deny

Documents and storage — monitors (@On):

document.created            file.uploaded
document.updated            file.deleted
document.deleted

Those ten are @On only. A document event fires after the row is written and a file event after the object is stored, so there is nothing left to cancel; declaring one as @Hook is refused when the deploy applies the manifest, rather than shipping a gate that cannot gate. The same is true of the five after.* auth events — the operation has already answered its caller by the time your handler runs. To refuse an upload, use @Upload; that one runs before the object is finalised and is the blocking contract for files.

An event name that is not on these lists is refused by name too, before anything is registered — a typo would otherwise become a hook that never fires and never says so.

How hooks are defined and discovered

  • A hook is a class a module lists in providers. Discovery is not a directory — there is no hooks/ and nothing reads one, so the file lives with the domain that owns the events it watches (modules/accounts/signup-gate.hook.ts) and nothing about its path decides anything.
  • Methods carry @Hook("before.…") (blocking) or @On(…) (listening). A class with neither refuses the build, and so does a decorator naming a method the class does not define.
  • Each event has one handler per project. Two classes claiming the same event refuse the push, naming both.
  • Declaring the same event twice on one class refuses the build.
  • A class listed nowhere is refused at build, by name — the same refusal a controller or a job gets.

palbase push reads what your project declares out of the container your modules describe, never off the disk, and prints the events the artifact carries:

bundled hook(s) → before.user.create, after.session.revoke

That events list is the registration the stack applies: it upserts what the artifact declares and prunes what it does not, so deleting a hook class — or dropping it from providers — un-registers its event on the next push.

A hooks/ directory used to be the discovery, and it shipped nothing: a hook class outside it never fired, and one inside it that no module claimed fired anyway, with no error either way. Asking the container is what closes that for good — a hook exists because a module lists it, and the same module is what the bundle imports, so the declaration and the artifact cannot disagree the way a folder and a bundle could.

At boot the runtime prints what it loaded, marking the monitors:

[runtime] hooks: before.user.create, after.session.revoke (listener)

Warning: hook registration is not reaching the stack right now — measured 2026-09-11. The auth module learns which events a project handles by reading .palbase/hooks/hooks.manifest.json out of the artifact, and no CLI has written that file since 2026-09-01: the rewrite that moved discovery from directories to the container replaced two manifest writers with one, and only the job manifest survived it. palbase push still prints bundled hook(s) → …, and that line is honest — it is read out of the container and says what the CODE carries — but nothing tells the stack which events to call, so a @Hook or @On deployed today does not fire. The rest of this page describes the contract as designed and as the runtime implements it; the delivery half is the gap. Check palbase --version against the release notes before relying on a hook.

How it reaches your code

Nothing to configure and no URL to register. The push records which events your project handles, and the platform calls your runtime at an address it generates itself — the manifest never carries a URL, so a hook's target is always this stack's own runtime and there is nothing an author could point somewhere else. The call lands on the runtime's internal probe listener, for the same reason a job run does: a URL that runs your gate on a signup must not be reachable from the internet. That port is not published to the host and nothing routes to it from outside.

The default per-hook timeout is 15 seconds.

Note: That unpublished port is the security property, stated plainly. The runtime performs no signature check on an incoming hook call — the auth module mints a per-registration signing key, but whether a signature travels and is simply not verified was not established, so do not build on the assumption that hook calls are authenticated end to end.

HookMeta

Every handler receives (event, meta).

FieldWhat it is
requestIdA correlation id, so one signup's gate can be found in the log
envA snapshot of process.env, into which the runtime mirrors this Environment's vault — see Secrets
environmentIdThe stack's own boot ref — read the warning below

Services are not on meta. Import the singletons — Database, Log, Notifications, and the rest — from @palbase/backend exactly as a controller does; a hook runs inside the same scope, out of the same bundle.

Warning: meta.environmentId is not your Environment's ref. It is the runtime's PALBASE_STACK_REF, and on the Palbase cloud every project boots with the same compile-time constant — the literal project — because a stack's identity is deliberately not something a request can choose. Use meta.env.PALBASE_PUBLIC_ORIGIN (https://<ref>.palbase.studio) if you need to know which Environment you are in.

The engine's own credentials are deleted from process.env before your bundle loads, so DATABASE_URL and the service-role key are not in meta.env.

Watching documents and files

These cover the paths @Upload does not. @Upload wraps one client call; a document your backend writes with the SDK, and an object stored through your project's own storage calls, never pass through it.

// modules/audit/stack-listeners.hook.ts
import { Log, On } from "@palbase/backend";
import type {
  DocumentHookEvent,
  FileDeletedEvent,
  FileUploadedEvent,
  HookMeta,
} from "@palbase/backend";

export class StackListeners {
  @On("document.created")
  async onDocumentCreated(event: DocumentHookEvent, meta: HookMeta): Promise<void> {
    Log.info(`document ${event.documentId} created in ${event.collection}`);
  }

  @On("file.uploaded")
  async onFileUploaded(event: FileUploadedEvent, meta: HookMeta): Promise<void> {
    Log.info(`stored ${event.bucket}/${event.path} (${event.size} bytes)`);
  }

  @On("file.deleted")
  async onFileDeleted(event: FileDeletedEvent, meta: HookMeta): Promise<void> {
    Log.info(`removed ${event.bucket}/${event.path}`);
  }
}

The event types are exported from @palbase/backend and describe what the stack actually sends, so a handler written against them compiles against the real shape. DocumentHookEvent carries event, collection, documentId, path, version, timestamp and an optional data — note that documentId is camelCase. FileDeletedEvent carries bucket and path only: the object is gone, and the type says so by what it does not offer.

Note: The document module speaks its database trigger's vocabulary — insert, update, delete — and the runtime translates that into document.created / document.updated / document.deleted at its inside door, rewriting the payload's own event field to match. So a handler reading event.event sees the product's name, never INSERT, and branching on the trigger word would never match. An unrecognised name is passed through unchanged so that it reaches the 404 that says nothing declares it.

Both are listeners, and that word is the whole contract: the operation answers its caller before your handler runs, so nothing you do here can undo it. A throw is logged and the verdict is still allow; the document stays written and the file stays stored. Delivery is best-effort — do not put a step your data depends on in one.

File events are delivered by the storage module itself, which reads the same manifest out of the active artifact and only delivers the file.* events the loaded bundle declares. A manifest it cannot parse yields the empty set, which means no file events at all rather than a guess.

Hooks vs. the other event surfaces

SurfaceEvent comes fromCan it stop the operation?
@WebhookA third-party serviceNo — it reports a status back to the sender
@HookThis stackYes — a throw cancels it
@OnThis stackNo — a monitor
@UploadA client uploading a fileYes — an error discards the object
@JobA schedule you wroteNot applicable