Recipes

All examples use three settings in your CI system:

Name Kind Value
PLAYBOOK_URL variable https://deployment-playbook.com
PLAYBOOK_TEMPLATE_ID variable the Template ID (Templates → your template → Copy)
PLAYBOOK_API_TOKEN secret a workspace API token (Admin → Connections & webhooks)

How the gate maps to pipeline jobs

GET /api/gate/<planId>?phase=<p> answers "may work in phase p start?". So:

Job waits until… Poll
the plan is approved (nothing else) ?phase=before
every Before step is done ?phase=during
every During step is done ?phase=after
every After step is done (plan complete) ?phase=after and planStatus == "done"

?phase=after becomes allow: true as soon as Before and During are done, before any After step is approved. Use planStatus == "done" for a final job that must only turn green when the whole plan is complete.

Create a plan on a git tag

POST /api/plans is idempotent per repository + ref: re-running the pipeline for the same tag returns the same plan (200, created: false) instead of creating a duplicate.

curl -fsS -X POST "$PLAYBOOK_URL/api/plans" \
  -H "Authorization: Bearer $PLAYBOOK_API_TOKEN" -H "Content-Type: application/json" \
  -d "{\"templateId\":\"$PLAYBOOK_TEMPLATE_ID\",\"ref\":\"v1.4.0\",\"repository\":\"acme/shop\",\"commitSha\":\"$(git rev-parse HEAD)\",\"environment\":\"production\"}"
# => {"id":"cm…","name":"Release v1.4.0","status":"PROPOSED","planUrl":"…","gateUrl":"…/api/gate/cm…","created":true}

A reusable polling script

Save as scripts/wait-for-playbook.sh (bash + curl + jq). It fails closed: a non-200 response or invalid JSON counts as "not allowed" and polling continues until the timeout.

