Swarmz
White label

Branding automation reference

Read, validate, preview, publish, remove, and restore platform branding through the current HTTP endpoints

Use these advanced endpoints to automate Design and Code branding for one active platform account. The combined Code snapshot is the source for concurrency values, saved drafts, and the live release. Use the Code branding guide for the file contract and the Assets guide for managed-image references.

Choose an authentication method

Send credentials in an Authorization: Bearer ... header. The API does not select an account from a request field; it derives the account from the bearer credential and scopes every lookup to that account.

  • An owner or active platform admin can use the bearer token from a signed-in browser session.
  • An account owner can issue an sk_live_ key with the theme scope for server-side automation. A legacy key with the * scope also passes the theme check. Keep either key out of browser code and logs; its plaintext is shown only when it is issued.
  • Managed Assets, the legacy direct-image uploader, the full dashboard preview, version history, and rollback are browser-only surfaces. They require an owner/admin session and reject an sk_live_ key.
Method and pathOwner/admin sessionTheme keyBody limitRoute rate limit
PATCH /api/reseller/branding/configYesYes128 KiB JSONNo route-local bucket
GET /api/reseller/theme/raw-cssYesYesNoneShared theme bucket: 120 requests per IP per minute
PUT /api/reseller/theme/raw-cssYesYes400 KiB JSON; compiled CSS must be at most 200 KiBShared theme bucket: 120 requests per IP per minute
DELETE /api/reseller/theme/raw-cssYesYesNoneShared theme bucket: 120 requests per IP per minute
POST /api/reseller/theme/raw-css/publishYesYes4 KiB JSONShared theme bucket: 120 requests per IP per minute
POST /api/reseller/theme/rules/preview-tokenYesYesJSONShared theme bucket: 120 requests per IP per minute
GET /api/reseller/theme/contractYesYesNoneShared theme bucket: 120 requests per IP per minute
GET /api/reseller/theme/versionsYesNoNoneNo route-local bucket
POST /api/reseller/theme/rollbackYesNo4 KiB JSONShared theme bucket: 120 requests per IP per minute
GET /api/reseller/branding/assetsYesNoNoneAsset-read bucket: 120 requests per IP per minute
POST /api/reseller/branding/assetsYesNo4 MiB file; multipart envelope up to 4 MiB + 64 KiBAsset-write bucket: 20 requests per IP per minute
PATCH /api/reseller/branding/assets/{assetId}YesNo8 KiB JSON, or the multipart limits aboveAsset-write bucket: 20 requests per IP per minute
DELETE /api/reseller/branding/assets/{assetId}YesNoNoneAsset-write bucket: 20 requests per IP per minute
GET /t/{accountId}/theme-{hash}.cssNot required for live or archived CSSNot requiredNoneNo route-local bucket
GET /t/{accountId}/asset-{hash}.{extension}Not requiredNot requiredNoneNo route-local bucket

The shared theme limit is cumulative across routes that use that bucket. A limited request returns 429 with {"error":"rate_limited"} and a Retry-After header containing the remaining seconds in the current minute.

Read the branding snapshot

Request the paired semantic configuration, source CSS, live release, and recent drafts before writing:

The production API origin is https://swarmz.net. Set BASE=https://swarmz.net for a production request, or use the origin assigned to another environment.

curl --fail-with-body --silent --show-error \
  "$BASE/api/reseller/theme/raw-css" \
  -H "Authorization: Bearer $THEME_KEY"

The response has these top-level fields:

FieldMeaning
accountThemeCurrent live account theme. Treat it as account-private data.
revisionCurrent branding revision. Send this exact string as expectedRevision on the next draft or publish write.
liveLive Code artifact, or null. A live item includes hash, bytes, css, config, and publishedAt.
draftsUp to the ten newest saved artifacts. Each item includes hash, bytes, css, config, and createdAt. Older-format items can also include requiresUpgrade: true.

The account retains the newest 20 raw drafts even though this response returns at most ten. The current baseHash is the newest draft only when it was created after the live artifact was published; otherwise it is the live hash. It is null when neither exists.

Treat the authenticated snapshot as private, no-store data because it contains authored source and the complete semantic configuration. Do not put it in a shared cache or general-purpose log.

Revisions are PostgreSQL timestamps with up to six fractional digits. They are serialization tokens, not display dates. Preserve the returned text exactly: converting 2026-08-09T12:34:56.123456+00:00 through a millisecond-only date type can turn a current write into a 409 conflict.

