API reference
The integration API
Register what you deploy, from any CI, and read the catalog back as JSON. Base URL https://blueforge.studio. Requests and responses are JSON unless noted.
Overview
| Endpoint | Auth | Purpose |
|---|---|---|
POST /api/deployments | Org key or deploy token | Register a deployment and its sidecar |
DELETE /api/deployments/:repo/:app | Deploy token | Retire a registration |
GET /api/deployments | Public | List registrations |
GET /api/products | Public | The merged catalog |
GET /api/products/:id/assets | Public | Asset manifest for one product |
GET /api/products/:id/assets/:kind | Public | Rendered image bytes |
PUT / DELETE …/assets/:kind | Deploy token | Upload or remove an asset |
GET /api/search | Public | Catalog search |
GET /api/blog | Public | Published posts |
/feed.xml, /sitemap.xml | Public | RSS and sitemap |
Errors are JSON with an error string; validation failures (422) add an issues array with the path of each problem.
Authentication
Org API key (your CI)
Send Authorization: Bearer <key> with an API key minted for your organization on forge-auth. The site resolves the key with forge-auth and takes the organization it returns as the tenant for the write. The tenant always comes from the key, never from the request body — a tenant_id field in the body is ignored.
An org admin mints the key on forge-auth with POST /api/v1/keys/org, authenticated with their own key:
curl -X POST https://auth.blueforge.studio/api/v1/keys/org \
-H "Authorization: Bearer $YOUR_FORGE_AUTH_KEY" \
-H "Content-Type: application/json" \
-d '{ "organizationId": "<your-org-uuid>", "name": "ci-register", "expiresInDays": 90 }'
# 201 { key, keyId, name, scopes, organizationId, expiresAt } — the key is shown onceThe caller must be an owner or admin of the organization. expiresInDays is 1–90; omit it for a key that does not expire. forge-auth answers 409 ORGS_DISABLED where organizations are not enabled.
Deploy token (the platform)
X-Forge-Deploy-Token is the shared token forge-control uses for platform deploys. It writes as the platform tenant, and it is the only credential the retire and asset-upload routes accept today. When an Authorization: Bearer header is present it always wins; the two are never combined.
401 or 403
| Status | Body | Meaning |
|---|---|---|
401 | { "error": "unauthorized" } | The key is unknown, revoked, expired, not bound to an organization, or could not be resolved — or the deploy token is wrong or missing. |
403 | { "error": "tenant-mismatch", "keyOrgId", "rowOrgId" } | The key is valid, but this (repo, app) is already registered to a different organization. The first organization to register a pair owns it. |
503 | { "error": "deployments endpoint not configured" } | Deploy-token path only: the server has no token configured. |
Register a deployment
/api/deployments
Bearer org key · or X-Forge-Deploy-TokenRecords that an app is live: upserts the (repo, app) row the catalog reads and appends to the deploy log. Call it after a successful deploy. Treat a failure as a warning — the catalog should never be able to fail your release.
| Field | Type | Notes |
|---|---|---|
repo | string, required | Lowercase a-z, 0-9, hyphens; 2–80 characters. |
app | string, required | Lowercase a-z, 0-9, . _ -; up to 80 characters. |
url | URL | Where the app answers. |
role | string | e.g. site or app; up to 50 characters. |
commit | string | Commit sha, up to 64 characters. |
deployment_id | string | Your deploy's id. One of deployment_id or commit is required. |
deployed_at | ISO datetime | Defaults to now. |
image | string | Container image reference, optional. |
source | enum | forge-control (default), manual, backfill. |
product | object | The contents of blueforge.product.yml — the whole file ({ version, products }, first entry used) or one product's fields. |
features | object | The contents of blueforge.features.yml, or one row; the row whose product matches is merged in, and top-level promotions are kept. |
curl -X POST https://blueforge.studio/api/deployments \
-H "Authorization: Bearer $FORGE_ORG_KEY" \
-H "Content-Type: application/json" \
-d '{
"repo": "acme-store",
"app": "site",
"url": "https://acme.example.com",
"role": "site",
"commit": "'"$(git rev-parse HEAD)"'",
"deployment_id": "'"$GITHUB_RUN_ID"'",
"source": "manual",
"product": { "version": "2.0.0", "products": [ { "id": "acme-store", "name": "Acme Store", "apps": ["site"] } ] },
"features": { "version": "2.0.0", "features": [ { "product": "acme-store", "features": ["Stock synced across channels"] } ] }
}'| Status | Meaning |
|---|---|
201 | { ok: true, deployment: { repo, app, url, productId, logged: true } } — recorded. |
200 | Same body with logged: false: this deployment_id was already recorded (a retry); nothing new was written. |
400 | The body is not valid JSON. |
401 / 403 | See Authentication. |
422 | { error: "validation failed", issues }. |
503 | The registry database is not configured. |
A registration updates the pre-rendered pages straight away (for a client organization, only its own site). When the product's primary app registers without declaring assets, the site then reads the app's live <head> for a logo and social card — see Asset sync.
From GitHub Actions
An example step: convert the sidecars to JSON, build the body, post it.
# .github/workflows/deploy.yml (after your own deploy step)
- name: Register with BlueForge
env:
FORGE_ORG_KEY: ${{ secrets.FORGE_ORG_KEY }}
run: |
PRODUCT=$(npx --yes yaml@2 --json < blueforge.product.yml)
FEATURES=$(npx --yes yaml@2 --json < blueforge.features.yml)
jq -n --arg sha "$GITHUB_SHA" --arg run "$GITHUB_RUN_ID" \
--argjson product "$PRODUCT" --argjson features "$FEATURES" \
'{repo:"acme-store", app:"site", url:"https://acme.example.com",
commit:$sha, deployment_id:$run, source:"manual",
product:$product[0], features:$features[0]}' \
| curl -fsS -X POST https://blueforge.studio/api/deployments \
-H "Authorization: Bearer $FORGE_ORG_KEY" \
-H "Content-Type: application/json" --data-binary @-Retire a registration
/api/deployments/:repo/:app
X-Forge-Deploy-TokenRemoves the app from the registry so the site stops linking to it, and logs the reason. Only for a real removal: the next successful registration of the same pair brings it back. Body: { "reason": "…" } (3–500 characters, required).
curl -X DELETE https://blueforge.studio/api/deployments/acme-store/site \
-H "X-Forge-Deploy-Token: $FORGE_DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "reason": "Site shut down" }'200 { ok: true, retired: { … } }; 404 when no such registration exists; 400 without a JSON body; 422 for an invalid reason; 401 for a bad token. This route does not accept org keys yet — ask us to retire an app registered from your CI.
List registrations
/api/deployments
Public · CORS *Every registered (repo, app). Optional ?tenant=<org-uuid> limits the list to one organization; a value that is not a UUID is a 400. Never cached.
curl "https://blueforge.studio/api/deployments?tenant=<org-uuid>"
# { configured, count, deployments: [ { repo, app, productId, url, role, commit,
# deployedAt, hasProduct, updatedAt, tenantId } ] }The catalog
/api/products
Public · CORS *The merged catalog exactly as the site renders it — registry rows and sidecar copy, with links, status and image URLs resolved.
| Query | Meaning |
|---|---|
tenant | A forge-auth organization UUID. Returns only that organization's registered products — nothing else is mixed in. Not a UUID → 400. Omitted → the BlueForge catalog. |
curl "https://blueforge.studio/api/products?tenant=<org-uuid>"
# { generatedAt, count, degraded, sources: { registry, catalog }, products: [ … ] }Each product carries id, name, tagline, description, status, ecosystems, features, categories, tags, pricing, links, assets, source (deploy or catalog), deployedAt, commit and repo, among others.
Degraded answers
When the registry cannot be read the response saysdegraded: true, sets the header x-catalog-degraded: registry-unavailable and is sent with no-store. Do not treat a degraded answer as the complete catalog. Healthy answers are cacheable for 60 seconds.Product assets
/api/products/:id/assets
Public · CORS *The manifest: { product, assets: [ { kind, slot, sourceSha256, source, variants: { "<variant>": { url, width, height, bytes } } } ] }. Compare sourceSha256 with your file to decide whether to upload.
/api/products/:id/assets/:kind
PublicThe rendered bytes. kind is logo, og, cover, hero or screenshot; ?v= picks the variant (defaults: logo 512, og 1200x630, others 1024); ?slot= picks a screenshot (0–99). Supports If-None-Match. When ?s= carries the asset's source sha the response is cached as immutable.
/api/products/:id/assets/:kind
X-Forge-Deploy-TokenUploads one original image (raw body, Content-Type: image/*, up to 10 MB); the site renders every size itself. 201 when renditions were written, 200 when the stored original already had this sha. Headers, statuses and the sync loop are on Asset sync. DELETE …/:kind?slot=N removes one (404 when there was nothing to remove).
Search
/api/search
Public?q= (1–100 characters, required) and ?limit= (default 12, at most 24). The query may include ecosystem:<id> and status:<value> filters. On a promotion site's own host, results are that site's products only.
curl "https://blueforge.studio/api/search?q=inventory&limit=5"
# { results: [ { id, name, tagline, ecosystems, status, domain, logo, icon, color,
# gradient, detailHref, rank, highlights: { name, tagline } } ], total, query }400 { error: "Invalid query", details } when q is missing or too long.
Blog, feed and sitemap
/api/blog
Public{ posts: [ … ] } — every published post. On a promotion site's host, that site's posts.
/feed.xml
PublicRSS 2.0 for the blog (cached for an hour at the edge). A promotion site serves its own feed on its host.
/sitemap.xml
PublicEvery public page, product page and post. A promotion site serves its own sitemap, on its own origin, listing only its pages.
Site forms
Two public endpoints back the forms on this site. They are rate limited per IP and are not an integration surface, but they are here for completeness.
/api/contact
Public · 10 requests/minute{ email, message, firstName?, subject? } — message 10–5000 characters; subject is a short lowercase slug naming where the visitor came from (anything else is dropped, not rejected). The message is saved first, then a notification is sent. 200 on success (with queued: true when only the save succeeded), 422 for invalid input, 429 when rate limited, 500 when it could not be saved.
/api/send
Public · 5 requests/hourNewsletter or waitlist signup: { email, firstName?, intent?, source?, locale? }. intent is newsletter or waitlist, source a short lowercase slug naming where the signup happened, locale a two-letter language code passed on to the newsletter service; malformed values are dropped, not rejected. 200 { ok: true, subscriberId }, or { ok: true, queued: true } when the signup was saved but delivery to the newsletter service is pending; 422, 429 and 500 as above.