Skip to main content

Manifest endpoints

Endpoints for uploading, downloading, validating, and history-walking flag-namespace manifests. The flag-namespace manifest itself is documented in reference/manifest/; this page is the wire contract.

EndpointAuthStatus
PUT /api/v1/tenants/{tenant}/namespaces/{namespace}/manifestmanifest.writev0
GET /api/v1/tenants/{tenant}/namespaces/{namespace}/manifestmanifest.readv0
GET .../manifest/versionsmanifest.readv0
GET .../manifest/versions/{version}manifest.readv0
/api/v1/tenants/{tenant}/namespaces/{namespace}/manifest.git/...manifest.read / manifest.writev0
POST .../manifest/lintmanifest.readv0

See conventions for the auth header, error envelope, pagination, and rate limits.


PUT /api/v1/tenants/{tenant}/namespaces/{namespace}/manifest

Upload a new manifest version for (tenant, namespace). The body is multipart/form-data with a single field archive containing a .tar.gz of the flag-namespace directory contents (everything inside <namespace-slug>/, NOT the directory itself).

The server runs the full linter on the uploaded archive before accepting it. Any lint errors → manifest_lint_failed (422), current manifest unchanged.

Auth required: namespace admin, tenant_admin for {tenant}, namespace-write bound to (tenant, namespace), tenant-admin token, or superadmin. Permission: manifest.write.

Request

Content-Type: multipart/form-data

Form fieldTypeRequiredDescription
archivefile (tar.gz)yesThe manifest archive. Maximum 50 MB uncompressed, 5 MB compressed.

Optional headers

HeaderDescription
If-Version: <N>Optimistic concurrency control. If the current server-side manifest version is not <N>, upload is rejected with 409 version_conflict. Use If-Version: 0 for the first upload to a flag namespace that has no prior manifest. Omit to unconditionally replace (force-push).

Response: 200 OK

{
"version": 8,
"uploaded_at": "2026-04-25T12:00:00Z",
"flag_count": 13,
"segment_count": 4,
"lint": {
"errors": [],
"warnings": [
{
"code": "W003",
"severity": "warning",
"file": "flags/payment-retry-v2.toml",
"line": 1,
"message": "Flag has no rules in any environment and always returns its `value`"
}
],
"infos": []
},
"request_id": "01HV5XK6UFQT8N3JRDCP7MW04F"
}

Example

# Create the archive from the flag-namespace directory
tar -czf payments.tar.gz -C payments/ .

# Upload with optimistic concurrency (current version must be 7)
curl -X PUT https://exd.example.com/api/v1/tenants/acme/namespaces/payments/manifest \
-H "Authorization: Bearer exd_write_7gHkLmN3pQrStUvWxYz2AbCdEfGhJkLmNpQr3s" \
-H "If-Version: 7" \
-F "archive=@payments.tar.gz"

GET /api/v1/tenants/{tenant}/namespaces/{namespace}/manifest

Download the current manifest for (tenant, namespace) as a .tar.gz.

Auth required: namespace admin, tenant_admin, namespace-read, namespace-write, tenant-admin token, or superadmin. Permission: manifest.read.

Optional headers

HeaderDescription
If-None-Match: "v<N>"Conditional GET. The server compares against the current manifest's ETag ("v<current>"). Match → 304 Not Modified (empty body). Accepts comma-separated lists, the * wildcard, the W/ weak prefix, and bare numeric values (1) for caller convenience.

Response: 200 OK

Body is application/octet-stream (tar.gz). Response headers:

Content-Type: application/octet-stream
Content-Disposition: attachment; filename="payments-v8.tar.gz"
ETag: "v8"
X-Exd-Manifest-Version: 8
X-Exd-Uploaded-At: 2026-04-25T12:00:00Z
X-Request-Id: 01HV5XK7VFQT8N3JRDCP7MW04G

ETag is a strong validator — a given numbered manifest's archive bytes are immutable, so byte-for-byte equality is guaranteed when the version matches. The version-history endpoint also emits and honors the same ETag format.

Response: 304 Not Modified

Returned when If-None-Match matches the current version. Body is empty; ETag and X-Exd-Manifest-Version are still set so callers can correlate without parsing a body.

Example

# Initial pull
curl https://exd.example.com/api/v1/tenants/acme/namespaces/payments/manifest \
-H "Authorization: Bearer exd_read_4tRvBn9wKjMpXzQsUyAeCdFgHiJkLnOqRtWvYb" \
-o payments-current.tar.gz

# Second poll, conditional. The server replies 304 if nothing has changed.
curl -i https://exd.example.com/api/v1/tenants/acme/namespaces/payments/manifest \
-H "Authorization: Bearer exd_read_4tRvBn9wKjMpXzQsUyAeCdFgHiJkLnOqRtWvYb" \
-H 'If-None-Match: "v8"'

GET /api/v1/tenants/{tenant}/namespaces/{namespace}/manifest/versions

List the version history for (tenant, namespace)'s manifest. Returns a summary of each version without the full archive content.

Auth required: manifest.read.

Query parameters

Supports limit + after (pagination).

Response: 200 OK

{
"versions": [
{
"version": 8,
"uploaded_at": "2026-04-25T12:00:00Z",
"uploaded_by_token_prefix": "exd_write_7gHkLm",
"size_bytes": 4821,
"flag_count": 13,
"segment_count": 4
},
{
"version": 7,
"uploaded_at": "2026-04-24T16:45:11Z",
"uploaded_by_token_prefix": "exd_write_7gHkLm",
"size_bytes": 4603,
"flag_count": 12,
"segment_count": 4
}
],
"next_cursor": "01HV5ABC...",
"request_id": "01HV5XK8WFQT8N3JRDCP7MW04H"
}
FieldDescription
versionInteger version counter, monotonically increasing per flag namespace.
uploaded_atISO 8601 timestamp of when this version was accepted by the server.
uploaded_by_token_prefixFirst 14 characters of the token used to upload this version. Full tokens are never stored.
size_bytesUncompressed size of the manifest archive.
flag_countNumber of flag files in this version.
segment_countNumber of segment files in this version.

Example

curl "https://exd.example.com/api/v1/tenants/acme/namespaces/payments/manifest/versions?limit=10" \
-H "Authorization: Bearer exd_read_4tRvBn9wKjMpXzQsUyAeCdFgHiJkLnOqRtWvYb"

GET /api/v1/tenants/{tenant}/namespaces/{namespace}/manifest/versions/{version}

Download a specific historical version of the manifest as a .tar.gz.

Auth required: manifest.read.

Optional headers

HeaderDescription
If-None-Match: "v<N>"Conditional GET against the requested version's ETag. Match → 304 Not Modified. Useful when a debugger has already pinned a historical version locally and wants to confirm bytes have not been re-uploaded.

Response: 200 OK

Body is application/octet-stream. Response headers follow the same pattern as the current-version download endpoint, with X-Exd-Manifest-Version and ETag set to the requested version.

Example

curl https://exd.example.com/api/v1/tenants/acme/namespaces/payments/manifest/versions/6 \
-H "Authorization: Bearer exd_read_4tRvBn9wKjMpXzQsUyAeCdFgHiJkLnOqRtWvYb" \
-o payments-v6.tar.gz

Git smart-HTTP push

In addition to multipart PUT /manifest, the server exposes each flag namespace as a git repository over standard smart-HTTP. Developers can git clone, edit on disk, commit, and git push to publish a new manifest version. The bare repo at ${EXD_GIT_ROOT}/<tenant>/<slug>.git is the canonical record of manifest history; the manifest_versions table records the (version, commit) mapping.

Endpoints

All rooted at /api/v1/tenants/<tenant>/namespaces/<slug>/manifest.git/:

PathMethodPurpose
info/refs?service=git-upload-packGETSmart-HTTP advertisement for clone/fetch
git-upload-packPOSTPack body for clone/fetch
info/refs?service=git-receive-packGETSmart-HTTP advertisement for push
git-receive-packPOSTPack body for push

Auth

Both Authorization: Bearer <token> and Authorization: Basic <base64(user:token)> are accepted (the username is ignored by convention). The server returns 401 Unauthorized with WWW-Authenticate: Basic realm="exd" for unauthenticated requests so the git CLI prompts for credentials and credential helpers can supply them.

