Documentation

ChangeFreeze documentation

ChangeFreeze is a change freeze controller: you define freeze windows once, and every pipeline, script and person asks the API before changing production. This page is the complete reference.

Overview & concepts

Organizations, services, environments

An organization owns everything: services, environments, freezes, API keys, channels and members. A service is something you deploy (checkout-api, payments-db), identified by a slug and optionally carrying tags (finance, pci, customer-facing) and a tier. An environment is a deployment target (prod, staging), one of which may be flagged as production. Services and environments are referenced by slug in every API call.

Change types

Checks may declare what kind of change is about to happen. The fixed set is code, infra, config, data, flag and hotfix. A freeze can be scoped to a subset, so a code freeze can leave feature-flag toggles untouched or a data freeze can stop migrations while deploys continue.

Freezes: hard vs soft

A freeze has a kind. A hard freeze produces decision: "block" and HTTP 423 Locked from POST /v1/check; a soft freeze produces decision: "warn" with HTTP 200. If both a hard and a soft freeze match, the result is block. Each freeze also has a reason_category: planned, holiday, peak_season, financial_close, audit, incident, customer, security or other. Cancelled freezes are kept for the audit trail but never match.

Scope matching

A freeze's scope has five optional lists: services, environments, change_types, tags and exclude_services. The rules, in order:

  1. If the target's service is in exclude_services, the freeze does not match.
  2. An empty or missing list matches everything in that dimension. A freeze with an empty scope applies to the whole organization.
  3. An unspecified target dimension matches. If a check omits environment, a freeze scoped to ["prod"] still applies. This is deliberate: a vague check is treated conservatively. Pass every dimension you know.
  4. If services is set and the target service is not in it, the freeze can still match through tags when the service carries one of the listed tags.
  5. If only tags is set, a service matches when it carries one of those tags; a named service without any of them does not match.
  6. environments and change_types are then matched by simple inclusion.

Service tags are resolved server-side from the service catalog, so callers only send the slug.

Recurrence & time zones

Every freeze carries an IANA timezone. starts_at and ends_at define the first occurrence (and its duration). An optional recurrence object repeats it: freq (daily, weekly, monthly, yearly), interval (every N units, 1–52), by_weekday (weekly only, 0 = Sunday … 6 = Saturday), until (ISO timestamp after which no occurrence starts) and count (maximum occurrences, up to 500). Occurrences are generated on the wall clock of the freeze's time zone, so a window that starts at 15:00 local keeps starting at 15:00 across daylight-saving changes; month-end dates clamp to the last day of shorter months. All timestamps in API responses are ISO 8601 UTC.

Decisions

decisionMeaningHTTP (POST /v1/check)
allowNo active freeze matches the target.200
warnOnly soft freezes match. Proceed, but it is logged.200
blockA hard freeze matches and no valid exception token was supplied.423 (200 with soft_fail)
overrideA hard freeze matches but an approved, in-window exception token covers the target.200

frozen in the response is true whenever a hard freeze matches, even when the decision is override. mode is hard, soft or null.

Quickstart

  1. Create an organization. Sign up; your first organization and default environments are created with you. Set the organization time zone in Settings.
  2. Add a service. Catalog → Services → New. Give it a slug such as checkout-api and any tags you plan to freeze by.
  3. Create an API key. Integrations → API keys → New, scope read. Copy the key once; only its hash is stored. It looks like cfk_r_….
  4. Create a freeze. Freezes → New, or pick a template (Black Friday / Cyber Monday, year-end, quarter close, no-deploy Fridays, incident, audit window).
  5. Gate your pipeline. Add the check before anything that changes production:
curl -sf -X POST https://changefreeze.io/v1/check \
  -H "Authorization: Bearer $CHANGEFREEZE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"service":"checkout-api","environment":"prod","change_type":"code","actor":"ci","source":"my-pipeline"}' \
  || { echo "Change freeze active"; exit 1; }

Hit Freeze now in the app and run the step again: it should fail with 423. Lift the freeze and it passes. Then share https://changefreeze.io/s/<your-slug> with the team.

