Skip to main content

Release channels

A release channel is a named pointer from a template to one of its versions. Your code renders the channel, and moving the pointer decides which version it gets, without a deploy on your side.

Every template has the channel published: publishing a version moves it, and it is what a render uses when it names no version. Named channels such as staging or beta sit beside it. A typical setup:

ChannelPoints atRendered by
publishedVersion 7Production (version left out)
stagingVersion 8Your staging environment ("version": "staging")

When version 8 looks right on staging, you publish it (or move published to it), and production renders it within a minute. If it turns out wrong, roll back and version 7 is live again.

Render a channel​

Pass the channel's name as version:

{
"template": "invoice",
"version": "staging",
"data": { "number": "RE-2026-001" }
}
versionRendersKeysPlans
left out or publishedThe published version (and its canary, if it has one)live and testall
a channel nameThe version the channel points at (and its canary, if it has one)live and testStarter and above
a numberExactly that versionlive and testall
latestThe newest saved version, published or nottest onlyall

latest is the newest version anyone saved, in the editor or with formfeed templates push; unsaved edits in an open editor are in no version. A live key that sends latest gets 400 invalid_request. Unlike latest, a channel is a deliberate decision someone made, so live keys may render it. Prefer a channel over latest for a staging environment: it changes only when someone moves it, not with every save.

The render records where the version came from:

"template": { "id": "tpl_03b04e8c168dfc7aa5fdcdcf3f54489c", "slug": "invoice", "version": 8, "channel": "staging", "canary": false }

channel is null when the request named a number or latest. The same template object is in GET /renders/{id} and in the webhook events.

A channel the template does not have answers 404 channel_not_found. A template without a published version still answers 404 template_not_found for published. On the Free plan, any channel other than published answers 403 forbidden (see Plans).

Moves take up to a minute

The API caches what a channel points at for 60 seconds, the same as for publishing. After a move, renders pick up the new version within a minute; some may still use the previous one until then.

GET /templates/{id}/versions/staging reads the channel's version with all its files, the same way as published or a number. The same name works wherever a version is read or checked: GET /templates/{id}/schema?version=staging returns that version's data schema, and POST /templates/{id}/validate with "version": "staging" checks data against it.

In Zapier, Make and n8n​

  • Zapier: the Create Document action shows a Version field once the template has a channel besides published, listing each channel with the version it renders; a template without named channels shows no version field at all.
  • Make: the Create a document module works the same way, and lets you map a version number into the select.
  • n8n: the node's Version Name or ID option (under the node's options) always lists Published and every named channel with its version, and takes a version number as an expression.

Channel names​

1 to 32 lowercase letters, digits and dashes, starting with a letter: staging, beta, customer-a. latest and draft are reserved, and published always exists and cannot be deleted (DELETE …/channels/published answers 400 invalid_request). Any other name, for example Staging or 2026-q4, answers 422 channel_name_invalid.

Move a channel​

Moving a channel that does not exist yet creates it. Moving published is the same as publishing that version: the previously published version is archived. Moving published works on every plan; every other channel needs Starter or above. Every move remembers where the channel pointed before as previous_version, which is what roll back returns to.

The templates list shows each template's named channels in its Channels column. To change them, open the template in the editor and then the Versions panel:

  • The Channels section at the top lists every channel with the version it points at, its canary (Canary v8 · 10 %) and, per version, the renders and failed renders of the last 24 hours.
  • Every version row has Move channel here: pick a channel to point it at this version, or New channel… to create one there. Each version also shows the named channels that point at it.
  • The ⋮ menu beside a channel (Channel actions) has Start canary… (or Change canary…), Promote canary v…, End canary, Roll back to v… and, for named channels, Delete channel.

Owners, admins and editors move channels; on the Free plan the panel says that channels start with Starter.

Writing channels needs the template:write scope, listing them template:read.

Canary releases​

A channel can send a share of its renders to a second version, the canary, while the rest keeps using the main version. Use it to try a new version on real traffic before everyone gets it.

npx formfeed channels set invoice published 7 --canary 8 --percent 10
PUT /v1/templates/invoice/channels/published

{ "version": 7, "canary": { "version": 8, "percent": 10 } }
  • The share is a whole number from 1 to 50 percent, and the canary must be a different version than the main one; otherwise the API answers 400 invalid_request.
  • Canary releases need Pro or above (Plans); published can have a canary too.
  • template.canary is true on renders the canary share picked.
  • Setting a channel without canary removes its canary. To stop a canary, set the channel to its main version again (End canary in the app).
  • Compare the canary's failures with the main version's in formfeed channels list, the Versions panel or usage_24h, then promote or remove it. Nothing is promoted or rolled back automatically.

Who gets the canary​

Each render is assigned to the main version or the canary by a hash, so the share holds on average and the same input always lands on the same side. What goes into the hash:

The request sendsRenders on the same version
canary_keyEvery render with that value, e.g. all documents of one customer
no canary_keyOnly retries of the same request (its Idempotency-Key); separate renders are split independently, and without a key at random
a batch without canary_keyAll items of that batch; set canary_key on the batch or on single items to split by customer instead

Pass canary_key when documents that belong together should look alike, for example an order confirmation and the invoice of the same order:

{ "template": "invoice", "data": { "…": "…" }, "canary_key": "customer-4711" }

canary_key is a string of 1 to 200 characters. On a batch, a top-level canary_key applies to every item that has none of its own. Use an opaque id rather than an email address; the value is only hashed and never stored. Do not reuse an Idempotency-Key for this: the API answers a repeated key with the first render's stored response for 24 hours, so every later document would be the first one again.

Promote and roll back​