To update the semantic Design configuration without replacing CSS, send PATCH /api/reseller/branding/config with brandingJson, expectedRevision, and baseHash. brandingJson is a serialized complete brand document. The response contains accountTheme, the new revision, and draftHash. Follow the Design branding guide for the semantic fields; use the Code draft endpoint when your change also includes styles.css or reusable components.

Save a Code draft

PUT /api/reseller/theme/raw-css validates and saves the complete Code bundle as one immutable draft. Send these JSON fields:

FieldTypeRequirement
cssstringComplete UTF-8 styles.css source. The complete request must be at most 400 KiB. Sanitized output must be at most 200 KiB.
brandingJsonstringSerialized complete brand document. Use the schema and values in the Code branding guide.
expectedRevisionstringExact revision from the current snapshot.
baseHashstring or nullCurrent 16-character lowercase hexadecimal artifact hash, or null when no artifact exists.

The dashboard edits components.json separately, then embeds that library under dashboard.surface.components in the brand document before sending the request. A direct client must preserve the same relationship. Do not add a second top-level request field for the component file.

The server validates both JSON layers, enforces account-scoped asset references, sanitizes and scopes CSS, ingests allowed external resources, and stores authored CSS separately from compiled CSS. The response contains:

FieldMeaning
hashThe saved artifact's 16-character lowercase hexadecimal content hash.
revisionThe new revision to use for the next write.
bytesCompiled CSS size in bytes.
strippedSanitizer report for removed selectors, at-rules, or declarations.
previewPathRelative immutable CSS path. A draft still needs a preview token before this path can be fetched.

Saving does not change customer-facing branding. Review stripped before previewing; a 200 response can still contain intentional sanitizer removals.

Preview a draft

Mint a preview capability for an artifact owned by the authenticated account:

{
  "hash": "0123456789abcdef"
}

Send that body to POST /api/reseller/theme/rules/preview-token. The response contains token and a relative previewUrl in this form:

/t/11111111-1111-4111-8111-111111111111/theme-0123456789abcdef.css?p=PREVIEW_TOKEN

The token is bound to the account and hash and expires after 30 minutes. The draft response uses Cache-Control: private, no-store. A missing, expired, future-dated, foreign, or mismatched token gets the same 404 not found response as a nonexistent artifact.

A theme key can mint and fetch the CSS preview URL. The full customer-dashboard preview at /platform/theme-preview?draft={hash} is browser-only because it also loads the private semantic configuration through the signed-in platform session. See Preview and publish in Code for that workflow.

Publish a draft

Publish only a saved draft that you have previewed. Send POST /api/reseller/theme/raw-css/publish with the draft hash, the same value in confirm, and the newest revision returned by the draft save:

{
  "hash": "0123456789abcdef",
  "confirm": "0123456789abcdef",
  "expectedRevision": "2026-08-09T12:34:56.123456+00:00"
}

confirm must equal hash. The server publishes the stored semantic document, compiled CSS, icon-pack binding, and pinned managed-asset versions together. It accepts a draft or an already-live artifact; a missing artifact returns 404, while an archived or concurrently changed artifact returns 409.

A successful response contains ok: true, hash, accountTheme, and a new revision. Read the live snapshot again and compare live.hash with the published hash before treating the deployment as verified.

Unpublish CSS

The dashboard removes live CSS with:

curl --fail-with-body --silent --show-error -X DELETE \
  "$BASE/api/reseller/theme/raw-css" \
  -H "Authorization: Bearer $THEME_KEY"

This take-down path does not require expectedRevision and remains available when authoring features are disabled. It publishes a release with empty compiled CSS while preserving the live semantic brand document, pinned assets, and saved drafts. The response contains ok: true, hash (a 16-character release hash or null), accountTheme, and revision.

Direct API clients can make the compatible removal request through the publish route:

{
  "hash": null,
  "confirm": "clear"
}

The dashboard does not use this POST shape. Neither removal method restores an older version; use rollback only when you intend to restore the complete recorded snapshot.

Read the styling contract

GET /api/reseller/theme/contract returns the current machine-readable styling surface. Read it at runtime instead of pinning a catalog size or value. Its fields include:

  • contractVersion and starterCss;
  • parts with name, status, optional variants, optional state names, and description;
  • universal states, property grammars, tokens, modes, breakpoints, and caps;
  • customer-dashboard route, component, and action manifests.