Authentication

All /v1 endpoints except the public status endpoints require an API key. Send it either way:

Authorization: Bearer cfk_r_8fK2mQ…
X-API-Key: cfk_r_8fK2mQ…

Scopes

ScopePrefixAllows
readcfk_r_GET /v1/status, POST /v1/check, GET /v1/freezes, GET /v1/services. Use this in pipelines.
writecfk_w_Everything above plus POST /v1/freezes (freeze-now automations, incident bots).
admincfk_a_Reserved for administrative automation; a superset of write.

Keys are created by organization admins, may carry an expiry (up to 730 days) and can be revoked instantly. A key with insufficient scope receives 403 forbidden; a missing, revoked, expired or unknown key receives 401.

Rate limits & quotas

Requests are limited per key per fixed one-minute window: 60 (Free), 600 (Team), 3,000 (Business), 10,000 (Enterprise). Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). Exceeding the window returns 429 rate_limited. GET /v1/status and POST /v1/check also count against the plan's monthly check quota (5,000 / 100,000 / 1,000,000 / unlimited); when it is exhausted the API returns 429 quota_exceeded until the next month.

API reference

Base URL https://changefreeze.io. Requests and responses are JSON. Every response has ok; errors look like:

{ "ok": false, "error": "plan_required", "message": "Recurring freezes require the Team plan or higher" }
StatuserrorWhen
400bad_requestValidation failed; message says which field.
401unauthenticated, invalid_api_keyNo key, or key revoked / expired / unknown.
402plan_required, plan_limitFeature not in plan, or a plan cap (e.g. active freezes) reached.
403forbiddenKey scope too low, or invalid calendar token.
404not_foundUnknown route or status page slug.
423—POST /v1/check decided block. Body is a normal check result with ok: false.
429rate_limited, quota_exceededPer-minute limit or monthly quota exceeded.

Target parameters are shared by the status and check endpoints: service (slug), environment (alias env), change_type (alias type). All are optional; see scope matching for why you should pass them anyway.

GET /v1/status

Evaluate the current freeze state for a target without recording a check. Use it for dashboards, Terraform data sources and anything that polls. Requires read.

QueryDescription
service, environment, change_typeTarget dimensions.
exception_tokenOptional EXC-XXXX-XXXX; may also be sent as header X-Exception-Token.
GET /v1/status?service=checkout-api&environment=prod&change_type=code

200 OK   Cache-Control: no-store
{
  "ok": true,
  "org": { "id": "org_01J8ZK", "slug": "acme", "name": "Acme", "timezone": "America/New_York" },
  "frozen": false,
  "decision": "allow",
  "mode": null,
  "now": "2026-11-03T14:02:11.482Z",
  "target": { "service": "checkout-api", "environment": "prod", "change_type": "code", "tags": ["customer-facing"] },
  "active": [],
  "next": { "id": "frz_01JBWQ", "name": "Black Friday / Cyber Monday", "kind": "hard",
            "starts_at": "2026-11-24T05:00:00.000Z", "ends_at": "2026-12-01T05:00:00.000Z" },
  "exception": null,
  "message": null
}

