Skip to main content
Hooks allow you to execute custom logic at specific points in the document lifecycle, enabling validation, transformation, and integration with external systems.

Overview

Permission Mongo supports:
  • Pre-operation hooks - Execute before create, update, or delete
  • Post-operation hooks - Execute after operations complete
  • Multiple hook types - Validation, field setting, transformations, HTTP webhooks
  • Conditional execution - Run hooks based on conditions
  • Async webhooks - Non-blocking HTTP calls for integrations

Hook events

Hooks are triggered at these lifecycle points:
pre_create
event
Before document creation. Can modify the document, validate, or call webhooks.
post_create
event
After document is created. Useful for notifications and external system updates.
pre_update
event
Before document update. Can validate changes, transform fields, or prevent updates.
post_update
event
After document is updated. Includes change information.
pre_delete
event
Before document deletion. Can prevent deletion or log the operation.
post_delete
event
After document is deleted. Useful for cleanup and notifications.
const (
    HookPreCreate  HookEventType = "pre_create"
    HookPostCreate HookEventType = "post_create"
    HookPreUpdate  HookEventType = "pre_update"
    HookPostUpdate HookEventType = "post_update"
    HookPreDelete  HookEventType = "pre_delete"
    HookPostDelete HookEventType = "post_delete"
)

Hook actions

Each hook can perform one or more actions:

Set field

Automatically set field values:
hooks.yml
collections:
  documents:
    pre_create:
      - action: set_field
        field: created_at
        value: $now
      
      - action: set_field
        field: owner_id
        value: $user.id
      
      - action: set_field
        field: tenant_id
        value: $user.tenant_id
// executeSetField sets a field value on the document
func (e *Executor) executeSetField(doc map[string]interface{}, action *config.HookAction, hookCtx *HookContext) error {
    if action.Field == "" || action.Value == "" {
        return fmt.Errorf("set_field requires field and value")
    }

    // Substitute variables in the value
    value := SubstituteVariables(action.Value, hookCtx)

    // Set the field (supports nested fields with dot notation)
    setNestedField(doc, action.Field, value)

    return nil
}

Validate

Add custom validation rules:
hooks.yml
collections:
  documents:
    pre_create:
      - action: validate
        condition: "$doc.status == 'published'"
        when: "len($doc.content) == 0"
        error: "Published documents must have content"
      
      - action: validate
        condition: "$doc.priority > 0 && $doc.priority <= 5"
        error: "Priority must be between 1 and 5"
condition
string
Expression that must evaluate to true for validation to pass. If false, the hook fails with the error message.
when
string
Guard condition - only run this validation if the condition is true.
error
string
Error message returned if validation fails.

Validate reference

Ensure referenced documents exist:
hooks.yml
collections:
  documents:
    pre_create:
      - action: validate_ref
        field: customer_id
        collection: customers
        error: "Customer not found"
      
      - action: validate_ref
        field: project_id
        collection: projects
        optional: true  # Only validate if field is present
        error: "Project not found"
// executeValidateRef validates that a referenced document exists
func (e *Executor) executeValidateRef(ctx context.Context, doc map[string]interface{}, action *config.HookAction) error {
    if action.Field == "" || action.Collection == "" {
        return fmt.Errorf("validate_ref requires field and collection")
    }

    // Get the reference value from the document
    refValue := getNestedField(doc, action.Field)
    if refValue == nil {
        if action.Optional {
            return nil
        }
        return fmt.Errorf("%w: field %s is empty", ErrRefNotFound, action.Field)
    }

    refID, ok := refValue.(string)
    if !ok {
        return fmt.Errorf("reference field %s is not a string", action.Field)
    }

    // Check if the referenced document exists
    var filter bson.M
    if oid, err := primitive.ObjectIDFromHex(refID); err == nil {
        filter = bson.M{"_id": oid}
    } else {
        filter = bson.M{"_id": refID}
    }

    exists, err := e.store.Exists(ctx, action.Collection, filter)
    if err != nil {
        return fmt.Errorf("failed to check reference: %w", err)
    }

    if !exists {
        return fmt.Errorf("%w: %s", ErrRefNotFound, action.Error)
    }

    return nil
}

Transform