ActionWhat happens
PromoteThe canary becomes the channel's main version and the canary is removed; the old main version becomes previous_version, so a rollback undoes the promotion. Answers 400 invalid_request when the channel has no canary. In the app: Promote canary v….
Roll backThe channel returns to the version it pointed at before its last move (previous_version) and any canary is removed. A second rollback returns to where the first started. Answers 400 invalid_request when there is no earlier version. In the app: Roll back to v….
DeleteRemoves a named channel. Refused with 409 channel_in_use while renders of the last hour used it, unless you force it. Renders that still name it then fail with 404 channel_not_found. In the app: Delete channel, then Delete anyway when it is in use.

Promote and roll back take allow_breaking in their body like a move, because the schema check runs for them too.

npx formfeed channels promote invoice published
npx formfeed channels rollback invoice published
npx formfeed channels delete invoice beta --force
await client.templates.channels.promote('invoice', 'published');
await client.templates.channels.rollback('invoice', 'published');
await client.templates.channels.delete('invoice', 'beta', { force: true });
client.templates.channels.promote("invoice", "published")
client.templates.channels.rollback("invoice", "published")
client.templates.channels.delete("invoice", "beta", force=True)

Over HTTP these are POST …/channels/{name}/promote, POST …/channels/{name}/rollback and DELETE …/channels/{name}?force=true.

Plans​

FreeStarterProBusinessEnterprise
publishedyesyesyesyesyes
Named channels per templatenone21010unlimited
Canary releasesnonoyesyesyes
  • On the Free plan, creating or rendering a named channel answers 403 forbidden with feature: "channels". After a downgrade to Free, renders that name a channel answer the same; renders of published keep working.
  • Creating a channel beyond the plan's count answers 403 with feature: "channels" and the limit. After a downgrade from Pro to Starter, all existing channels keep working and can still be moved; only new ones are refused.
  • Setting a canary without canary releases answers 403 with feature: "canary". A channel that already has a canary renders only its main version on such a plan, so a downgrade never breaks renders.

The schema check​

Before a version goes live, Formfeed compares its data schema with the schema of the version callers use now. It runs whenever a channel is about to point at another version:

  • publishing (POST /templates/{id}/versions/{number}/publish, and POST /templates/{id}/versions with "publish": true);
  • moving a channel, published included, for the main version and the canary;
  • promoting and rolling back.

The response carries the result as schema_check:

"schema_check": {
"source": "stored",
"breaking": [
{ "path": "customer.email", "pointer": "/properties/customer/properties/email", "kind": "field-became-required", "breaking": true, "message": "\"customer.email\" is now required" }
],
"safe": [
{ "path": "notes", "pointer": "/properties/notes", "kind": "field-added-optional", "breaking": false, "message": "New optional field \"notes\"" }
]
}

Each change names the field as a dotted path (items[] for the items of an array, empty for the whole data), a JSON pointer into the schema, whether it is breaking, a readable message and its kind: field-added-required, field-added-optional, field-removed, field-became-required, field-became-optional, type-narrowed, type-widened, enum-values-removed, enum-values-added, enum-added, enum-removed, const-changed, constraint-tightened, constraint-relaxed, additional-properties-closed, additional-properties-opened or keyword-changed. A refused request carries the same breaking and safe lists in its problem.

sourceWhenEffect
storedBoth versions store a data schemaBreaking changes refuse the request with 409 schema_breaking_change, unless it sends allow_breaking: true
inferredAt least one version has no stored schema; schemas inferred from the sample data were comparedA warning only, never a refusal: an inferred schema is not a promise
noneNothing to compare: a new channel, the first publish, the same version, or no data at allNothing

What counts as breaking​

The check looks at the schema from the side of the caller, who sends data the template reads. A change is breaking when data that fitted the old schema may be refused by the new one.

BreakingSafe
A new required fieldA new optional field
An optional field becomes requiredA required field becomes optional
A type narrowed: ["string", "null"] to "string", number to integer, {} to a typeA type widened
An enum value removed, or an enum added to a field that had noneAn enum value added, or the enum removed
const added or changedconst removed
minimum, maximum, minLength, maxLength, minItems, maxItems and similar tightened; a pattern or format added or changedThose constraints relaxed or removed
additionalProperties becomes falseadditionalProperties opened again
A field removed from an object that accepts no unlisted fieldsA field removed from an open object (callers may keep sending it)
Any other keyword that differs, since the check cannot judge itDescriptions, titles, examples and defaults

Going ahead anyway​

Sometimes a breaking change is intended, for example because every caller already sends the new field. Then say so:

  • App: the publish dialog lists the breaking changes; choose Publish anyway (Move anyway when you move a channel).
  • CLI: add --allow-breaking to templates push --publish, templates push --channel, channels set, channels promote or channels rollback. Without it the CLI prints the changes and exits with code 1.
  • API: send "allow_breaking": true in the body of the publish, channel or version request.
  • SDKs: versions.publish(id, 8, { allowBreaking: true }) and channels.set(id, name, move, { allowBreaking: true }) in TypeScript, publish(id, 8, allow_breaking=True) and channels.set(…, allow_breaking=True) in Python.

Regenerate your types afterwards, so the callers' compilers see the change too.

A typical workflow​

  1. Push to staging. npx formfeed templates push invoice --channel staging creates a version from your files and points staging at it.
  2. Test. Your staging environment renders with "version": "staging", with a test key so it costs nothing.
  3. Go live. Publish the version (formfeed channels set invoice published 8, or Publish in the editor). The schema check runs; fix the data or allow the change.
  4. Or roll out gradually on Pro and above: formfeed channels set invoice published 7 --canary 8 --percent 10, watch formfeed channels list invoice, then formfeed channels promote invoice published.
  5. Undo if needed: formfeed channels rollback invoice published.

In CI, the step that used templates push --publish on the default branch can push to staging on every merge instead, and publishing becomes a separate, manual step.