Docs

Build a release from files

POST
/releases/build

Builds a release from the resources of .zitadel/ as authored on disk, grouped by kind, in one transaction: a failure anywhere leaves no revision and no release. The caller records nothing.

If the project already holds a release with the same content, the same content_hash, the request answers 200 with that release and allocates nothing, however long ago it was built. Otherwise every resource is compared against the project's newest revision of the same handle: unchanged content reuses that revision, changed content allocates a new one, and the request answers 201 with the new release. A policy whose config matches only an older revision allocates a new one too, so a request that resolves no release, which reads the newest revision, applies it as well. Metadata is excluded from the comparison, so the same content under a different message is the same release.

A structurally malformed request is refused while decoding with req.invalid, naming the offending fields. A well-formed bundle is then checked resource by resource, as each kind's create endpoint checks it, and every failure is reported at once: the request answers 400 with rel.invalid, and details.resources lists each resource that failed. A handle that resolves neither in the bundle nor in the project answers rel.unresolved_reference, naming the pointer and the handle.

Building writes revisions, so besides release.write it needs the write scope of every kind the bundle carries a resource of: schema.write, flow_definition.write, branding.write, idp.write and policy.write, as the per-kind create endpoints do. A missing scope answers 403 with rel.permission_denied, naming the kind. An empty kind needs no scope.

Building a release does not deploy it. A release is not tied to an origin, and the same release can later be deployed to any number of origins unchanged.

Authorization

oauth2 release.write
headerAuthorizationBearer <token>

Scope: release.write

Query Parameters

project_id*string

The unique identifier of the project

Match^[a-zA-Z0-9_-]+$

Request Body

application/json
  1. body

The resources to build a release from, plus the metadata to record alongside it.

created_at and created_by are derived server-side from the caller's authentication context and are not accepted here.

bundle*

The resources of a project's .zitadel/ directory as authored on disk, grouped by kind.

Resources reference each other by handle: a flow definition's user_schema names a schema by its objectType, and a flow step's sso_providers, like a schema's x-auth-methods.sso.providers, name IDP connections by slug. A reference resolves to the resource with that handle in this bundle; failing that, to the project's newest revision of it, which the release then pins as well. A handle that neither holds refuses the release, naming the pointer and the handle. A bundle that references only resources it carries builds the same release on any project.

A handle names one resource of its kind in a bundle. Two schemas with the same objectType, two flow definitions with the same name, two IDP connections with the same slug, two policies for the same operation, or a schema without an objectType refuse the release with rel.invalid, with an entry for each such resource.

Each kind is optional, and the arrays may be empty: a project that never ejected its branding omits branding. The bundle carries at least one resource, and the release it builds pins at most 50, the most a release pins: the resources the bundle carries plus those resolved from the project. The server refuses anything outside that with rel.invalid. Unknown keys are refused. More kinds join as they become release kinds.

message?string

A short summary of what the release changes, analogous to a git commit message. Recorded on the release and shown when listing releases.

Lengthlength <= 1024
git_sha?string

The source commit the caller was operating from.

Lengthlength <= 64
git_dirty?boolean

Set when the working tree had uncommitted changes, so the release content does not correspond exactly to git_sha.

Defaultfalse

Response Body

The project already held a release with this content, built earlier, and it was returned unchanged. No new release and no new revision was created.

application/json
  1. response

An immutable, project-scoped snapshot that pins one revision of every resource it includes.

A release owns pointers and metadata, not content — it records which revisions belong together. Callers that need the resource bytes resolve each revision_id through the per-kind read endpoints (GET /schemas/{id}, GET /flow_definitions/{id}, …).

A release exists on its own and is not tied to an origin. Deploying it is a separate call.

Two identifiers: id is the one paths and release_id fields take; content_hash is the digest of the content the release pins, printed by the CLI and pinned by builds, and looked up through fields that name it.

id*release-id

The opaque, immutable resource id, assigned at construction.

Match^rel_[a-zA-Z0-9_-]+$
project_id*project-id

The project this release belongs to.

Match^[a-zA-Z0-9_-]+$
content_hash*ReleaseContentHash