Apply transformations to field values:
hooks.yml
collections:
  users:
    pre_create:
      - action: transform
        field: email
        transform: lowercase
      
      - action: transform
        field: username
        transform: trim
    
    pre_update:
      - action: transform
        field: name
        transform: trim
Supported transformations:
  • lowercase - Convert to lowercase
  • uppercase - Convert to uppercase
  • trim - Remove leading/trailing whitespace
// executeTransform applies a transformation to a field
func (e *Executor) executeTransform(doc map[string]interface{}, action *config.HookAction) error {
    if action.Field == "" || action.Transform == "" {
        return fmt.Errorf("transform requires field and transform type")
    }

    value := getNestedField(doc, action.Field)
    if value == nil {
        return nil
    }

    strValue, ok := value.(string)
    if !ok {
        return nil
    }

    var transformed string
    switch config.TransformType(action.Transform) {
    case config.TransformLowercase:
        transformed = strings.ToLower(strValue)
    case config.TransformUppercase:
        transformed = strings.ToUpper(strValue)
    case config.TransformTrim:
        transformed = strings.TrimSpace(strValue)
    default:
        return fmt.Errorf("unknown transform type: %s", action.Transform)
    }

    setNestedField(doc, action.Field, transformed)
    return nil
}

HTTP webhook

Call external HTTP endpoints:
hooks.yml
collections:
  orders:
    post_create:
      - action: http
        url: https://api.example.com/webhooks/order-created
        method: POST
        headers:
          Authorization: "Bearer ${WEBHOOK_TOKEN}"
          X-Tenant-ID: $user.tenant_id
        body:
          order_id: $doc._id
          customer_id: $doc.customer_id
          total: $doc.total
          created_by: $user.id
        async: true
        timeout: 5000
        retry_count: 3
        on_error: log
url
string
required
Webhook endpoint URL
method
string
default:"POST"
HTTP method: GET, POST, PUT, PATCH, DELETE
headers
object
HTTP headers to send. Supports variable substitution.
body
object
JSON body to send. Supports variable substitution.
async
boolean
default:"false"
If true, execute webhook asynchronously without blocking the operation
timeout
integer
default:"30000"
Timeout in milliseconds
retry_count
integer
default:"0"
Number of retry attempts on failure
on_error
string
default:"fail"
Error handling: fail (abort operation), log (log and continue), ignore (silent failure)
// executeHTTP makes an HTTP webhook call
func (e *Executor) executeHTTP(ctx context.Context, action *config.HookAction, hookCtx *HookContext) error {
    if action.IsAsync() {
        // Execute asynchronously
        go func() {
            if err := e.doHTTPRequest(context.Background(), action, hookCtx); err != nil {
                switch action.GetOnError() {
                case config.OnErrorLog:
                    logging.Error("Async HTTP hook failed",
                        slog.String("url", action.URL),
                        slog.String("error", err.Error()),
                    )
                case config.OnErrorIgnore:
                    // Do nothing
                }
            }
        }()
        return nil
    }

    // Execute synchronously
    return e.doHTTPRequest(ctx, action, hookCtx)
}

Variable substitution

Hooks support variable substitution in value, url, headers, and body fields:

Document variables

$doc.field_name      # Current document field
$doc.nested.field    # Nested field with dot notation
$old.field_name      # Previous value (in pre_update/post_update)
$new.field_name      # New value (in post_update)

User variables

$user.id             # User ID
$user.tenant_id      # Tenant ID
$user.roles          # Array of user roles
$user.claims.custom  # Custom JWT claim

System variables

$now                 # Current timestamp
$changes             # Array of field changes (in post_update)
// resolveVariable resolves a variable expression
func resolveVariable(expr string, hookCtx *HookContext) interface{} {
    expr = strings.TrimSpace(expr)
    if !strings.HasPrefix(expr, "$") {
        return nil
    }

    expr = expr[1:]
    parts := strings.Split(expr, ".")

    var root interface{}
    rootName := parts[0]

    switch rootName {
    case "doc":
        root = hookCtx.Doc
    case "old":
        root = hookCtx.Old
    case "new":
        root = hookCtx.New
    case "user":
        root = userToMap(hookCtx.User)
    case "now":
        return hookCtx.Now
    case "changes":
        return changesToSlice(hookCtx.Changes)
    }

    // Navigate nested fields
    if len(parts) == 1 {
        return root
    }

    if rootMap, ok := root.(map[string]interface{}); ok {
        return getNestedField(rootMap, strings.Join(parts[1:], "."))
    }

    return nil
}

