# Publications

A publication records one approved post variant, its channel and its scheduled time. Use it to track delivery state, cost, reminders, attempts and metrics. Scheduling creates the record; publication completes later, after a person confirms a reminder or delivery settles.

## The shape of a publication

| Field | What it is |
|---|---|
| `id` | Publication id |
| `publicationId` | The same id again, under the name the settled event uses |
| `postId`, `channelId` | What is being published, and where |
| `text` | Approved variant text rendered as plain network text |
| `postType` | Variant post type captured at scheduling; `null` for publications created before targets were supported |
| `fields` | Variant fields captured at scheduling, such as a title or subreddit |
| `costUsd` | What publishing this costs, computed when it was scheduled, or `null` when unknown |
| `costBasis` | `plain`, `link` or `free`, or `null` |
| `scheduledAt` | ISO-8601 slot it is due in |
| `state` | `pending`, `publishing`, `published`, `failed` or `cancelled` |
| `attempts` | Dispatch attempts made so far |
| `idempotencyKey` | The key that makes a retry the same publication |
| `externalId`, `externalUrl` | The network's id and permalink, once known |
| `reminderSentAt`, `reminderCount` | When the most recent reminder went out, and how many have |
| `publishedAt` | When it was confirmed published |
| `lastError` | The most recent failure, if any |
| `createdAt`, `updatedAt` | ISO-8601 timestamps |

The post retains the Markdown source. The publication stores the exact rendered text, post type and fields selected from the approved version, so a subsequent change cannot alter the queued content.

## States

```text
pending ──▶ publishing ──▶ published
   │             │
   └──────┬──────┴──▶ failed
          └────────▶ cancelled
```

`published`, `failed` and `cancelled` are terminal; nothing moves out of them.

For reminders, `publishing` means a person has been notified and still needs to publish and confirm. Confirmation moves the publication to `published`.

## Validate before you schedule

Call `content_publications_validate` with a `postId` and up to 20 entries in `channels: [{ channelId }]`. It checks each network’s current variant against the channel’s capabilities and publishes nothing. The report includes:

- `ok` — true when nothing blocks scheduling.
- `errorCount` — how many error-severity issues there are across every channel. Warnings are not counted; they do not block.
- `firstErrorMessage` — the first error in report order, ready to show a person.
- `channels[]` — one result per requested channel, in the order given, each with the network, the display name, the Markdown that was checked, the `renderedText` that would actually go out, the issue list, the cost, and the daily ceiling that was checked.

You can validate before approval. Validation reads the current draft; scheduling uses the approved version.

### Issue codes

The set is stable and append-only.

| Code | Meaning |
|---|---|
| `text_empty` | There is no text to publish |
| `text_too_long` | Longer than the channel's character limit |
| `too_many_media` | More attachments than the channel accepts |
| `link_not_supported` | The network will not take a link |
| `multiple_links` | More links than LinkedIn will preview (advisory) |
| `media_missing` | An attached asset does not exist |
| `media_not_ready` | An attached asset is not stored yet |
| `media_not_supported` | The network will not take this kind of attachment |
| `video_required` | The post type requires video, but no video is attached |
| `cost_unknown` | The cost cannot be computed, which blocks publishing |
| `daily_ceiling_exceeded` | The channel's daily ceiling is already spent for that slot |
| `network_not_targeted` | The post has no target for this channel’s network |
| `post_type_not_supported` | The channel does not accept the variant’s post type |
| `field_required` | A required variant field is empty |
| `field_too_long` | A variant field exceeds its maximum length |

Each issue includes `severity` (`error` or `warning`), a readable message and the affected input when applicable. Field issues identify `fields.<id>`, such as `fields.title`. YouTube uploads and Shorts, Instagram and Facebook Reels, and TikTok video posts require an attached video.

The ceiling report says where the limit came from: `channel` (an explicit override), `adapter` (a live figure from the network) or `catalog` (the built-in fallback).

## Cost

Validation reports the calculated cost before you schedule. Scheduling stores that cost on the publication. Both use the channel descriptor’s current rates, including your overrides, rather than reading the built-in rates directly.

`costBasis` names the rate that was applied:

- `plain` — the flat per-post rate.
- `link` — the higher rate a post carrying a link is charged. The default X rates are $0.20 with a link and $0.015 without one.
- `free` — the channel's rates are both zero.

A post counts as carrying a link when its text contains an explicit `http://` or `https://` URL, a host beginning `www.`, or a bare host ending in a recognized top-level domain. Handles and hashtags are not links. A new or unrecognized top-level domain can be missed and priced as plain text. Include `https://` to make link detection explicit.

## Scheduling and publishing

Call `content_publications_schedule` with the approved post, a channel and `scheduledAt`. A past time queues it for the next scheduler tick. A post already in `scheduled` state can be scheduled to additional target channels without another approval.

