Skip to content

Hooks & Events

Hooks are the primary extension point in Kirak. Every CRUD operation, every auth event and every payments operation fires before_* and after_* hooks. Business logic belongs in hooks – not in runtime code.


All hooks must be registered in the on_kirak_ready callback. This runs after the database connects but before routers mount, guaranteeing hooks are in place for the first request.

def on_kirak_ready(kirak):
# Register hooks here -- database is connected, routers not yet mounted
@kirak.on("posts").hook("after_create")
async def welcome(result):
return result
app = create_kirak_app(
models_path="./models/",
on_kirak_ready=on_kirak_ready,
)

Hooks registered after mount_routers() may miss the first request.


There are two ways to register hooks: a decorator for one function, and a class to group several.

@kirak.on("posts").hook("after_create")
async def after_post_created(result):
post_id = result["data"]["id"]
# side effects...
return result

.hook(hook_type) returns the decorator; @ is the usual way to apply it, but it is a plain function, so it can also be called directly for programmatic registration:

kirak.on("posts").hook("after_create")(after_post_created)

Single-purpose modules (auth, payments, notifications, storage, ai, scheduler) have a single default model, so they accept the same decorator directly, without .on():

@kirak.auth.hook("after_login") # shorthand
@kirak.auth.on().hook("after_login") # equivalent, explicit
async def after_login(result):
return result

2. Hook class (to group and isolate several hooks)

Section titled “2. Hook class (to group and isolate several hooks)”

Group related hooks into a class with a register classmethod. Use this to keep a project’s hooks organized – one class per feature area, for example – instead of a flat list of module-level functions:

class PostHooks:
@classmethod
def register(cls, kirak):
@kirak.on("posts").hook("after_create")
async def after_create(result):
return result
@kirak.on("posts").hook("before_delete")
async def before_delete(payload):
return payload
kirak.register_hook_class(PostHooks)
# or register multiple at once:
kirak.register_hook_classes(PostHooks, OrderHooks, UserHooks)

A hook class still registers its hooks with the decorator internally – the class is just a container, not a third registration mechanism.


  • A hook receives one argument: the current payload (before hooks) or result (after hooks).
  • A hook must return the value it receives (modified or unchanged). Return None to pass through unchanged.
  • All hooks in the chain run serially in registration order.
  • Hooks can be async or sync, and the two behave differently:
Sync hook (def) Async hook (async def)
Can reject the operation Yes: an exception propagates, stops the hook chain and the operation No: a timeout or exception is logged and skipped, and the chain continues with the previous payload
Time limit None hook_timeout_seconds in kirak.json (default 5 seconds)
Blocks the request Yes Yes, until it finishes or times out: async hooks are awaited one after another, they do not run in the background

Use a sync hook when the hook must be able to stop the request (validation, policy checks). Use an async hook for work that may fail without blocking the operation (notifications, audit records).

@kirak.on("orders").hook("after_create")
async def send_confirmation_email(result):
order_id = result["data"]["id"]
try:
await kirak.notifications.send_email({
"to": "user@example.com",
"subject": f"Order #{order_id} confirmed",
"text_body": "Your order is being processed.",
})
except KirakException as e:
# Log error but don't block the order creation
print(f"Failed to send confirmation email: {e.message}")
return result # always return

Raise a Kirak exception from a sync before_* hook:

from kirak.core.exceptions import ValidationError
@kirak.on("bookings").hook("before_create")
def check_dates(payload):
data = payload.get("data", {})
if data.get("start_date") and data.get("end_date") and data["start_date"] >= data["end_date"]:
raise ValidationError("end_date must be after start_date")
return payload

The client gets the standard error envelope with the exception’s own status code (ValidationError is 400, PermissionDenied is 403, and so on). A plain exception such as ValueError is not mapped: it comes back as a generic 500 INTERNAL_ERROR. Raising from an async def hook does not reject anything, as the table above says.


Hooks do not run inside the operation’s database transaction:

  • For REST and direct calls (kirak.create(...)), before_* hooks run before the operation opens its transaction, and after_* hooks run after it has committed.
  • In a GraphQL mutation with several root fields, the hooks of each field run inside the one shared transaction. If a later root field fails, the whole transaction rolls back, but a side effect of an earlier field’s after_* hook (an email that was already sent, for example) has already happened and is not undone.

