# Fwozen > Org-wide merge and deploy freezes for GitHub. Freeze from the web, from Slack, from the REST > API, from the CLI, or on a schedule — with an audit trail that names an actor in every row. Fwozen is a GitHub App plus a web dashboard, a Slack app, a CLI, and a REST API. It blocks merges using a required check run named `fwozen`, refuses a requested deployment freeze through a `fwozen/deploy-gate` check and a GitHub Action your pipeline calls, reads GitHub's effective branch rules for each watched branch to report what it found, and records every action with a real actor. ## Getting started as an agent You can take an organisation from nothing to an enforced freeze without a human driving a browser for anything except consent. Four moments need a person, and they are marked HUMAN below. Nothing in this API charges a card, and nothing here should be run on your own initiative — see "What an agent must not decide" at the end of this section. Sign in (HUMAN #1 — approves the code in a browser). Fwozen runs its OWN authorization server at https://fwozen.com/api/auth; sign-in is standard device authorization, RFC 8628: - POST https://fwozen.com/api/auth/device/code with a JSON body `{"client_id": "fwozen-agent"}` and `Content-Type: application/json`. THIS endpoint also accepts the `application/x-www-form-urlencoded` body RFC 8628 illustrates; the TOKEN endpoint below does NOT, so send JSON to both and the whole flow works. The client is PUBLIC — a public client has no secret, so never look for one and never send `client_secret`. - Show the human `verification_uri_complete` and `user_code`. The approval page is https://fwozen.com/device, and getting to an approval takes THREE acts, none of them yours: the link only PREFILLS the code in a form — opening it approves nothing and claims nothing; the signed-in human SUBMITS that form, which CLAIMS the code and reveals the client and the scopes; and approve is then a separate, explicit press. A code no session has claimed cannot be approved, so there is nothing here you can complete on their behalf — do not try. - Poll POST https://fwozen.com/api/auth/device/token with a JSON body carrying `grant_type=urn:ietf:params:oauth:grant-type:device_code`, `device_code`, `client_id` — JSON ONLY on this one: a form-encoded body here is answered with HTTP 415, which is why a client that follows RFC 8628 literally starts a login successfully and can never finish it. `authorization_pending` means keep waiting; `slow_down` means add 5s to your interval; `access_denied` and `expired_token` are terminal. The device code expires in ~30 minutes. - What comes back is a SESSION TOKEN, and it is not the credential the API takes. Store it at 0600 and send it to https://fwozen.com and nowhere else. Exchange it for an API token: GET https://fwozen.com/api/auth/token with `Authorization: Bearer ` answers `{ token }` — a short-lived RS256 JWT, which is what https://api.fwozen.com takes as `Authorization: Bearer `. Cache the JWT until it expires; do not mint one per request. - There is no refresh token and no rotation to get wrong. The session is what expires, a device-minted session is fixed-length and does NOT extend by being used, and POST https://fwozen.com/api/auth/sign-out with the session token revokes it immediately — which is what you do the moment a credential might have leaked. - Org switch: POST https://fwozen.com/api/auth/org/set-active with the session token and `{"organizationId": ""}`, then fetch a fresh `/api/auth/token`. That is the ONLY way to change the `org_id` claim, and the id is `organizations.id` — the same `org_…` id GET /v1/me/organizations returns. Then, against https://api.fwozen.com: 1. POST /v1/onboarding/bootstrap — creates the organisation for the signed-in user. Idempotent. 2. GET /v1/github/install-url — returns the GitHub App install URL carrying a signed `state` nonce. Mint it here; never hand-build an install URL. 3. HUMAN #2 installs the App at that URL. A GitHub organisation owner has to do it, because Fwozen requests one organisation permission (members:read). If the person is not an owner, GitHub files an approval request and the install sits pending — GitHub sends nothing in between, so an agent that assumes failure after 30 seconds is wrong. 4. GET /v1/installations/status?state= — poll it. Answers `pending`, `installed_unclaimed` or `installed_claimed`. - `installed_claimed` is the finish line and the only status carrying ids. Go to step 5. - `pending` means nothing has landed yet, and see HUMAN #2: it can last hours. - `installed_unclaimed` means an installation landed on an organisation nobody has proved they administer — the Marketplace-first order, where the App was installed before anybody bootstrapped. It names nothing and returns no ids, so POST /v1/installations/claim cannot resolve it: that route needs GitHub's numeric `installation_id`, which only the browser redirect straight after Install ever carries, and it needs an organisation you are already in. The repair is the GitHub organisation claim, and it is a browser trip end to end (HUMAN): the person opens https://fwozen.com/api/github/claim while signed in to the dashboard, GitHub asks which organisations they administer, and their browser redeems the single-use ticket that comes back at POST /v1/onboarding/claim-organization. You cannot make either leg yourself — the first is a top-level navigation on the web origin (the API takes credentials on the `Authorization` header only), and the ticket never leaves that browser. When they say it is done, switch your session to that organisation — GET /v1/me/organizations names it and gives you its `org_…` id, then POST https://fwozen.com/api/auth/org/set-active and fetch a fresh `/api/auth/token` — and poll this route once more. It now answers `installed_claimed`. Doing step 1 FIRST is what avoids this state: an organisation you are a member of is claimed by construction, so the poll goes straight from `pending` to `installed_claimed`. 5. POST /v1/repos/enable with an explicit repository list, then POST /v1/repos/{repoId}/branches for each branch to watch. Fwozen NEVER enables a repository on its own — private repos are the billable unit and enabling one silently is a charge nobody agreed to. Ask, then enable what the human named. 6. GET /v1/enforcement/branches/{branchId}/snippets — gh CLI, Terraform and ruleset JSON for the one native GitHub rule. HUMAN #3 makes the check required. On an installation that has granted repository administration that is a button in the dashboard and Fwozen writes the ruleset itself; otherwise they save the rule in GitHub's own settings. Setting enforcement up is never yours to do either way: enforcement setup is session-only — POST /v1/enforcement/setup refuses an `fwz_` key with 403 `credential_type_not_allowed`, and no scope in the list below grants it. POST /v1/enforcement/verify does take a key, and reports what GitHub actually says. 7. POST /v1/billing/trial starts the 14-day Business trial: no card, and it does NOT auto-convert — at expiry the organisation downgrades to Free. Nothing is charged. It is once per organisation AND once per person: a second organisation does not earn a second trial, and asking for one answers 409 `trial_already_used`. 8. POST /v1/billing/checkout returns `{ url }` for a Stripe Checkout page. Hand that URL to HUMAN #4 and stop. No Fwozen route, tool, or CLI command ever completes a payment. Credentials for unattended automation: - `fwz_` API keys are the automation credential: `Authorization: Bearer fwz_…`, flat scopes (`freeze:read`, `freeze:write`, `repo:read`, `repo:write`, `schedule:read`, …) that cannot be widened after creation. - `billing:read` and `billing:write` exist but are NOT granted by default — the human minting the key has to tick them. Neither can move money: with `billing:write` a key can start the no-card trial and ask for a Checkout or Portal URL, and that is where its involvement ends. - A key may declare itself an AGENT key at creation. An agent key must expire within 90 days, and everything it does is labelled as an agent's action in the audit log — distinct from the human who authorised it. - Keys are minted by POST /v1/api-keys, and that route takes a SESSION only. A key can never mint another key. The device-flow session above is how an agent gets its first key. A human makes one at https://fwozen.com/app/settings/api-keys as an owner or an admin. Create key, tick the scopes, and the secret is shown once. - The secret is shown once. Never print, log, or echo it. Rate limits an unattended caller will meet: - Freezes and repository changes carry a per-CREDENTIAL write limit on top of the organisation's own per-tier ceiling, so one retry-happy agent cannot spend the whole organisation's budget. Lifting a freeze is exempt from that per-CREDENTIAL bucket — the way out is never the thing one runaway client can spend. It is NOT exempt from the organisation's ceiling: a lift is an ordinary write there and can answer 429, so honour `Retry-After` on a lift like any other write. - Creating organisations is limited too, both per person per day and in how many may be held with no GitHub App installed. - No numbers are published here, and that is deliberate: they are operational settings tuned per deployment, so a figure quoted in this document would be a promise we did not make. Read `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` off every CREDENTIALLED /v1 response, and GET /v1/entitlements for the per-tier ceilings. Size retry budgets from those, never from prose. The two routes that take no credential — GET /v1/plans and GET /v1/github/oauth/callback — sit outside the organisation bucket and carry no rate headers at all, so do not read a budget off them. - Every REST refusal is a 429 with `Retry-After`. On MCP, a refused tool call comes back as an `isError` result whose text carries the same retry seconds — there are no rate headers on a tool result. Honour the wait either way; retrying faster will not help. If you speak MCP, the same journey is a tool call: - Streamable-HTTP MCP endpoint: https://api.fwozen.com/mcp. One POST, no session store. - Send the SAME credential you would send the REST API: `Authorization: Bearer ` (the short-lived token from `/api/auth/token` above) or `Authorization: Bearer fwz_…`. There is no MCP-specific credential to go looking for. A request without one answers 401 with a `WWW-Authenticate` header naming https://api.fwozen.com/.well-known/oauth-protected-resource (RFC 9728), which lists the authorization server. - The tools are thin adapters over the routes above and carry the same rules: reads and writes are never mixed in one tool, `fwozen_enable_repos` takes an explicit repository list, `fwozen_checkout_link` returns a URL and nothing else, and `fwozen_end_freeze` is marked destructive and refuses without `confirm: true`. Machine-readable surfaces: - OpenAPI 3.1 spec: https://api.fwozen.com/v1/openapi.json - This file, plus every documentation page and the public route table: https://fwozen.com/llms-full.txt (also at https://api.fwozen.com/llms-full.txt) - App + auth discovery document: https://api.fwozen.com/.well-known/fwozen-app (also at https://fwozen.com/.well-known/fwozen-app) - Agent registration guide (auth.md convention): https://api.fwozen.com/auth.md (also at https://fwozen.com/auth.md) - Agent policy — what agents may do here: https://fwozen.com/agents - Agent skill index: https://fwozen.com/.well-known/skills/index.json → https://fwozen.com/skills/fwozen/SKILL.md What an agent must not decide: - Never start or lift a freeze on your own judgement. A freeze blocks every engineer in the organisation; a thaw unblocks a merge someone deliberately stopped. Both need an explicit human instruction, and `reason` is required. - Never enable repositories that a human did not name. - Never treat "the API was unreachable" as "not frozen". The gate fails closed on purpose. - Never report a branch as protected because enrollment succeeded. Read the verification fields. ## Facts - Pricing: Free $0 (5 private repos) / Team $29/mo / Business $99/mo. There is no Enterprise tier — that is the whole ladder, and the top of it is self-serve. Annual is 2 months free. No per-user charge on any plan, including Free. Unlimited private repos on every paid plan. - Scheduled and recurring freezes, the Slack app and the REST API are included in the FREE tier. Any workspace can install the Slack app; it is not listed in the Slack Marketplace. - The `fwozen/gate-action` CI step and `GET /v1/freeze-state` are on EVERY plan, Free included, and a Free freeze can carry `blocksDeploys`, so a Free pipeline already fails closed. What starts at Team ($29/mo) is `fwozen/deploy-gate`, the second required check you switch on per branch, alongside per-PR exceptions and unlimited private repos. - Permissions Fwozen ASKS FOR, on the REPOSITORY and ORGANISATION axis — eight: checks:write, statuses:write, pull_requests:read, contents:read, metadata:read, merge_queues:read, org members:read, administration:write. Fwozen never requests contents:write, and organisation administration is requested in neither direction — so an organisation ruleset and your organisation's settings stay out of reach. Installing grants every permission Fwozen asks for, repository administration included. An installation made before Fwozen asked for it keeps its old set until an organisation owner approves, and some never will. Every live installation grants at least seven of the eight. Repository administration:write is granted by every new installation, and by an older one when its owner accepts it; it exists to write the one ruleset that makes the fwozen check required, it can edit no rule Fwozen did not create, and no endpoint deletes a ruleset at all. The full inventory, including that row and where its rollout stands, is at https://fwozen.com/security. Where Fwozen is not writing that rule — an installation without the permission, a repository marked as managed as code, and organisation scope always — setup is one native GitHub rule that a person with that authority saves in GitHub's own settings; Fwozen shows the exact values and verifies afterwards. - On the ACCOUNT axis there is exactly ONE: email_addresses:read. It is a different axis and it is disclosed here so the count above is not read as the whole consent screen. It is granted by the individual who authorises at sign-in, not by the organisation that installs the App, it confers nothing over any repository, and it exists only so sign-in can identify a person by their verified email — GitHub returns no email at all for an account whose address is private. The sign-in request itself carries the `read:user` and `user:email` scopes, sent by better-auth against the GitHub App's own client id; the App's account permission set is the one row above. - Verification: for GitHub rulesets, Fwozen verifies whether its check is active, required, and pinned to the Fwozen App, and separately reports what GitHub exposed about bypass actors. Classic branch protection is guidance-only — the effective-rules endpoint reports rulesets rather than classic protection, and no classic read has been demonstrated against a live classic rule. Bypass assurance is usually `unknown`, because GitHub rarely discloses a ruleset's bypass list to the app reading it, and holding repository administration has not been measured to change that; missing evidence is never reported as "nobody can bypass this". - Merge-group freezing is implemented and unit-tested, but has not been demonstrated against a live GitHub merge queue. We claim the mechanism, not the proof. - No SAML single sign-on, no SCIM directory sync, no incident triggers, and no policy-as-code. A freeze starts because a person or a schedule started it. - GitHub Cloud only at launch. No GitLab, Bitbucket, or GitHub Enterprise Server. - There is a CLI and a GitHub Action. Install the CLI with `curl -fsSL https://fwozen.com/install | sh`: one file, Node 24 or newer required, no package manager, and it verifies a published sha256 before it writes anything — `https://fwozen.com/cli/release.json` restates that digest. It is still NOT published to npm, so `npx fwozen` and `npm i -g fwozen` do not work today; that installer and the source tree are the two ways in. The GitHub Action is what a pipeline calls for deploy gating, and CI should call the Action rather than pipe a script into a shell. - If Fwozen is unreachable, a check already written as failure keeps blocking; repos with no active freeze keep their passing check and merge normally. The reconciler converges state after recovery. - You can cancel in the app. No email required. ## Docs Every documentation slug is flat under `/docs/` — the group is navigation, not a path segment. - Install Fwozen: https://fwozen.com/docs/install - Your first freeze: https://fwozen.com/docs/first-freeze - When it does not block: https://fwozen.com/docs/troubleshooting - Scheduled and recurring freezes: https://fwozen.com/docs/schedules - Per-PR exceptions: https://fwozen.com/docs/exceptions - Gating deploys: https://fwozen.com/docs/deploy-gating - REST API: https://fwozen.com/docs/api - Error codes: https://fwozen.com/docs/api/errors - Slack commands: https://fwozen.com/docs/slack - The CLI: https://fwozen.com/docs/cli - Installing Fwozen across an enterprise: https://fwozen.com/docs/enterprise-bulk-install - Migrating from MergeFreeze: https://fwozen.com/docs/migrating-from-another-tool - Leaving Fwozen: https://fwozen.com/docs/uninstall - OpenAPI spec: https://api.fwozen.com/v1/openapi.json ## Comparisons and guides - how enforcement works: https://fwozen.com/how-it-works - vs GitHub rulesets: https://fwozen.com/vs/github-rulesets - what Fwozen is, and isn't: https://fwozen.com/gaps - Black Friday code freeze checklist: https://fwozen.com/guides/black-friday-code-freeze-checklist - scheduling GitHub branch protection: https://fwozen.com/guides/github-branch-protection-schedule ## Contact - security@fwozen.com · hello@fwozen.com · https://status.fwozen.com # Documentation, in full Every page below is the marketing site's own text. Links inside are site-relative to https://fwozen.com. --- https://fwozen.com/docs/install # Install Fwozen Install the GitHub App, pick the repositories Fwozen watches, and make the fwozen check required on the branch you care about — in one click where your installation has granted it, or one saved GitHub rule where it has not. Fwozen blocks merges with a required GitHub check run named `fwozen`. Installing takes about ninety seconds; making the check required is the step that turns a red X into an actual block, so this page spends most of its words there. ## Before you start - GitHub Cloud. GitHub Enterprise Server, GitLab, and Bitbucket are not supported yet. - A GitHub organisation owner installs the app, because Fwozen requests one organisation permission (`members: read`) and GitHub requires an owner for that. - Someone who can save a ruleset for the enforcement step: an organisation owner, or a repository admin on the per-repository path. Fwozen asks for no organisation `administration` in either direction, so enforcement at organisation scope is always a rule you save. On the per-repository path it can write the rule itself, but only on an installation that has granted it repository `administration: write` — /security (/security) says where that stands. - A GitHub plan and repository visibility that support rulesets or protected branches. Where GitHub offers neither, Fwozen reports setup as unavailable and says why, rather than showing you a button that cannot work. - Rules already in Terraform? Mark those repositories as managed as code before step 4, on the repository’s page in Fwozen. Fwozen then writes nothing to them and shows you the values for your own module instead, so the next `terraform plan` has no drift to report. ## 1. Install the GitHub App Choose All repositories unless you have a reason not to. Selected repositories is fine too. Fwozen only counts private repos you explicitly enable, so a wide installation does not cost you anything. ## 2. Enable the repositories you care about Fwozen reads your repository list and shows open pull request counts and last-push times, so you can tell which repositories are alive. Enable them individually, in bulk, or with a glob: ```text services/** matches 14 repositories *-api matches 3 repositories !archived-* excludes archived work ``` ## 3. Watch a branch Start with one branch, usually `main` on the repository with the most open pull requests. Watching a branch is what tells Fwozen where to write the check and whose rules to read. ## 4. Make the fwozen check required This is the make-or-break step. Until `fwozen` is a required status check on the branch, a freeze is advisory. The check will fail, and anyone with merge rights can merge anyway. On an installation that has granted Fwozen repository `administration: write`, setup offers a button. Fwozen writes the repository ruleset itself, on the branch you enrolled, and then reads it back from GitHub before anything in the product or in your audit log says the rule exists. A queued write says queued until that read-back returns, and a failed one says which of the four ways it failed. It writes nothing to a repository you have marked as managed as code. Everywhere else — an installation whose owner has not approved that permission, a repository you manage as code, and organisation scope, which Fwozen asks for no administration over — the sequence is: - Fwozen writes the current policy check. Before it offers you anything to click, it evaluates the real verdict and writes it to every enforcement-relevant commit: each watched branch tip, the head of every open pull request targeting it, and every live merge-group head. That is also what makes `fwozen` appear in GitHub’s required-check picker, which only lists checks it has seen recently. - Fwozen opens the right GitHub settings. One organisation ruleset covering every selected repository where your plan and role allow it, otherwise one repository ruleset per repository. Either way it carries the exact rule name, target branches, context `fwozen`, the Fwozen app to pin it to, and the recommendation to leave the bypass list empty. It shows how many saves are still needed; on the per-repository path that is one per repository, because that is GitHub’s boundary. - You save one native rule. In GitHub, as yourself. GitHub’s audit log shows a human, because a human did it. - Fwozen verifies the ruleset. When you come back it re-reads GitHub’s effective rules for the branch and tells you what it found, including when it could not determine the answer. Verify again is there for the retry. If a correct ruleset already exists, from a reinstall or from a rule someone made earlier, setup finishes with zero saves. If one exists but is wrong, Fwozen opens that rule’s edit page with the mismatch named, rather than talking you into a duplicate. Managing rules as code? The same plan is available as `gh api` and Terraform, as an alternative rather than the normal path. Fwozen detects the rule within about thirty seconds either way. Both samples pin the check to Fwozen’s GitHub App id, which is published, is not a secret, and is the difference between a verified rule and one Fwozen reports as needing attention: Require the fwozen check with gh ```bash FWOZEN_APP_ID=$(curl -fsSL https://api.fwozen.com/.well-known/fwozen-app \ | jq -r .githubAppId) gh api -X POST repos/acme/api-server/rulesets \ -f name='fwozen' \ -f target='branch' \ -f enforcement='active' \ -F conditions[ref_name][include][]='refs/heads/main' \ -F rules[][type]='required_status_checks' \ -F 'rules[][parameters][required_status_checks][][context]=fwozen' \ -F "rules[][parameters][required_status_checks][][integration_id]=$FWOZEN_APP_ID" ``` The Terraform is the same object. Signed in, the enforcement step generates this block for the branch you enrolled with the app id already filled in; here it is as a shape you can read before you install anything: github_repository_ruleset ```text resource "github_repository_ruleset" "fwozen_main" { name = "fwozen" repository = "api-server" target = "branch" enforcement = "ACTIVE" conditions { ref_name { include = ["refs/heads/main"] exclude = [] } } rules { required_status_checks { strict_required_status_checks_policy = false required_check { context = "fwozen" integration_id = var.fwozen_app_id } } } } ``` NOTE — Two things that quietly break this Bypass actors. A ruleset with `bypass_actors` lets those actors merge straight through the freeze. Leave the list empty. Fwozen names the actors GitHub discloses to it, and says so plainly when GitHub discloses nothing. GitHub rarely discloses a ruleset’s bypass list to the app reading it, and holding repository Administration has not been measured to change that, so we do not turn missing evidence into “nobody can bypass this”. Pinning. If the required check is not pinned to Fwozen’s app id, any GitHub App can satisfy it. Fwozen calls this out as needing attention rather than verified. Both samples above pin it, and the button does too. ## 5. Verify it would block a merge Fwozen reads GitHub’s effective rules for each watched branch and reports what it found, including when it could not determine the answer, with a badge saying how long ago it last looked. A verified result means the `fwozen` check is active, required, and pinned to the Fwozen app on that branch. It does not mean nobody can merge. Bypass actors are a separate question, and one GitHub usually will not answer at all. Classic branch protection is guidance only. The effective-rules endpoint Fwozen reads on every branch reports rulesets rather than classic protection, and we have not demonstrated a classic read against a live classic rule, so Fwozen tells you what to configure and then says the result is unknown rather than showing you a green light it has not earned. ## If fwozen is not in GitHub’s dropdown GitHub only lists checks it has seen complete recently, and Fwozen writes the real current verdict to every enforcement-relevant commit before offering you the GitHub step, which is normally what puts `fwozen` in the picker. The rest of that answer, and the other three things that go wrong at this step, are on troubleshooting (/docs/troubleshooting). Next: your first freeze (/docs/first-freeze). --- https://fwozen.com/docs/first-freeze # Your first freeze Freeze a branch for two minutes, watch the fwozen check land on every open pull request, and let it thaw itself. The safest way to see Fwozen work is a two-minute freeze on a real branch. It lifts itself, so the worst case is that a few pull requests wait two minutes. ## Freeze it Three equivalent ways, and all three end up in the same audit log with a real actor. Start with the first one. ### From the dashboard Open Freezes. The Quick freeze band at the top is four buttons: freeze every enabled repository or one of them, merges or deploys. One click each, no dialog. A quick freeze holds until somebody lifts it, and Lift on the freeze underneath is the undo. For a freeze that ends on its own, use Scheduled or scoped freeze… instead and set a duration; the impact preview there tells you how many repositories and open pull requests are about to be affected before you commit. ### From the API You need a key first: Settings → API keys → Create key, tick `freeze:write`, and copy the secret, which the dashboard shows once. Export it as `FWOZEN_TOKEN`. The branch id comes from `GET /v1/branches`, which lists every branch you watch: Find the branch you want to freeze ```bash curl -s https://api.fwozen.com/v1/branches \ -H "Authorization: Bearer $FWOZEN_TOKEN" # => { "data": [ { "id": "wbr_7Fq2Kd9mXbT4hLwR", # "repoFullName": "acme/api-server", "branch": "main", … } ] } ``` Freeze that branch for two minutes ```bash curl -X POST https://api.fwozen.com/v1/freezes \ -H "Authorization: Bearer $FWOZEN_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "scope": { "kind": "branches", "branchIds": ["wbr_7Fq2Kd9mXbT4hLwR"] }, "reason": "Testing Fwozen", "durationMinutes": 2 }' ``` The `Content-Type` header is not optional: `curl -d` declares a form body on its own, and the API answers `400 unsupported_content_type` to that. An org-wide or glob scope needs one more field, because a freeze that size cannot be created blind; see impact_preview_required (/docs/api/errors#impact_preview_required). ### From the CLI The same freeze, from a terminal ```bash fwozen freeze acme/api-server --branch main \ --duration 2m --reason "Testing Fwozen" ``` The CLI page (/docs/cli) covers installing it and signing in. The Slack app takes the same freeze as `/fwozen freeze api-server 2h "4.2 cut"`, and it is free on every plan. ## What happens next - Fwozen writes the `fwozen` check to the head commit of every open pull request in scope, riskiest first: anything queued, on auto-merge, or already approved and green. You can watch the fan-out complete in the dashboard. We are not publishing a propagation time until the load test has run and we have a measured number to publish. - Each pull request shows the check as failing, with your reason verbatim: Frozen by @you · “Testing Fwozen” · thaws in 1m 48s. - Any pull request already in a merge queue has its live merge-group head failed, so it cannot merge behind the freeze. - At expiry the freeze lifts itself, the checks flip to passing, and merges flow again. NOTE — No open pull requests? That is not an error. There was nothing to write to, so nothing was written. But the freeze is still in force, and the next pull request opened against that branch is blocked as soon as its check lands. ## Thawing Every freeze can be lifted from the dashboard, Slack, or the API, and lifting is never blocked by a failed billing state or an in-flight fan-out. A freeze you cannot lift is a worse bug than a freeze that did not apply. --- https://fwozen.com/docs/troubleshooting # When it does not block The four things that go wrong: fwozen missing from the required-checks dropdown, a live freeze that does not block, a check stuck red after a thaw, and a merge that went through anyway. Four things go wrong often enough to have a heading. Each one is a symptom, not a cause, because a symptom is what you have when you arrive here. ## fwozen is not in GitHub’s required-checks dropdown GitHub only offers checks it has seen complete recently. Fwozen writes the real current verdict to every enforcement-relevant commit before it offers you the GitHub step, which is normally what puts `fwozen` in the picker, and it re-writes quiet repositories on a timer. The case where it still will not appear: you are enrolling a branch that is already frozen. The honest result then is a failing check, and GitHub may decline to offer one it has never seen succeed. Fwozen will not post a fake success to get around that. Lift the freeze, wait for the check to land green on an open pull request, then add it. The target stays visibly pending in the meantime and retries itself after the thaw. ## A freeze is live but pull requests are still mergeable The freeze and the block are two different things. Fwozen writes a failing check; GitHub stops the merge only if that check is required on the branch. Until it is, a freeze is advisory: red, visible, ignorable. - Open the branch in the dashboard and read its enforcement state. Only enforcing asserts anything; anything else names what is missing. - Bypass actors. A ruleset with a non-empty bypass list lets those actors merge straight through. Fwozen names the actors GitHub discloses and says so plainly when GitHub discloses nothing, which is most of the time. - Classic branch protection. The effective-rules endpoint reports rulesets rather than classic protection, so Fwozen reports the result as unknown rather than showing you a green light it has not earned. Move the branch to a ruleset. ## The check is still failing after the freeze ended Ending a freeze re-queues every target and rewrites each check to success. If one is still red, the fan-out has not reached that commit yet: the freeze page shows the targets and their state, and Retry re-queues the ones that failed. Pushing a new commit also produces a fresh check. If GitHub is rate-limiting us, the dashboard says so rather than pretending the write happened. ## A merge went through during a freeze The audit log is where this starts: it names the actor, the source and the reason for every freeze, thaw and exception, so the first question is whether the freeze was actually in force at that moment and whether an exception was granted for that pull request. - An exception on that pull request lets exactly it through, with a reason and a name. - A bypass actor on the ruleset merges through anything. This is the common answer, and it is invisible to us when GitHub does not disclose the list. - A pull request already in a merge queue is handled: Fwozen fails the live merge-group head. That mechanism is implemented and unit-tested, and has not been demonstrated against a live GitHub merge queue, so if you see one slip we want the details at support@fwozen.com. - The check was never required on that branch. Back to the section above. Nothing here matching? Support (/support) lists what we answer and how fast. If it is an API response, quote the `requestId` from the error envelope and we can find the exact request. --- https://fwozen.com/docs/schedules # Scheduled and recurring freezes Build a recurring release window in the dashboard or through the API, and know which claim wins when a schedule and a manual freeze overlap. A schedule is a recurring or one-off freeze window defined in a real timezone. A Friday 17:00 → Monday 09:00 window ends at 09:00 local even when the clocks change in between, because the end of a window is a calendar offset on the wall clock rather than a duration in seconds. That distinction is the entire reason this feature is careful. Scheduled and recurring freezes are in the free tier. ## Build one in the dashboard - Schedules → New schedule. Nothing is written until you save. - Name and reason. The reason is copied onto every freeze the window creates, so write the one you want to read in the audit log in March. Eight characters minimum. - Scope. Everything, a set of repositories, a glob, or named branches. The same picker the freeze composer uses. - Timezone. An IANA name, defaulting to your organisation’s. This is the zone the wall clock below is read in, and it is the field that makes the DST rule work. - Recurrence. Daily, weekly, monthly by date, or one-off. Weekly asks for days of the week; monthly asks for days of the month, where −1 means the last day. - Start time, end time, end day. End day is calendar days after the start date: 0 for a window that closes the same evening, 3 for Friday to Monday. - Read the panel on the right. It shows the next five occurrences in both the schedule’s timezone and yours, computed by the same code that will enqueue them, and it says when an occurrence crosses a daylight-saving change. Save stays disabled while those occurrences cannot be computed, because a schedule nobody can preview is a freeze nobody can predict. ## The same schedule from the API `POST /v1/schedules`. This is a Friday evening to Monday morning release freeze on every enabled repository’s default branch, in London time: A weekly release window ```bash curl -X POST https://api.fwozen.com/v1/schedules \ -H "Authorization: Bearer $FWOZEN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Weekend release freeze", "scope": { "kind": "org" }, "reason": "Weekend release freeze", "timezone": "Europe/London", "recurrence": "weekly", "daysOfWeek": [5], "startTimeLocal": "17:00", "endTimeLocal": "09:00", "endDayOffset": 3, "reminders": [15] }' ``` `daysOfWeek` is ISO, so 1 is Monday and 5 is Friday. `endDayOffset: 3` is what carries the window over the weekend, and `reminders` is minutes before it ends. `POST /v1/schedules/preview` takes the same body and answers with the occurrences without saving anything, which is what the panel in the dashboard calls. `$FWOZEN_TOKEN` is an API key with `schedule:write`, created at Settings → API keys. ## What wins when two things overlap Claims resolve by rank rather than by a priority number, and the first rank with a live claim wins: an exception on a pull request, then a manual freeze, then a thaw override, then a scheduled freeze, then open. So a manual freeze beats a schedule and beats a manual thaw, which is the fail-safe direction, and a thaw override cancels a scheduled window but only the freezes it names. Behaviour folds across the whole winning set, restrictively. One live claim that blocks deploys blocks deploys, one that forbids exceptions forbids them, the branch thaws at the latest end among the winners, and a tie resolves toward freezing. ## Skipping, running early, turning one off - Skip next drops the next occurrence and leaves the schedule alone (`POST /v1/schedules/{id}/skip-next`). - Run now starts the window early (`POST /v1/schedules/{id}/run-now`). - Enabled off keeps the schedule and plans nothing. - A freeze a schedule created is lifted like any other, from the freezes list. That ends the occurrence, not the schedule, and the next window still fires. --- https://fwozen.com/docs/exceptions # Per-PR exceptions Let a single pull request through a freeze with a reason, a time limit, and a record of who decided. The dashboard steps, the API call, and when a grant is refused. An exception lets one pull request through an active freeze without lifting it for everyone else. It requires a reason, it expires, and both the grant and the expiry land in the audit log with the actor who decided. Per-PR exceptions start at Team. ## Grant one in the dashboard - Exceptions → Grant an exception. - Pull request. Paste the URL, or `owner/repo#123`, or a bare number. Fwozen resolves it and shows which freeze it is about to cut through. - Reason. Eight characters minimum, and the field an auditor reads first. Name the incident or the fix rather than the fact that it is urgent. - Expires. One hour, four hours, until the freeze ends, or a custom number of minutes between 5 minutes and 7 days. - Policy override, when this one has to differ from the organisation default. Sticky survives new commits; strict revokes the exception the moment a new commit lands on the pull request. The `fwozen` check on that pull request flips to passing, and it alone. Every other pull request on the branch stays blocked. ## From the API Let one pull request through for an hour ```bash curl -X POST https://api.fwozen.com/v1/exceptions \ -H "Authorization: Bearer $FWOZEN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "repo": "acme/api-server", "prNumber": 4821, "reason": "Hotfix for the checkout 500s", "expiresInMinutes": 60 }' ``` Send `repoId` instead of `repo` if you hold Fwozen’s ids: one or the other, never both. Leave `expiresInMinutes` out and the exception ends with the freeze itself. The key needs `exception:write`. There is no `freezeId` field, on purpose: one grant covers every freeze currently blocking that pull request, so a repository caught by an org freeze and a release window at the same time does not need two. `POST /v1/exception-requests` is the other half. Someone without the authority to grant asks, an approver decides with `POST /v1/exception-requests/{id}/decide`, and the audit log carries both names. An organisation that turns on two-person approval gets that flow for every grant. ## Revoking, and when one cannot be granted - Revoke ends it immediately (`POST /v1/exceptions/{id}/revoke`), and the check goes red again on the next write. - A freeze created with exceptions turned off refuses every grant while it is in force, with exceptions_not_permitted (/docs/api/errors#exceptions_not_permitted). That is the setting to reach for during a real incident, rather than a sterner reason string. - An exception is about merging. It never unblocks a deploy: the deploy gate reads the freeze, and letting one pull request merge is not a freeze-wide decision. --- https://fwozen.com/docs/deploy-gating # Gating deploys The fwozen/deploy-gate check, the published Action, and the freeze-state endpoint your pipeline calls. The gate refuses on a requested deployment freeze. Your pipeline enforces a requested deployment freeze by asking Fwozen, through the `fwozen/gate-action` (https://github.com/Fwozen/gate-action) step or an endpoint any pipeline can poll. The gate refuses the deploy when your workflow calls it. Fwozen does not reach into GitHub and stop a deployment on its own, and this is not GitHub’s native deployment protection rules, which need Enterprise for private repositories. The trade is that it works on every GitHub plan, and the cost is that a deploy job which never calls the gate is never gated. .github/workflows/deploy.yml ```yaml - uses: fwozen/gate-action@v1 with: token: ${{ secrets.FWOZEN_TOKEN }} ``` Put it as the first step of every deploy job. A job without it is not gated. The gate fails closed: a timeout, a non-2xx, or a missing `Fwozen-Frozen` header all count as frozen. A deploy gate that fails open is not a gate. `secrets.FWOZEN_TOKEN` is an API key with the `freeze:read` scope. Create it at Settings → API keys → Create key in the dashboard, copy the secret, which is shown once, and paste it into the repository or organisation secret of that name. Outside GitHub Actions, poll the same endpoint from any CI system: Any CI system ```bash curl -s https://api.fwozen.com/v1/freeze-state?repo=acme/api-server \ -H "Authorization: Bearer $FWOZEN_TOKEN" # => Fwozen-Frozen: true (header — the deploy decision) # => { "frozen": true, "allowDeployments": false, "reason": "4.2 release cut", # "endsAt": "2026-08-01T09:00:00Z" } ``` The `fwozen/gate-action` step and `GET /v1/freeze-state` run on every plan, Free included. What starts at Team, $29/mo, is `fwozen/deploy-gate`, the second required check you switch on per branch so a pull request shows the deploy verdict too. There is no per-environment gating yet: the answer is per repository and branch, so a pipeline that deploys two environments from one branch gets the same verdict for both. --- https://fwozen.com/docs/api # REST API Get a key, call the API, connect an MCP client, and read every public route. Generated from the same route table the OpenAPI document is generated from. The REST API is in the free tier, rate-limited, with scoped tokens and a published OpenAPI document. Everything the dashboard does, the API does: it is the same `/v1` routes the dashboard calls, authenticated with an `fwz_` key instead of a session. There is no second code path. - The rendered reference (/docs/api/reference) — every operation with its request and response schema. - Error codes (/docs/api/errors) — what each `code` in the envelope means, and what to do about it. Every non-2xx body carries a `docsUrl` pointing at its own anchor there. - openapi.json (https://api.fwozen.com/v1/openapi.json) — the document both of the above are built from. ## Get a key - Sign in and open Settings → API keys (/app/settings/api-keys). Minting a key needs the owner or admin role. - Create key, give it a name, and tick the narrowest scopes that do the job: `freeze:read` for a deploy gate, `freeze:write` to freeze and thaw. Scopes are fixed at creation and can never be widened. - Pick an expiry. A key declared as an agent key must expire within 90 days. - Copy the secret. It is shown once. Then `export FWOZEN_TOKEN=fwz_live_…` and send it as `Authorization: Bearer $FWOZEN_TOKEN`. A key is organisation-wide and never user-scoped, so the scope list is the authorization. Two routes it can never call are minting another key and setting enforcement up: both are session-only and answer `403 credential_type_not_allowed`, never a 401, so a script can tell the two apart. Rotating a key issues a new secret and keeps the old one working for a grace window, which is what makes a rotation something you can roll through CI without a deploy. ## Calling it Is this repository frozen? ```bash curl -s "https://api.fwozen.com/v1/freeze-state?repo=acme/api-server" \ -H "Authorization: Bearer $FWOZEN_TOKEN" ``` - JSON in, JSON out. Send `Content-Type: application/json` on anything with a body. `curl -d` declares a form body on its own and the API answers `400 unsupported_content_type`. - Idempotency. `Idempotency-Key` on a write makes a retry safe. The same key with a different body is a `409`, so use one key per intent. - Pagination. Cursor-based. Follow the cursor the response gives you rather than building one. - Rate limits. Read `RateLimit-Remaining` and `RateLimit-Reset` off every credentialled response and honour `Retry-After` on a 429. No ceiling is published here, because the numbers are operational settings and a figure in prose would be a promise nobody made. - Unknown fields. `/v1` is additive-only: new response fields ship without notice, so ignore the ones you do not know and treat an unfamiliar enum value as opaque. ## Connect an MCP client The same routes are a streamable-HTTP MCP server at `https://api.fwozen.com/mcp`. It takes the same two credentials the REST API does, and there is no MCP-specific token to go looking for. The header path is the one that works everywhere: Claude Code, with an API key ```bash claude mcp add --transport http fwozen https://api.fwozen.com/mcp \ --header "Authorization: Bearer $FWOZEN_TOKEN" ``` Clients that read a config file take the same two lines: mcpServers ```json { "mcpServers": { "fwozen": { "type": "http", "url": "https://api.fwozen.com/mcp", "headers": { "Authorization": "Bearer fwz_live_…" } } } } ``` There is an OAuth path too, for clients that support client id metadata documents: point the client at the same URL with no header and it will be sent to the consent page. Dynamic client registration is deliberately not offered, so a client that can only do DCR has to use the header instead. A request with no credential answers 401 with a `WWW-Authenticate` header naming `/.well-known/oauth-protected-resource`, which is the discovery chain an MCP client follows on its own. The tools are thin adapters over the routes below and carry the same authorization: a key that cannot call a route cannot call its tool. Reads and writes are never mixed in one tool, `fwozen_enable_repos` takes an explicit repository list, and `fwozen_end_freeze` is marked destructive and refuses without `confirm: true`. ## Every public route Generated from the same route table the OpenAPI document and llms-full.txt (/llms-full.txt) are generated from. auth says which credential a route takes: `key` is an `fwz_` API key, `session` is a signed-in session or a device-flow token, `both` is either, and `none` takes no credential at all. Where scopes are listed, a key needs any one of them. ### Organizations - `GET /v1/me` — Current user and organization auth: session - `GET /v1/me/organizations` — Organizations the current user belongs to auth: session - `GET /v1/onboarding` — Onboarding state auth: session - `POST /v1/onboarding/bootstrap` — Create the organization for the signed-in user (idempotent) auth: session - `GET /v1/github/install-url` — GitHub App installation URL carrying a signed state nonce auth: session - `GET /v1/installations/status` — Poll a GitHub App installation minted by GET /v1/github/install-url auth: both · scopes: repo:read - `POST /v1/installations/claim` — Claim a GitHub App installation for this organization auth: session - `GET /v1/orgs/current` — Get the organization auth: both · scopes: repo:read - `PATCH /v1/orgs/current` — Update organization settings auth: session - `GET /v1/members` — List members auth: session - `PATCH /v1/members/{userId}` — Change a member's role auth: session - `DELETE /v1/members/{userId}` — Remove a member auth: session - `GET /v1/invitations` — List invitations auth: session - `POST /v1/invitations` — Invite a member auth: session - `DELETE /v1/invitations/{invitationId}` — Revoke an invitation auth: session - `GET /v1/invitations/pending` — Invitations addressed to you auth: session - `GET /v1/invitations/preview` — Read an invitation from its token auth: session - `POST /v1/invitations/{invitationId}/accept` — Accept an invitation auth: session - `GET /v1/invitations/{invitationId}/link` — Mint the accept link for a pending invitation auth: session - `GET /v1/overview` — Dashboard rollup auth: both · scopes: freeze:read - `GET /v1/attention` — Things needing attention auth: both · scopes: freeze:read ### Repositories - `GET /v1/repos` — List repositories auth: both · scopes: repo:read - `POST /v1/repos/sync` — Re-sync the repository mirror from GitHub auth: both · scopes: repo:write - `POST /v1/repos/enable` — Enable repositories auth: both · scopes: repo:write - `POST /v1/repos/disable` — Disable repositories auth: both · scopes: repo:write - `POST /v1/repos/preview-glob` — Preview which repositories a glob matches auth: both · scopes: repo:read - `GET /v1/repos/{repoId}` — Get a repository auth: both · scopes: repo:read - `PATCH /v1/repos/{repoId}` — Update repository settings auth: both · scopes: repo:write - `GET /v1/repos/{repoId}/pulls` — List tracked pull requests of a repository auth: both · scopes: freeze:read - `GET /v1/pulls/lookup` — Resolve a PR from a URL, owner/repo#123, or a number auth: both · scopes: freeze:read ### Branches - `GET /v1/repos/{repoId}/branches` — List watched branches of a repository auth: both · scopes: repo:read - `GET /v1/repos/{repoId}/remote-branches` — List branches live from GitHub (cached 60s, budget-gated) auth: both · scopes: repo:read - `POST /v1/repos/{repoId}/branches` — Watch a branch auth: both · scopes: repo:write - `GET /v1/branches` — List watched branches auth: both · scopes: repo:read - `GET /v1/branches/{branchId}` — Get a watched branch auth: both · scopes: repo:read - `PATCH /v1/branches/{branchId}` — Update watched-branch settings auth: both · scopes: repo:write - `DELETE /v1/branches/{branchId}` — Stop watching a branch auth: both · scopes: repo:write ### Enforcement - `GET /v1/enforcement` — Enforcement traffic light across the organization auth: both · scopes: repo:read - `GET /v1/enforcement/branches/{branchId}` — Enforcement detail for a branch auth: both · scopes: repo:read - `GET /v1/enforcement/branches/{branchId}/snippets` — gh CLI, Terraform and ruleset JSON snippets for a branch auth: both · scopes: repo:read - `POST /v1/enforcement/verify` — Queue an enforcement probe auth: both · scopes: repo:write - `POST /v1/enforcement/setup` — Plan the native GitHub rule, or have Fwozen apply it auth: session - `POST /v1/enforcement/seed-check` — Write the current fwozen verdict to every enforcement-relevant head auth: session ### Freezes - `POST /v1/scope/preview` — Resolve a freeze scope without creating a freeze auth: both · scopes: freeze:read - `POST /v1/freezes` — Start a freeze auth: both · scopes: freeze:write - `GET /v1/freezes` — List freezes auth: both · scopes: freeze:read - `POST /v1/freezes/lift` — Lift every freeze matching a scope auth: both · scopes: freeze:write - `GET /v1/freezes/{freezeId}` — Get a freeze auth: both · scopes: freeze:read - `GET /v1/freezes/{freezeId}/targets` — List the materialized targets of a freeze auth: both · scopes: freeze:read - `POST /v1/freezes/{freezeId}/lift` — Lift a freeze auth: both · scopes: freeze:write - `POST /v1/freezes/{freezeId}/extend` — Extend a freeze auth: both · scopes: freeze:write - `POST /v1/freezes/{freezeId}/thaw-override` — Unfreeze inside a scheduled window, suppressing only that freeze auth: both · scopes: freeze:write - `POST /v1/freezes/{freezeId}/retry` — Retry failed fan-out targets auth: both · scopes: freeze:write - `GET /v1/freeze-state` — Is this repository, branch or organization frozen? auth: both · scopes: freeze:read ### Schedules - `GET /v1/schedules` — List schedules auth: both · scopes: schedule:read - `POST /v1/schedules` — Create a schedule auth: both · scopes: schedule:write - `POST /v1/schedules/preview` — Preview the occurrences of an unsaved schedule draft auth: both · scopes: schedule:read - `GET /v1/schedules/occurrences` — List planned and past occurrences in a time range auth: both · scopes: schedule:read - `GET /v1/schedules/calendar` — Occurrences grouped by day for one month auth: both · scopes: schedule:read - `POST /v1/schedules/import` — Import a holiday calendar as schedules auth: session - `GET /v1/schedules/{scheduleId}` — Get a schedule auth: both · scopes: schedule:read - `PATCH /v1/schedules/{scheduleId}` — Update a schedule and re-materialize its horizon auth: both · scopes: schedule:write - `DELETE /v1/schedules/{scheduleId}` — Delete a schedule auth: both · scopes: schedule:write - `GET /v1/schedules/{scheduleId}/preview` — Preview the next occurrences of a saved schedule auth: both · scopes: schedule:read - `POST /v1/schedules/{scheduleId}/skip-next` — Skip the next planned occurrence auth: both · scopes: schedule:write - `POST /v1/schedules/{scheduleId}/run-now` — Apply or lift a schedule's window immediately auth: session ### Exceptions - `GET /v1/exceptions` — List exceptions auth: both · scopes: exception:read - `POST /v1/exceptions` — Grant a per-PR exception (one row per covering freeze) auth: both · scopes: exception:write · requires the pr_exceptions entitlement - `GET /v1/exceptions/{exceptionId}` — Get an exception auth: both · scopes: exception:read - `POST /v1/exceptions/{exceptionId}/revoke` — Revoke an exception (never entitlement-gated) auth: both · scopes: exception:write - `GET /v1/exception-requests` — List pending exception requests auth: both · scopes: exception:read - `POST /v1/exception-requests` — Request an exception for a pull request auth: both · scopes: exception:write · requires the pr_exceptions entitlement - `POST /v1/exception-requests/{exceptionId}/decide` — Grant or deny an exception request auth: both · scopes: exception:write · requires the pr_exceptions entitlement ### Audit - `GET /v1/audit` — List audit entries (read-time retention filter) auth: both · scopes: audit:read - `GET /v1/audit/export` — Stream the audit log as CSV (hard cap 500 000 rows) auth: both · scopes: audit:read · requires the audit_export entitlement - `GET /v1/audit/actions` — The audit action vocabulary auth: both · scopes: audit:read ### API keys - `GET /v1/api-keys/scopes` — The API key scope vocabulary auth: session - `GET /v1/api-keys` — List API keys auth: session - `POST /v1/api-keys` — Create an API key auth: session - `POST /v1/api-keys/{apiKeyId}/rotate` — Rotate an API key with a grace window auth: session - `DELETE /v1/api-keys/{apiKeyId}` — Revoke an API key auth: session ### Billing - `GET /v1/plans` — The plan catalog auth: none - `GET /v1/billing` — Billing summary auth: both · scopes: billing:read - `GET /v1/billing/prices` — Price catalog auth: session - `POST /v1/billing/checkout` — Create a Stripe Checkout session auth: both · scopes: billing:write - `POST /v1/billing/portal` — Create a Stripe Customer Portal session auth: both · scopes: billing:write - `POST /v1/billing/trial` — Start the 14-day no-card Business trial auth: both · scopes: billing:write - `GET /v1/entitlements` — Resolved entitlements, limits and usage auth: both · scopes: repo:read, billing:read --- https://fwozen.com/docs/api/errors # Error codes The error envelope, and what every code in it means and what to do about it. The docsUrl on any non-2xx response points at its own anchor here. Every non-2xx response carries the same envelope, and the `docsUrl` in it points at the anchor on this page for its `code`. The `code` is the field to branch on: it is stable, it is narrower than the HTTP status, and it is the only part of the body written for a machine. The envelope ```json { "error": { "type": "validation_failed", "code": "impact_preview_required", "message": "An org-wide or pattern-matched freeze needs its blast radius resolved first.", "details": [{ "path": "previewedRepoCount", "message": "required for an org or glob scope" }], "docsUrl": "https://fwozen.com/docs/api/errors#impact_preview_required", "requestId": "req_9tKQ2mF7bX4vZnRs" } } ``` - `type` is the coarse family and follows the status. `code` is the specific one. New codes ship without notice, so fall back to `type` for one you do not recognise rather than failing. - `details` is present on validation failures, one row per field, each with a path. - `requestId` identifies that exact request in our logs. Quote it to support@fwozen.com and we can find it. - The compatibility namespace is the one exception: it answers with the older `{ "error": "message" }` shape plus an additive `code` and `request_id`, so a migrated script’s error handling keeps working. ## 400 invalid_request ### malformed_json The body did not parse as JSON. Check the quoting. A shell eats single quotes inside a single-quoted argument, so put the body in a file and send `-d @body.json`. ### missing_parameter A required query or body parameter is not there. The `message` names it. ### invalid_parameter A parameter arrived with the wrong type or a value outside its range. Read `error.details`: each row carries the field path and what was wrong with it. Inside a validated request body the same code answers 422. ### invalid_cursor The `cursor` is not one this endpoint issued. Cursors are opaque and endpoint-specific. Start the listing again with no cursor rather than editing one. ### unsupported_content_type The request declared a body type this endpoint does not accept. Send `Content-Type: application/json`. `curl -d` declares a form body unless you add that header, which is the single most common first failure against this API. ### unknown_api_version Reserved for a second API version. Nothing emits this today. `/v1` is the only version and it is permanent. ### compat_feature_removed The compatibility namespace carries the route, but the behaviour behind it is deliberately not reproduced. Only `POST /api/compat/v1/generate_access_token` answers this. Create keys at /app/settings/api-keys; a key can never mint another key. ## 401 unauthorized ### missing_credentials No `Authorization` header, or one this endpoint cannot read. Send `Authorization: Bearer fwz_…` or a session JWT. Create a key at /app/settings/api-keys. The compatibility namespace takes `fwz_` keys only. ### invalid_token The credential was read and rejected: an unknown key, a revoked one, or a JWT that does not verify. Check the key is the one you minted and has not been revoked or rotated past its grace window. ### token_expired The credential is past its expiry: an API key that has reached the date it was given, or a session JWT that has aged out. Mint a fresh JWT at `GET /api/auth/token` on the web origin with your session token, and cache it until it expires rather than per request. An expired key cannot be extended; create another at /app/settings/api-keys. ### invalid_signature A signed payload did not verify. GitHub webhooks and Slack requests authenticate this way. Check the shared secret, and that no proxy rewrote the body before it reached Fwozen. ## 403 forbidden ### insufficient_permission Your role in the organisation does not carry this permission. Ask an owner or an admin. Roles are set in organisation settings. ### insufficient_scope The API key does not carry a scope this route requires. Scopes are fixed at creation and cannot be widened. Mint a new key with the scope at /app/settings/api-keys. ### credential_type_not_allowed The route takes a session credential and got an `fwz_` key. It is never a 401, so a script can tell the two apart. Minting keys and setting enforcement up are session-only. Sign in, or use the device flow, and call it with the JWT. ### feature_not_available The organisation’s plan does not include this feature. `GET /v1/entitlements` lists what the plan carries. ### github_permission_denied GitHub refused a call Fwozen made for you. Usually the installation does not cover that repository any more, or the permission it needs was never granted. Re-check the installation on GitHub. ### organization_not_provisioned The credential is valid but resolves to no Fwozen organisation. `POST /v1/onboarding/bootstrap` creates one for the signed-in user, and is idempotent. ### no_organization_selected The session belongs to more than one organisation and none is active. `POST /api/auth/org/set-active` with the organisation id, then fetch a fresh token. ### impersonation_read_only A support session is reading your organisation. Support sessions cannot write. Nothing to do. It is the guarantee, not the bug. ## 404 not_found ### not_found No such object in this organisation. Another organisation’s id answers 404 rather than 403, on purpose: a 403 would confirm the id exists. ### repository_not_found No repository with that id or `owner/name` in this organisation. `GET /v1/repos` lists what Fwozen can see. A repository outside the installation is not visible at all. ### branch_not_found That branch is not watched. `GET /v1/branches` lists the watched branches and their `wbr_` ids. `POST /v1/repos/{repoId}/branches` starts watching one. ### freeze_not_found No freeze with that id. A freeze that ended keeps its id and still reads; check the id itself. ### schedule_not_found No schedule with that id. `GET /v1/schedules` lists them. ### occurrence_not_found No occurrence with that id on that schedule. Occurrences are generated ahead of time. List the schedule’s occurrences and use an id from the response. ### exception_not_found No exception with that id. An exception that expired keeps its id. Check the id itself. ### api_key_not_found No API key with that id in this organisation. Keys are organisation-scoped, so another organisation’s key id reads as absent. ### member_not_found That user is not a member of this organisation. `GET /v1/members` lists them. ### invitation_not_found No pending invitation with that id. An accepted or revoked invitation is gone from this route. ### installation_not_found No GitHub App installation with that id belongs to this organisation. `GET /v1/installations/status` resolves an install that has just landed. ### pull_request_not_found Fwozen has no state for that pull request. It sees pull requests on watched branches of enabled repositories. Check the repository is enabled and the base branch watched. ## 409 conflict ### invalid_state The object exists but is not in a state where this call means anything, such as revoking a key that is already revoked. Read the object first and branch on what it says. ### already_frozen The branch is frozen, and what you asked for would strand the check. Un-watching a frozen branch answers this. Lift the freeze first. Leaving a watched branch behind with a failing check run and nothing left to clear it is the one state the API refuses to create. ### not_frozen There is nothing to lift. Treat it as success in a retry loop. A thaw that finds nothing frozen has already got what it wanted. ### billing_source_conflict The organisation is already billed through another source, such as the GitHub Marketplace. Cancel at the source that owns the subscription before subscribing directly. ### slack_already_connected A Slack workspace is already connected to this organisation. Disconnect the existing workspace first. ### schedule_overlap The schedule collides with one that already exists. The response names the schedule it collided with. Widen one window or delete the other. ### idempotency_key_reuse That `Idempotency-Key` was already used with a different body, or an identical request is still in flight. One key per logical request. Generate a new one for a new freeze, and reuse the same one only when retrying the same call. ### exceptions_not_permitted This freeze was created with exceptions turned off. Nobody can grant one while it is in force. Lift the freeze, or wait for it to end. ### admin_authorization_required Writing the ruleset needs a one-time GitHub authorization from an admin on that repository. An admin opens the repository in the dashboard and runs the enforcement step once. ### installation_claimed_by_other_org That GitHub installation already belongs to a different Fwozen organisation. An installation belongs to one organisation. Uninstall and reinstall, or use the organisation that holds it. ### already_exists An object with that name or natural key is already there. Read it and patch it instead of creating a second one. ### trial_already_used You have had your no-card trial. It is once per person, not once per organisation. Making another organisation will not earn a second one. Subscribe, or stay on Free. ## 410 gone ### organization_deleted The organisation was deleted. Data is purged 30 days after deletion. Anything still holding a credential for it should stop. ### installation_removed The GitHub App installation was removed. Install the App again. Freezes ended when it went; the rulesets it wrote stayed in GitHub. ## 413 payload_too_large ### export_too_large The audit range holds more rows than one export may carry. The message names the row count and the cap. Narrow `from`/`to` and export again. ### too_many_targets The scope resolves to more targets than one request may carry. Split the scope, or freeze the organisation rather than enumerating it. ## 422 validation_failed ### validation_failed The body is well-formed JSON that does not match the schema. `error.details` carries one row per field, each with a path and a message. ### reason_required Every freeze carries a reason, and it has to be at least eight characters. Write what a colleague reading the audit log in March would need. `"4.2 release cut"` is enough; `"freeze"` is not. ### no_targets_in_scope The scope resolved to nothing, so there would be nothing to freeze. Enable a repository and watch a branch first. `POST /v1/scope/preview` shows what a scope resolves to before you commit to it. ### invalid_timezone The timezone is not an IANA name. Use `Europe/London`, not `GMT` or `BST`. The whole point of the name is that it carries the daylight-saving rules. ### invalid_recurrence The recurrence fields do not describe a window that can repeat. The message names the field. `POST /v1/schedules/preview` resolves a draft and answers this before you save one. ### invalid_glob A repository pattern did not parse. Patterns match `owner/name` with `*` and `**`, and `!` excludes. `POST /v1/scope/preview` shows what one matches. ### impact_preview_required An org-wide or pattern-matched freeze cannot be created blind, because the request does not say how big it is. Send `previewedRepoCount` from `POST /v1/scope/preview`, or `impactAcknowledged: true` to accept an uncalculated blast radius. Both answers are recorded; neither changes what gets frozen. ## 429 rate_limited ### rate_limited You are over the organisation ceiling or the per-credential write budget. Honour `Retry-After`. Read `RateLimit-Remaining` off every credentialled response and size retries from it rather than from a number in prose. ### github_rate_limited GitHub is rate-limiting Fwozen, not the other way round. Freezes already written stay in force. Wait out `Retry-After`; the reconciler catches up on its own. ### github_secondary_rate_limit GitHub’s secondary limit, which is about how fast a burst arrives rather than how many calls it holds. Back off further than the primary limit asks. Retrying faster makes it worse. ## 500 internal_error ### internal_error Something broke on our side, and it is not something you did. Nothing about your freezes changed: enforcement lives in GitHub’s required checks. Send `error.requestId` to support@fwozen.com and we can find the exact request. ## 502 upstream_error ### github_unavailable GitHub did not answer. A check already written as failing keeps blocking. Retry; the reconciler re-asserts state after a recovery. ### slack_unavailable Slack did not answer. The freeze itself is unaffected. Slack is a notification surface, never the enforcement one. ### stripe_unavailable Stripe did not answer. Retry. Billing failures never lift a freeze or block a thaw. ### workos_unavailable Reserved. Authentication is first-party, so there is no identity provider to be unavailable. Nothing emits this. It stays in the enum so an older build inside a rollback window still validates the value. ## 503 unavailable ### database_unavailable Fwozen cannot reach its own database. Retry. Checks already written to GitHub are unaffected, which is the property the whole design rests on. ### maintenance Fwozen is in a maintenance window. Retry after the window. status.fwozen.com carries the current state. --- https://fwozen.com/docs/slack # Slack commands What /fwozen does, how the channel announcement keeps itself current, and how a signed single-use link ties a Slack account to a GitHub user. (This page is still expanding — it is a stub, not the finished reference.) The Slack app is in the free tier and is bot-only: no user tokens, no message history access. - `/fwozen freeze api-server 2h "4.2 cut"` freezes with a reason and a duration. - `/fwozen` on its own returns a status board with buttons for the freezes you are allowed to lift. - Channel announcements update themselves as state changes rather than posting a second message. - Fwozen links Slack accounts to GitHub users with a signed single-use link, rather than asking people to paste a member ID. Add it from Settings → Slack in the dashboard, or in step 5 of setup. It is free on every plan and not listed in the Slack Marketplace, so you install it from Fwozen rather than from Slack. The full command grammar and the permission model are still being written up here. --- https://fwozen.com/docs/cli # The CLI Check a freeze before you merge or deploy, freeze and thaw from a terminal, and gate a pipeline. The commands, the auth, and the exit codes. There is a Fwozen CLI. It talks to the same REST API the dashboard does, so it can do what the dashboard does: ask whether you are frozen, freeze, thaw, read the per-branch enforcement state, and gate a deploy from any pipeline. Install the CLI ```bash curl -fsSL https://fwozen.com/install | sh ``` One file, no package manager, and nothing added to your shell profile. It needs Node 24 or newer. The script is plain text at that URL, so you can read it before you run it. It verifies a published sha256 before it writes anything, and `https://fwozen.com/cli/release.json` restates the same digest next to the current version, its size, and the Node floor. Re-run the same line to upgrade; it replaces the payload in place and leaves you logged in. NOTE — It is not on npm. The package is not published, so `npx fwozen` and `npm i -g fwozen` do not work today. The installer above and the source tree are the two ways in. When it is published, that lands in the changelog (/changelog) with a date and this paragraph goes away. If you are working in the repository, build it from source instead. Same binary, no download: Build and run it from the repo ```bash pnpm --filter fwozen build # emits apps/cli/dist/index.js pnpm exec fwozen status ``` ## Signing in `fwozen login` runs a device flow: it prints a code, you confirm it in the browser, and the credentials land in `$XDG_CONFIG_HOME/fwozen/credentials.json` at mode `0600`. The access token is short-lived and re-minted for you. The session behind it is fixed at 30 days and does not slide. Using the CLI does not extend it. After that, commands exit 4 and you log in again. `fwozen whoami` says who you are. The session token in that file is the one long-lived secret on your machine, and it stays valid for its full 30 days until somebody revokes it. Deleting the file does not. `fwozen logout` revokes the session server-side and then removes the file, and exits non-zero if the revocation failed. So if a laptop or a credentials file is lost, run `fwozen logout`, or end that session from your account settings, rather than waiting for it to lapse. For CI, agents, and anything non-interactive, use an API key instead. Create one at Settings → API keys in the dashboard (the steps (/docs/api#get-a-key)). It overrides the stored credentials and is never refreshed: CI ```bash export FWOZEN_TOKEN=fwz_live_… ``` Key auth is organisation-wide, so `org switch` is unavailable. A command whose key lacks a scope degrades to a narrower answer and names the missing scope rather than failing silently. For the full CLI, a key with `freeze:read freeze:write repo:read schedule:read` covers everything except refreshing the enforcement probe, which needs `repo:write`. ## The commands - `fwozen status` — freeze state, schedules, enforcement health, reconciler lag. - `fwozen freeze --reason "…"` — freeze `all` (every enabled repository’s default branch) or repository globs. `--branch`, `--all-branches`, `--duration 4h`, `--until`, `--block-deploys`, `--allow-deploys`, and `--dry-run`, which resolves the scope and writes nothing. - `fwozen thaw [freezeId]` — lift the active freeze, or `--all` of them. - `fwozen verify [--strict]` — the per-branch enforcement reading, worst first. `--strict` exits 3 when any branch is red, which is what makes it usable as a pre-release check. - `fwozen gate` — the deploy gate, with the same fail-closed decision table as the `fwozen/gate-action` composite action. - `fwozen org list` / `fwozen org switch `, `fwozen whoami`, `fwozen login`, `fwozen logout`. - `fwozen skill install` — copies the bundled agent skill into `.claude/skills/fwozen`, or `--global` into your home directory. Global flags are `--json`, `--api-url`, and `--no-color`. `--json` writes machine-clean JSON to stdout with no ANSI and no spinner; progress and errors always go to stderr. `--reason` on a freeze is mandatory and must be at least eight characters, because a freeze with no reason is the thing the audit log exists to prevent. ## Gating a deploy from any CI system `fwozen gate` asks `GET /v1/freeze-state` with a 10 second timeout and three attempts, then reads the decision from the `Fwozen-Frozen` response header, which tracks the deploy decision rather than the merge one. A transport failure, a non-2xx response, or a missing header is indeterminate, and the gate treats indeterminate as frozen. It refuses the deploy when your job calls it; a job that never calls it is never gated. Any CI system ```bash export FWOZEN_TOKEN=fwz_live_… export FWOZEN_REPO=acme/api-server # default: the origin remote fwozen gate || exit 1 ``` ## Exit codes - `0` — success; for `gate`, thawed. - `1` — `gate` frozen or indeterminate; or the operation failed or was refused. - `2` — `gate` only: no token, so no request was made. - `3` — `verify --strict` found red branches. - `4` — not authenticated, or the session expired. - `64` — usage error. `fwozen --help` prints the full flag reference for any command. The REST API (/docs/api) is the authority for anything the CLI wraps. --- https://fwozen.com/docs/enterprise-bulk-install # Installing Fwozen across an enterprise How your own GitHub Enterprise Cloud admin installs Fwozen into many organisations at once with one REST call, and why Fwozen itself can never do that to you. If your company runs GitHub Enterprise Cloud with dozens of organisations, installing Fwozen one consent screen at a time is a bad afternoon. GitHub has a REST endpoint for exactly this. An administrator of your enterprise can install a GitHub App into any organisation in it, without a per-organisation approval, one call at a time. The important half of that sentence is your. Read the permission model below before the recipe. It is the reason this page exists on a vendor’s own documentation site, and it is a fact about your safety rather than about our convenience. ## Who can do this, and who cannot The endpoint is authorised by a GitHub App that is installed on the enterprise and holds the `Enterprise organization installations` permission at write level. That permission is scoped to the enterprise the calling App is installed on and does not cross an enterprise boundary. NOTE — Fwozen can never do this to you Fwozen is not installed on your enterprise and holds no enterprise-level permission of any kind, so it cannot call this endpoint against your organisations. Not with our own credentials, not with a support session, not at all. It runs in one direction only: your admin, using your enterprise’s tooling, fanning out an App they chose. If you take one thing from this page, take that. ## Which credential the calls below use Not the one your `gh auth login` holds. That is a user token, and the permission above belongs to an App. So the calls use an installation access token for a GitHub App you created and installed on your own enterprise with `Enterprise organization installations` at write level. GitHub documents that exchange, JWT and all, under Automating app installations in your enterprise’s organizations (https://docs.github.com/en/enterprise-cloud@latest/admin/managing-github-apps-for-your-enterprise/automate-installations); it is three requests and we will not reproduce it badly here. Follow it, export the installation token, and the recipes below work as written: The credential the two recipes assume ```bash export ENTERPRISE_APP_TOKEN= ``` The App you install through these calls is Fwozen. The App you authenticate with is yours, and it never leaves your enterprise. ## The call One request per organisation. `client_id` identifies the App to install, which can be any valid GitHub App, including a third-party one like Fwozen. `repository_selection` chooses between all repositories and a selected set. If that organisation already has a pending request to install the App, this approves it. Install Fwozen into one organisation ```bash gh api -X POST \ --header "Authorization: Bearer $ENTERPRISE_APP_TOKEN" \ /enterprises/ACME/apps/organizations/acme-payments/installations \ -f client_id="$FWOZEN_APP_CLIENT_ID" \ -f repository_selection='all' ``` And across the estate, which is the whole reason to be here. Keep it boring: read the organisation list from GitHub, loop, and let a failure stop the loop rather than scroll past. The listing endpoint is the install endpoint’s own companion. It answers with the enterprise-owned organisations an App can be installed into. Fan out across every organisation in the enterprise ```bash set -euo pipefail gh api --paginate \ --header "Authorization: Bearer $ENTERPRISE_APP_TOKEN" \ /enterprises/ACME/apps/installable_organizations --jq '.[].login' \ | while read -r org; do echo "installing into $org" gh api -X POST "/enterprises/ACME/apps/organizations/$org/installations" \ --header "Authorization: Bearer $ENTERPRISE_APP_TOKEN" \ -f client_id="$FWOZEN_APP_CLIENT_ID" \ -f repository_selection='all' done ``` ## Getting the client id `client_id` is the OAuth client identifier of the Fwozen GitHub App. It is not a secret: it is the public half of the pair, and it appears in the URL of the App’s own authorisation flow. Read it, and the numeric App id the enforcement rule pins to, from the discovery document: The two public identifiers ```bash curl -fsSL https://api.fwozen.com/.well-known/fwozen-app \ | jq '{ githubAppId, githubAppClientId }' export FWOZEN_APP_CLIENT_ID=$(curl -fsSL \ https://api.fwozen.com/.well-known/fwozen-app | jq -r .githubAppClientId) ``` It is the same string for every customer. If the document is unreachable, mail support@fwozen.com (mailto:support@fwozen.com) and you will get the exact value. Do not guess it from the App slug and do not scrape it out of a screenshot. An install call that names the wrong client id installs the wrong App into your organisation. ## What this does not do Installing is not enrolling, and it is worth being blunt about the gap, because an admin who expects otherwise will believe an estate is covered when it is not. - It does not enable any repository. Fwozen never enables a repository on its own. Private repositories are the billable unit, and enabling one silently would be a charge nobody agreed to. After the fan-out, every organisation still has zero repositories enabled, and someone chooses. - It does not make anything billable. An installed-but-unenrolled organisation costs nothing. The bill follows enabled private repositories, not installs. - It does not create a ruleset. Installing grants the App its permission set on the repositories you chose. It writes nothing: a ruleset is only ever written by an explicit enforcement setup, on a branch somebody enrolled, in a repository not marked as managed as code, by a signed-in person. Enforcement at organisation scope is always a rule a person saves — Fwozen asks for no organisation `administration` in either direction — so a fan-out leaves every organisation needing somebody to go and set enforcement up, whether that is the one-click write on a repository ruleset or the one native GitHub rule saved by hand. Until that happens, a freeze is recorded and visible but does not block a merge. - It does not create Fwozen organisations or link identities. Each installed organisation is claimed in Fwozen the ordinary way, by someone who administers it. NOTE — Enterprise Cloud only This endpoint exists for GitHub Enterprise Cloud. There is no equivalent for GitHub Enterprise Server, which Fwozen does not support at all, and no equivalent for an organisation that is not part of an enterprise. Those install one at a time, from the ordinary install URL. ## A sensible order - Pilot one organisation by hand first, all the way through to a freeze that blocks a real pull request. The install guide (/docs/install) is that path, and doing it once tells you more than reading this page twice. - Fan the install out with the loop above. - In each organisation, enable the repositories that matter and watch their default branches. A glob does most of the work. - Have somebody in each organisation set enforcement up, which is a button where that installation has granted repository `administration: write` and the one native rule Fwozen shows them the exact values for where it has not. Either way Fwozen verifies it against GitHub and reports what GitHub actually said. If you are driving that from a script or an agent rather than by hand, the REST API (/docs/api) covers every step after the install, and the agent policy (/agents) says which of them still need a person. --- https://fwozen.com/docs/migrating-from-another-tool # Migrating from MergeFreeze Run Fwozen alongside your current tool, create an fwz_ key, and point your existing scripts at the compatibility namespace by changing one base URL. What carries over and what does not. This page is a compatibility note, not a pitch. If your current freeze tool works, that is a good reason to keep it; what follows is what the compatibility namespace does, for people who have already decided to move. There is no importer of your repository list or schedules. It is not built, so this describes what works today. ## Three steps - Install Fwozen alongside your current tool and enable the same repositories. Nothing breaks while both are installed: two required checks both have to pass. Recreate your schedules in the Fwozen schedule builder, which shows the next five occurrences in the schedule’s timezone and in yours before you save. Enrolling a repository needs no email request to us; you install the App and enable what you want. - Create an API key. Settings → API keys → Create key, with `freeze:read` and `freeze:write`, plus `repo:write` if your scripts enrol branches and `exception:write` if they unblock pull requests. The secret is shown once. This is the one thing that does not carry over: Fwozen keys start with `fwz_`, and the compatibility namespace refuses anything else with a 401 rather than pretending to recognise it. - Change one base URL and the token behind it. Paths, query parameters, request bodies and response field names stay as they are. What changes in your scripts ```bash # before: your current tool's API base, the part before /branches BASE_URL=…/api TOKEN=mfo_… # after BASE_URL=https://api.fwozen.com/api/compat/v1 TOKEN=fwz_live_… ``` ## What the namespace answers - `GET /branches/{account}/{repo}/{branch}` — the freeze state of one branch, in the old field names. A repository it cannot see answers `{}` rather than a 404, which is the quirk your scripts already handle. - `POST /branches/{account}/{repo}/{branch}` — freeze or thaw with `frozen`, plus `note`, `user_name`, `allow_deployments` and `unblocked_prs`. Form-encoded or JSON, either way. Freezing something already frozen answers 200 with a warning, not an error. - `POST /branches` — enrol a repository and start watching a branch. - `DELETE /branches` — stop watching one. - `POST /scheduled_freezes` — a one-off window from `one_off_freeze_at` and `one_off_unfreeze_at`, in epoch seconds. - `GET /freeze_logs` — freeze history, with `actor`, `actor_type`, `reason` and `duration_seconds` added to each row. Additive, so a reader that ignores unknown fields is unaffected. - `POST /generate_access_token` — answers 400 `compat_feature_removed`. See below. ## Two behaviours deliberately not reproduced - Minting a token from a token. An API key can never mint another API key here, whatever namespace it is called through. Create keys in the dashboard. - Revoke-by-omission on `unblocked_prs`. Sending the list is additive: a pull request you leave out of a later call keeps its exception rather than silently losing it. Revoke an exception explicitly, in the dashboard or through `/v1`. Fwozen also requires the key to carry `exception:write` for that field. ## What you keep, what you lose, what you gain - You keep your scripts, your freeze and thaw semantics, your field names, and the ability to run both tools at once while you check. - You lose, today, an importer for your repository list and schedules, and your years of freeze history: the audit log starts on the day you install. There is no Mac menu-bar app. - You gain enforcement Fwozen reads back from GitHub per branch rather than assumes, merge-queue freezing, deploy gating through a required check and a published Action, per-PR exceptions with a reason and a time limit, and an audit log with a named actor on every row. - Stay where you are if what you have works and nobody has been surprised by a merge during a freeze. That is a real answer, and the honest case against code freezes (/blog/the-honest-case-against-code-freezes) is the argument against the whole category, written by us. The compatibility namespace exists because breaking someone’s scripts is a bad way to be chosen. It stays supported whether or not you keep the other tool installed. When you are ready, remove the old app’s required check from your branch rules and uninstall it, and leaving Fwozen (/docs/uninstall) is written down too. --- https://fwozen.com/docs/uninstall # Leaving Fwozen The exit, in the order that matters: lift the freezes, remove fwozen from the required checks in GitHub first, then uninstall the App. What is left behind and when data is deleted. Leaving is three steps, and the order matters. Do the middle one before the last one or you will leave a branch that cannot be merged to, with nothing left running to clear it. NOTE — The one thing that outlives the App Fwozen deletes no ruleset, ever. Where it wrote the rule that makes `fwozen` a required check, that rule is a GitHub object and it stays behind when the App goes. A required check whose App has been uninstalled never reports again, so the rule blocks every pull request on that branch until somebody removes it. ## The sequence - Lift the freezes, or let them expire. Uninstalling ends them anyway, but doing it first means the checks flip to passing while Fwozen is still there to write them. - In GitHub, remove `fwozen` from the required checks. Settings → Rules → Rulesets, open the rule named `fwozen`, and either delete it or set it to disabled. If your own rule required the check rather than Fwozen’s, edit that one and drop the `fwozen` context. This is the step to do first, and the reason is in the callout above. - Uninstall the GitHub App from your organisation’s Installed GitHub Apps page. Fwozen ends every active freeze, cancels every schedule, stops billing, and schedules your data for deletion 30 days later. No further writes to GitHub are possible after this, with or without your cooperation: the installation token is gone. ## What is left afterwards - Your rules stay yours. Everything Fwozen ever wrote is a GitHub object in your account, readable and editable by you, and the only one it creates is the ruleset that requires a check. - Check runs stay on their commits. Historical `fwozen` results are part of GitHub’s record of those commits and are not ours to remove. - Your Fwozen data is deleted after 30 days. Export the audit log before you go if you need it: `GET /v1/audit/export` streams CSV, and the dashboard’s audit page has the same button. - Billing stops at the uninstall. There is no exit fee and no email required to cancel. Reinstalling later is the ordinary install path. Nothing about a removed installation blocks it, and the enforcement step will find and reuse a correct ruleset it recognises rather than writing a second one. # Public REST routes Base https://api.fwozen.com. Full request and response schemas: https://api.fwozen.com/v1/openapi.json. Auth column: `session` = a Fwozen session JWT (device flow above), `key` = an `fwz_` API key, `both` = either, `none` = no credential. Routes that are session-only are absent from the key column on purpose; a key can never mint a key. ## Organizations - GET /v1/me — Current user and organization [auth: session] - GET /v1/me/organizations — Organizations the current user belongs to [auth: session] - GET /v1/onboarding — Onboarding state [auth: session] - POST /v1/onboarding/bootstrap — Create the organization for the signed-in user (idempotent) [auth: session] - GET /v1/github/install-url — GitHub App installation URL carrying a signed state nonce [auth: session] - GET /v1/installations/status — Poll a GitHub App installation minted by GET /v1/github/install-url [auth: both · scopes: repo:read] - POST /v1/installations/claim — Claim a GitHub App installation for this organization [auth: session] - GET /v1/orgs/current — Get the organization [auth: both · scopes: repo:read] - PATCH /v1/orgs/current — Update organization settings [auth: session] - GET /v1/members — List members [auth: session] - PATCH /v1/members/{userId} — Change a member's role [auth: session] - DELETE /v1/members/{userId} — Remove a member [auth: session] - GET /v1/invitations — List invitations [auth: session] - POST /v1/invitations — Invite a member [auth: session] - DELETE /v1/invitations/{invitationId} — Revoke an invitation [auth: session] - GET /v1/invitations/pending — Invitations addressed to you [auth: session] - GET /v1/invitations/preview — Read an invitation from its token [auth: session] - POST /v1/invitations/{invitationId}/accept — Accept an invitation [auth: session] - GET /v1/invitations/{invitationId}/link — Mint the accept link for a pending invitation [auth: session] - GET /v1/overview — Dashboard rollup [auth: both · scopes: freeze:read] - GET /v1/attention — Things needing attention [auth: both · scopes: freeze:read] ## Repositories - GET /v1/repos — List repositories [auth: both · scopes: repo:read] - POST /v1/repos/sync — Re-sync the repository mirror from GitHub [auth: both · scopes: repo:write] - POST /v1/repos/enable — Enable repositories [auth: both · scopes: repo:write] - POST /v1/repos/disable — Disable repositories [auth: both · scopes: repo:write] - POST /v1/repos/preview-glob — Preview which repositories a glob matches [auth: both · scopes: repo:read] - GET /v1/repos/{repoId} — Get a repository [auth: both · scopes: repo:read] - PATCH /v1/repos/{repoId} — Update repository settings [auth: both · scopes: repo:write] - GET /v1/repos/{repoId}/pulls — List tracked pull requests of a repository [auth: both · scopes: freeze:read] - GET /v1/pulls/lookup — Resolve a PR from a URL, owner/repo#123, or a number [auth: both · scopes: freeze:read] ## Branches - GET /v1/repos/{repoId}/branches — List watched branches of a repository [auth: both · scopes: repo:read] - GET /v1/repos/{repoId}/remote-branches — List branches live from GitHub (cached 60s, budget-gated) [auth: both · scopes: repo:read] - POST /v1/repos/{repoId}/branches — Watch a branch [auth: both · scopes: repo:write] - GET /v1/branches — List watched branches [auth: both · scopes: repo:read] - GET /v1/branches/{branchId} — Get a watched branch [auth: both · scopes: repo:read] - PATCH /v1/branches/{branchId} — Update watched-branch settings [auth: both · scopes: repo:write] - DELETE /v1/branches/{branchId} — Stop watching a branch [auth: both · scopes: repo:write] ## Enforcement - GET /v1/enforcement — Enforcement traffic light across the organization [auth: both · scopes: repo:read] - GET /v1/enforcement/branches/{branchId} — Enforcement detail for a branch [auth: both · scopes: repo:read] - GET /v1/enforcement/branches/{branchId}/snippets — gh CLI, Terraform and ruleset JSON snippets for a branch [auth: both · scopes: repo:read] - POST /v1/enforcement/verify — Queue an enforcement probe [auth: both · scopes: repo:write] - POST /v1/enforcement/setup — Plan the native GitHub rule, or have Fwozen apply it [auth: session] - POST /v1/enforcement/seed-check — Write the current fwozen verdict to every enforcement-relevant head [auth: session] ## Freezes - POST /v1/scope/preview — Resolve a freeze scope without creating a freeze [auth: both · scopes: freeze:read] - POST /v1/freezes — Start a freeze [auth: both · scopes: freeze:write] - GET /v1/freezes — List freezes [auth: both · scopes: freeze:read] - POST /v1/freezes/lift — Lift every freeze matching a scope [auth: both · scopes: freeze:write] - GET /v1/freezes/{freezeId} — Get a freeze [auth: both · scopes: freeze:read] - GET /v1/freezes/{freezeId}/targets — List the materialized targets of a freeze [auth: both · scopes: freeze:read] - POST /v1/freezes/{freezeId}/lift — Lift a freeze [auth: both · scopes: freeze:write] - POST /v1/freezes/{freezeId}/extend — Extend a freeze [auth: both · scopes: freeze:write] - POST /v1/freezes/{freezeId}/thaw-override — Unfreeze inside a scheduled window, suppressing only that freeze [auth: both · scopes: freeze:write] - POST /v1/freezes/{freezeId}/retry — Retry failed fan-out targets [auth: both · scopes: freeze:write] - GET /v1/freeze-state — Is this repository, branch or organization frozen? [auth: both · scopes: freeze:read] ## Schedules - GET /v1/schedules — List schedules [auth: both · scopes: schedule:read] - POST /v1/schedules — Create a schedule [auth: both · scopes: schedule:write] - POST /v1/schedules/preview — Preview the occurrences of an unsaved schedule draft [auth: both · scopes: schedule:read] - GET /v1/schedules/occurrences — List planned and past occurrences in a time range [auth: both · scopes: schedule:read] - GET /v1/schedules/calendar — Occurrences grouped by day for one month [auth: both · scopes: schedule:read] - POST /v1/schedules/import — Import a holiday calendar as schedules [auth: session] - GET /v1/schedules/{scheduleId} — Get a schedule [auth: both · scopes: schedule:read] - PATCH /v1/schedules/{scheduleId} — Update a schedule and re-materialize its horizon [auth: both · scopes: schedule:write] - DELETE /v1/schedules/{scheduleId} — Delete a schedule [auth: both · scopes: schedule:write] - GET /v1/schedules/{scheduleId}/preview — Preview the next occurrences of a saved schedule [auth: both · scopes: schedule:read] - POST /v1/schedules/{scheduleId}/skip-next — Skip the next planned occurrence [auth: both · scopes: schedule:write] - POST /v1/schedules/{scheduleId}/run-now — Apply or lift a schedule's window immediately [auth: session] ## Exceptions - GET /v1/exceptions — List exceptions [auth: both · scopes: exception:read] - POST /v1/exceptions — Grant a per-PR exception (one row per covering freeze) [auth: both · scopes: exception:write · requires the pr_exceptions entitlement] - GET /v1/exceptions/{exceptionId} — Get an exception [auth: both · scopes: exception:read] - POST /v1/exceptions/{exceptionId}/revoke — Revoke an exception (never entitlement-gated) [auth: both · scopes: exception:write] - GET /v1/exception-requests — List pending exception requests [auth: both · scopes: exception:read] - POST /v1/exception-requests — Request an exception for a pull request [auth: both · scopes: exception:write · requires the pr_exceptions entitlement] - POST /v1/exception-requests/{exceptionId}/decide — Grant or deny an exception request [auth: both · scopes: exception:write · requires the pr_exceptions entitlement] ## Audit - GET /v1/audit — List audit entries (read-time retention filter) [auth: both · scopes: audit:read] - GET /v1/audit/export — Stream the audit log as CSV (hard cap 500 000 rows) [auth: both · scopes: audit:read · requires the audit_export entitlement] - GET /v1/audit/actions — The audit action vocabulary [auth: both · scopes: audit:read] ## API keys - GET /v1/api-keys/scopes — The API key scope vocabulary [auth: session] - GET /v1/api-keys — List API keys [auth: session] - POST /v1/api-keys — Create an API key [auth: session] - POST /v1/api-keys/{apiKeyId}/rotate — Rotate an API key with a grace window [auth: session] - DELETE /v1/api-keys/{apiKeyId} — Revoke an API key [auth: session] ## Billing - GET /v1/plans — The plan catalog [auth: none] - GET /v1/billing — Billing summary [auth: both · scopes: billing:read] - GET /v1/billing/prices — Price catalog [auth: session] - POST /v1/billing/checkout — Create a Stripe Checkout session [auth: both · scopes: billing:write] - POST /v1/billing/portal — Create a Stripe Customer Portal session [auth: both · scopes: billing:write] - POST /v1/billing/trial — Start the 14-day no-card Business trial [auth: both · scopes: billing:write] - GET /v1/entitlements — Resolved entitlements, limits and usage [auth: both · scopes: repo:read, billing:read]