Skip to main content
PocketBase provides numerous event hooks that allow you to execute custom logic at specific points in the application lifecycle.

Hook types

Hooks are categorized by when they fire in the lifecycle:
  • Before hooks - Fire before an operation (can prevent the operation)
  • After hooks - Fire after a successful operation
  • Error hooks - Fire when an operation fails

Record hooks

Record lifecycle

// Before record is validated
onRecordValidate((e) => {
  console.log("Validating record in", e.collection.name)
  return e.next()
})

// Before record is created (after validation)
onRecordCreate((e) => {
  console.log("Creating record:", e.record.id)
  return e.next()
})

// During record creation (database transaction)
onRecordCreateExecute((e) => {
  console.log("Executing create for:", e.record.id)
  return e.next()
})

// After successful creation
onRecordAfterCreateSuccess((e) => {
  console.log("Record created successfully:", e.record.id)
  return e.next()
})

// After creation error
onRecordAfterCreateError((e) => {
  console.log("Failed to create record:", e.error)
  return e.next()
})

Filtering by collection

You can filter hooks to specific collections using tags:
// Only for "posts" collection
onRecordCreate((e) => {
  const record = e.record
  console.log("Creating post:", record.getString("title"))
  return e.next()
}, "posts")

// Multiple collections
onRecordCreate((e) => {
  console.log("Creating record in:", e.collection.name)
  return e.next()
}, "posts", "articles", "pages")

Record create hooks

// Auto-set author to current user
onRecordCreate((e) => {
  if (e.collection.name === "posts") {
    const authRecord = e.requestInfo()?.auth
    if (authRecord) {
      e.record.set("author", authRecord.id)
    }
  }
  return e.next()
}, "posts")

// Generate slug from title
onRecordCreate((e) => {
  if (e.collection.name === "posts") {
    const title = e.record.getString("title")
    const slug = title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-|-$/g, "")
    
    e.record.set("slug", slug)
  }
  return e.next()
}, "posts")

// Send notification after creation
onRecordAfterCreateSuccess((e) => {
  if (e.collection.name === "posts") {
    const record = e.record
    const author = $app.findRecordById("users", record.getString("author"))
    
    const message = new MailerMessage({
      from: {
        address: $app.settings().meta.senderAddress,
        name: $app.settings().meta.senderName,
      },
      to: [{address: author.getString("email")}],
      subject: "Post Published",
      html: `<p>Your post "${record.getString("title")}" has been published!</p>`,
    })
    
    $app.newMailClient().send(message)
  }
  return e.next()
}, "posts")

Record update hooks

// Prevent updating published posts
onRecordUpdate((e) => {
  if (e.collection.name === "posts") {
    const currentStatus = e.record.getString("status")
    const originalStatus = e.record.originalCopy().getString("status")
    
    if (originalStatus === "published" && currentStatus !== "published") {
      throw new BadRequestError("Cannot unpublish a post")
    }
  }
  return e.next()
}, "posts")

// Track changes
onRecordUpdate((e) => {
  if (e.collection.name === "posts") {
    const original = e.record.originalCopy()
    const current = e.record
    
    if (original.getString("title") !== current.getString("title")) {
      console.log("Title changed from:", original.getString("title"))
      console.log("To:", current.getString("title"))
    }
  }
  return e.next()
}, "posts")

// Update timestamp
onRecordUpdate((e) => {
  if (e.collection.name === "posts") {
    e.record.set("updated_at", new DateTime().string())
  }
  return e.next()
}, "posts")

Record delete hooks

// Prevent deletion of published posts
onRecordDelete((e) => {
  if (e.collection.name === "posts") {
    if (e.record.getString("status") === "published") {
      throw new ForbiddenError("Cannot delete published posts")
    }
  }
  return e.next()
}, "posts")

