# X credits and spend

Flaghand prepays X's API. If those credits run out, publishing jobs are **held, not lost**. Owners see the reason and
can retry the same approved job in the desk after a top-up. Agents can read the job's `state`, `error_code` and `error`
through the API, CLI and MCP, but cannot start it. See [Approvals](approvals).

## Read-only operations feed

`GET /ops/x`, with `Authorization: Bearer <X_OPS_TOKEN>`, returns counts, dollars, outage ids and internal workspace/job
ids only. It contains no post text, account handles, platform error bodies or credentials. `Cache-Control: no-store`.
Other methods are refused. The token grants no access to workspace data, approval, posting, billing or X itself.
Ordinary workspace tokens cannot read this company-wide feed.

The Worker secret is `X_OPS_TOKEN`; the company credential is in 1Password, Trellis Agents, service
`trellis-flaghand-x-ops`. Growth and the Trellis monitor read it with `trellis-auth get trellis-flaghand-x-ops`, piped
straight to their HTTP client, never printed or put in a file, command argument or report. This is a separate token,
not a workspace/agent token or the waitlist stats token.

Response fields:

- `outages[]`: `id` (monotonic integer), `started_at` (ISO UTC), `recovered_at` (ISO UTC or null), `failures` (count).
- `failures[]`: `outage_id`, `at` (ISO UTC), `workspace_id` and `job_id` (internal ids or null when not applicable).
  Each actual credit refusal is recorded; paused hourly reads make no X call and add no failure record. Workspace
  deletion removes its individual failure records; the outage's aggregate count remains.
- `spend.currency`: `USD`.
- `spend.estimate`: `true`. These are config-priced estimates, not an X invoice or a balance API.
- `spend.prices`: the current per-unit `X_PRICES` configuration.
- `spend.days[]`: one row per `workspace_id` and UTC `day` (`YYYY-MM-DD`). An unattributed legacy call uses null.
  - `comped`: whether the workspace is currently operator-comped (company, demo or personal); null when unknown.
  - `test_mode`: true if the Worker's Stripe key or the workspace's subscription is in test mode; false only when
    both are live. A missing subscription mode is null unless the Worker itself is in test mode. These flags describe
    the current entitlement, not its state on the usage day; they reveal no Stripe ids, notes or customer details.
  - `counts`: units by `post`, `post_with_url`, `post_delete`, `media_upload`, `media_metadata`, `post_read`, `user_read`, `dm_read`, `community_read`.
    `post` excludes URL posts; `post_with_url` is a separate, disjoint count.
  - `usd`: the same keys, counts times configured unit price, rounded to six decimals; null means unpriced.
  - `total_usd`: sum of known `usd` values, rounded to six decimals. It excludes unpriced operations.
  - `unpriced`: nonzero counts whose rate is unknown, e.g. `{ "media_upload": 2 }`. Never interpret them as free.