#!/usr/bin/env bash
# usage: wait-for-playbook.sh <planId> <before|during|after> [require-done] [timeout-minutes]
set -uo pipefail
plan="$1"; phase="$2"; require_done="${3:-false}"; timeout="${4:-60}"
deadline=$(( $(date +%s) + timeout * 60 ))
while :; do
  body=$(curl -sS --max-time 30 -w '\n%{http_code}' -H "Authorization: Bearer $PLAYBOOK_API_TOKEN" \
    "$PLAYBOOK_URL/api/gate/$plan?phase=$phase" 2>/dev/null) || body=$'\n000'
  code=${body##*$'\n'}; json=${body%$'\n'*}
  allow=false; status=""
  if [ "$code" = 200 ] && echo "$json" | jq -e 'type=="object"' >/dev/null 2>&1; then
    allow=$(echo "$json" | jq -r '.allow == true'); status=$(echo "$json" | jq -r '.planStatus // ""')
    echo "allow=$allow planStatus=$status reason=$(echo "$json" | jq -r .reason) $(echo "$json" | jq -r .planUrl)"
  else echo "HTTP $code (not allowed)"; fi
  if [ "$allow" = true ] && { [ "$require_done" != true ] || [ "$status" = done ]; }; then exit 0; fi
  [ "$status" = rolled_back ] && { echo "plan rolled back"; exit 1; }
  [ "$(date +%s)" -ge "$deadline" ] && { echo "timed out"; exit 1; }
  sleep 15
done

GitHub Actions

The complete, maintained version (composite actions create-playbook-plan and wait-for-playbook, job summary, Vercel bypass support) is in Generation-Y-BV/deployment-playbook-example. The shape:

name: Deploy
on:
  push:
    tags: ['v*']
jobs:
  create-plan:
    runs-on: ubuntu-latest
    outputs:
      plan-id: ${{ steps.plan.outputs.id }}
    steps:
      - id: plan
        env:
          PLAYBOOK_URL: ${{ vars.PLAYBOOK_URL }}
          TEMPLATE_ID: ${{ vars.PLAYBOOK_TEMPLATE_ID }}
          TOKEN: ${{ secrets.PLAYBOOK_API_TOKEN }}
        run: |
          body=$(jq -n --arg t "$TEMPLATE_ID" --arg r "$GITHUB_REF_NAME" --arg repo "$GITHUB_REPOSITORY" --arg sha "$GITHUB_SHA" \
            --arg run "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
            '{templateId:$t, ref:$r, repository:$repo, commitSha:$sha, runUrl:$run}')
          res=$(curl -fsS -X POST "$PLAYBOOK_URL/api/plans" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$body")
          echo "id=$(echo "$res" | jq -r .id)" >> "$GITHUB_OUTPUT"
          echo "Plan: $(echo "$res" | jq -r .planUrl)" >> "$GITHUB_STEP_SUMMARY"
  before:          # waits until every Before step is done
    needs: create-plan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: scripts/wait-for-playbook.sh "${{ needs.create-plan.outputs.plan-id }}" during
        env: { PLAYBOOK_URL: '${{ vars.PLAYBOOK_URL }}', PLAYBOOK_API_TOKEN: '${{ secrets.PLAYBOOK_API_TOKEN }}' }
  deploy:          # runs in parallel with "during"
    needs: [create-plan, before]
    runs-on: ubuntu-latest
    steps:
      - run: echo "deploy here"
  during:          # waits until every During step is done
    needs: [create-plan, before]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: scripts/wait-for-playbook.sh "${{ needs.create-plan.outputs.plan-id }}" after
        env: { PLAYBOOK_URL: '${{ vars.PLAYBOOK_URL }}', PLAYBOOK_API_TOKEN: '${{ secrets.PLAYBOOK_API_TOKEN }}' }
  after-deployment: # green only when the After steps are done (planStatus == done)
    needs: [create-plan, deploy, during]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: scripts/wait-for-playbook.sh "${{ needs.create-plan.outputs.plan-id }}" after true
        env: { PLAYBOOK_URL: '${{ vars.PLAYBOOK_URL }}', PLAYBOOK_API_TOKEN: '${{ secrets.PLAYBOOK_API_TOKEN }}' }

GitLab CI

Store PLAYBOOK_API_TOKEN as a masked, protected CI/CD variable; PLAYBOOK_URL and PLAYBOOK_TEMPLATE_ID as variables.

stages: [plan, before, deploy, after]
default:
  image: alpine:3.20
  before_script: [apk add --no-cache bash curl jq]

workflow:
  rules:
    - if: $CI_COMMIT_TAG =~ /^v/

create-plan:
  stage: plan
  script:
    - |
      body=$(jq -n --arg t "$PLAYBOOK_TEMPLATE_ID" --arg r "$CI_COMMIT_TAG" --arg repo "$CI_PROJECT_PATH" \
        --arg sha "$CI_COMMIT_SHA" --arg run "$CI_PIPELINE_URL" '{templateId:$t, ref:$r, repository:$repo, commitSha:$sha, runUrl:$run}')
      res=$(curl -fsS -X POST "$PLAYBOOK_URL/api/plans" -H "Authorization: Bearer $PLAYBOOK_API_TOKEN" -H "Content-Type: application/json" -d "$body")
      echo "PLAN_ID=$(echo "$res" | jq -r .id)" > plan.env
      echo "Plan: $(echo "$res" | jq -r .planUrl)"
  artifacts:
    reports:
      dotenv: plan.env

before:
  stage: before
  timeout: 2h
  script: [bash scripts/wait-for-playbook.sh "$PLAN_ID" during]

deploy:            # same stage as "during" => runs in parallel
  stage: deploy
  script: [echo "deploy here"]

during:
  stage: deploy
  timeout: 2h
  script: [bash scripts/wait-for-playbook.sh "$PLAN_ID" after]

after-deployment:
  stage: after
  timeout: 2h
  script: [bash scripts/wait-for-playbook.sh "$PLAN_ID" after true]

Azure DevOps Pipelines

Add PLAYBOOK_API_TOKEN as a secret pipeline variable (or variable group) and PLAYBOOK_URL, PLAYBOOK_TEMPLATE_ID as normal variables. Secret variables must be mapped into env explicitly.

trigger:
  tags:
    include: ['v*']
pool:
  vmImage: ubuntu-latest

stages:
- stage: plan
  jobs:
  - job: create_plan
    steps:
    - bash: |
        body=$(jq -n --arg t "$(PLAYBOOK_TEMPLATE_ID)" --arg r "$(Build.SourceBranchName)" --arg repo "$(Build.Repository.Name)" \
          --arg sha "$(Build.SourceVersion)" --arg run "$(System.CollectionUri)$(System.TeamProject)/_build/results?buildId=$(Build.BuildId)" \
          '{templateId:$t, ref:$r, repository:$repo, commitSha:$sha, runUrl:$run}')
        res=$(curl -fsS -X POST "$(PLAYBOOK_URL)/api/plans" -H "Authorization: Bearer $PLAYBOOK_API_TOKEN" -H "Content-Type: application/json" -d "$body")
        echo "##vso[task.setvariable variable=planId;isOutput=true]$(echo "$res" | jq -r .id)"
        echo "Plan: $(echo "$res" | jq -r .planUrl)"
      name: plan
      env: { PLAYBOOK_API_TOKEN: $(PLAYBOOK_API_TOKEN) }

- stage: before
  dependsOn: plan
  variables: { planId: $[ stageDependencies.plan.create_plan.outputs['plan.planId'] ] }
  jobs:
  - job: wait
    timeoutInMinutes: 120
    steps:
    - bash: bash scripts/wait-for-playbook.sh "$(planId)" during
      env: { PLAYBOOK_URL: $(PLAYBOOK_URL), PLAYBOOK_API_TOKEN: $(PLAYBOOK_API_TOKEN) }

- stage: deploy_and_during
  dependsOn: [plan, before]
  variables: { planId: $[ stageDependencies.plan.create_plan.outputs['plan.planId'] ] }
  jobs:
  - job: deploy            # jobs in one stage run in parallel
    steps: [ { bash: echo "deploy here" } ]
  - job: during
    timeoutInMinutes: 120
    steps:
    - bash: bash scripts/wait-for-playbook.sh "$(planId)" after
      env: { PLAYBOOK_URL: $(PLAYBOOK_URL), PLAYBOOK_API_TOKEN: $(PLAYBOOK_API_TOKEN) }

- stage: after_deployment
  dependsOn: [plan, deploy_and_during]
  variables: { planId: $[ stageDependencies.plan.create_plan.outputs['plan.planId'] ] }
  jobs:
  - job: wait
    timeoutInMinutes: 120
    steps:
    - bash: bash scripts/wait-for-playbook.sh "$(planId)" after true
      env: { PLAYBOOK_URL: $(PLAYBOOK_URL), PLAYBOOK_API_TOKEN: $(PLAYBOOK_API_TOKEN) }

Alternatively, gate an Azure DevOps environment with Approvals and checks → Invoke REST API: URL $(PLAYBOOK_URL)/api/gate/<planId>?phase=during, header Authorization: Bearer <token>, success criteria eq(root['allow'], true).

Gate a deploy per phase

Use the table at the top: phase=during before you deploy (Before phase done), phase=after once the During checks are done, and phase=after + planStatus == "done" for the final job.

Deploy and During in parallel

Start the deploy job and a during job that polls ?phase=after at the same time, both after the before job. Your deploy runs while people tick off the During steps (watch the rollout, smoke tests); the final job needs both.

Final job green only when After is done

Poll ?phase=after and require planStatus == "done" (see the script's require-done argument). A plan becomes done automatically when its last step is approved or skipped. Fail the job if planStatus becomes rolled_back.

Gate on a single step (&step=)

?step=<planStepId> additionally requires every earlier step in the same phase to be resolved. Example: let the deploy job wait for everything before Trigger the deploy (so the Go or no-go decision must be approved):

curl -fsS -H "Authorization: Bearer $PLAYBOOK_API_TOKEN" \
  "$PLAYBOOK_URL/api/gate/$PLAN_ID?step=$TRIGGER_STEP_ID" | jq -e .allow

Step ids are shown in the plan's webhook payloads (step.id) and via the MCP tool get_plan.

Environments

environment is free text on each plan (production, staging, eu-prod, …). Create one plan per environment you release to; with the API, pass environment and make the ref unique per environment if you want separate plans for the same tag (e.g. v1.4.0@staging), because idempotency is keyed on repository + ref.

Idempotent re-runs

Re-running a pipeline for the same tag calls POST /api/plans with the same repository + ref and gets the existing plan back (200, created: false), so approvals already given are kept. To start over, create a new tag (or pass a different ref).

Status webhooks (outgoing)

Set a webhook URL on Connections & webhooks. Every plan/step status change is POSTed as JSON with headers:

  • X-Playbook-Event: e.g. plan.approved, step.approved, plan.done
  • X-Playbook-Timestamp: unix seconds
  • X-Playbook-Signature-256: sha256= + hex HMAC-SHA256 of <timestamp>.<raw body> with the webhook secret

Verify (Node.js):

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyPlaybookWebhook(rawBody, headers, secret) {
  const ts = headers["x-playbook-timestamp"];
  const sig = headers["x-playbook-signature-256"] ?? "";
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window
  const expected = "sha256=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Payload fields: event, status, planStatus, phase, steps {approved, skipped, total}, plan {id, name, environment, fourEyes, status}, step {…} (for step events), signedOffBy[], planUrl, workspace {id, slug}, timestamp. Deliveries are logged on the Connections page; failures never block approvals.