braze-sync
GitOps CLI for managing Braze configuration as code.
braze-sync lets you keep Braze workspace state in a Git repository and
synchronize it to Braze with the same workflow you'd use for
terraform plan / kubectl diff — including dry-run previews, drift
detection in CI, and an --allow-destructive gate that has to be
crossed explicitly before anything is dropped.
Status: v0.8.0 (4 resources + init)
braze-sync manages Braze configuration as code. The four managed resource kinds are:
- Catalog Schema (field definitions, types, constraints)
- Content Block (reusable Liquid fragments)
- Email Template (HTML/Liquid templates)
- Custom Attribute registry (definition-level; deprecation toggle)
Out of scope: runtime data like catalog items, user attribute values, events, and campaigns. Those have their own systems of record; use the Braze REST API or data pipelines directly. See docs/scope-boundaries.md.
| Command | What it does |
|---|---|
braze-sync init |
Scaffolds a new workspace (config, directories, .gitignore) |
braze-sync export |
Pulls current Braze state into local files |
braze-sync diff |
Shows drift between local files and Braze |
braze-sync apply |
Applies local intent to Braze (dry-run by default) |
braze-sync validate |
Local-only structural and naming checks (no API call) |
Content Block specifics
Content Blocks live as content_blocks/<name>.liquid files: YAML
frontmatter (name, description, tags, state) followed by the Liquid
body. braze-sync apply can create new blocks and update existing
ones, but the Braze API has no DELETE for content blocks, so blocks
that exist in Braze but not in Git become orphans — diff flags
them and apply does nothing about them by default. Pass
--archive-orphans to rename them remotely with an
[ARCHIVED-YYYY-MM-DD] prefix; the data is never silently dropped.
Install
Pre-built binaries (recommended):
Download from GitHub Releases for Linux (x86_64, aarch64), macOS (x86_64, Apple Silicon), and Windows (x86_64).
Homebrew (macOS / Linux):
cargo install (requires Rust toolchain):
Build from source:
Quick start
-
Scaffold a new workspace (config, directories,
.gitignore):This writes a commented
braze-sync.config.yaml(pointing at the EU default endpoint — edit if your instance is elsewhere) plus emptycatalogs/,content_blocks/,email_templates/, andcustom_attributes/directories. Safe to re-run: existing configs are kept unless--forceis passed. -
Set your Braze API key in an environment variable:
-
Pull the current Braze state into the scaffolded layout:
Or do steps 1 and 3 in one shot:
-
Edit a resource (e.g. add a catalog field) and check the drift:
-
Apply the change — dry-run first, then for real:
-
In CI, fail builds on drift or local validation issues:
validateis local-only and does not need an API key, so it runs cleanly on fork PRs that don't have access to repository secrets.
Safety by default
braze-sync apply is dry-run by default. You must pass --confirm
to write to Braze. Destructive operations (field deletes) require an
additional --allow-destructive flag — apply exits with code 6
if you try to drop a field without it.
API keys never live in the config file. The config only references the
name of the environment variable (api_key_env), and the key is
held in secrecy::SecretString from the moment it leaves the OS so
that tracing / Debug / panic messages cannot leak it.
Limitations
These will be lifted across the v0.x → v1.0 milestones:
- No catalog delete.
applycreates new catalogs and adds fields, but it will not delete a catalog that exists in Braze and is missing from Git — the side effect (loss of all items) is too large to do implicitly. Drop the catalog manually in the Braze dashboard. - No field type changes. Changing a field's type from
stringtonumber(or similar) is not auto-applied because the operation is data-losing on the field. Drop the field manually in Braze, then runbraze-sync applyto re-add it with the new type. - No DELETE for content blocks. Braze's content blocks API does
not expose a DELETE endpoint, so blocks that exist in Braze but not
in Git become orphans.
diffflags them;applydoes nothing about them unless you pass--archive-orphans, which renames them remotely with an[ARCHIVED-YYYY-MM-DD]prefix instead of pretending they were dropped. - Content block
stateis local-only and not observable. Thestate: active|draftfield incontent_blocks/<name>.liquidfrontmatter is a purely local authoring annotation. Braze's/content_blocks/infoendpoint does not return state, sobraze-sync exportwrites nostate:line for any block fetched from Braze rather than defaulting toactiveand pretending it knows. If you want the annotation, add it to the file by hand afterexport.applywrites the field exactly once — when creating a new block — and never sends it on updates, so editingstateon a block that already exists in Braze has no effect and the nextexportwill strip it again. The diff layer also ignores the field to prevent an "infinite drift" loop (Braze has no DELETE, so a persistently-Modified Content Block is a trap). - No pagination yet. v0.2.0 sends a single page request to
/catalogsand/content_blocks/list(limit 100). For/content_blocks/listthis is a hard error if Braze reports more results than fit on one page, or if a full page comes back with no total to verify against — workspaces with >100 content blocks cannot use v0.2.0 yet. Without the guard,applycould create duplicates of blocks living on page 2+ (their names would diff asAdded). This limit is symmetric for--name <foo>: content blocks have no get-by-name endpoint, sodiff --name,apply --name, andexport --namestill list-then-filter and hit the same page cap. For/catalogsv0.2.0 still only warns; the same guard will be applied symmetrically in a follow-up. Pagination support lands in Phase C scale validation. --archive-orphansis a two-step read-modify-write. The rename fetches/content_blocks/infoto preserve the body, then posts/content_blocks/updatewith the archived name. If another operator edits the same block in the dashboard between those two calls, the update clobbers their change with the pre-rename body. Safe for the single-operator GitOps workflow v0.2.0 targets; a compare-and-swap header would lift it, but Braze's content_blocks API does not currently document one.--no-coloronly affects tracing output. v0.2.0 does not emit ANSI colors in table or diff output, so the flag currently only suppresses ANSI escapes from the tracing subscriber on stderr.
Exit codes
These are frozen at v1.0: scripts and CI configs can rely on them across all v1.x releases.
| Code | Meaning |
|---|---|
0 |
Success |
1 |
General error |
2 |
Drift detected (diff --fail-on-drift) |
3 |
Config / argument error (or validate issues) |
4 |
Authentication failed (invalid API key) |
5 |
Rate limit retries exhausted |
6 |
Destructive change blocked (pass --allow-destructive) |
Output formats
The global --format flag picks between human-readable and
machine-readable output for diff and apply:
The JSON shape is frozen at v1.0 with an explicit version: 1
field on the root. Future schema bumps will increment version, so
CI consumers can branch on it.
Verifying release artifacts
Release archives from GitHub Releases
are signed with Sigstore cosign
in keyless mode — the signing identity is the release workflow itself,
not a long-lived key. Each .tar.gz / .zip ships with a .cosign.bundle
carrying the signature and Fulcio certificate. To verify, download both
and run:
A successful run prints Verified OK. Any mismatch — tampering,
wrong repo, or a build from a different workflow — fails. The
SHA-256 digests (.sha256) are still published for consumers that
only need a content hash.
Further reading
- Configuration reference — every field in
braze-sync.config.yaml. - CI integration — drift detection and apply-on-merge workflows.
- Orphan tracking — how Content Blocks and Email Templates are handled when Braze has no DELETE.
- Custom Attribute registry mode — why attributes work differently and what
applyactually does.