Using Custom Hooks
Custom Hooks are only available on self-hosted GrowthBook Enterprise.
- Global — runs for every feature or experiment (depending on hook type).
- Project — runs only for resources in the selected projects.
- Feature — runs only for a single feature.
validateConfig and
validateConfigRevision hook types run when a Config is saved or published and
can use a global, Project, or Config scope. Manage Config-scoped hooks from the
Config’s Validation tab or from Settings → Custom Hooks.
A Config-scoped hook also runs for every Config that inherits from it, including
indirect descendants through parent or extends. If a publish moves a Config
into that family, the hook runs during the same publish. A descendant Config’s
page lists both its own hooks and the hooks it inherits.
Creating or changing a global, Project-scoped, or Config-scoped hook requires the
Custom Hooks permission. A hook for one Feature Flag requires Edit access
to that flag instead.
The REST API can change a hook’s scope after creation. The caller must have
permission to manage both the old and new scopes. To remove an entity scope,
send entityType: null and entityId: null.
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 likeprocess.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. Hook errors are prefixed withCustom hook:, which distinguishes them from schema-conformance errors (prefixed with the offending field, e.g. value: …) and from validation-rule failures (which surface the rule’s own message).
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 ofthrow, a hook can call addWarning("message") to raise a soft
warning. In the app, a user can review the warning and select Save anyway.
A REST API client can send "ignoreWarnings": true.
A thrown error blocks the change. ignoreWarnings does not bypass it. A REST
API caller with Bypass draft approvals in every Project can send
"skipHooks": true to continue despite the error. The
"skipSchemaValidation" field does not bypass Custom Hooks.
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. A hook receives only the arguments listed for its type, and they always describe the proposed state. To act on what a change introduces, check that state and enable this option: GrowthBook re-runs the hook against the previous state and discards the error when both runs report the same message, so avoid interpolating changing values into a message you want suppressed.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 the feature itself is written: on create, and on publish, including an edit that publishes immediately. Changes staged into a draft are covered byvalidateFeatureRevision instead.
Receives one argument, feature: the feature as it will look once the change lands.
Example: Require a non-empty description before the feature is toggled ON in production.
validateFeatureRevision
Called whenever a feature revision is created or updated, including while a change is still staged in a draft. Receives two arguments:feature: the live feature, as it stands before this revision publishes.revision: the revision being written, includingversion,baseVersion,status,comment,title,defaultValue,rules,createdBy,contributors,reviews, andmetadata.
revision.metadata holds the feature-level fields the revision carries (tags, description, owner, project, customFields, jsonSchema, and more), captured when the draft was created and updated as further changes are staged. Publishing applies only the difference between the revision and its base, so a value here is not necessarily one this revision will change. Older revisions may omit fields, so read them with optional chaining.
Example: Block staging a reserved tag. Enable Incremental Changes Only so features that already carry it aren’t blocked on unrelated edits.
userId as the hashing attribute.
Enforcing approval policies at publish
The hook runs at publish time withrevision.status === "published" on the proposed revision, so you can gate publishes specifically. revision.reviews holds the active reviewer verdicts for the current review cycle — one entry per reviewer in the shape { userId, user, status, timestamp }:
userId— stable reviewer identifier: the user ID for dashboard users, or the API key ID for service accounts.user— the full event user:{ type: "dashboard", id, name, email }for humans,{ type: "api_key", apiKey }for service accounts (whereapiKeyis the key’s ID, not its secret).status—"approved"or"changes-requested"for active verdicts. When draft content changes after a verdict is given (and the org’s review settings reset reviews on change), the verdict is demoted to"approved-stale"/"changes-requested-stale"— still attributable, but no longer an active verdict, so policies matching on the active statuses ignore it automatically. A verdict that was retracted no longer appears, and all verdicts clear when a new review cycle starts (review re-requested or recalled).timestamp— when the verdict was submitted. Compare againstrevision.dateUpdatedto detect approvals that predate later edits.
validateConfig
Called whenever a config is created or updated. Receives the config (its fields, stagedvalue, a lineage object, isHookTarget / hookTargetKey, and — when the config is an environment/project override — a scopedConfig object) as input. config.value is a parsed JSON object — read its keys directly, no JSON.parse needed. Since a config-scoped hook also runs for descendants, config.isHookTarget is true only for the exact config the hook is pinned to. When present, config.scopedConfig is { parent, environments, projects } — the base this config overrides and the scope it applies to — so you can enforce environment-specific rules (e.g. require a field in the production override).
Example: Require a config to have a name and a non-empty value.
validateConfigRevision
Called before every Config publish, including manual publishes, direct REST updates, automatic publishing after approval, and scheduled publishing. A thrown error blocks the publish and leaves the draft editable. Skipping approval does not skip the hook. Receives:config— the config’s published content:key,name,project, stagedvalue(a parsed JSON object),schema, and lineage (parent/extends, plus alineageobject withancestors,descendants,hasParent,hasChildren,isRoot,isLeaf). Because a config-scoped hook also runs for descendants,config.isHookTargetistrueonly when this is the exact config the hook is pinned to (andfalsefor a descendant it inherited);config.hookTargetKeynames that pinned config (nullfor project/global hooks). Use these to enforce a rule only on the target config, or on the whole family. When the config is an environment/project override,config.scopedConfigis{ parent, environments, projects }(its base and the scope it applies to) — absent otherwise — so you can gate environment-specific rules.revision— the revision being published (when publishing a reviewed draft):version,status,comment,authorId,contributors, andreviews. Each review is{ userId, decision, comment, stale, dateCreated }wheredecisionis"approve"/"request-changes"/"comment"anduserIdis the reviewer’s user ID (or the API key ID for a service account).revisionis absent on direct (non-draft) writes.
config.value is already a parsed object.
validateFeatureRevision gate). Disable Incremental Changes Only for this hook so it runs on every publish.
validateExperiment
Called whenever an experiment is about to be created or updated. Receives the full experiment object, including any Custom Field values underexperiment.customFields. It runs on create, edit, start, and stop, so a thrown error blocks that action.
Not every field is populated at creation.An experiment created in the UI starts as a draft, and most of the work — assigning metrics, choosing a hash attribute, configuring variations — happens afterwards, before it is started. The hook still runs on creation, so a rule that requires one of those later fields would block the draft from ever being created. Experiments created through the import flow or the API often do have those fields available up front.If a rule should only apply once an experiment is ready to start, skip drafts early:

