Custom Hooks
With self-hosted GrowthBook Enterprise, you can extend GrowthBook's validation logic with Custom Hooks.
Custom Hooks are JavaScript snippets that run on the server during validation. Use them to enforce naming conventions for feature flags, check for required metadata, or add other custom validation logic.
Using Custom Hooks
You can create multiple hooks of the same type for flexible, granular validation rules. Each hook has a scope that controls which features it runs for:
- Global — runs for every feature.
- Project — runs only for features in the selected projects.
- Feature — runs only for a single feature.
Global and project-scoped hooks are managed under Settings → Custom Hooks. Feature-scoped hooks are created and managed from a feature's Validation tab by anyone who can edit that feature; that tab also lists any global and project hooks that apply to the feature.
Limits
Custom Hooks are executed in a V8 Isolate, which provides a secure and efficient environment for running untrusted code. All modern JavaScript language features are supported (including async/await and fetch), but certain global objects like process.env are not available for security reasons.
The following default limits are in place to prevent abuse and ensure performance. They can all be tweaked via environment variables:
CUSTOM_HOOK_MEMORY_MB- Maximum memory allocation for the isolate (default: 32MB)CUSTOM_HOOK_CPU_TIMEOUT_MS- Maximum active CPU time (default: 100ms)CUSTOM_HOOK_WALL_TIMEOUT_MS- Maximum total run time (including async calls) (default: 5000ms)CUSTOM_HOOK_MAX_FETCH_RESP_SIZE- Maximum response size from fetch calls in bytes (default: 500KB)
Execution Frequency
A hook may execute multiple times for a single save. GrowthBook runs hooks early in a request (before related records are written) and again immediately before the final database write, and the Incremental Changes option adds additional runs against the previous state. Keep hooks fast and free of side effects — they should validate their inputs and either return, throw, or addWarning(), nothing else.
Debugging
If a Custom Hook throws an error during execution, the error message will be used as the validation error shown in the UI. This allows you to provide clear feedback to users about why their changes were rejected.
When creating a Custom Hook, use the built-in test interface to tweak inputs and run the hook. The test output shows all errors, warnings, console messages, and the return value (if any).
Use console.log statements liberally while developing hooks to inspect variables and understand the flow of execution.
Warnings
Instead of throw, a hook can call addWarning("message") to raise a soft warning. A warning doesn't hard-block: in the UI the user can review it and click Save anyway, and REST API clients can re-submit the request with ?ignoreWarnings=true. A throw always blocks and cannot be bypassed.
if (feature.tags.length === 0) {
addWarning("Consider adding at least one tag");
}
Because execution continues after addWarning, you can raise warnings and still throw later in the same hook.
Incremental Changes
Each Custom Hook has an Incremental Changes Only option that affects behavior during update operations. When enabled, the hook is skipped if the same error was already present before the update.
This is especially useful for enforcing rules that may be difficult to fix retroactively. For example, if you require all features to have at least one tag, enabling this option will prevent users from being blocked by existing features that violate this rule when they attempt to make unrelated changes.
In some cases, you should NOT enable this option. For example, a hook that prevents publishing changes to "locked" features should run every time to ensure the lock is still in place.
Important: When using this option, be sure to limit each hook to a single validation check. Otherwise, a failing check early in the hook code may cause later checks to be bypassed unintentionally.
Hook Types
GrowthBook supports several Custom Hook types. Each is triggered at a different point in the validation process and receives different input parameters.
validateFeature
Called whenever a feature is about to be created or updated. Receives the full feature object as input.
Example: Require a non-empty description before the feature is toggled ON in production.
if (feature.environmentSettings.production.enabled) {
if (feature.description.trim() === "") {
throw new Error(
"Feature description is required when enabling in production."
);
}
}
Example: Require all features to have at least 1 tag.
if (feature.tags.length === 0) {
throw new Error(
"All features must have at least one tag."
);
}
Example: Don't allow empty objects as the default value for JSON features.
if (feature.valueType === "json" && feature.defaultValue === "{}") {
throw new Error(
"Default value for JSON features cannot be an empty object."
);
}
validateFeatureRevision
Called whenever a feature revision is about to be created or updated. Receives the full feature and the revision as inputs.
Example: Require a comment before publishing a draft.
if (revision.status === "published" && !revision.comment) {
throw new Error(
"A comment is required before publishing a revision."
);
}
Example: Require all percentage rollouts to use userId as the hashing attribute.
for (const env in revision.rules) {
for (const rule of revision.rules[env]) {
if (rule.type === "rollout" && rule.hashAttribute !== "userId") {
throw new Error(
"All rollouts must use 'userId' as the hash attribute."
);
}
}
}
Example: Don't allow targeting by PII (e.g. email address)
const piiAttributes = ["email", "phone", "ssn"];
for (const env in revision.rules) {
for (const rule of revision.rules[env]) {
if (rule.condition) {
for (const attr of piiAttributes) {
// `condition` is a stringified JSON object
// Look for the quoted attribute name anywhere in the string
if (rule.condition.includes(`"${attr}"`)) {
throw new Error(
`Targeting by PII (${attr}) is not allowed.`
);
}
}
}
}
}
Example: Call an external service to validate feature naming conventions.
const response = await fetch(
"https://example.com/validate-feature-name",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ featureName: feature.name }),
}
);
const result = await response.json();
if (!result.isValid) {
throw new Error(
result.message || "Feature name validation failed."
);
}
Example: If a feature has a "locked" tag, prevent publishing changes (except for one specific admin).
if (
feature.tags.includes("locked")
&& revision.status === "published"
) {
if (
revision.publishedBy?.email
!== "admin@example.com"
) {
throw new Error(
"This feature is locked and cannot be published."
);
}
}