Rates live in `scheduler/wrangler.jsonc` under `X_PRICES`: plain post $0.015, post with URL $0.20, post delete $0.005,
post read $0.005, user read $0.010, Community read $0.005, media metadata $0.005. Community names use a per-workspace
cache; the first lookup of a new ID is metered as `community_read`, deduplicated app-wide by resource and UTC day
like other reads. `DELETE /2/tweets/{id}` is **Content: Manage**
("Managing content (deleting or hiding posts). Charged per request."), not Interaction: Delete. Verified on
2026-10-25 against [X's public pricing](https://developer.x.com/#pricing), its
[pricing documentation](https://docs.x.com/x-api/getting-started/pricing) and the
[delete-post endpoint](https://docs.x.com/x-api/posts/delete-post). A thread costs $0.005 per deleted part.
Confirmed deletes count as `post_delete` (`x_post_deletes` in workspace usage); already-absent or refused calls do not
count as confirmed deletes. As with other counters, an unclear response may undercount actual billed requests.
X's published table does not give a media-upload rate, so `media_upload` is
null until an actual credit draw establishes it. Upload initialization is counted once, not each append/finalize/status
request. Failed requests are not counted as successful billable use. A retry can initialize a new upload; that counts
as another upload, not a duplicate post. Media metadata is distinct from upload bytes.

Read resource ids are SHA-256 hashed and deduplicated per app, metric and UTC day, following X's published billing
rule. The first workspace reading a resource that day gets its cost; later reads in any workspace don't add cost.
Expanded users/posts count at their respective rates too. This is billing attribution, not raw request volume. Existing
historical counters predate full media/insights coverage and read deduplication and cannot be reconstructed; compare
complete days after this rollout. Estimates use current configured rates, including when reading past days. URL
classification uses the existing URL/bare-domain heuristic; actual X billing remains authoritative.

## Failed jobs feed

`GET /ops/jobs`, with the same `Authorization: Bearer <X_OPS_TOKEN>`, counts the jobs that ended `failed` in the last
`minutes` (default 60, a whole number above 0), by platform and code. Like `/ops/x` it is counts only: no job, draft,
workspace or account ids, no post text and no error sentence. Trellis's failure watch (`tools/flaghand-failure-watch`)
reads it every 5 minutes.

- `from`, `to` (ISO UTC) and `minutes`: the window, by when each job last changed.
- `failed[]`: `platform` (`x`, `linkedin`, `tiktok`), `code` and `count`. `failed_total` is their sum.
- `last_tick` (ISO UTC or null): the cron's last run, as on `/api/v1/status`.

A job's own `error_code` is its code (`workspace_inactive`: the plan wasn't active; `duplicate_post`: X or LinkedIn
refused it as a repeat of a recent post). The other failures carry only the
owner's sentence, so their code comes from how it starts: `approval_refused` (the scheduler's guard refused it: no
approval, voided, changed, not an owner's), `interrupted` (a run ended while posting), `tiktok_unconfirmed` (it ended
while asking TikTok, so it may be live), `first_post_wait_expired` (post 1 by hand never showed up), `missed_time`
(more than 2 hours late), `tiktok_failed` (TikTok reported it failed) and `post_failed` (X, LinkedIn or TikTok refused
the post, or the request broke). `unrecorded` is a failed job with no sentence at all.

## Browser errors feed

`GET /ops/browser-errors`, with the same ops token, returns the last hour at minute resolution: sixty minute buckets
through the current minute. `from` and `to` are their UTC boundaries (`to` is the end of the current minute),
`minutes` is 60, `errors[]` holds `page`, `browser`, `hash` and `count`, and `error_total` is their sum.
Ordinary workspace tokens cannot read it; other methods are refused and responses use `Cache-Control: no-store`.

The desk and door pages load `/browser-errors.js` before their app entrypoint, so a startup error is counted even
when that entrypoint cannot parse. It listens for JavaScript `error` and `unhandledrejection` events and uses
`sendBeacon` to POST to the same origin's `/browser-errors`. The message and first stack frame (with no stack, the
script's address and line, never the page's query or fragment) are SHA-256 hashed **in the browser**. Only that hash, a canonical page path (never query parameters or a fragment), and a browser
family (`safari`, `chrome`, `firefox`, `edge`, `other`) are sent. A rejected object is never serialized. No draft or
form data, raw error text, stack, email, account/workspace/draft id or IP address is collected by this code. It reads
no cookies or browser storage, sets no cookie, uses no third-party service and adds no consent prompt. The server
ignores request cookies, IP/browser headers and the referrer; it accepts only the three declared fields and a matching
Origin. Failure to hash or send has no effect on the app and is not itself reported.

D1 `browser_errors` retains aggregate minute/page/browser/hash counters, like the other operations counts. There are
no per-event rows, user linkage, client timestamps or automatic expiry. The minute-first primary key bounds the
report's range scan. Trellis's failure watch reads this feed with its existing ops credential and alerts per
page/browser/hash cause, following its existing hourly notification deduplication. This reports error **events**, not
people or sessions, and does not prove a browser without errors (the beacon itself may be blocked).

## Launch funnel feed

`GET /ops/funnel` uses this same operator token for growth's counts-only account, trial, conversion, MRR,
cancellation and onboarding reads. It requires explicit `from`, `to` and `cutoff` instants. See [Launch funnel](funnel-ops)
for every field, denominator, source category, historical coverage and Unknown response. No customer ids or raw UTM text
leave that route.

