Pushing to a collection
Pushing entries to a collection
A collection sync is the CMS deciding when to look. Some sources know the
moment they changed, and no API answers for them: a repository's CHANGELOG.md, its contributor
roster, its docs folder. For those the source pushes, from its own CI, when it changes.
POST /api/collections/{type}/push
{type} is an existing content type in the caller's tenant. A push only fills a type; it never
creates one.
The key
Mint a key limited to the types the push fills. An admin with manage_api_keys does this:
curl -s https://cms.example.com/api/api-keys \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "changelog CI", "scopes": ["content:write"], "contentTypes": ["release"] }'
The response holds the secret once. A key that names contentTypes reaches
POST /api/collections/{type}/push for those types and gets 403 on every other route, whatever its
scopes. It needs content:write. It acts in its own tenant only, and as the admin who minted it, so
it can never do more than that admin could. Revoke it with DELETE /api/api-keys/{id}.
A signed-in user, or a key that names no types, can push too, with the same permission checks.
The request
{
"entries": [
{ "slug": "4-3-0", "title": "4.3.0", "date": "2026-09-20", "body": "..." },
{ "slug": "4-2-0", "title": "4.2.0", "date": "2026-09-02", "body": "..." }
],
"archiveMissing": false,
"status": "Published"
}
Each entry is the field values of one entry. The type's slug field (the field of type slug, or
the field named slug) is the key: an entry whose slug is already stored updates that entry, and
one whose slug is not creates it. A type with no slug field cannot be pushed to.
status is Published or Draft, and defaults to Published. An entry in the push ends at that
status, so an entry archived earlier is published again when the push carries it again.
What happens to each entry
Every entry goes through what POST /api/contents and PUT /api/contents/{id} run: the type's
create or update permission, the write-path sensitivity rule, the schema validator (types,
required fields, slug uniqueness, references) and any module lifecycle hooks. A push is a faster
way in, not a looser one.
An entry whose data and status are the same as what is stored is left alone: no new version, no event, no workflow and no webhook. A CI job can push the whole collection on every commit and only what changed is written.
The whole push is one transaction. If any entry is refused, nothing is written and the response is 400 with every refused entry listed. If another writer changes an entry while the push runs, the push is refused with 409 and nothing is written; push again.
archiveMissing
"archiveMissing": true archives every published entry of the type whose slug is not in the push.
It runs only once every entry in the push has passed, in the same transaction, so a push with a
refused entry archives nothing. Drafts and entries with no slug are left alone. A push that would
archive more than 1,000 entries is refused. Each archive is audited as content.archived, the same
as archiving by hand, and a type with a lifecycle cannot be pushed with archiveMissing, because its
entries move by named transitions.
Only turn it on when the push carries the whole collection, the way a changelog push carries every release.
The response
{ "created": 1, "updated": 1, "unchanged": 38, "archived": 0, "errors": [] }
On a refused push, 400 and the entries that were refused:
{
"created": 0, "updated": 0, "unchanged": 0, "archived": 0,
"errors": [
{ "index": 2, "slug": "broken", "messages": ["Field 'Count' expects type 'int' but received 'string'"] }
]
}
A malformed request (no entries, too many, a status that is not Draft or Published) is a 400 in
the usual validation shape instead.
| Status | Meaning |
|---|---|
| 200 | Written. The counts say what changed. |
| 400 | Refused entries, a malformed request, or a type with no slug field. Nothing written. |
| 401 | No credentials. |
| 403 | The key is limited to other types or lacks content:write, or the caller may not create or update entries of this type. |
| 404 | No such content type in the caller's tenant. |
| 409 | A concurrent write, or a repeated Idempotency-Key. Nothing written. |
| 413 | The body is over the size limit. |
Limits
At most 1,000 entries and 4 MB per push, or the server's RequestLimits:MaxBodyBytes if that is
smaller.
Retries and webhooks
Idempotency-Key works here as on every other write (see idempotency.md): a
repeat of a key that succeeded is answered 409, and a key whose push failed can be retried. Pushing
the same entries again without a key is also safe, since unchanged entries are not written.
Workflows and their webhooks fire from the events a push appends, the same as for the content API:
once per created entry (Created), once per changed entry (Updated), and Published when a
push moves an entry to published. An unchanged entry fires nothing. There is no single "push"
event; a renderer that rebuilds per change gets one delivery per changed entry.
From CI
A GitHub Actions job that pushes the changelog when it changes. The script that turns
CHANGELOG.md into entries is the repository's own; the push is one request.
name: push changelog
on:
push:
branches: [master]
paths: [CHANGELOG.md]
jobs:
push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: node scripts/changelog-to-entries.mjs CHANGELOG.md > entries.json
- run: |
jq '{ entries: ., archiveMissing: true }' entries.json > push.json
curl --fail-with-body -s "$CMS_URL/api/collections/release/push" \
-H "Authorization: Bearer $CMS_PUSH_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $GITHUB_SHA" \
--data @push.json
env:
CMS_URL: ${{ vars.CMS_URL }}
CMS_PUSH_KEY: ${{ secrets.CMS_PUSH_KEY }}
--fail-with-body fails the job on a refused push and prints which entries were refused.