The digest of the pinned content. Identical for two releases with the same content, which is why a project never holds two such releases.

Match^sha256:[0-9a-f]{64}$
Length71 <= length <= 71
metadata*

Who assembled the release, when, and from what source. Set when the release is assembled and never mutated afterwards.

revoked_at*|

When the release was revoked, or null while it is not. A revoked release cannot be deployed and is refused on every path that would serve it, including to a build that pins its digest. No origin serves a revoked release, since a release an origin still serves cannot be revoked; earlier deployments that named it stay in the history.

pointers*array<>

The revisions this release pins, one entry per (kind, handle). Never empty: a release must contain at least one resource, and never longer than the set POST /releases accepts.

Items1 <= items <= 50
Example Request
import { createZitadelClient } from "@zitadel/api/client";const client = createZitadelClient({  baseUrl: process.env.ZITADEL_URL ?? "http://localhost:8080",  token: process.env.ZITADEL_PROJECT_SECRET!, // server-side only});const release = await client.buildRelease(  {    bundle: {      schemas: [        {          $schema: "https://json-schema.org/draft/2020-12/schema",          kind: "user-schema",          metaSchema: "https://auth.acme.com/user-schema.json",          objectType: "human-user",          "x-auth-methods": { password: { enabled: true } },          "x-identifier": "email",          properties: {            email: {              type: "string",              format: "email",              "x-unique": "project",            },            phone_number: { type: "string" },          },        },      ],      flow_definitions: [        {          name: "default-login",          status: "active",          user_schema: "human-user",          purposes: { login: "identify" },          steps: [            {              name: "identify",              fields: ["email"],              actions: [                {                  name: "submit",                  kind: "submit",                  primary: true,                },              ],              transitions: { submit: { target: "authenticate" } },            },            {              name: "authenticate",              fields: ["x-auth-methods#password"],              actions: [                {                  name: "submit",                  kind: "submit",                  primary: true,                },              ],              transitions: { submit: { target: "done" } },            },            { name: "done", complete: "redirect" },          ],        },      ],      idps: [        {          slug: "google",          protocol: "oidc",          template: "google",          display_name: "Google",          subject_claim: "sub",          claim_mapping: { email: "email" },          oidc: {            issuer: "https://accounts.google.com",            client_id: "${{ GOOGLE_CLIENT_ID }}",            client_secret: "${{ GOOGLE_CLIENT_SECRET }}",            scopes: ["openid", "email"],          },        },      ],      policies: [        {          operation: "auth.factor.password.set",          config: { min_length: 12 },        },      ],      branding: {        layout: "centered",        liquid_template: "<main>{{ step.name }}</main>",      },    },    message: "add phone_number to human-user",    git_sha: "4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7",    git_dirty: false,  },  { project_id: "proj_01KWH1P4MYS7F0N9WD3P2E4YM5" },);
Example Response

The content matched an existing release, so that release is returned — note the message it was originally created with, not the one just submitted.

{  "id": "rel_01KX3RG8A7F0N9WD3P2E4YM5C1",  "project_id": "proj_01KWH1P4MYS7F0N9WD3P2E4YM5",  "content_hash": "sha256:9f2c1a7b4e83d05f6c2b19ae7d430f821c6b5de90a4f73829f2c1a7b4e83d05f",  "metadata": {    "message": "add phone_number to human-user",    "git_sha": "4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7",    "git_dirty": false,    "created_at": "2026-07-14T09:11:00Z",    "created_by": "user_01KWH3B72K7M7F0N9WD3P2E4YM",    "created_by_type": "human"  },  "revoked_at": null,  "pointers": [    {      "kind": "schema",      "handle": "human-user",      "revision_id": "sch_01KWHF18816ZQE7F0N9WD3P2E4"    },    {      "kind": "flow_definition",      "handle": "default-login",      "revision_id": "flowdef_01KWHG09JXA7F0N9WD3P2E4YM5"    },    {      "kind": "branding",      "handle": "default",      "revision_id": "brnd_01KWH1P4MYS7F0N9WD3P2E4YM5"    }  ]}

Was this helpful?