// Cleanup related data
onRecordAfterDeleteSuccess((e) => {
  if (e.collection.name === "posts") {
    const postId = e.record.id
    
    // Delete all comments for this post
    const comments = arrayOf(new Record())
    $app.findAllRecordsByData("comments", "post", postId, comments)
    
    for (let comment of comments) {
      $app.delete(comment)
    }
  }
  return e.next()
}, "posts")

Record validation hooks

onRecordValidate((e) => {
  if (e.collection.name === "posts") {
    const title = e.record.getString("title")
    
    // Check for spam
    const spamWords = ["viagra", "casino", "lottery"]
    for (let word of spamWords) {
      if (title.toLowerCase().includes(word)) {
        throw new BadRequestError("Content flagged as spam")
      }
    }
    
    // Check for duplicate titles
    const existing = $app.findFirstRecordByData("posts", "title", title)
    if (existing && existing.id !== e.record.id) {
      throw new BadRequestError("A post with this title already exists")
    }
  }
  return e.next()
}, "posts")

API request hooks

Record request hooks

// Before record create request
onRecordCreateRequest((e) => {
  console.log("Create request for:", e.collection.name)
  console.log("Request data:", e.requestInfo().data)
  return e.next()
})

// Before record update request
onRecordUpdateRequest((e) => {
  // Only allow updating own records
  const authRecord = e.requestInfo().auth
  if (!authRecord) {
    throw new UnauthorizedError()
  }
  
  if (e.record.getString("author") !== authRecord.id) {
    throw new ForbiddenError("You can only update your own records")
  }
  
  return e.next()
}, "posts")

// Before record delete request
onRecordDeleteRequest((e) => {
  // Admins only
  const authRecord = e.requestInfo().auth
  if (!authRecord || authRecord.getString("role") !== "admin") {
    throw new ForbiddenError("Only admins can delete records")
  }
  
  return e.next()
}, "posts")

// Before record view request
onRecordViewRequest((e) => {
  // Track views
  const record = e.record
  record.set("view_count", record.getInt("view_count") + 1)
  $app.save(record)
  
  return e.next()
}, "posts")

// Before records list request
onRecordsListRequest((e) => {
  console.log("Listing records from:", e.collection.name)
  console.log("Filters:", e.request.url.query.get("filter"))
  return e.next()
})

Authentication hooks

// Before auth request
onRecordAuthRequest((e) => {
  console.log("Auth request for collection:", e.collection.name)
  return e.next()
})

// After successful password auth
onRecordAuthWithPasswordRequest((e) => {
  const record = e.record
  
  // Update last login timestamp
  record.set("last_login", new DateTime().string())
  $app.save(record)
  
  // Log login
  console.log("User logged in:", record.getString("email"))
  
  return e.next()
}, "users")

// After OAuth2 auth
onRecordAuthWithOAuth2Request((e) => {
  const record = e.record
  const provider = e.providerName
  
  console.log("OAuth2 login:", record.getString("email"), "via", provider)
  
  return e.next()
}, "users")

// After OTP auth
onRecordAuthWithOTPRequest((e) => {
  const record = e.record
  console.log("OTP login:", record.getString("email"))
  return e.next()
}, "users")

Email hooks

// Before verification request
onRecordRequestVerificationRequest((e) => {
  const record = e.record
  console.log("Verification requested for:", record.getString("email"))
  return e.next()
}, "users")

// After verification confirmed
onRecordConfirmVerificationRequest((e) => {
  const record = e.record
  
  // Send welcome email
  const message = new MailerMessage({
    from: {
      address: $app.settings().meta.senderAddress,
      name: $app.settings().meta.senderName,
    },
    to: [{address: record.getString("email")}],
    subject: "Welcome!",
    html: "<p>Thanks for verifying your email!</p>",
  })
  
  $app.newMailClient().send(message)
  
  return e.next()
}, "users")

// Before password reset request
onRecordRequestPasswordResetRequest((e) => {
  console.log("Password reset requested for:", e.record.getString("email"))
  return e.next()
}, "users")

