Skip to main content

Overview

The hooks configuration file (hooks.yml) defines lifecycle hooks that execute before or after document operations. Hooks enable validation, field transformations, reference validation, and integration with external systems via HTTP webhooks.

Configuration Structure

version: "1.0"
hooks:
  <collection_name>:
    pre_create: []
    post_create: []
    pre_update: []
    post_update: []
    pre_delete: []
    post_delete: []

Hook Types

Each collection can define hooks for six lifecycle events:
pre_create
array
Execute before creating a new document. Can modify the document or reject creation.
post_create
array
Execute after creating a new document. Document is already saved.
pre_update
array
Execute before updating a document. Can modify changes or reject update.
post_update
array
Execute after updating a document. Changes are already saved.
pre_delete
array
Execute before deleting a document. Can prevent deletion.
post_delete
array
Execute after deleting a document. Document is already deleted.

Hook Actions

Each hook is an object with an action field specifying the operation type.

set_field

Set a field value using an expression.
action
string
set_field
field
string
required
Name of the field to set.
value
string
required
Expression evaluating to the field value. Supports context variables.
condition
string
Optional condition expression. Hook only executes if condition evaluates to true.

Example

pre_create:
  - action: set_field
    field: tenant_id
    value: $user.tenant_id
  
  - action: set_field
    field: order_number
    value: $auto.sequence.orders

validate

Validate a condition and reject the operation if it fails.
action
string
validate
condition
string
required
Expression that must evaluate to true for validation to pass.
error
string
required
Error message returned if validation fails.

Example

pre_update:
  - action: validate
    condition: $new.manager_id != $doc._id
    error: "User cannot be their own manager"
  
  - action: validate
    condition: $doc.cost == null || $doc.cost <= $doc.price
    error: "Cost cannot exceed price"

validate_ref

Validate that a referenced document exists.
action
string
validate_ref
field
string
required
Field containing the reference ID.
collection
string
required
Name of the referenced collection.
optional
boolean
default:"false"
Whether the reference is optional (null allowed).
error
string
Custom error message if reference is not found.

Example

pre_create:
  - action: validate_ref
    field: manager_id
    collection: users
    optional: true
    error: "Manager not found"
  
  - action: validate_ref
    field: customer_id
    collection: customers
    error: "Customer not found"

validate_unique

Ensure field values are unique within a scope.
action
string
validate_unique
fields
array
required
List of fields that must be unique together.
scope
array
Additional fields defining the uniqueness scope (e.g., tenant_id).
error
string
Error message if uniqueness constraint is violated.

Example

pre_create:
  - action: validate_unique
    fields: [email]
    scope: [tenant_id]
    error: "A user with this email already exists"
  
  - action: validate_unique
    fields: [sku]
    scope: [company_id]
    error: "A product with this SKU already exists"

transform

Transform a field value using a predefined transformation.
action
string
transform
field
string
required
Name of the field to transform.
transform
string
required
Transformation type: lowercase, uppercase, trim, trim_left, trim_right.

Example

pre_create:
  - action: transform
    field: email
    transform: lowercase
  
  - action: transform
    field: sku
    transform: uppercase

http

Make an HTTP request to an external webhook.
action
string
http
method
string
required
HTTP method: GET, POST, PUT, PATCH, DELETE.
url
string
required
Webhook URL. Supports environment variable substitution.
headers
object
HTTP headers to include in the request.
body
object
Request body. Supports context variable expressions.
async
boolean
default:"false"
Execute webhook asynchronously without blocking the operation.
timeout
integer
default:"5000"
Request timeout in milliseconds.
retry
integer
default:"0"
Number of retry attempts on failure.
on_error
string
default:"log"
Error handling strategy: log (log and continue), fail (abort operation), ignore (silently ignore). Default is log for async hooks, fail for sync hooks.
condition
string
Only execute webhook if condition evaluates to true.

Example

post_create:
  - action: http
    method: POST
    url: "${ENV.WEBHOOK_URL}/orders/created"
    headers:
      Authorization: "Bearer ${ENV.WEBHOOK_SECRET}"
      Content-Type: "application/json"
    body:
      event: "order.created"
      order_id: $doc._id
      order_number: $doc.order_number
      company_id: $doc.company_id
      total: $doc.total
    async: true
    timeout: 5000
    retry: 3

post_update:
  - action: http
    condition: $old.status != $new.status
    method: POST
    url: "${ENV.WEBHOOK_URL}/orders/status-changed"
    headers:
      Content-Type: "application/json"
    body:
      event: "order.status_changed"
      order_id: $doc._id
      old_status: $old.status
      new_status: $new.status
    async: true

Context Variables

Hooks have access to context variables in expressions:
$doc
object
The current document (for all hooks).
$old
object
The previous document state (for update/delete hooks).
$new
object
The new document state with changes (for update hooks).
$user
object
Current user context from JWT token (contains id, email, role, tenant_id, etc.).
$now
date
Current timestamp.
$changes
array
List of changed field names (for post_update hooks).
$auto
object
Auto-generated values like sequences.

