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 does | A throw means | |
|---|---|---|
@Hook("before.…") | Runs before the operation and can stop it | The operation is cancelled; your reason is returned to the caller |
@On(…) | Runs after, as a monitor | Logged. 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 abefore.user.createhook 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 nohooks/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.jsonout 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 pushstill printsbundled 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@Hookor@Ondeployed 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. Checkpalbase --versionagainst 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).
| Field | What it is |
|---|---|
requestId | A correlation id, so one signup's gate can be found in the log |
env | A snapshot of process.env, into which the runtime mirrors this Environment's vault — see Secrets |
environmentId | The 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.environmentIdis not your Environment's ref. It is the runtime'sPALBASE_STACK_REF, and on the Palbase cloud every project boots with the same compile-time constant — the literalproject— because a stack's identity is deliberately not something a request can choose. Usemeta.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 intodocument.created/document.updated/document.deletedat its inside door, rewriting the payload's owneventfield to match. So a handler readingevent.eventsees the product's name, neverINSERT, 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
| Surface | Event comes from | Can it stop the operation? |
|---|---|---|
@Webhook | A third-party service | No — it reports a status back to the sender |
@Hook | This stack | Yes — a throw cancels it |
@On | This stack | No — a monitor |
@Upload | A client uploading a file | Yes — an error discards the object |
@Job | A schedule you wrote | Not applicable |
Related
- Modules & Dependency Injection — the
providerslist that makes a hook exist - Webhooks — the same shape, for a third party's events
- Scheduled Jobs — the other surface that travels with a push
- Direct Uploads — the blocking contract for files
- Authentication — the auth operations these events come from
- Secrets — the vault behind
meta.env - Deploying — the manifest, and the gate that refuses a dropped surface