The publication takes the approved variant’s text, post type and fields. Schedule, publish and validate do not accept replacement `text`: an extra text field receives `400` over REST or an unrecognized-key error over MCP. Set `targets[].text` on the post and have it approved before scheduling a different network version.

Scheduling rejects a channel outside the post’s targets (`network_not_targeted`) or a variant that fails the channel’s type, required-field, field-length or video checks. Validate first to see these issues before queueing.

Only one `pending`, `publishing` or `published` publication is allowed for a post on a channel. A second scheduling request returns `409 already_scheduled` with the existing `publicationId` and `scheduledAt`. Reschedule that publication to change its time, or cancel it before starting again. Once it is published, write a new post to publish on that channel again. Failed and cancelled publications do not reserve the channel.

Concurrent scheduling requests follow the same rule: one succeeds and the other receives `already_scheduled`. If needed, list publications filtered by `postId` to inspect the result. Publications created before Content 0.12.1 were not changed, so older records may show more than one live publication for the same post and channel.

`content_publications_publish` uses the same queue with the current time. Both calls return a queued record. Wait for `content/publication.settled` and match its `publicationId` to the returned `id` to learn the outcome.

### Idempotency

Supply a product-unique `idempotencyKey` to identify a publishing intent. Without one, Content derives a key from the post, channel and time. Repeating a successful request returns its publication before duplicate-scheduling checks run.

A key whose publication failed or was cancelled is spent. Reusing it returns `409 idempotency_key_spent`, including `publicationId` and `scheduledAt`, and queues nothing. To try again, use a new key or a different scheduled time.

## Reminders and confirmation

At the scheduled time, a reminder channel emits `content/publication.reminder-due`, notifies a person and moves the publication to `publishing`. The reminder includes the post type and variant fields above the text. Members control these notifications under the `publishing` scope.

After publishing on the network, call `content_publications_confirm`. Supply `externalUrl` to identify the published post and `externalId` if available. Confirmation stays available while the publication is open, including when the person published earlier but forgot to confirm.

If the first reminder was missed, call `content_publications_remind`. Content never repeats a reminder automatically because the person might publish twice. The call requires an existing reminder and a reminder channel; an automatic channel returns `409 not_a_reminder_channel`.

## Retries and failure

On an `auto` channel, Content sends the queued publication through its bound connector when the slot is due. Read the publication state or wait for `content/publication.settled` to learn the result.

Automatic publications allow up to five attempts. The four retry delays are 1 minute, 5 minutes, 15 minutes and 1 hour, without jitter. If all attempts are used, this spans a little over an hour.

Retrying is only ever done when it is known to be safe: the publication carries no network id and owes no reconciliation, or the network has been asked and has answered that nothing was published, or the adapter itself reported that nothing was sent.

When the attempts are spent the publication becomes `failed` and `lastError` says why. `content/publication.failed` carries the same short diagnostic, which never contains a credential.

A paused channel holds its publications for later dispatch. Content rechecks the pause instead of marking those publications failed.

## Cancelling and rescheduling

Use `content_publications_cancel` to stop an unfinished publication. For a reminder channel, cancellation stays available after the reminder until confirmation. The response contains the cancelled record, so no settled event is needed to learn that result.

Use `content_publications_reschedule` to move the same unfinished publication to another time. It returns to the queue, and a new reminder is sent at the new time. Finished publications (`published`, `failed` or `cancelled`) cannot be cancelled or moved.

An automatic publication in `publishing` state is being sent and its outcome is unknown. Cancel, reschedule, confirm and remind return `409 publication_in_flight`. Read `content_publications_get` or wait for `content/publication.settled` before acting. This restriction does not apply to reminder publications in `publishing` state.

If a publication changes between a call’s read and write, the action is checked against its new state. A finished or in-flight publication returns the corresponding conflict; other changes return `409 publication_changed`. Read the record again and retry only if the action still applies.

## Listing

`content_publications_list` returns publications newest slot first. Filter by `state`, `postId`, `channelId`, and the slot window `scheduledFrom` / `scheduledTo`; page with `limit` (1 to 100, default 50) and `offset`. A window whose start is after its end is rejected.

## Tools

| Tool | What it does |
|---|---|
| `content_publications_validate` | Dry run against one or more channels |
| `content_publications_schedule` | Queue an approved post for a slot |
| `content_publications_publish` | Queue it for this instant |
| `content_publications_list` | List publications, newest slot first |
| `content_publications_get` | Read one publication |
| `content_publications_cancel` | Cancel one that has not finished |
| `content_publications_reschedule` | Move it to a new slot |
| `content_publications_confirm` | Record that a person published it by hand |
| `content_publications_remind` | Send the reminder again |

## Next steps

- [Channels](/acquire/content/channels): where the limits and the rates come from.
- [Metrics](/acquire/content/metrics): what a published publication reports back.
