Create a workflow hook
Attach a hook to a workflow transition. From then on, Plane runs the hook each time a work item moves along that transition, before the move for a pre hook and after it for a post hook.
Plane assigns execution_order one past the highest existing value for the same phase on this transition. The hook is enabled unless you send "is_enabled": false.
For a send_webhook hook, Plane generates the signing secret and returns it in this response as config.secret_plaintext. No other response returns the secret. Store it now, or rotate it later with Regenerate a workflow hook secret.
Creating a hook adds a transition_rule entry to the workflow's activity.
Path Parameters
slug:requiredstringThe workspace slug. It appears in your Plane URLs. In https://app.plane.so/my-team/projects/, the slug is my-team.
project_id:requiredstringThe project the workflow belongs to. Accepts the project UUID or its bare identifier, for example ENG.
workflow_id:requiredstring (uuid)The workflow the transition belongs to.
transition_id:requiredstring (uuid)The transition to attach the hook to. Plane checks it before anything else, so an unknown transition returns 404 even when the body is invalid.
Body Parameters
phase:requiredstringWhen the hook runs. Immutable after creation.
pre. Runs before the move. If the hook fails, Plane blocks the transition.post. Runs after the move. Plane records a failure but does not block or undo the move.
handler_name:requiredstringWhat the hook does. Immutable after creation.
send_webhook. Sends the transition event toconfig.url, signed with the hook's secret.run_script. Runs the script inconfig.script_id.
Each handler needs its own plan feature. When the workspace doesn't have it, the request returns 402 payment_required.
config:requiredobjectThe handler's settings.
For send_webhook:
urlstring, required. The destination,httporhttpsonly. Plane rejects URLs that resolve to private or internal addresses and domains that the instance disallows for webhooks.headersarray, optional. Up to 20 objects of the form{ "key", "value", "is_secret" }.keyis 1 to 256 characters and must be unique without regard to case.valueis required and up to 8192 characters.is_secretdefaults totrue. When it istrue, Plane stores the value encrypted. The nameshost,content-length,content-type,user-agent,x-plane-delivery,x-plane-event, andx-plane-signatureare reserved.
Don't send a secret. Plane generates it.
For run_script:
script_idstring, required. A system script, or a script in this workspace that is either workspace-wide or belongs to this project.execution_variablesobject, optional. Variables passed to the script.
is_enabled:optionalbooleanWhether the hook runs. Defaults to true.
Response shaping
fields:optionalstringComma-separated list of fields to return. Unrequested keys are omitted from the response, not returned as null, so absent means "not requested" and null means "actually null". id always comes back whether or not you name it.
Pass all for every requestable field. An unknown name is a 400 that lists the valid set and suggests the closest match, so a typo can't silently cost you the saving.
Requestable here: config, created_at, execution_order, handler_name, id, is_enabled, phase, project_id, updated_at, workflow_transition_id.
See Sparse fields.
Scopes
projects.workflows:write
Errors
| Status | Code | Cause |
|---|---|---|
400 | invalid_request | Missing phase, handler_name, or config, a value outside the enums, a send_webhook URL or header list that fails validation, or a missing or unknown script_id. |
401 | unauthorized | Missing or invalid credentials. |
402 | payment_required | Workflows aren't enabled on your plan, or the plan feature for this handler_name isn't enabled for the workspace. |
403 | forbidden | Your role or token scope can't edit this project's workflows. |
404 | not_found | No such workspace, project, workflow, or transition, including a catalog transition in a governed workspace. |
406 | not_acceptable | The Accept header asks for a representation the API can't produce. |
409 | conflict | Not raised by the project hook routes. Listed for parity with the workspace hook routes. |
413 | payload_too_large | The request body is over the size limit. |
415 | unsupported_media_type | The Content-Type isn't one this endpoint accepts. |
429 | rate_limited | Throttled. Honor the Retry-After header before retrying. |
curl -X POST \
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b/workflows/9e3f6a2c-7b14-4d8e-a5c1-2f60b8d4e719/state-transitions/5c8d2e71-4a9f-4b36-8e05-d17f3a6b9c42/hooks/" \
-H "X-Api-Key: $PLANE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phase": "post",
"handler_name": "send_webhook",
"config": {
"url": "https://hooks.example.com/plane/transitions",
"headers": [
{ "key": "X-Team", "value": "platform", "is_secret": false }
]
}
}'import requests
response = requests.post(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b"
"/workflows/9e3f6a2c-7b14-4d8e-a5c1-2f60b8d4e719"
"/state-transitions/5c8d2e71-4a9f-4b36-8e05-d17f3a6b9c42/hooks/",
headers={"X-Api-Key": "your-api-key"},
json={
"phase": "post",
"handler_name": "send_webhook",
"config": {
"url": "https://hooks.example.com/plane/transitions",
"headers": [{"key": "X-Team", "value": "platform", "is_secret": False}],
},
},
)
hook = response.json()
secret = hook["config"]["secret_plaintext"] # returned only on createconst response = await fetch(
"https://api.plane.so/api/v2/workspaces/my-team/projects/4af68566-94a4-4eb3-94aa-50dc9427067b" +
"/workflows/9e3f6a2c-7b14-4d8e-a5c1-2f60b8d4e719" +
"/state-transitions/5c8d2e71-4a9f-4b36-8e05-d17f3a6b9c42/hooks/",
{
method: "POST",
headers: {
"X-Api-Key": "your-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify({
phase: "post",
handler_name: "send_webhook",
config: {
url: "https://hooks.example.com/plane/transitions",
headers: [{ key: "X-Team", value: "platform", is_secret: false }],
},
}),
},
);
const hook = await response.json();
const secret = hook.config.secret_plaintext; // returned only on create{
"id": "3b7e9a15-6c2d-4f84-9b31-0e5d8c7a2f69",
"workflow_transition_id": "5c8d2e71-4a9f-4b36-8e05-d17f3a6b9c42",
"project_id": "4af68566-94a4-4eb3-94aa-50dc9427067b",
"phase": "post",
"handler_name": "send_webhook",
"is_enabled": true,
"execution_order": 1,
"config": {
"url": "https://hooks.example.com/plane/transitions",
"headers": [
{
"key": "X-Team",
"value": "platform",
"is_secret": false
}
],
"secret_plaintext": "plane_wh_4f9c2a7e1b8d4e63a5c0f7d29b1e8a46"
},
"created_at": "2026-01-14T09:22:41.478363Z",
"updated_at": "2026-01-14T09:22:41.478363Z"
}{
"type": "payment_required",
"code": "payment_required",
"detail": "The send_webhook handler is not enabled for this workspace."
}Store the secret from this response
config.secret_plaintext appears only here. Every later read of the hook omits the secret. If you lose it, regenerate it. Regenerating invalidates the old secret.