Always 200 when the key is valid, whatever the decision. message is populated only when the decision is block (your organization's custom block message, or a default naming the freeze).

POST /v1/check

Evaluate and record a check. This is the call to put in pipelines: it writes a row to the check log (the audit evidence) and, on block, fires check.blocked notifications (de-duplicated per actor, service, environment and hour). Requires read.

Body fieldDescription
service, environment, change_typeTarget dimensions (strings).
actorWho is making the change (≤200 chars): an email, a CI identity, a bot name.
sourceWhich system is asking (≤64): github-actions, terraform, deploy.sh.
refA pointer back to the change (≤300): commit SHA, pipeline URL, ticket.
exception_tokenApproved exception token; or header X-Exception-Token.
soft_failtrue returns HTTP 200 even for block (the body still says decision: "block", ok: false). Also accepted as ?soft_fail=true, or set org-wide in Settings.
metadataFree-form object stored with the check (≤4 KB).
POST /v1/check
{ "service": "checkout-api", "environment": "prod", "change_type": "code",
  "actor": "jane@acme.io", "source": "github-actions", "ref": "acme/shop@9f3c1a2" }

423 Locked
{
  "ok": false,
  "check_id": "chk_01JBX4Q7N2",
  "org": { "id": "org_01J8ZK", "slug": "acme", "name": "Acme", "timezone": "America/New_York" },
  "frozen": true,
  "decision": "block",
  "mode": "hard",
  "now": "2026-11-27T16:40:03.118Z",
  "target": { "service": "checkout-api", "environment": "prod", "change_type": "code", "tags": ["customer-facing"] },
  "active": [{ "id": "frz_01JBWQ", "name": "Black Friday / Cyber Monday", "kind": "hard",
               "reason_category": "peak_season", "starts_at": "2026-11-24T05:00:00.000Z",
               "ends_at": "2026-12-01T05:00:00.000Z", "customer_label": null,
               "description": "Peak retail traffic. No production changes." }],
  "next": null,
  "exception": null,
  "message": "Changes are blocked by the \"Black Friday / Cyber Monday\" freeze for Acme. Request an exception in ChangeFreeze or wait for the window to end."
}

With a valid token the same request returns 200, decision: "override" and "exception": { "id": "exc_…", "token": "EXC-7K2M-9QXD", "title": "Hotfix for checkout 500s", "valid_until": "2026-11-27T20:00:00.000Z" }. check_id is the identifier you will find in the check log and evidence exports.

GET /v1/freezes

List active and upcoming freeze occurrences for the next 90 days, recurrence already expanded, sorted by start. Requires read.

200 OK
{ "ok": true, "freezes": [
  { "id": "frz_01JBWQ", "name": "No-deploy Fridays", "kind": "soft", "reason_category": "planned",
    "scope": { "environments": ["prod"] }, "starts_at": "2026-11-06T15:00:00.000Z",
    "ends_at": "2026-11-09T09:00:00.000Z", "timezone": "Europe/London", "customer_label": null },
  { "id": "frz_01JC2R", "name": "Contoso peak season", "kind": "hard", "reason_category": "customer",
    "scope": { "tags": ["tenant-contoso"] }, "starts_at": "2026-11-20T00:00:00.000Z",
    "ends_at": "2026-12-02T00:00:00.000Z", "timezone": "America/Chicago", "customer_label": "Contoso" } ] }
POST /v1/freezes

Create a freeze. Requires write. Built for automation: an incident bot calling it with freeze_now, or a customer-success tool creating tenant freezes.

Body fieldDescription
nameRequired, ≤120 chars.
descriptionOptional, ≤2000 chars. Shown on the status page and in notifications.
kindhard (default) or soft.
reason_categoryOne of the reason categories; defaults to planned.
starts_at, ends_atISO 8601. Required unless freeze_now. Max 400 days per window.
freeze_now, duration_minutesStart immediately for duration_minutes (default 240, min 5, max 43,200).
timezoneIANA zone; defaults to the organization's zone.
recurrence{ freq, interval, by_weekday, until, count }. Team plan or higher.
scope{ services, environments, change_types, tags, exclude_services }; unknown change types are dropped.
customer_label≤80 chars. Marks a customer/tenant-driven freeze. Business plan or higher.
ticket_urlLink to the change or incident record.
POST /v1/freezes
{ "name": "INC-2291 payment gateway degradation", "kind": "hard", "reason_category": "incident",
  "freeze_now": true, "duration_minutes": 180,
  "scope": { "tags": ["payments"], "environments": ["prod"] },
  "ticket_url": "https://acme.pagerduty.com/incidents/INC-2291" }

201 Created
{ "ok": true, "freeze": { "id": "frz_01JC9T", "status": "scheduled", "state": "active",
  "recurrence_text": "One-time", "starts_at": "2026-11-03T14:10:00.000Z", "ends_at": "2026-11-03T17:10:00.000Z",
  "occurrences": [{ "freeze_id": "frz_01JC9T", "starts_at": "2026-11-03T14:10:00.000Z", "ends_at": "2026-11-03T17:10:00.000Z" }], "…": "…" } }

Creating a freeze that is already in effect sends freeze.started to your channels; a future one sends freeze.created. Plan errors: 402 plan_required for recurrence or customer_label, 402 plan_limit when the Free plan's two scheduled windows are used.

GET /v1/services

The catalog your key can see: non-archived services and all environments. Handy for generating pipeline matrices. Requires read.

200 OK
{ "ok": true,
  "services": [{ "id": "svc_01J9A1", "name": "Checkout API", "slug": "checkout-api", "tier": "tier1",
                 "tags": ["customer-facing", "pci"], "description": null, "archived_at": null, "…": "…" }],
  "environments": [{ "slug": "prod", "name": "Production", "is_production": 1 },
                   { "slug": "staging", "name": "Staging", "is_production": 0 }] }

Public endpoints (no key)

Available when the organization's status page is enabled; :slug is the status page slug. Limited to 120 requests per minute per IP.

GET /v1/public/:slug/status.json

Same target query parameters as /v1/status (no exception tokens). Returns the evaluation plus title, message (from the status page settings) and upcoming: up to 20 future occurrences within the page's configured horizon (30 days by default). Cached for 30 seconds.

{ "ok": true, "org": { "name": "Acme", "slug": "acme", "timezone": "America/New_York" },
  "title": "Acme change freeze status", "message": null,
  "frozen": true, "decision": "block", "mode": "hard", "now": "…", "target": {}, "active": [ … ], "next": null, "exception": null,
  "upcoming": [{ "id": "frz_…", "name": "Year-end holiday freeze", "kind": "hard", "reason_category": "holiday",
                 "starts_at": "2026-12-19T05:00:00.000Z", "ends_at": "2027-01-03T05:00:00.000Z", "scope": {} }] }
GET /v1/public/:slug/badge.svg

Shields-style SVG: FROZEN (red), soft freeze (amber) or open (green). Accepts the target parameters and label= (≤40 chars, default "change freeze"). Cached for 60 seconds. Unknown slugs return a grey "not found" badge with 404.

GET /v1/public/:slug/calendar.ics?token=…

iCalendar feed of occurrences from 90 days ago to 400 days ahead. Requires the page's calendar token (regenerate it in the app). Team plan or higher; otherwise 402 plan_required. Wrong token: 403.

GET /s/:slug

The human-readable status page. See Status page.

Integration guides

Every guide is the same idea: call POST /v1/check with as much context as you have, and let a non-2xx status stop the job. Store the key as a secret. Use a read-scoped key.

GitHub Actions

Add an exception_token input so an approved token can be supplied from the Run workflow dialog without editing the pipeline.

on:
  workflow_dispatch:
    inputs:
      exception_token:
        description: "ChangeFreeze exception token (EXC-XXXX-XXXX), if approved"
        required: false
        default: ""
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Change freeze gate
        env:
          CF_KEY: ${{ secrets.CHANGEFREEZE_KEY }}
          EXCEPTION_TOKEN: ${{ inputs.exception_token }}
        run: |
          code=$(curl -s -o cf.json -w '%{http_code}' -X POST https://changefreeze.io/v1/check \
            -H "Authorization: Bearer $CF_KEY" -H "Content-Type: application/json" \
            -d "{\"service\":\"checkout-api\",\"environment\":\"prod\",\"change_type\":\"code\",
                 \"actor\":\"${{ github.actor }}\",\"source\":\"github-actions\",
                 \"ref\":\"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\",
                 \"exception_token\":\"$EXCEPTION_TOKEN\"}")
          echo "decision=$(jq -r .decision cf.json)" >> "$GITHUB_OUTPUT"
          if [ "$code" = "423" ]; then echo "::error::$(jq -r .message cf.json)"; exit 1; fi
          if [ "$code" != "200" ]; then echo "::error::ChangeFreeze returned HTTP $code"; exit 1; fi
          [ "$(jq -r .decision cf.json)" = "warn" ] && echo "::warning::Soft freeze in effect: $(jq -r '.active[0].name' cf.json)"
      - run: ./deploy.sh

GitLab CI

Put the gate in before_script of production jobs, or in a shared template that production jobs extends. Set CHANGEFREEZE_KEY as a masked CI/CD variable; engineers pass EXCEPTION_TOKEN when running the pipeline manually.

.freeze_gate:
  before_script:
    - |
      code=$(curl -s -o cf.json -w '%{http_code}' -X POST https://changefreeze.io/v1/check \
        -H "Authorization: Bearer $CHANGEFREEZE_KEY" -H "Content-Type: application/json" \
        -d "{\"service\":\"$CI_PROJECT_NAME\",\"environment\":\"$CI_ENVIRONMENT_NAME\",\"change_type\":\"code\",
             \"actor\":\"$GITLAB_USER_EMAIL\",\"source\":\"gitlab-ci\",\"ref\":\"$CI_PIPELINE_URL\",
             \"exception_token\":\"${EXCEPTION_TOKEN:-}\"}")
      [ "$code" = "200" ] || { cat cf.json; echo; echo "Change freeze gate failed ($code)"; exit 1; }

deploy_prod:
  extends: .freeze_gate
  stage: deploy
  environment: prod
  script: ./deploy.sh

Jenkins

stage('Change freeze gate') {
  environment { CF_KEY = credentials('changefreeze-read-key') }
  steps {
    sh '''
      code=$(curl -s -o cf.json -w '%{http_code}' -X POST https://changefreeze.io/v1/check \
        -H "Authorization: Bearer $CF_KEY" -H "Content-Type: application/json" \
        -d "{\\"service\\":\\"${SERVICE}\\",\\"environment\\":\\"prod\\",\\"change_type\\":\\"code\\",
             \\"actor\\":\\"${BUILD_USER_ID:-jenkins}\\",\\"source\\":\\"jenkins\\",\\"ref\\":\\"${BUILD_URL}\\",
             \\"exception_token\\":\\"${EXCEPTION_TOKEN:-}\\"}")
      if [ "$code" != "200" ]; then cat cf.json; exit 1; fi
    '''
  }
}

Add EXCEPTION_TOKEN as a string build parameter so it appears on the "Build with Parameters" form.

Azure DevOps

Use the Invoke REST API check on your production environment (Environments → … → Approvals and checks). Create a Generic service connection to https://changefreeze.io with an Authorization: Bearer cfk_r_… header, then:

Method:          POST
URL suffix:      /v1/check
Body:            {"service":"checkout-api","environment":"prod","change_type":"code",
                  "actor":"$(Build.RequestedForEmail)","source":"azure-devops","ref":"$(Build.BuildUri)"}
Success criteria: eq(root['ok'], true)

A 423 fails the check and the stage waits or fails according to your retry settings. The same gate also works as a classic bash step using the script below.

Terraform

Use GET /v1/status rather than /v1/check: the http data source treats non-2xx responses as errors before your precondition can produce a readable message, and a plan should not be recorded as a check anyway. Record the apply with a POST /v1/check in the wrapper script if you want it in the evidence log.

variable "changefreeze_key" { type = string, sensitive = true }

data "http" "freeze" {
  url             = "https://changefreeze.io/v1/status?service=payments-db&environment=prod&change_type=infra"
  request_headers = { Authorization = "Bearer ${var.changefreeze_key}" }
}

locals { freeze = jsondecode(data.http.freeze.response_body) }

resource "aws_db_instance" "payments" {
  # …
  lifecycle {
    precondition {
      condition     = local.freeze.decision != "block"
      error_message = "Change freeze active: ${coalesce(local.freeze.message, "see https://changefreeze.io/s/acme")}"
    }
  }
}

Kubernetes

An initContainer gates a Job or CronJob pod: if the check returns 423 the init container exits non-zero and the main container never starts. Use restartPolicy: Never (or a small backoffLimit) so a blocked job does not retry for hours.

apiVersion: batch/v1
kind: CronJob
metadata: { name: nightly-etl }
spec:
  schedule: "0 2 * * *"
  jobTemplate:
    spec:
      backoffLimit: 0
      template:
        spec:
          restartPolicy: Never
          initContainers:
            - name: change-freeze-gate
              image: curlimages/curl:8.10.1
              env:
                - { name: CF_KEY, valueFrom: { secretKeyRef: { name: changefreeze, key: api-key } } }
              command: ["sh", "-c"]
              args:
                - |
                  curl -sf -X POST https://changefreeze.io/v1/check \
                    -H "Authorization: Bearer $CF_KEY" -H "Content-Type: application/json" \
                    -d '{"service":"nightly-etl","environment":"prod","change_type":"data","actor":"cronjob/nightly-etl","source":"kubernetes"}'
          containers:
            - { name: etl, image: ghcr.io/acme/etl:1.42 }

Generic bash & exit codes

A reusable script. Exit codes: 0 allowed (allow / warn / override), 2 blocked, 3 API unreachable or unexpected response. Decide per job whether 3 should stop the deploy (fail closed) or continue (fail open) by appending || [ $? -eq 3 ].

#!/usr/bin/env bash
# usage: freeze-gate.sh <service> <environment> [change_type]
set -u
SERVICE=$1 ENVIRONMENT=$2 CHANGE_TYPE=${3:-code}
body=$(printf '{"service":"%s","environment":"%s","change_type":"%s","actor":"%s","source":"%s","ref":"%s","exception_token":"%s"}' \
  "$SERVICE" "$ENVIRONMENT" "$CHANGE_TYPE" "${ACTOR:-$(whoami)@$(hostname)}" "${SOURCE:-freeze-gate.sh}" "${REF:-}" "${EXCEPTION_TOKEN:-}")
code=$(curl -s --max-time 10 -o /tmp/cf.json -w '%{http_code}' -X POST https://changefreeze.io/v1/check \
  -H "Authorization: Bearer ${CHANGEFREEZE_KEY:?set CHANGEFREEZE_KEY}" -H "Content-Type: application/json" -d "$body")
case "$code" in
  200) d=$(sed -n 's/.*"decision":"\([a-z]*\)".*/\1/p' /tmp/cf.json)
       [ "$d" = "warn" ] && echo "WARN: soft freeze in effect for $SERVICE/$ENVIRONMENT" >&2
       [ "$d" = "override" ] && echo "OVERRIDE: proceeding under exception $EXCEPTION_TOKEN" >&2
       exit 0 ;;
  423) echo "BLOCKED: $(sed -n 's/.*"message":"\([^"]*\)".*/\1/p' /tmp/cf.json)" >&2; exit 2 ;;
  *)   echo "ChangeFreeze unavailable (HTTP $code)" >&2; exit 3 ;;
esac

Notifications

Channels are configured under Integrations → Channels by an admin. Each channel subscribes to a list of event types (empty = all):

EventFires when
freeze.upcomingA freeze occurrence is about to start (reminder from the scheduler).
freeze.started / freeze.endedAn occurrence begins or ends; also when a freeze is created already in effect, or an active one is lifted.
freeze.created / freeze.cancelledA future freeze is scheduled or cancelled.
exception.requested / exception.approved / exception.deniedException workflow; approvers are also emailed on request.
check.blockedA POST /v1/check returned block (one alert per actor / service / environment / hour).
  • Slack, Microsoft Teams, Discord: paste an incoming-webhook URL (hooks.slack.com, *.webhook.office.com or discord.com/api/webhooks). Slack gets a colour-coded attachment with fields; Teams and Discord get formatted text. Free plan: one channel.
  • Webhook: any HTTPS URL, optionally with a signing secret. Team plan or higher.
  • Email: a list of recipients. Team plan or higher.

Deliveries are de-duplicated by event key, so scheduler re-runs never double-post. Use Send test on a channel to verify it.

Webhook payload & signature verification

POST https://hooks.example.com/changefreeze
Content-Type: application/json
User-Agent: ChangeFreeze-Webhooks/1.0
X-ChangeFreeze-Event: freeze.started
X-ChangeFreeze-Timestamp: 1764000000
X-ChangeFreeze-Signature: v1=3f1a9c…e0b2

{ "id": "evt_01JCA3M8", "type": "freeze.started", "created_at": "2026-11-24T05:00:02.311Z",
  "org": { "id": "org_01J8ZK", "name": "Acme", "slug": "acme", "timezone": "America/New_York", "plan": "business" },
  "title": "Freeze started: Black Friday / Cyber Monday",
  "text": "Scheduler started a hard freeze (peak season) until 2026-12-01T05:00:00.000Z.",
  "url": "https://changefreeze.io/app/freezes/frz_01JBWQ?org=org_01J8ZK",
  "data": { "freeze": { "id": "frz_01JBWQ", "name": "Black Friday / Cyber Monday", "kind": "hard", "…": "…" } } }

The signature is v1= followed by hex HMAC-SHA256 of "{timestamp}.{raw body}" using the channel secret. Verify against the raw request bytes, compare in constant time, and reject timestamps older than a few minutes. Requests time out after 10 seconds; respond 2xx quickly and process asynchronously.

// Node.js (Express with express.raw({ type: "application/json" }))
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyChangeFreeze(req, secret) {
  const ts = req.get("x-changefreeze-timestamp");
  const sig = req.get("x-changefreeze-signature") ?? "";
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = "v1=" + createHmac("sha256", secret).update(`${ts}.${req.body.toString("utf8")}`).digest("hex");
  return expected.length === sig.length && timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}
# Python (Flask)
import hmac, hashlib, time
from flask import request, abort
def verify_changefreeze(secret: str) -> None:
    ts = request.headers.get("X-ChangeFreeze-Timestamp", "")
    sig = request.headers.get("X-ChangeFreeze-Signature", "")
    if not ts or abs(time.time() - int(ts)) > 300: abort(400)
    expected = "v1=" + hmac.new(secret.encode(), f"{ts}.".encode() + request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig): abort(401)

Email

Email channels receive the same events with an HTML body. Separately from channels, approvers are emailed when an exception is requested and the requester is emailed when it is decided (Team plan or higher).

Public status page, badge & calendar

Integrations → Status page lets an admin enable the page, choose its slug, title and message, how many days of upcoming windows to show (default 30) and whether to list per-service status. The page at https://changefreeze.io/s/<slug> shows the current state (open / soft freeze / frozen), active windows, per-service status for the production environment and upcoming windows, all in the organization's time zone. It is cached for 30 seconds and is available on every plan.

Badge

[![change freeze](https://changefreeze.io/v1/public/acme/badge.svg)](https://changefreeze.io/s/acme)

<!-- scoped to one service / environment, with a custom label -->
![deploys](https://changefreeze.io/v1/public/acme/badge.svg?service=checkout-api&environment=prod&label=checkout%20deploys)

Calendar (ICS) feed

Subscribe in Google Calendar, Outlook or Apple Calendar with the URL shown on the status page settings: https://changefreeze.io/v1/public/<slug>/calendar.ics?token=<ics_token>. Events are named with 🧊 for hard and ⚠️ for soft freezes, categorised by reason, and link back to the status page. Clients refresh on their own schedule (often hours); the feed itself is cached for five minutes. Team plan or higher.

Exceptions workflow

  1. Request. A member opens Exceptions → New (optionally from a specific freeze) with a title, justification, rollback plan, change reference, risk level (low / medium / high), scope (services, environments, change types) and validity window. Validity defaults to 4 hours from now and may not exceed 14 days.
  2. Notify. Approvers are emailed and the exception.requested event goes to channels.
  3. Decide. Users whose role is in the organization's approver roles (default owner, admin, approver) approve or deny with a note. Requesters cannot approve their own request unless Allow self-approval is enabled. Settings can require more than one approval.
  4. Use. An approved request exposes its token, EXC-XXXX-XXXX. Pass it as exception_token (or X-Exception-Token) on checks. A block becomes override when the token is approved, inside its validity window, bound to one of the matching freezes (or to none) and its scope matches the target.
  5. Expire or revoke. Tokens stop working at valid_until; an admin can revoke earlier. Statuses: pending, approved, denied, expired, revoked.

Tokens only affect hard freezes. Every check that used a token is linked to the exception, so the evidence export shows who approved what, and what was shipped under it. Exceptions require the Team plan or higher.

Compliance & evidence exports

Reports → Evidence (admin role, Business plan or higher) exports a date range as CSV or JSON. The export contains:

  • summary: organization, range, generation time and the control references the export supports.
  • freeze_windows: every occurrence that overlapped the range (recurrence expanded) with kind, reason, scope, status and creator.
  • exceptions and approvals: each request with justification, rollback plan, risk, requester, validity, decision, decider and note; approvals one row per approver.
  • checks: id, timestamp, decision, service, environment, change type, actor, source, ref, exception id, matching freeze ids and client IP.
  • audit_log: freeze, exception, key, channel and membership changes with actor, before/after and IP.
FrameworkControlWhat the export evidences
SOC 2CC8.1 Change managementRestricted-change periods were defined and enforced; emergency changes were approved and traceable.
SOXITGC change control / emergency changesFinancial-close freezes, segregation of requester and approver, complete population of overrides.
PCI DSS6.5 Change control proceduresChanges to in-scope components during restricted periods had documented approval and rollback plans.
ISO 27001A.8.32 Change managementDefined procedure, approvals and an audit trail for changes to information-processing facilities.

Retention is enforced per plan and applies to checks, audit entries and exports: 30 days (Free), 365 days (Team), 1,095 days (Business), 2,555 days (Enterprise). Check logs and audit logs can also be exported as CSV on any plan within that window; the structured evidence bundle is Business and above.

Roles & permissions

RoleCan
viewerRead everything: freezes, checks, exceptions, catalog, keys (metadata only), reports.
memberViewer + create, edit and cancel freezes; add and edit services; request exceptions; leave the organization.
approverMember + approve or deny exception requests (default approver roles are owner, admin and approver).
adminApprover + environments, API keys, notification channels, status page, members and invitations, organization settings, evidence exports.
ownerAdmin + billing (checkout and portal) and deleting the organization.

API keys act with a role derived from their scope: read as viewer, write as member, admin as admin. Every action an API key takes is attributed to the key in the audit log.

Billing & limits

Plans and limits are listed in full on the pricing page. Limits are enforced on creation: when a cap is reached the API returns 402 plan_limit and the app explains which limit. Existing objects are never deleted on downgrade. Usage (checks, notifications) resets each calendar month and is visible under Billing. Payments are processed by Stripe; only owners can start a checkout or open the customer portal.

FAQ & troubleshooting

401 with a key that works elsewhere

Check the header is exactly Authorization: Bearer cfk_… (or X-API-Key), that the key has not been revoked or expired, and that your CI secret does not include a trailing newline.

A freeze matched when I expected it not to

Most often the check omitted a dimension; an unspecified environment or change_type matches any scoped freeze. Pass all three, and check the service's tags: a tag-scoped freeze matches any service carrying that tag.

A freeze did not block

Confirm the freeze is hard (soft only warns), the occurrence is active in its own time zone, the service slug matches the catalog exactly, and the pipeline uses curl -f or checks the status code. With soft_fail (or the org default) a block returns 200.

429 responses

rate_limited: spread calls or upgrade for a higher per-minute limit; back off until X-RateLimit-Reset. quota_exceeded: the monthly check quota is used; polling dashboards should use the public status.json, which does not count.

An exception token does not override

The token must be approved (not pending), inside valid_from–valid_until, scoped to include the target, and if it was requested against a specific freeze, that freeze must be one of the ones blocking.

Still stuck?

Email support@changefreeze.io with the check_id or the request's timestamp and organization slug.