## Outages and the CEO check

Every X transport checks for a credit problem, including OAuth, identity, media phases, inherited-mention reads,
posting, live read-back and the legacy DM proxy. A publishing/discovery refusal holds the job with
`error_code: "x_credits_exhausted"`, without using its ordinary retry budget. A credit failure only in read-back leaves
an already-published job `posted` and puts the code in `result.check`. Partial threads keep their posted ids.

Hourly insights and on-demand insights refreshes pause while an outage is open. A later successful credit-bearing X
read/write call establishes recovery; OAuth refreshes and unbilled upload chunks cannot. Older in-flight successes
cannot close a newer outage. No balance probe or automatic replay spends the next top-up: an owner can retry a held
post or verify a connection, and hourly insights resume on the next cron after recovery.

Trellis runs `tools/flaghand-x-watch` every minute. It reads only this feed and tells the CEO once per outage id,
including an outage recovered between polls. It also sums `post_with_url` for the current UTC month and tells the CEO
once per workspace and month when the count passes 60 (61 or more), with the internal workspace id, count and month's
estimated X dollars (all priced operations, excluding unpriced operations). Comped and test-mode workspaces are excluded;
unknown flags and unattributed usage are not treated as paying workspaces. This is an internal alert only: nothing is
blocked, and no customer is messaged. It never tops up, posts or probes X. Its private persistent ledger of notified
ids and workspace/month pairs survives restarts; concurrent pollers serialize. Failed notifications are retried, not marked sent.
`--dry-run` prints pending safe alerts without notifying or advancing the ledger. Normal polls/restarts deduplicate;
like any CLI notification without an idempotency key, a crash between a successful `trellis tell` and saving its ledger
can repeat that alert. It cannot silently mark an unsent alert sent.

## Exact credit problem signatures and evidence

Two answers are classified as depleted credits, and nothing else:

- HTTP **402** with `title: "CreditsDepleted"` or problem type `https://api.twitter.com/2/problems/credits` /
  `https://api.x.com/2/problems/credits-depleted` (either X API host): the prepaid balance is at $0. An X staff reply
  on the developer forum confirms "The 402 means your prepaid credit balance is depleted ($0 remaining)"
  ([thread, 2026-08-20](https://devcommunity.x.com/t/402-payment-required-errors-despite-not-reaching-current-spending-cap/273680)).
- HTTP **403** whose `detail` (or an `errors[].message`) says the account "has reached its billing cycle spend cap":
  the developer console's per-cycle spend cap is used up, and X blocks requests until the next cycle or until the cap
  is raised. Observed text: "Your enrolled account [...] has reached its billing cycle spend cap. API requests will be
  blocked until the next cycle begins on 2026-02-28. To resume access immediately, you can increase your spend cap in
  the developer console." ([thread, 2026-02-15](https://devcommunity.x.com/t/pay-per-use-cap-issue/257255)). Flaghand's
  own X app has such a cap, so this is the answer most likely to arrive first.

Both are held the same way (`x_credits_exhausted`); the CEO alert tells whoever tops up to check the balance and the
cycle cap. The same signatures inside `errors[]` are recognized. Ordinary HTTP 429 rate limits and usage caps, any
other 403 (for example "You are not permitted to perform this action"), unrelated 402s and workspace billing failures
are not credit outages.

X's official [pricing](https://docs.x.com/x-api/getting-started/pricing) and
[billing](https://docs.x.com/x-api/getting-started/billing) describe prepaid credits and blocked calls at exhaustion;
its [response codes](https://docs.x.com/x-api/fundamentals/response-codes-and-errors) currently omit the exact 402 and
spend-cap 403 bodies (checked 2026-10-24, including the full docs index). The narrow fixtures are observed response bodies corroborated by
[CreditsDepleted report](https://note.com/kumakokoko/n/ncb9b294329ff) and
[credits-depleted report](https://note.com/comani/n/n33b973e98822), not a claimed official schema. They are tested with
fake X only; no live posting or real X developer console was used to provoke exhaustion. Update the fixtures if
X publishes its schema or a safely redacted real failure establishes another signature.