OperationRequired permission
Clone / fetch (git-upload-pack)namespace-read or higher
Fast-forward push (git-receive-pack)namespace-write or higher
Non-fast-forward push (force-push)tenant-admin or superadmin

Branch policy

Only refs/heads/main is accepted. Pushes to any other ref are rejected by the pre-receive hook with a message identifying the policy. Branch deletion is rejected.

Validation

Lint runs server-side inside the pre-receive hook (the same pipeline as PUT /manifest). Lint errors are streamed to stderr — git's CLI surfaces them prefixed with remote: — and the hook exits non-zero, so the ref does not move. Warnings do not block. On success, the hook produces a tar.gz cache via git archive, writes a manifest_versions row with source = "push", and bumps current_manifest_version atomically.

Concurrency and idempotency

Pushes serialize per flag namespace via an in-process mutex shared with PUT /manifest, so the two upload paths cannot interleave their version bumps. If the bare repo and database get out of sync (e.g., after a crash), the hook detects an existing manifest_versions row for the pushed commit and skips the duplicate write. Lazy backfill replays any pre-existing PUT /manifest versions into the bare repo on the first git request.

Body limits

Smart-HTTP pack uploads are capped at 200 MB (4× the multipart cap, since git history is included).

Token visibility

Authentication credentials are placed in the Authorization header. Using HTTP Basic with embedded URL credentials (http://token:<secret>@…) or the user's git credential helper exposes the secret in the local credential store. Operators should mint short-lived tokens for individual users.

Example workflow

# Clone — Basic creds via the git CLI's standard credential helper.
git clone https://exd.example.com/api/v1/tenants/acme/namespaces/payments/manifest.git
# (git prompts; username = `token`, password = the bearer secret)

cd payments
$EDITOR flags/payment-retry-v2.toml
git add -A
git commit -m "Bump retry budget to 8s"
git push origin main
# remote: exd: lint passed; recorded version 9 at 4f2c1ab9 (warnings: 0)

Example error (lint failure rejects the push)

$ git push origin main
remote: exd lint error [E004] flags/payment-retry-v2.toml:5: variant 'maybe' not declared in [flag.variants]
remote: exd: manifest validation failed with 1 error(s); push rejected
To https://exd.example.com/api/v1/tenants/acme/namespaces/payments/manifest.git
! [remote rejected] main -> main (pre-receive hook declined)
error: failed to push some refs

The previous version remains unchanged.

Deferred: branch protection / required reviewers, repo-level pack-bomb mitigation knobs, hosting bare repos on shared/replicated storage for multi-instance deployments, optional GPG-signed-commit enforcement.


POST /api/v1/tenants/{tenant}/namespaces/{namespace}/manifest/lint

Lint a manifest archive without uploading it or changing flag-namespace state. Useful for CI validation in pull-request checks before merging flag changes.

Auth required: manifest.read (any token type with flag-namespace read access).

Request

Content-Type: multipart/form-data

Form fieldTypeRequiredDescription
archivefile (tar.gz)yesThe manifest archive to lint.

Response: 200 OK

Returned even when lint errors are present — the HTTP status reflects whether the request itself succeeded, not whether the manifest is clean.

{
"namespace": "payments",
"passed": false,
"error_count": 1,
"warning_count": 2,
"info_count": 1,
"diagnostics": [
{
"code": "E006",
"severity": "error",
"file": "segments/checkout-redesign-rollout-10.toml",
"line": 34,
"message": "Bucket range must satisfy 0 <= start <= end <= 9999"
},
{
"code": "W003",
"severity": "warning",
"file": "flags/payment-retry-v2.toml",
"line": 1,
"message": "Flag has no rules in any environment and always returns its `value`"
}
],
"request_id": "01HV5XK9XFQT8N3JRDCP7MW04I"
}

Example

tar -czf payments.tar.gz -C payments/ .

curl -X POST https://exd.example.com/api/v1/tenants/acme/namespaces/payments/manifest/lint \
-H "Authorization: Bearer exd_read_4tRvBn9wKjMpXzQsUyAeCdFgHiJkLnOqRtWvYb" \
-F "archive=@payments.tar.gz"

See also