Every command that returns data has a machine-readable mode (--json) and a conventional exit code. (login and logout have no --json — they print a fixed status line.) What it does not have is a machine-readable error format, retries, or automatic paging — so a script has to check the exit status itself, walk pages itself, and pace its own calls. This page covers what a non-interactive caller can rely on.
Five commands write: posts create, posts delete, slots create, slots delete and media upload. Read Before you automate a write before putting any of them in a loop.
Authenticate unattended
Set ZILFU_TOKEN in the environment and drop the --token flag entirely:
export ZILFU_TOKEN="$(cat /run/secrets/zilfu_token)"
zilfu spaces list --json
--token <token> works too, but it lands in shell history and is visible in ps output to anyone on the machine. Use the environment variable, or zilfu login --token <token> once on a persistent machine to write the stored config — see Get started for the precedence rules and where credentials are stored.
An empty
ZILFU_TOKENis used as-is rather than falling back to the stored config, because onlynull/undefined values fall through.ZILFU_TOKEN=""in a CI environment producesNo API token found.even when~/.config/zilfu/config.jsonholds a perfectly good token. Unset the variable instead of blanking it.
zilfu health is the only API call that needs no token (zilfu logout needs none either — it just deletes the local file).
A CLI token is full-access: there is no read-only token, so a script that only lists things still holds one that could publish. Give automation its own token from Additional tokens rather than your API key — both to scope the blast radius of a leak and because the 120/min API budget is per token.
JSON output
--json writes pretty-printed JSON (two-space indent, one trailing newline) to stdout and nothing else. It never emits NDJSON.
| Command | --json emits |
|---|---|
zilfu whoami --json |
Object — id, name, email |
zilfu health --json |
Object — {"status": "ok"} |
zilfu spaces list --json |
Bare array of space objects |
zilfu accounts list --space <id> --json |
Bare array of account objects |
zilfu posts list --space <id> --json |
The whole paginated envelope — data, links, meta |
zilfu posts create … --json |
Bare array of the posts created, one per --account |
zilfu posts delete … --json |
{"deleted": true, "id": <post id>} |
zilfu media upload --file <path> --json |
Object — url, type, mime |
zilfu slots list --space <id> --json |
Bare array of slot objects (id, day_of_week, time) |
zilfu slots create … --json |
Bare array of slot objects, one per requested day |
zilfu slots delete … --json |
{"deleted": true, "id": <slot id>} |
zilfu analytics overview --space <id> --json |
Bare array of per-account summary rows |
zilfu analytics account … --json |
Object — the whole report |
zilfu analytics posts … --json |
Object — contract, account, range, collection, sort, columns, data, meta |
Three of those shapes are worth reading twice:
posts listkeeps its envelope. The API's response wrapper is unwrapped everywhere else, but not here: droppingmetawould leave a caller unable to tell a full page from the last one. The rows are at.data, not at the root, so ajq '.[]'written against an older CLI now fails.analytics postsis an object, not a list. The rows are at.datatoo;.columns,.sortand.metasit beside them, and.sort.optionsis the only place the account's valid sort keys are published.- The two
deletecommands print an acknowledgement the CLI invented. The API answers204 No Content, so there is no server payload;{"deleted": true}asserts nothing beyond "the request did not fail".
--json has to come after the leaf subcommand (zilfu posts list --json, never zilfu --json posts list). See Commands.
--jsonand the table are different shapes for most commands, not just narrower ones. Onposts listandposts createthe table prints a status label while--jsoncarries the numeric code the API stores (scheduledis"status": 0— the mapping is in Commands). Onslotsthe table printsdayasMon…Sunwhile--jsonkeepsday_of_weekas1…7. On the analytics commands the table is several tables plus prose, and--jsonis one document; a null metric renders as-in the table and staysnullin JSON, which is the distinction that matters — it is not a zero.
Exit codes
| Code | Meaning |
|---|---|
0 |
The command succeeded, including when it returned no results |
1 |
Every failure — API error, bad argument, missing token, unreachable host, refused write |
130 |
Ctrl-C at the interactive token prompt of zilfu login |
There is no exit-code granularity.
401,403,404,422,429,5xx, an invalid--space, a post that was too long for X, a missing token and a host that never answered all exit1. To tell them apart, parse theError <status>:prefix on stderr. A network-level failure — DNS, connection refused, TLS — reports status0, soError 0:means no HTTP response happened at all, and an error with no prefix at all was raised locally before any request was made.
Error output
Errors always go to stderr as plain text, with a status prefix:
Error 401: Unauthenticated.
A 422 adds one indented line per field message:
Error 422: The per_page parameter must be between 10 and 100.
- per_page: The per_page parameter must be between 10 and 100.
The write commands are the ones that can realistically produce one — a 422 from posts create is the API rejecting content the CLI's own checks did not cover. Most of the obvious cases never get that far: a bad --status, an out-of-range --per-page, a --time that isn't HH:MM, copy that is too long for one of the platforms in the call, an account id that isn't in the space, and a --at in the past are all refused locally, and those messages carry no status prefix:
Content for account 13 (x) is 402 characters; x allows 280.
Use --account-text 13=<text> to tailor the copy for this account.
Note that local errors can be multi-line — No API token found. is three lines — so a script parsing stderr should not assume one line per failure.
Not everything on stderr is a failure.
posts createwrites advisory notes there — a disconnected account, a comment that will be dropped for some platforms — and then completes successfully with exit0. Decide on the exit code, never on stderr being non-empty.
--json never applies to errors. There is no JSON error envelope, no error code field, and stdout stays empty on failure.
Usage text is printed to stdout, not stderr, when an argument is missing or wrong.
zilfu posts list --json > out.jsonwith--spaceomitted writes a usage block intoout.jsonand exits1(ANSI-coloured on a normal shell; plain whenCI,NO_COLOR, orTERM=dumbis set). Always check the exit code before parsing stdout:if ! zilfu posts list --space 42 --json > posts.json; then echo "zilfu failed" >&2 exit 1 fi
Empty results are not errors
An empty list prints No results. to stdout and exits 0; with --json it prints [] (or, for posts list, an envelope whose data is empty). An empty record prints (empty). None of these are failures — a script that treats "no output" as an error will misfire on a space with nothing in it.
count=$(zilfu posts list --space 42 --json | jq '.meta.total')
Filter and page
posts list and analytics posts take the API's filter and paging parameters directly, so jq is for reshaping what came back, not for making up for what the CLI could not ask:
# Everything awaiting approval for two accounts, 100 to a page
zilfu posts list --space 42 --status pending_approval \
--account 12 --account 13 --per-page 100 --page 1 --json
# Last month's posts for one account, best first
zilfu analytics posts --space 42 --account 12 \
--from 2026-07-01 --to 2026-07-31 --sort interactions --dir desc --json
Paging is manual. Nothing follows links.next for you, so walk it yourself and stop on meta.last_page:
page=1
while :; do
body=$(zilfu posts list --space 42 --per-page 100 --page "$page" --json) || exit 1
echo "$body" | jq -c '.data[]'
[ "$page" -ge "$(echo "$body" | jq '.meta.last_page')" ] && break
page=$((page + 1))
done
analytics posts pages the same way, with its own meta.current_page / meta.last_page and a page size between 10 and 100.
Some shaping recipes:
# Scheduled posts only, as id + scheduled time
zilfu posts list --space 42 --status scheduled --json \
| jq -r '.data[] | "\(.id)\t\(.scheduled_at)"'
# Accounts that need reconnecting
zilfu accounts list --space 42 --json \
| jq -r '.[] | select(.disconnected_at != null) | .handler'
# Accounts parked because the plan lapsed (reconnecting will not fix these —
# upgrade, or switch another account off to make room)
zilfu accounts list --space 42 --json \
| jq -r '.[] | select(.quota_parked_at != null) | .handler'
# The space id for a space you know by name
zilfu spaces list --json | jq -r '.[] | select(.name == "Acme") | .id'
# Which metrics this account can actually be sorted by
zilfu analytics posts --space 42 --account 12 --json \
| jq -r '.sort.options[] | select(.sortable) | .key'
Do not filter on a metric value you read out of
analytics postswithout checking its status.metrics.<key>.valueandinteractions.totalarenullwhenever the figure behind them cannot be trusted, andjqwill happily comparenullto a number. Filter on.status == "ok"first.
Before you automate a write
The write commands are ordinary HTTP calls with no dry-run flag, no confirmation prompt and no undo. What that means in practice:
posts createis not idempotent. Re-running it after a timeout creates a second set of posts. There is no request key to deduplicate on. If a run's outcome is uncertain, list the space and check before retrying.- One invocation is all-or-nothing. The posts for every
--accountare created in a single transaction, and for anything that is not a--draftthe plan's post allowance is checked for the whole batch up front — so a call that would cross the limit creates nothing and answers422. Read the returned ids out of--jsonand record them; there is no other handle on what a run produced. slots createis idempotent. The endpoint upserts by day and time, so re-running the same command changes nothing and returns the same rows. It is safe in a provisioning script that runs on every deploy.- The two deletes are immediate and unrecoverable. No prompt, no undo, no soft-delete you can restore from the CLI.
media uploadis two calls plus an upload. The presigned URL expires 15 minutes after it is issued, and thePUTgoes straight to storage without your token. A failure there printsUpload failed: <status> <text>and exits1— the media is simply not there, and nothing was created in Zilfu.- Slot writes are owner-only. They are authorised against space ownership rather than permissions, so a token belonging to a member is refused with
403however much that member can otherwise do. - Publishing without permission does not publish. If the token's user can create but not publish,
posts createreturns rows with statuspending_approvaland a nullscheduled_at. Exit code0; nothing goes out. A script that treats a successful create as "published" will be wrong for those users — check the status in the response.
Timeouts, retries, and rate limits
The CLI sets no request timeout and never retries. A hung server ties up the command until Node's own fetch timeout fires (about five minutes), then fails with Error 0: fetch failed — wrap invocations in your own timeout if a stuck job would block a pipeline:
timeout 30 zilfu spaces list --json
A 429 surfaces as Error 429: Too Many Attempts. and nothing more. The underlying error object carries no response headers, so Retry-After, X-RateLimit-Remaining and X-RateLimit-Reset are unreachable from the CLI. Pace your own calls, and note that two ceilings apply:
| Limit | Applies to | Cap |
|---|---|---|
api |
Every command that calls the API | 120/min, per token |
publish |
posts create |
30/min, per user |
The publish ceiling is keyed by user, not by token, so splitting a backfill across several tokens does not raise it. Both are detailed in Rate limits. A retry loop around posts create is especially dangerous here: it is neither idempotent nor cheap against that bucket.
Note also that every posts create makes two API calls, not one — it lists the space's accounts first, to resolve each account's platform — so a loop of n invocations spends 2n against the 120/min budget.
Do not parse the table
Table rows have trailing whitespace stripped per line, so a row whose last cells are empty simply ends early — the column count varies from row to row:
id account_id status scheduled_at published_at
-- ---------- --------- --------------------------- ---------------------------
10 12 scheduled 2026-08-01T09:00:00.000000Z
11 12 published 2026-07-01T09:00:00.000000Z
12 13 draft
Row 10 has four fields, row 11 has four fields carrying different data, and row 12 has three. awk '{print $4}' returns a scheduled time on one row and a published time on the next. Nulls render as empty strings, and the table is a lossy projection anyway.
Three more reasons the non-JSON output is not a data format: posts list prints a Showing 1-15 of 62. footer after the table; the analytics commands print several tables, headings and free prose in one run, with the valid sort keys as a trailing sentence; and numbers are formatted for humans, so 4,201 is thousands-separated and a rate reads 3.4%. Use --json for anything a program reads.
One more pipeline hazard: closing the pipe early can kill the process. zilfu spaces list | head -1 may abort with a Node EPIPE stack trace on stderr and exit 1 even though the command worked. Buffer to a file first, or tolerate the non-zero status.