For side effects that must match what was committed, enqueue a scheduler job from the after_* hook instead of doing the work in the hook.


Async hooks run inside the application process. If the process stops while a hook is running, its side effect is lost and nothing retries it. For reliable side effects (an invoice email, a webhook to another system), enqueue a scheduler job from the hook: jobs are stored, retried, and survive a restart. For work the request should not wait for at all, do the same.


Hook type Argument Contents
before_* payload The raw parameters dict passed to the operation (except before_enqueue – see note below)
after_* result The full response envelope from the operation

Before hooks can modify the incoming parameters (e.g. inject extra fields). After hooks can modify the outgoing response (e.g. enrich records with computed data).

Exception: Scheduler’s before_enqueue hook receives the canonical envelope ({"status": ..., "data": {...}}) instead of the raw params dict, with the job’s task, payload, queue, priority, run_at, max_retries and provider under data. Return the envelope (changed or not), {"data": {...}} with just the fields you change, or None; a plain params dict raises TypeError and nothing is queued. Changes to payload, queue, priority, run_at and max_retries are what gets queued; task and provider cannot be changed. See Scheduler hooks.


Registered on model names via kirak.on("model_name"). These hooks fire for both REST and GraphQL requests – a GraphQL mutation that creates a record fires before_create and after_create on that model, just as a REST POST does.

Event Fired
before_fetch Before a fetch query executes
after_fetch After fetch – result.data is the records array
before_search Before full-text search query executes
after_search After search
before_count Before count query
after_count After count – result.data.count is the integer count
before_exists Before exists check
after_exists After exists – result.data.exists is a boolean
before_create Before INSERT
after_create After INSERT – result.data.id is the new record’s ID
before_update Before UPDATE
after_update After UPDATE
before_upsert Before upsert
after_upsert After upsert
before_delete Before soft-delete
after_delete After soft-delete
before_destroy Before hard delete
after_destroy After hard delete
before_restore Before restore (un-soft-delete)
after_restore After restore
before_graphql Before a GraphQL request executes (register on "graphql", not a model name)
after_graphql After a GraphQL request commits (register on "graphql", not a model name)

Registered via @kirak.auth.hook("event") or @kirak.auth.on().hook("event").

Event Fired
before_login Before credentials are checked
after_login After successful login – result contains user + tokens
before_register Before user INSERT
after_register After user created – result contains user data
before_logout Before token blacklisting
after_logout After token blacklisted
before_refresh_token Before new access token generated
after_refresh_token After tokens refreshed
before_change_password Before password updated
after_change_password After password updated
before_request_reset_password Before reset email sent
after_request_reset_password After reset email sent
before_reset_password Before password reset applied
after_reset_password After password reset applied
before_generate_verification_link Before a verification or reset link is built (also from register and request-reset-password)
after_generate_verification_link After the link is built – result.data.secure_link
before_get_current_user Before the signed-in user is read (GET /auth/me, and every auth route that needs the caller)
after_get_current_user After the signed-in user is read
before_social_login Before OAuth redirect generated
after_social_login After OAuth redirect URL built
before_social_callback Before social user looked up / created
after_social_callback After social login completed – result contains user + tokens
before_social_mobile_auth Before a mobile social sign-in is checked
after_social_mobile_auth After a mobile social sign-in – result contains user + tokens
before_request_otp Before OTP generated and sent
after_request_otp After OTP sent
before_verify_otp Before OTP checked
after_verify_otp After OTP verified – result contains user + tokens
before_setup_mfa Before TOTP secret generated
after_setup_mfa After TOTP secret returned to client
before_verify_mfa Before TOTP code checked
after_verify_mfa After TOTP verified
before_disable_mfa Before MFA disabled
after_disable_mfa After MFA disabled
before_create_api_key / after_create_api_key Around API key creation – the after result holds the raw key, shown once
before_list_api_keys / after_list_api_keys Around listing the caller’s API keys
before_revoke_api_key / after_revoke_api_key Around revoking an API key

