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:
| Channel | Points at | Rendered by |
|---|---|---|
published | Version 7 | Production (version left out) |
staging | Version 8 | Your 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" }
}
version | Renders | Keys | Plans |
|---|---|---|---|
left out or published | The published version (and its canary, if it has one) | live and test | all |
| a channel name | The version the channel points at (and its canary, if it has one) | live and test | Starter and above |
| a number | Exactly that version | live and test | all |
latest | The newest saved version, published or not | test only | all |
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).
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.
- In the app
- CLI
- API
- SDKs
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.
npx formfeed channels list invoice # versions, canaries and renders of the last 24 hours
npx formfeed channels set invoice staging 8 # create staging or move it to version 8
npx formfeed templates push invoice --channel staging # push the local files as a new version and move staging to it
templates push --channel moves the channel even when the files did not change: it then points the channel at the version recorded by your last pull or push. It sets the channel without a canary, so a canary that channel had ends.
curl -X PUT https://api-eu.formfeed.dev/v1/templates/invoice/channels/staging \
-H "Authorization: Bearer $FORMFEED_API_KEY" -H "Content-Type: application/json" \
-d '{ "version": 8 }'
| Body field | Type | Default | Meaning |
|---|---|---|---|
version | integer, required | The version the channel points at (its main version). | |
canary | object or null | null | { "version": 9, "percent": 10 }: a second version that gets percent (1 to 50) of the renders. Left out or null removes the canary. See Canary releases. |
allow_breaking | boolean | false | Go ahead although the schema check finds breaking changes. |
{
"name": "staging",
"version": 8,
"canary": null,
"previous_version": 6,
"updated_at": "2026-09-13T09:12:00Z",
"schema_check": { "source": "stored", "breaking": [], "safe": [] }
}
201 when the channel was created, 200 when it moved. A version number the template does not have answers 404 template_not_found. GET /templates/{id}/channels lists published and every named channel with usage_24h (renders and failed renders per version) and the plan's limits ({ "channels": 2, "canary": false }, channels is null when unlimited).
await client.templates.channels.set('invoice', 'staging', { version: 8 });
const { data, limits } = await client.templates.channels.list('invoice');
client.templates.channels.set("invoice", "staging", version=8)
channels = client.templates.channels.list("invoice")
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);
publishedcan have a canary too. template.canaryistrueon renders the canary share picked.- Setting a channel without
canaryremoves 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 orusage_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 sends | Renders on the same version |
|---|---|
canary_key | Every render with that value, e.g. all documents of one customer |
no canary_key | Only retries of the same request (its Idempotency-Key); separate renders are split independently, and without a key at random |
a batch without canary_key | All 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
| Action | What happens |
|---|---|
| Promote | The 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 back | The 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…. |
| Delete | Removes 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
| Free | Starter | Pro | Business | Enterprise | |
|---|---|---|---|---|---|
published | yes | yes | yes | yes | yes |
| Named channels per template | none | 2 | 10 | 10 | unlimited |
| Canary releases | no | no | yes | yes | yes |
- On the Free plan, creating or rendering a named channel answers
403 forbiddenwithfeature: "channels". After a downgrade to Free, renders that name a channel answer the same; renders ofpublishedkeep working. - Creating a channel beyond the plan's count answers
403withfeature: "channels"and thelimit. 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
403withfeature: "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, andPOST /templates/{id}/versionswith"publish": true); - moving a channel,
publishedincluded, 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.
source | When | Effect |
|---|---|---|
stored | Both versions store a data schema | Breaking changes refuse the request with 409 schema_breaking_change, unless it sends allow_breaking: true |
inferred | At least one version has no stored schema; schemas inferred from the sample data were compared | A warning only, never a refusal: an inferred schema is not a promise |
none | Nothing to compare: a new channel, the first publish, the same version, or no data at all | Nothing |
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.
| Breaking | Safe |
|---|---|
| A new required field | A new optional field |
| An optional field becomes required | A required field becomes optional |
A type narrowed: ["string", "null"] to "string", number to integer, {} to a type | A type widened |
| An enum value removed, or an enum added to a field that had none | An enum value added, or the enum removed |
const added or changed | const removed |
minimum, maximum, minLength, maxLength, minItems, maxItems and similar tightened; a pattern or format added or changed | Those constraints relaxed or removed |
additionalProperties becomes false | additionalProperties opened again |
| A field removed from an object that accepts no unlisted fields | A field removed from an open object (callers may keep sending it) |
| Any other keyword that differs, since the check cannot judge it | Descriptions, 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-breakingtotemplates push --publish,templates push --channel,channels set,channels promoteorchannels rollback. Without it the CLI prints the changes and exits with code1. - API: send
"allow_breaking": truein the body of the publish, channel or version request. - SDKs:
versions.publish(id, 8, { allowBreaking: true })andchannels.set(id, name, move, { allowBreaking: true })in TypeScript,publish(id, 8, allow_breaking=True)andchannels.set(…, allow_breaking=True)in Python.
Regenerate your types afterwards, so the callers' compilers see the change too.
A typical workflow
- Push to staging.
npx formfeed templates push invoice --channel stagingcreates a version from your files and pointsstagingat it. - Test. Your staging environment renders with
"version": "staging", with a test key so it costs nothing. - 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. - Or roll out gradually on Pro and above:
formfeed channels set invoice published 7 --canary 8 --percent 10, watchformfeed channels list invoice, thenformfeed channels promote invoice published. - 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.