Complete Example

version: "1.0"

hooks:
  users:
    pre_create:
      # Auto-set tenant_id from JWT
      - action: set_field
        field: company_id
        value: $user.tenant_id
      
      # Normalize email to lowercase
      - action: transform
        field: email
        transform: lowercase
      
      # Ensure email is unique within company
      - action: validate_unique
        fields: [email]
        scope: [company_id]
        error: "A user with this email already exists"
      
      # Validate manager exists
      - action: validate_ref
        field: manager_id
        collection: users
        optional: true
        error: "Manager not found"
    
    pre_update:
      # Normalize email if changed
      - action: transform
        field: email
        transform: lowercase
      
      # Prevent self-management loop
      - action: validate
        condition: $new.manager_id != $doc._id
        error: "User cannot be their own manager"
    
    post_update:
      # Sync hierarchy when manager changes
      - action: http
        condition: $old.manager_id != $new.manager_id
        method: POST
        url: "http://localhost:8080/_pm/hierarchy/sync"
        headers:
          Content-Type: "application/json"
        body:
          user_id: $doc._id
          manager_id: $new.manager_id
          tenant_id: $doc.company_id
        async: false

  orders:
    pre_create:
      # Auto-set ownership fields
      - action: set_field
        field: company_id
        value: $user.tenant_id
      
      - action: set_field
        field: created_by
        value: $user.id
      
      # Generate order number
      - action: set_field
        field: order_number
        value: $auto.sequence.orders
      
      # Validate customer exists
      - action: validate_ref
        field: customer_id
        collection: customers
        error: "Customer not found"
      
      # Validate has items
      - action: validate
        condition: $doc.items && $doc.items.length > 0
        error: "Order must have at least one item"
    
    post_create:
      # Notify external system
      - action: http
        method: POST
        url: "${ENV.WEBHOOK_URL}/orders/created"
        headers:
          Authorization: "Bearer ${ENV.WEBHOOK_SECRET}"
          Content-Type: "application/json"
        body:
          event: "order.created"
          order_id: $doc._id
          order_number: $doc.order_number
          total: $doc.total
        async: true
        timeout: 5000
        retry: 3
    
    pre_update:
      # Prevent changing ownership
      - action: validate
        condition: |
          $new.created_by == null || $new.created_by == $old.created_by
        error: "Cannot change order owner"
      
      # Status transition validation
      - action: validate
        condition: |
          !($old.status == "delivered" && $new.status != null && $new.status != "delivered")
        error: "Cannot change status after delivery"
      
      # Set shipped_at timestamp
      - action: set_field
        condition: $old.status != "shipped" && $new.status == "shipped"
        field: shipped_at
        value: $now
    
    post_update:
      # Notify on status change
      - action: http
        condition: $old.status != $new.status
        method: POST
        url: "${ENV.WEBHOOK_URL}/orders/status-changed"
        headers:
          Authorization: "Bearer ${ENV.WEBHOOK_SECRET}"
        body:
          event: "order.status_changed"
          order_id: $doc._id
          old_status: $old.status
          new_status: $new.status
        async: true
    
    pre_delete:
      # Only allow deleting draft orders
      - action: validate
        condition: $doc.status == "draft"
        error: "Only draft orders can be deleted"

Webhook Payload Format

HTTP webhooks receive context information about the operation:
{
  "collection": "orders",
  "operation": "create",
  "document": {
    "_id": "507f1f77bcf86cd799439011",
    "order_number": "ORD-1001",
    "customer_id": "507f1f77bcf86cd799439012",
    "total": 99.99
  },
  "user": {
    "id": "507f1f77bcf86cd799439013",
    "email": "[email protected]",
    "role": "admin",
    "tenant_id": "507f1f77bcf86cd799439014"
  },
  "timestamp": "2026-03-04T12:34:56Z"
}
For update operations, the payload includes both old and new states:
{
  "collection": "orders",
  "operation": "update",
  "old": { "status": "pending" },
  "new": { "status": "shipped" },
  "changes": ["status", "shipped_at"],
  "user": { ... },
  "timestamp": "2026-03-04T12:34:56Z"
}

Environment Variables

All string values support environment variable substitution:
post_create:
  - action: http
    url: "${ENV.WEBHOOK_URL}/notifications"
    headers:
      Authorization: "Bearer ${ENV.API_KEY}"

Loading Hooks Configuration

Load the hooks configuration at server startup:
permission-mongo --hooks=/path/to/hooks.yml
Or use the PM_HOOKS environment variable:
export PM_HOOKS=/path/to/hooks.yml
permission-mongo

Execution Order

Hooks execute in the order they are defined in the configuration file. If any hook fails:
  • Pre-hooks: Operation is aborted, no database changes occur
  • Post-hooks (sync): Operation is aborted, changes are rolled back
  • Post-hooks (async): Operation completes, error is logged based on on_error setting

Build docs developers (and LLMs) love