Conditional execution

Run hooks conditionally with when clauses:
hooks.yml
collections:
  documents:
    pre_update:
      # Only set published_at when status changes to published
      - action: set_field
        field: published_at
        value: $now
        when: "$new.status == 'published' && $old.status != 'published'"
      
      # Validate only for high-priority documents
      - action: validate
        condition: "len($doc.content) > 100"
        when: "$doc.priority >= 4"
        error: "High-priority documents need detailed content"
    
    post_update:
      # Send webhook only when status changes
      - action: http
        url: https://api.example.com/webhooks/status-changed
        when: "$old.status != $new.status"
        async: true
Condition expressions support:
  • Comparison: ==, !=, >, <, >=, <=
  • Logical: &&, ||, !
  • Membership: in, not in
  • Functions: len()

Hook execution order

Hooks execute in the order defined:
  1. Pre-operation hooks run first, in definition order
  2. Schema validation runs after pre-operation hooks
  3. RBAC authorization checks permissions
  4. Database operation executes (create/update/delete)
  5. Post-operation hooks run last, in definition order
If any synchronous hook fails, the operation is aborted.
// ExecuteHooks runs all hooks for an event
func (e *Executor) ExecuteHooks(ctx context.Context, opts *ExecuteOpts) (*HookResult, error) {
    collHooks := e.hooks.GetCollectionHooks(opts.Collection)
    if collHooks == nil {
        return &HookResult{Document: opts.Document}, nil
    }

    actions := collHooks.GetHooks(opts.Event)
    if len(actions) == 0 {
        return &HookResult{Document: opts.Document}, nil
    }

    hookCtx := &HookContext{
        Doc:     copyMap(opts.Document),
        Old:     opts.OldDoc,
        New:     opts.NewDoc,
        User:    opts.User,
        Now:     time.Now().UTC(),
        Changes: opts.Changes,
    }

    result := &HookResult{
        Document: hookCtx.Doc,
        Errors:   []HookError{},
    }

    // Execute each action in order
    for _, action := range actions {
        // Check guard condition
        if action.GetCondition() != "" {
            conditionMet, err := e.evaluateCondition(action.GetCondition(), hookCtx)
            if err != nil || !conditionMet {
                continue
            }
        }

        // Execute the action
        var actionErr error
        switch config.ActionType(action.Action) {
        case config.ActionSetField:
            actionErr = e.executeSetField(hookCtx.Doc, action, hookCtx)
        case config.ActionValidate:
            actionErr = e.executeValidate(hookCtx.Doc, action, hookCtx)
        case config.ActionValidateRef:
            actionErr = e.executeValidateRef(ctx, hookCtx.Doc, action)
        case config.ActionTransform:
            actionErr = e.executeTransform(hookCtx.Doc, action)
        case config.ActionHTTP:
            actionErr = e.executeHTTP(ctx, action, hookCtx)
        }

        if actionErr != nil {
            result.Errors = append(result.Errors, HookError{
                Action:  action.Action,
                Field:   action.Field,
                Message: actionErr.Error(),
            })

            // For validation errors, abort if not async
            if !action.IsAsync() {
                result.Aborted = true
                break
            }
        }
    }

    result.Document = hookCtx.Doc
    return result, nil
}

Use cases

Use set_field in pre_create to automatically set created_at, owner_id, tenant_id, and other metadata fields.
Add validate hooks for complex business rules that go beyond schema validation, like checking date ranges or cross-field dependencies.
Apply transform actions to normalize data (lowercase emails, trim whitespace) before storage.
Use http webhooks in post_create and post_update to notify external systems of changes.
Validate foreign keys with validate_ref to ensure referenced documents exist before creating relationships.
Add custom metadata in pre_create/pre_update hooks to enrich audit logs with business context.

Best practices

Set async: true for webhooks that shouldn’t block the main operation. Handle failures with on_error: log.
Hooks should perform focused, single-purpose actions. Complex logic belongs in external services called via HTTP hooks.
Set retry_count: 3 for webhooks to handle transient failures automatically.
Provide clear, actionable error messages in validation hooks to help users fix issues.
Hooks run on every operation - test them with various document states and edge cases.

Build docs developers (and LLMs) love