Use only parts whose status is implemented. State names do not define their DOM selector syntax. The Code branding guide explains the current selector, sanitizer, resource, token, and component-file boundaries without duplicating the live contract here.

List and restore versions

GET /api/reseller/theme/versions returns {"versions":[...]} with the latest 50 records in descending version order. Each record contains version, note, and created_at. The response is browser-session only and sends Cache-Control: private, no-store.

POST /api/reseller/theme/rollback accepts one positive integer in a request of at most 4 KiB:

{
  "version": 7
}

Success returns {"ok":true}. The restore is account-scoped and rechecks the owner/admin role inside the database transaction. 404 not_found means the account or version was not found. A snapshot that cannot be restored returns 409 with {"error":"invalid_snapshot"}.

The Branding dashboard has no rollback button. Call this endpoint only from an owner/admin browser session, then read the current snapshot and verify the complete customer-facing result.

Manage assets

The managed library is browser-only and account-scoped. The Assets guide is the canonical reference for upload choices, aliases, immutable versions, archive blockers, and assets. references.

List assets

GET /api/reseller/branding/assets accepts these query parameters:

ParameterRequirement
kindOptional: logo, icon, background, image, or font-family.
qOptional case-insensitive name search, at most 100 characters before trimming.
cursorOptional opaque base64url cursor returned by the previous page. Do not construct it.

Each page contains at most 50 assets and returns assets, nextCursor, quota, and brokenRefs. An asset includes id, name, aliases, kind, archivedAt, currentVersion, usageCount, and createdAt. currentVersion, when present, contains id, version, mime, bytes, and url. The response sends Cache-Control: no-store.

Create or replace an image

Create an asset with POST /api/reseller/branding/assets and multipart fields kind, name, and file. kind must be logo, icon, background, or image. The trimmed name must contain 1–120 characters. The file must contain at least 1 byte and no more than 4 MiB; the complete multipart request can add up to 64 KiB of form overhead. Success returns 201 with assetId, versionId, numeric version, and boolean created.

Replace file content with multipart PATCH /api/reseller/branding/assets/{assetId} containing kind and file. Do not include name in a replacement request. A replacement creates another immutable version and returns the same four fields as create. These endpoints create image versions only.

Change metadata or archive an asset

A JSON PATCH request must contain exactly one operation:

Request fieldConstraintSuccess response
nameTrimmed length 1–120renamed: true, name
addAliasValid alias from the Assets guideadded: true, alias
removeAliasValid existing aliasremoved: true, alias, empty blockingRefs

Sending none or more than one returns 400 bad_request. Alias removal returns 409 with removed: false and blockingRefs when a live or saved brand document still uses the alias.

DELETE /api/reseller/branding/assets/{assetId} archives the asset. Success returns archived: true and empty blockingUsages. A referenced asset returns 409 with archived: false and the blocking usages; remove or replace those references before retrying.

Deliver immutable files

Published file URLs contain the content hash and never change in place:

PathAccess and caching
/t/{accountId}/theme-{hash}.css?p={token}A draft needs its 30-minute preview token and returns Cache-Control: private, no-store.
/t/{accountId}/theme-{hash}.cssA live or archived 16-character hash is public and returns Cache-Control: public, max-age=31536000, immutable.
/t/{accountId}/asset-{hash}.{extension}A verified 64-character SHA-256 asset is public and immutable for one year. Accepted delivery extensions are png, jpg, webp, avif, gif, woff, and woff2. Query strings are rejected.

There is no bearer header on public immutable delivery. Do not put secrets in CSS or assets. Invalid account IDs, hashes, extensions, tokens, storage metadata, bytes, or signatures all fail closed with plain 404 not found.

Handle errors and conflicts

Read the HTTP status before the body. Error bodies deliberately omit account, artifact, storage, and database details.

