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:
- If the target's service is in
exclude_services, the freeze does not match. - An empty or missing list matches everything in that dimension. A freeze with an empty scope applies to the whole organization.
- 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. - If
servicesis set and the target service is not in it, the freeze can still match throughtagswhen the service carries one of the listed tags. - If only
tagsis set, a service matches when it carries one of those tags; a named service without any of them does not match. environmentsandchange_typesare 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
| decision | Meaning | HTTP (POST /v1/check) |
|---|---|---|
allow | No active freeze matches the target. | 200 |
warn | Only soft freezes match. Proceed, but it is logged. | 200 |
block | A hard freeze matches and no valid exception token was supplied. | 423 (200 with soft_fail) |
override | A 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
- Create an organization. Sign up; your first organization and default environments are created with you. Set the organization time zone in Settings.
- Add a service. Catalog → Services → New. Give it a slug such as
checkout-apiand any tags you plan to freeze by. - Create an API key. Integrations → API keys → New, scope
read. Copy the key once; only its hash is stored. It looks likecfk_r_…. - Create a freeze. Freezes → New, or pick a template (Black Friday / Cyber Monday, year-end, quarter close, no-deploy Fridays, incident, audit window).
- 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
| Scope | Prefix | Allows |
|---|---|---|
read | cfk_r_ | GET /v1/status, POST /v1/check, GET /v1/freezes, GET /v1/services. Use this in pipelines. |
write | cfk_w_ | Everything above plus POST /v1/freezes (freeze-now automations, incident bots). |
admin | cfk_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" }
| Status | error | When |
|---|---|---|
| 400 | bad_request | Validation failed; message says which field. |
| 401 | unauthenticated, invalid_api_key | No key, or key revoked / expired / unknown. |
| 402 | plan_required, plan_limit | Feature not in plan, or a plan cap (e.g. active freezes) reached. |
| 403 | forbidden | Key scope too low, or invalid calendar token. |
| 404 | not_found | Unknown route or status page slug. |
| 423 | — | POST /v1/check decided block. Body is a normal check result with ok: false. |
| 429 | rate_limited, quota_exceeded | Per-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.
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.
| Query | Description |
|---|---|
service, environment, change_type | Target dimensions. |
exception_token | Optional 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).
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 field | Description |
|---|---|
service, environment, change_type | Target dimensions (strings). |
actor | Who is making the change (≤200 chars): an email, a CI identity, a bot name. |
source | Which system is asking (≤64): github-actions, terraform, deploy.sh. |
ref | A pointer back to the change (≤300): commit SHA, pipeline URL, ticket. |
exception_token | Approved exception token; or header X-Exception-Token. |
soft_fail | true 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. |
metadata | Free-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.
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" } ] }
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 field | Description |
|---|---|
name | Required, ≤120 chars. |
description | Optional, ≤2000 chars. Shown on the status page and in notifications. |
kind | hard (default) or soft. |
reason_category | One of the reason categories; defaults to planned. |
starts_at, ends_at | ISO 8601. Required unless freeze_now. Max 400 days per window. |
freeze_now, duration_minutes | Start immediately for duration_minutes (default 240, min 5, max 43,200). |
timezone | IANA 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_url | Link 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.
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.
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": {} }] }
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.
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.
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):
| Event | Fires when |
|---|---|
freeze.upcoming | A freeze occurrence is about to start (reminder from the scheduler). |
freeze.started / freeze.ended | An occurrence begins or ends; also when a freeze is created already in effect, or an active one is lifted. |
freeze.created / freeze.cancelled | A future freeze is scheduled or cancelled. |
exception.requested / exception.approved / exception.denied | Exception workflow; approvers are also emailed on request. |
check.blocked | A 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.comordiscord.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 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
[](https://changefreeze.io/s/acme)
<!-- scoped to one service / environment, with a custom label -->

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
- 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. - Notify. Approvers are emailed and the
exception.requestedevent goes to channels. - 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.
- Use. An approved request exposes its token,
EXC-XXXX-XXXX. Pass it asexception_token(orX-Exception-Token) on checks. A block becomesoverridewhen the token is approved, inside its validity window, bound to one of the matching freezes (or to none) and its scope matches the target. - 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.
| Framework | Control | What the export evidences |
|---|---|---|
| SOC 2 | CC8.1 Change management | Restricted-change periods were defined and enforced; emergency changes were approved and traceable. |
| SOX | ITGC change control / emergency changes | Financial-close freezes, segregation of requester and approver, complete population of overrides. |
| PCI DSS | 6.5 Change control procedures | Changes to in-scope components during restricted periods had documented approval and rollback plans. |
| ISO 27001 | A.8.32 Change management | Defined 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
| Role | Can |
|---|---|
viewer | Read everything: freezes, checks, exceptions, catalog, keys (metadata only), reports. |
member | Viewer + create, edit and cancel freezes; add and edit services; request exceptions; leave the organization. |
approver | Member + approve or deny exception requests (default approver roles are owner, admin and approver). |
admin | Approver + environments, API keys, notification channels, status page, members and invitations, organization settings, evidence exports. |
owner | Admin + 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.