// Before email change request
onRecordRequestEmailChangeRequest((e) => {
  const record = e.record
  const newEmail = e.requestInfo().data.newEmail
  
  console.log("Email change:", record.getString("email"), "->", newEmail)
  
  return e.next()
}, "users")

Collection hooks

// Before collection create
onCollectionCreate((e) => {
  console.log("Creating collection:", e.collection.name)
  return e.next()
})

// After collection created
onCollectionAfterCreateSuccess((e) => {
  console.log("Collection created:", e.collection.name)
  return e.next()
})

// Before collection update
onCollectionUpdate((e) => {
  const original = e.collection.originalCopy()
  const current = e.collection
  
  if (original.name !== current.name) {
    console.log("Renaming collection:", original.name, "->", current.name)
  }
  
  return e.next()
})

// Before collection delete
onCollectionDelete((e) => {
  console.log("Deleting collection:", e.collection.name)
  return e.next()
})

Application hooks

Serve hook

// Runs when the server starts
onServe((e) => {
  console.log("Server starting...")
  
  // Add custom routes
  e.router.GET("/health", (e) => {
    return e.json(200, {status: "ok"})
  })
  
  return e.next()
})

Settings hooks

// Before settings update
onSettingsUpdateRequest((e) => {
  console.log("Settings being updated")
  return e.next()
})

// After settings reload
onSettingsReload((e) => {
  console.log("Settings reloaded")
  return e.next()
})

Batch hooks

// Before batch request
onBatchRequest((e) => {
  console.log("Batch request with", e.batch.requests.length, "requests")
  return e.next()
})

File hooks

// Before file download
onFileDownloadRequest((e) => {
  console.log("Downloading file:", e.servedName)
  console.log("From record:", e.record.id)
  return e.next()
})

// Modify file token generation
onFileBeforeTokenRequest((e) => {
  // Add custom validation before generating file token
  const authRecord = e.requestInfo().auth
  
  if (!authRecord) {
    throw new UnauthorizedError("Authentication required")
  }
  
  return e.next()
})

Realtime hooks

// Before realtime connect
onRealtimeConnectRequest((e) => {
  console.log("Realtime connection from:", e.realIP())
  return e.next()
})

// Before realtime disconnect
onRealtimeDisconnectRequest((e) => {
  console.log("Realtime disconnection")
  return e.next()
})

// Before subscribe
onRealtimeSubscribeRequest((e) => {
  const subscriptions = e.subscriptions
  console.log("Subscribing to:", subscriptions)
  return e.next()
})

Hook execution order

For a record create operation, hooks fire in this order:
  1. onRecordCreateRequest - API request starts
  2. onRecordValidate - Validation runs
  3. onRecordCreate - Before database operation
  4. onRecordCreateExecute - During database transaction
  5. onRecordAfterCreateSuccess - After successful creation
If any hook throws an error:
  • Later hooks in the chain won’t execute
  • onRecordAfterCreateError will fire instead of success hooks
  • Database changes are rolled back

Best practices

Keep hooks focused

// Good - one responsibility
onRecordCreate((e) => {
  if (e.collection.name === "posts") {
    e.record.set("slug", generateSlug(e.record.getString("title")))
  }
  return e.next()
}, "posts")

// Separate hook for different responsibility
onRecordAfterCreateSuccess((e) => {
  if (e.collection.name === "posts") {
    sendNotification(e.record)
  }
  return e.next()
}, "posts")

Handle errors gracefully

onRecordAfterCreateSuccess((e) => {
  if (e.collection.name === "posts") {
    try {
      // Send notification (non-critical)
      sendNotification(e.record)
    } catch (err) {
      // Log but don't fail the request
      console.log("Failed to send notification:", err.message)
    }
  }
  return e.next()
}, "posts")

Use collection tags

// Good - specific collections only
onRecordCreate((e) => {
  // Logic here
  return e.next()
}, "posts", "articles")

// Bad - checks collection name inside
onRecordCreate((e) => {
  if (e.collection.name === "posts" || e.collection.name === "articles") {
    // Logic here
  }
  return e.next()
})