StatusCurrent error shapesAction
400bad_request, bad_value, confirm_mismatch, sanitizer text, or asset errors such as bad_query, bad_kind, bad_name, bad_alias, bad_asset_id, and no_fileFix the named field. When supplied, use path and error_description to locate a brand-document error.
401{"error":"unauthorized","reason":"invalid_key"}Replace or rotate the invalid or revoked key.
401{"error":"unauthorized","reason":"account_disabled"}The key's account is not active. Ask the account owner or platform operator to reactivate it; replacing the key or signing in again does not recover access.
403{"error":"unauthorized","reason":"insufficient_scope"}Use a key with the theme scope.
403{"error":"forbidden"}Use an active owner/admin browser session for a browser-only endpoint, or ask the account owner to restore your membership.
403{"error":"feature_disabled"} with an optional pathStop new authoring until the named capability is enabled. CSS removal remains available.
404not_found, or plain not found from immutable deliveryRe-read the account-scoped snapshot or asset list. Do not retry a foreign or missing publish target.
409Detail-free conflict, alias_taken, invalid_snapshot, or a blocked asset mutation responseReload current state. Do not overwrite it with stale values. Resolve listed asset blockers before retrying.
413payload_too_large, theme_too_large, too_large, or quota_exceededReduce the complete request body, processed image or account usage, or serialized account-theme snapshot according to the endpoint limit. A compiled-CSS limit failure is sanitizer text with 400, not 413.
429rate_limited with Retry-AfterWait for the current minute window, then retry the complete operation.
500server_error, internal_error, or upload_failedKeep the previous known-good state, then retry after checking service health.

A 409 {"error":"conflict"} means expectedRevision, baseHash, or the target artifact no longer matches the serialized server state. The response does not say which private value changed. GET the snapshot again, choose its current base, reapply your intended edit, save a new draft, preview it, and publish with the revision returned by that save.

Complete API workflow

This example requires curl, jq, Node.js, a complete brand.json, and a complete styles.css. It assumes brand.json is already transport-complete. If you maintain a separate components.json, embed its library under dashboard.surface.components first as described in Code branding.

Set THEME_KEY in your shell. The workflow deliberately defaults BASE to the reserved, non-routable https://example.invalid origin so it cannot write to an environment by accident. Replace it with https://swarmz.net for production or the assigned origin for another environment before running the workflow.

set -euo pipefail

: "${THEME_KEY:?Set THEME_KEY to a theme-scoped key}"
BASE="${BASE:-https://example.invalid}"
AUTH_HEADER="Authorization: Bearer ${THEME_KEY}"
PREVIEW_CSS="$(mktemp "${TMPDIR:-/tmp}/swarmz-preview.XXXXXX")"
trap 'rm -f "$PREVIEW_CSS"' EXIT

# 1. Read one authoritative paired snapshot.
SNAPSHOT="$(curl --fail-with-body --silent --show-error \
  "$BASE/api/reseller/theme/raw-css" \
  -H "$AUTH_HEADER")"

# 2. Preserve its microsecond revision and select the same current base as Code.
REVISION="$(jq -er '.revision | strings' <<<"$SNAPSHOT")"
BASE_HASH="$(node -e '
  const data = JSON.parse(require("node:fs").readFileSync(0, "utf8"));

  function parseRevision(value) {
    if (typeof value !== "string") return null;
    const match = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?(Z|([+-])(\d{2})(?::?(\d{2}))?)$/.exec(value);
    if (!match) return null;

    const year = Number(match[1]);
    const month = Number(match[2]);
    const day = Number(match[3]);
    const hour = Number(match[4]);
    const minute = Number(match[5]);
    const second = Number(match[6]);
    const isLeapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
    const maximumDay = [31, isLeapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
    const offsetHour = Number(match[10] ?? "0");
    const offsetMinute = Number(match[11] ?? "0");
    if (
      maximumDay === undefined
      || day < 1
      || day > maximumDay
      || hour > 23
      || minute > 59
      || second > 59
      || offsetHour > 23
      || offsetMinute > 59
    ) return null;

    const utc = new Date(0);
    utc.setUTCFullYear(year, month - 1, day);
    utc.setUTCHours(hour, minute, second, 0);
    const epochMillis = utc.getTime();
    if (!Number.isFinite(epochMillis)) return null;
    const offsetMagnitude = (offsetHour * 60 * 60) + (offsetMinute * 60);
    const offsetSeconds = match[9] === "-" ? -offsetMagnitude : offsetMagnitude;
    return {
      epochSecond: Math.floor(epochMillis / 1000) - offsetSeconds,
      microsecond: Number((match[7] ?? "").padEnd(6, "0")),
    };
  }

  function compareRevisions(left, right) {
    const parsedLeft = parseRevision(left);
    const parsedRight = parseRevision(right);
    if (!parsedLeft || !parsedRight) return null;
    if (parsedLeft.epochSecond !== parsedRight.epochSecond) {
      return parsedLeft.epochSecond < parsedRight.epochSecond ? -1 : 1;
    }
    if (parsedLeft.microsecond === parsedRight.microsecond) return 0;
    return parsedLeft.microsecond < parsedRight.microsecond ? -1 : 1;
  }

  const live = data.live ?? null;
  const drafts = Array.isArray(data.drafts) ? data.drafts : [];
  const draft = drafts.reduce((newest, candidate) => {
    if (!candidate || !parseRevision(candidate.createdAt)) return newest;
    if (!newest) return candidate;
    return compareRevisions(candidate.createdAt, newest.createdAt) === 1
      ? candidate
      : newest;
  }, null);
  const selected = !draft
    ? live
    : !live
      ? draft
      : compareRevisions(draft.createdAt, live.publishedAt) === 1
        ? draft
        : live;
  const selectedHash = selected?.hash ?? "";
  if (typeof selectedHash !== "string") {
    throw new Error("Snapshot contains an invalid artifact hash");
  }
  process.stdout.write(selectedHash);
' <<<"$SNAPSHOT")"

# 3. Send the complete stylesheet and serialized brand document as one draft.
BRANDING_JSON="$(jq -c . brand.json)"
DRAFT_REQUEST="$(jq -n \
  --rawfile css styles.css \
  --arg brandingJson "$BRANDING_JSON" \
  --arg expectedRevision "$REVISION" \
  --arg baseHash "$BASE_HASH" \
  '{
    css: $css,
    brandingJson: $brandingJson,
    expectedRevision: $expectedRevision,
    baseHash: (if $baseHash == "" then null else $baseHash end)
  }')"
