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.
Registration Window
Section titled “Registration Window”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.
Registration Syntaxes
Section titled “Registration Syntaxes”There are two ways to register hooks: a decorator for one function, and a class to group several.
1. Decorator (for a single hook)
Section titled “1. Decorator (for a single hook)”@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, explicitasync def after_login(result): return result2. 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.
Hook Contract
Section titled “Hook Contract”- A hook receives one argument: the current
payload(before hooks) orresult(after hooks). - A hook must return the value it receives (modified or unchanged). Return
Noneto pass through unchanged. - All hooks in the chain run serially in registration order.
- Hooks can be
asyncor 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 returnRejecting an Operation
Section titled “Rejecting an Operation”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 payloadThe 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.
Transactions and Side Effects
Section titled “Transactions and Side Effects”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, andafter_*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.
Durability of Async Hooks
Section titled “Durability of Async Hooks”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.
Payload vs Result
Section titled “Payload vs Result”| 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.
Complete Event Reference
Section titled “Complete Event Reference”CRUD Events
Section titled “CRUD Events”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) |
Auth Events
Section titled “Auth Events”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.
Notification Events
Section titled “Notification Events”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 |
Payment Events
Section titled “Payment Events”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.
Scheduler Events
Section titled “Scheduler Events”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) |
Execution Order
Section titled “Execution Order”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 valueHooks 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.
Practical Examples
Section titled “Practical Examples”Enqueue a background job after create
Section titled “Enqueue a background job after create”@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 resultAdd computed fields to fetch response
Section titled “Add computed fields to fetch response”@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 resultSend webhook notification on payment
Section titled “Send webhook notification on payment”@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 resultBlock before hook
Section titled “Block before hook”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