Email verification (GET /auth/verify-email) and the reset page (GET /auth/forgot-password) fire no hooks: they return HTML pages, not a response envelope.

Registered via @kirak.notifications.hook("event").

Event Fired
before_send_email Before email dispatched to provider
after_send_email After email sent – result contains id (provider message ID)
before_send_sms Before SMS dispatched to provider
after_send_sms After SMS sent
before_send_push Before push notification dispatched
after_send_push After push notification sent
before_send_im Before a chat message (Slack, Discord, Telegram) is posted
after_send_im After a chat message is posted
before_send_webhook Before a webhook is delivered
after_send_webhook After a webhook is delivered
after_send_notification After an in-app (inbox) notification is stored by send_inbox

Registered via @kirak.payments.hook("event"). Every payments operation fires before_<operation> and after_<operation>: list_providers, initiate_payment, verify_payment, refund_payment, get_billing_portal, webhook, onboard_merchant, create_onboarding_link, get_merchant_status, connect_checkout, update_merchant_fee, cancel_subscription, update_subscription, pause_subscription, resume_subscription, get_dispute, submit_dispute_evidence, setup_payment_method, attach_payment_method, list_payment_methods, detach_payment_method, set_default_payment_method, charge_off_session.

before_webhook receives the raw, unverified request params. after_webhook fires for every verified webhook; result["data"]["event"] is the provider-neutral event name (payment_completed, payment_failed, subscription_renewed, subscription_updated, subscription_past_due, subscription_cancelled, refund_completed, refund_pending, merchant_account_updated, merchant_deauthorized, dispute_created, dispute_updated, payment_method_saved, payment_method_removed, payment_action_required), or None for an unmapped event or a repeated delivery. See docs/modules/payments.md.

Registered via @kirak.scheduler.hook("event") or kirak.on("scheduler_jobs").

Event Fired
before_enqueue Before job is added to the queue
after_enqueue After job ID is returned
before_job Before a worker picks up a job (job payload as argument)
after_job After a job completes successfully
on_job_failure After every job failure (fires on each attempt, not just exhaustion)

When multiple hooks are registered for the same event, they run in registration order:

@kirak.on("users").hook("after_create")
async def hook_1(result): ... # runs first
@kirak.on("users").hook("after_create")
async def hook_2(result): ... # runs second, receives hook_1's return value

Hooks are independent chains per (model, event) pair. A failing async hook does not prevent subsequent hooks from running. A failing sync hook stops the chain immediately.


@kirak.on("users").hook("after_create")
async def queue_welcome_email(result):
user_id = result["data"]["id"]
await kirak.scheduler.enqueue({"task": "send_welcome_email", "payload": {"user_id": user_id}})
return result
@kirak.on("orders").hook("after_fetch")
async def add_display_status(result):
if isinstance(result.get("data"), list):
for order in result["data"]:
order["display_status"] = order["status"].replace("_", " ").title()
return result
@kirak.payments.hook("after_webhook")
async def on_webhook(result):
if result["data"]["event"] != "payment_completed":
return result
# Not every provider puts amount/email in the result; the transaction row has both.
# after_webhook runs as the system user, so it may read the internal model.
rows = await kirak.fetch("transactions", {"id": result["data"]["transaction_id"]})
txn = rows["data"][0] if rows["data"] else None
if txn and txn["payment_email"]:
try:
await kirak.notifications.send_email({
"to": txn["payment_email"],
"template_name": "payment_receipt",
"template_params": {
# minor units (2999 = 29.99 USD); the row's amount is per unit
"amount": txn["amount"] * txn["quantity"],
"currency": txn["currency"],
},
})
except KirakException as e:
# Log error but don't block the payment webhook processing
print(f"Failed to send payment receipt: {e.message}")
return result

The current user is available via the dispatch context – not injected into payload. Use get_user_context() to access it. It returns None in the hooks of a call made outside a request under set_user_context(...), since the context is cleared while those hooks run (Who a direct call runs as):

from kirak.core.context import get_user_context
@kirak.on("posts").hook("before_create")
async def require_email_verification(payload):
user = get_user_context() or {}
if not user.get("email_verified"):
raise PermissionDenied("Email verification required before creating posts.")
return payload