DRAFT="$(curl --fail-with-body --silent --show-error -X PUT \
  "$BASE/api/reseller/theme/raw-css" \
  -H "$AUTH_HEADER" \
  -H "Content-Type: application/json" \
  --data-binary "$DRAFT_REQUEST")"

# 4. Keep the saved hash, newest revision, and uncredentialed preview path.
DRAFT_HASH="$(jq -er '.hash | select(test("^[a-f0-9]{16}$"))' <<<"$DRAFT")"
DRAFT_REVISION="$(jq -er '.revision | strings' <<<"$DRAFT")"
PREVIEW_PATH="$(jq -er '.previewPath | strings' <<<"$DRAFT")"
printf 'Saved draft %s at %s\n' "$DRAFT_HASH" "$PREVIEW_PATH"

# 5. Mint the 30-minute capability and inspect this exact compiled stylesheet.
PREVIEW_REQUEST="$(jq -n --arg hash "$DRAFT_HASH" '{hash: $hash}')"
PREVIEW="$(curl --fail-with-body --silent --show-error -X POST \
  "$BASE/api/reseller/theme/rules/preview-token" \
  -H "$AUTH_HEADER" \
  -H "Content-Type: application/json" \
  --data-binary "$PREVIEW_REQUEST")"
PREVIEW_URL="$(jq -er '.previewUrl | strings' <<<"$PREVIEW")"
curl --fail-with-body --silent --show-error "$BASE$PREVIEW_URL" -o "$PREVIEW_CSS"
printf 'Saved temporary preview CSS at %s\n' "$PREVIEW_CSS"

# 6. Publish the previewed hash with the revision returned by its save.
PUBLISH_REQUEST="$(jq -n \
  --arg hash "$DRAFT_HASH" \
  --arg expectedRevision "$DRAFT_REVISION" \
  '{hash: $hash, confirm: $hash, expectedRevision: $expectedRevision}')"
curl --fail-with-body --silent --show-error -X POST \
  "$BASE/api/reseller/theme/raw-css/publish" \
  -H "$AUTH_HEADER" \
  -H "Content-Type: application/json" \
  --data-binary "$PUBLISH_REQUEST" >/dev/null

# 7. Verify that the live snapshot points to the exact published hash.
LIVE="$(curl --fail-with-body --silent --show-error \
  "$BASE/api/reseller/theme/raw-css" \
  -H "$AUTH_HEADER")"
LIVE_HASH="$(jq -er '.live.hash | strings' <<<"$LIVE")"
test "$LIVE_HASH" = "$DRAFT_HASH"
printf 'Verified live hash %s\n' "$LIVE_HASH"

If any state-changing request returns 409, stop this sequence. Read a new snapshot and rebuild the draft from the current server state instead of replaying the stale request.

On this page