<!-- Documentation Index: https://docs.publora.com/llms.txt -->
<!-- Canonical: https://docs.publora.com/endpoints/update-post -->
# Update Post

Edit an existing **draft** or **scheduled** post in place — its base text, its target accounts, its scheduling time, its status, its per-platform settings, or its media — without deleting and recreating it. Updating a post group also updates all associated platform-specific posts.

## Endpoint

```
PUT https://api.publora.com/api/v1/update-post/:postGroupId
```

## Headers

| Header | Required | Description |
|--------|----------|-------------|
| `x-publora-key` | Yes | Your API key |
| `x-publora-user-id` | No | Managed user ID (workspace only) |
| `x-publora-client` | No | Client identifier (e.g., `"mcp"`) |
| `Content-Type` | Yes | `application/json` |
| `Idempotency-Key` | No | Opt-in retry safety. Any client-generated unique string (e.g. a UUID). See [Idempotency](#idempotency). **Strongly recommended when sending `mediaUrls`** — without it, a retried update appends the media a second time. |

## Path Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `postGroupId` | string | The post group ID to update |

## Request Body

At least one of `status`, `scheduledTime`, `content`, `platforms`, `platformSettings`, or `mediaUrls` must be provided. Missing all six returns `400 { "error": "At least one of status, scheduledTime, content, platforms, platformSettings, or mediaUrls must be provided" }`.

Every field is a **patch**: omit it and the stored value is left unchanged.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `content` | string | No | Replacement **base** post text. Applies to every target that has no explicit per-account override (overrides set in the web editor are preserved). An empty string is accepted while the post stays a draft; scheduling still enforces each platform's content/media rules. See [Editing content and targets](#editing-content-and-targets). |
| `platforms` | string[] | No | Replacement target set — **the whole array replaces the stored one**, it is not merged. Use the exact connection IDs from [`GET /platform-connections`](./platform-connections.md); duplicates are rejected. `[]` is accepted only while the post remains a draft. See [Editing content and targets](#editing-content-and-targets). |
| `status` | string | No | `"draft"` or `"scheduled"` |
| `scheduledTime` | string | No | New ISO 8601 UTC datetime |
| `platformSettings` | object | No | Per-platform settings using the canonical [create-post allowlist](./create-post.md#unknown-platformsettings-paths), including X reply/quote and LinkedIn repost settings. Merged with existing settings server-side — fields you omit are preserved. Any unknown top-level platform **or** unknown nested key is **rejected with `400 PLATFORM_SETTING_UNKNOWN`** and nothing is persisted — see [Unknown platformSettings paths](#unknown-platformsettings-paths). |
| `mediaUrls` | string[] | No | Public **`https://`** URLs (1–10 per request) that the server downloads and attaches to the post. **Semantics are APPEND** — the media is added to the post's existing media, never replacing it. Ingestion is all-or-nothing: if any URL fails, none are attached. Plain `http://`, embedded credentials, non-443 ports, and internal/loopback hosts are rejected. Because a retry re-appends, **send an [`Idempotency-Key`](#idempotency) whenever this field is present.** |

> **YouTube playlist & thumbnail (partial update):** `platformSettings` supports partial per-platform updates, so you can change `youtube.playlist` or set/clear `youtube.thumbnail` without resending the other YouTube fields. The **thumbnail can only be set here** (not on `create-post`, which has no `postGroupId` yet). Clear either by sending empty strings — `youtube.playlist` as `{ "id": "", "platformId": "" }`, or `youtube.thumbnail` as `{ "url": "" }`. Playlist or thumbnail changes return **`409`** while the post is publishing/processing (e.g. *"Post group is currently being published; cannot change YouTube playlist. Retry once publishing completes."*). See [YouTube → Platform-Specific Settings](../platforms/youtube.md#platform-specific-settings).

> **Instagram Reels cover:** set or change `instagram.coverUrl` (alias: `cover_url`) here — a JPEG image used as the Reel's cover. Either pass a publicly accessible http(s) URL, or [upload a cover file](upload-instagram-cover.md) (JPEG/PNG/WebP up to 8 MB) and set the returned URL. Send `{ "instagram": { "coverUrl": "" } }` to clear it (the cover falls back to automatic/frame-based selection). Non-JPEG or non-http(s) URLs are rejected with `400`; a change is rejected with `409` while the post is publishing. See [Instagram → Platform-Specific Settings](../platforms/instagram.md#platform-specific-settings).

> **LinkedIn repost (reshare):** `platformSettings.linkedin` turns the group into a repost of an existing LinkedIn post — set `repostParentUrn` to a `urn:li:share:<id>` or `urn:li:ugcPost:<id>` (send an empty string to turn the repost off and revert to a normal post), and optionally `repostVisibility` (`PUBLIC` default; `CONNECTIONS` is personal-accounts-only). The group's post content becomes the reshare commentary (3,000-character LinkedIn limit). **Media is forbidden on repost groups** — scheduling a repost group with attached media fails validation with code `MEDIA_TYPE_NOT_SUPPORTED` (*"LinkedIn reposts cannot include media — remove the attached files or turn off the repost setting"*). If the parent post was deleted, made private, or is not reshareable, the post fails permanently at publish time with *"LinkedIn rejected the repost — the original post may have been deleted, made private, or is not reshareable."* See [Create Post → LinkedIn Repost Settings](./create-post.md#linkedin-repost-settings) for the full field reference and validation error strings.

> **X reply/quote (partial update):** set or clear `twitter.replyTo` and `twitter.quoteTweet` independently; omitted fields are preserved, and an empty string clears one. Each value is a full `x.com`/`twitter.com` status URL or a 1–19 digit post ID. `quoteTweet` may be combined with `replyTo` and media. On self-serve X API tiers, publication succeeds only when the target author mentioned the connected account in that same post, quoted one of its posts, or the connected account authored the target itself. Publora cannot prevalidate this relationship: an unrelated target can be accepted by this endpoint and later fail permanently at publish time with `X_REPLY_NOT_AUTHORIZED`. See [Create Post → X Reply and Quote Settings](./create-post.md#x-reply-and-quote-settings).

### Unknown platformSettings paths

Every path you send is checked against the accepted tree **before anything is written**. An unrecognized top-level platform, or an unrecognized nested key under a known one, is rejected — the whole request is a no-op:

```json
{
  "error": "Unknown platformSettings path: youtube.thumbnail.mediaID",
  "code": "PLATFORM_SETTING_UNKNOWN",
  "field": "youtube.thumbnail.mediaID"
}
```

`field` is the exact dotted path that failed, so you can point a user straight at the typo. Nested references are checked down to their leaves — `youtube.playlist.{id,platformId}` and `youtube.thumbnail.{mediaId,id,url,path}`. This matters most for thumbnails: a miscased `youtube.thumbnail.mediaID` previously slipped through the allowlist and **silently cleared an already-uploaded thumbnail**. It is now a `400`.

Values under a known leaf are never traversed, so these keep working unchanged:

- Aliases — `instagram.cover_url` (for `coverUrl`), `youtube.thumbnail.id` (for `mediaId`), `youtube.thumbnail.path` (for `url`)
- String booleans — `"true"` / `"false"` / `"1"` / `"0"` / `"yes"` / `"no"` / `"on"` / `"off"`
- Comma-separated `youtube.tags`

The deprecated flat fields `youtube.playlistId` and `youtube.thumbnailUrl` still return their existing guidance errors (e.g. *"playlistId is not supported; use …playlist"*) rather than `PLATFORM_SETTING_UNKNOWN`.

### Editing content and targets

`content` and `platforms` let you correct a post you already created instead of deleting it and starting over.

```bash
curl -X PUT https://api.publora.com/api/v1/update-post/507f1f77bcf86cd799439011 \
  -H "Content-Type: application/json" \
  -H "x-publora-key: YOUR_API_KEY" \
  -d '{
        "content": "Corrected launch announcement.",
        "platforms": ["linkedin-ABC123", "twitter-XYZ789"]
      }'
```

**How the fields behave**

- **`content` replaces the base text.** Each platform post is rewritten to its *effective* content: the base text for normal targets, or the account's own text where a per-account override exists (overrides created in the web editor survive a base-content edit). Editing the text of a Twitter or Threads target also clears the derived thread split so it is recomputed from the new text.
- **`platforms` replaces the entire target set.** Send the complete list you want — IDs not in the array have their platform posts removed. Added IDs are checked for ownership and plan entitlement before anything is written; a duplicate ID in the array is rejected. Copy IDs verbatim from [`GET /platform-connections`](./platform-connections.md).
- **Editing a post that is already `scheduled` re-runs post validation** against the new final state. A failure returns `400 { "error": "Validation failed", "validation": {…} }` and nothing is written.
- **Adding a target to a `scheduled` post additionally re-runs scheduling checks** — plan availability and posting limits. Reordering the same IDs is not a change and skips those checks.
- **Removing every target** is allowed only while the post is (or becomes) a draft. Scheduling an empty set returns `400 PLATFORMS_REQUIRED`.
- **The write is all-or-nothing.** Group, platform posts, and the group's post list commit together, guarded by a compare-and-swap. A failed validation or a lost race leaves the post exactly as it was.

**When editing is refused**

| Situation | Response |
|---|---|
| Group status is not `draft` or `scheduled` (e.g. `published`, `failed`) | `400` `POST_NOT_EDITABLE` |
| A platform post has already reached a terminal state | `400` `POST_NOT_EDITABLE` |
| The group is being published (`processingStatus: processing`) | `409` `POST_PUBLISH_IN_PROGRESS` |
| A platform post is already `pending` or `processing` | `409` `POST_PUBLISH_IN_PROGRESS` |
| A platform upload/publish has a resumable operation in flight | `409` `POST_PUBLISH_IN_PROGRESS` |
| Another edit committed while this one was in flight | `409` `POST_GROUP_VERSION_CONFLICT` |

`POST_PUBLISH_IN_PROGRESS` and `POST_GROUP_VERSION_CONFLICT` are conflicts, not permanent failures — re-read the post with [`GET /get-post`](./get-post.md) and retry only if it is still `draft` or `scheduled`. Do **not** blind-retry an edit that races publishing; that is exactly what these codes exist to prevent.

> **Retry safety:** send an [`Idempotency-Key`](#idempotency) with content/platform edits too. A replayed key returns the original response instead of re-applying the edit over a post that has since moved on.

### ISO 8601 DateTime Format

The `scheduledTime` must be a valid ISO 8601 UTC datetime string:

```
YYYY-MM-DDTHH:mm:ss.sssZ
```

**Valid formats:**
```
2026-03-15T10:00:00.000Z    ✓ Full format with milliseconds
2026-03-15T10:00:00Z        ✓ Without milliseconds
2026-03-15T10:00:00+00:00   ✓ With explicit UTC offset
```

**Invalid formats:**
```
2026-03-15 10:00:00         ✗ Missing T separator and timezone
March 15, 2026              ✗ Not ISO 8601
03/15/2026                  ✗ Not ISO 8601
2026-03-15                  ✗ Missing time component
```

### Timing Constraints

- **Past times:** Never silent. A time in the past is either clamped to the current server time **with a `warnings` entry in the response**, or rejected with `400 SCHEDULED_TIME_IN_PAST` — see [Past scheduled times](#past-scheduled-times) for the exact rule and the migration date.
- **No scheduled time:** If the resulting status is `scheduled` and neither the request body nor the existing post has a `scheduledTime`, it defaults to the current time (`new Date()`). This default does not apply when updating to `draft` — the existing value is preserved
- **Maximum:** Recommended within 2 months for best reliability
- **Timezone:** Always use UTC (Z suffix or +00:00 offset)

### Past scheduled times

| How far in the past | Today | When strict mode is active (scheduled **2026-08-25** by default) |
|---|---|---|
| Less than 5 minutes | Clamped to server time + `SCHEDULED_TIME_COERCED` warning | **Unchanged — still clamped + warned** |
| 5 minutes or more | Clamped to server time + `SCHEDULED_TIME_COERCED` warning | **Rejected — `400 SCHEDULED_TIME_IN_PAST`** |

> **The 5-minute tolerance is permanent.** The *5-minutes-or-more* case is scheduled to flip from clamp-and-warn to a hard `400` on 2026-08-25, unless production configuration overrides that date either way.

Clamped requests still return `200`, and the response carries the warning:

```json
{
  "success": true,
  "message": "Post updated successfully",
  "scheduledTime": "2026-07-15T09:31:04.812Z",
  "warnings": [
    {
      "code": "SCHEDULED_TIME_COERCED",
      "message": "Requested scheduled time 2026-07-15T08:00:00.000Z was in the past and was changed to server time 2026-07-15T09:31:04.812Z.",
      "requested": "2026-07-15T08:00:00.000Z",
      "effective": "2026-07-15T09:31:04.812Z"
    }
  ],
  "postGroup": {
    "_id": "507f1f77bcf86cd799439011",
    "status": "scheduled",
    "scheduledTime": "2026-07-15T09:31:04.812Z"
  }
}
```

Once strict mode is on, that same request returns:

```json
{
  "error": "Scheduled time is in the past. Server time is 2026-07-15T09:31:04.812Z UTC.",
  "code": "SCHEDULED_TIME_IN_PAST",
  "serverTime": "2026-07-15T09:31:04.812Z"
}
```

**Migrate now:** treat every `SCHEDULED_TIME_COERCED` warning where the gap is 5 minutes or more as a future `400`. Log it, and re-send a time in the future. `serverTime` on the error is the authoritative clock to compute against. For the full rationale and cross-endpoint behaviour, see [Scheduling → Past scheduled times](../guides/scheduling.md#past-scheduled-times).

> **Status-only transitions validate the *stored* time.** Sending `{ "status": "scheduled" }` with **no** `scheduledTime`, on a post that is not already scheduled, runs the post's **existing stored** time through the rules above. An old draft whose stored time has since passed therefore now returns a `SCHEDULED_TIME_COERCED` warning (and, once strict mode is on, a `400 SCHEDULED_TIME_IN_PAST`). Previously it silently published at the stale time — effectively immediately. If you flip old drafts to `scheduled`, always send an explicit fresh `scheduledTime` alongside the status.

### Scheduling Limits

When rescheduling a post (changing `scheduledTime`), the update flow performs two validations:

1. **Platform availability check** (`assertPlatformsAllowed`): Verifies that all platforms targeted by the post are still available on the user's current plan. If a platform is no longer allowed, a `PLATFORM_NOT_AVAILABLE` error is returned.
2. **Limits check**: Validates that the new time does not exceed the account's posting limits for the target time slot.

If either check fails, the request returns a **403** error with a `LimitExceededError` message describing which limit was hit.

> **Note:** There is no minimum-interval adjustment. A schedule-horizon violation returns `403`; only the documented past-time compatibility ramp can clamp a time, with a `SCHEDULED_TIME_COERCED` warning.

### Helper Functions

**JavaScript:**
```javascript
function toISO8601(date) {
  return new Date(date).toISOString();
}

function scheduleForTomorrow(hour = 9, minute = 0) {
  const date = new Date();
  date.setDate(date.getDate() + 1);
  date.setUTCHours(hour, minute, 0, 0);
  return date.toISOString();
}

// Usage
const scheduledTime = scheduleForTomorrow(14, 0); // Tomorrow at 2pm UTC
// "2026-02-21T14:00:00.000Z"
```

**Python:**
```python
from datetime import datetime, timedelta, timezone

def to_iso8601(dt):
    """Convert datetime to ISO 8601 UTC string."""
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.000Z')

def schedule_for_tomorrow(hour=9, minute=0):
    """Get ISO 8601 string for tomorrow at specified time (UTC)."""
    tomorrow = datetime.now(timezone.utc) + timedelta(days=1)
    scheduled = tomorrow.replace(hour=hour, minute=minute, second=0, microsecond=0)
    return to_iso8601(scheduled)

# Usage
scheduled_time = schedule_for_tomorrow(14, 0)  # Tomorrow at 2pm UTC
# "2026-02-21T14:00:00.000Z"
```

## Idempotency

Send an optional `Idempotency-Key` header with any value unique to the logical operation (a UUID works). It is **opt-in** — omit it and behaviour is exactly as before.

| Situation | Result |
|---|---|
| Same key, same body | The original response is **replayed** (same status code, same body). The update is not applied twice. |
| Same key, **different** body | `422` `IDEMPOTENCY_KEY_CONFLICT` |
| Same key, original still running | `409` `IDEMPOTENCY_IN_FLIGHT` |
| New key | Processed normally |

Keys are scoped to the **acting user** (the managed user when `x-publora-user-id` is set) and expire **24 hours** after first use. Two different users can safely use the same key string.

> **Why it matters most here:** `mediaUrls` on `update-post` has **append** semantics. A timeout or network blip on a request that already committed leaves you no way to tell whether it landed — and a naive retry appends the same media **again**, producing duplicate attachments on the post. With an `Idempotency-Key`, the retry replays the original response instead. **Always send one when the body contains `mediaUrls`.**

```javascript
const key = crypto.randomUUID();   // reuse this exact key for every retry

await fetch(`https://api.publora.com/api/v1/update-post/${postGroupId}`, {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'x-publora-key': process.env.PUBLORA_API_KEY,
    'Idempotency-Key': key
  },
  body: JSON.stringify({
    mediaUrls: ['https://example.com/photo.jpg']
  })
});
```

Generate the key **once per logical update** and reuse it across retries. Generating a fresh key per attempt defeats the protection entirely.

## Response

```json
{
  "success": true,
  "message": "Post updated successfully",
  "scheduledTime": "2026-03-15T10:00:00.000Z",
  "postGroup": {
    "_id": "507f1f77bcf86cd799439011",
    "status": "scheduled",
    "content": "Corrected launch announcement.",
    "platforms": ["linkedin-ABC123", "twitter-XYZ789"],
    "scheduledTime": "2026-03-15T10:00:00.000Z"
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `scheduledTime` | string/null | **Always present.** The effective stored time after any permitted past-time clamp. `null` if the post has no scheduled time. |
| `postGroup.content` | string | **Always present.** The effective base text stored after the update (`""` if the post has none). |
| `postGroup.platforms` | string[] | **Always present.** The effective target set stored after the update (`[]` for a draft with no targets). |
| `postGroup.scheduledTime` | string | The same value; included only when the post has a scheduled time set. |
| `warnings` | array | Present only when something was adjusted — e.g. a `SCHEDULED_TIME_COERCED` entry. See [Past scheduled times](#past-scheduled-times). |
| `mediaValidationStatus` | string | Present as `"pending"` only when media validation had not completed by the time the response was sent. |

## Examples

### Fix a typo and swap a target account

```javascript
// platforms REPLACES the stored set — send the complete final list.
const connections = await fetch('https://api.publora.com/api/v1/platform-connections', {
  headers: { 'x-publora-key': process.env.PUBLORA_API_KEY }
}).then(r => r.json());

await fetch(`https://api.publora.com/api/v1/update-post/${postGroupId}`, {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'x-publora-key': process.env.PUBLORA_API_KEY,
    'Idempotency-Key': crypto.randomUUID()
  },
  body: JSON.stringify({
    content: 'Corrected launch announcement.',
    platforms: ['linkedin-ABC123', 'threads-DEF456']
  })
});
```

```python
requests.put(
    f'https://api.publora.com/api/v1/update-post/{post_group_id}',
    headers={
        'Content-Type': 'application/json',
        'x-publora-key': os.environ['PUBLORA_API_KEY']
    },
    json={
        'content': 'Corrected launch announcement.',
        'platforms': ['linkedin-ABC123', 'threads-DEF456']
    }
)
```

### Reschedule a post

#### JavaScript (fetch)

```javascript
const response = await fetch(
  `https://api.publora.com/api/v1/update-post/${postGroupId}`,
  {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      'x-publora-key': 'YOUR_API_KEY'
    },
    body: JSON.stringify({
      scheduledTime: '2026-03-15T10:00:00.000Z'
    })
  }
);
```

#### Python (requests)

```python
response = requests.put(
    f'https://api.publora.com/api/v1/update-post/{post_group_id}',
    headers={
        'Content-Type': 'application/json',
        'x-publora-key': 'YOUR_API_KEY'
    },
    json={'scheduledTime': '2026-03-15T10:00:00.000Z'}
)
```

#### cURL

```bash
curl -X PUT https://api.publora.com/api/v1/update-post/507f1f77bcf86cd799439011 \
  -H "Content-Type: application/json" \
  -H "x-publora-key: YOUR_API_KEY" \
  -d '{"scheduledTime": "2026-03-15T10:00:00.000Z"}'
```

### Change a draft to scheduled

```javascript
await fetch(`https://api.publora.com/api/v1/update-post/${postGroupId}`, {
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'x-publora-key': 'YOUR_API_KEY'
  },
  body: JSON.stringify({
    status: 'scheduled',
    scheduledTime: '2026-04-01T12:00:00.000Z'
  })
});
```

### Pause a scheduled post (move to draft)

```python
response = requests.put(
    f'https://api.publora.com/api/v1/update-post/{post_group_id}',
    headers={
        'Content-Type': 'application/json',
        'x-publora-key': 'YOUR_API_KEY'
    },
    json={'status': 'draft'}
)
```

### Node.js (axios)

```javascript
const axios = require('axios');

const client = axios.create({
  baseURL: 'https://api.publora.com/api/v1',
  headers: { 'x-publora-key': process.env.PUBLORA_API_KEY }
});

async function reschedulePost(postGroupId, newTime) {
  const { data } = await client.put(`/update-post/${postGroupId}`, {
    scheduledTime: newTime
  });
  return data;
}

// Usage
await reschedulePost('507f1f77bcf86cd799439011', '2026-03-20T14:00:00.000Z');
```

### With Error Handling

```javascript
async function updatePostSafely(postGroupId, updates) {
  try {
    const response = await fetch(
      `https://api.publora.com/api/v1/update-post/${postGroupId}`,
      {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
          'x-publora-key': process.env.PUBLORA_API_KEY
        },
        body: JSON.stringify(updates)
      }
    );

    const data = await response.json();

    if (!response.ok) {
      switch (response.status) {
        case 400:
          if (data.error?.includes('status')) {
            throw new Error('Post is in a non-editable status. Cannot update.');
          }
          throw new Error(data.error || 'Invalid request');
        case 401:
          throw new Error('Authentication failed. Check your API key.');
        case 403:
          throw new Error('API access is not enabled for this account.');
        case 404:
          throw new Error('Post not found. It may have been deleted.');
        default:
          throw new Error(data.error || `HTTP ${response.status}`);
      }
    }

    return data;
  } catch (error) {
    console.error('Failed to update post:', error.message);
    throw error;
  }
}
```

```python
def update_post_safely(post_group_id, updates):
    """Update a post with comprehensive error handling."""
    try:
        response = requests.put(
            f'https://api.publora.com/api/v1/update-post/{post_group_id}',
            headers={
                'Content-Type': 'application/json',
                'x-publora-key': os.environ['PUBLORA_API_KEY']
            },
            json=updates
        )

        data = response.json()

        if response.status_code == 400:
            error = data.get('error', '')
            if 'status' in error.lower():
                raise ValueError('Post is in a non-editable status. Cannot update.')
            raise ValueError(error or 'Invalid request')

        if response.status_code == 401:
            raise ValueError('Authentication failed. Check your API key.')

        if response.status_code == 403:
            raise ValueError('API access is not enabled for this account.')

        if response.status_code == 404:
            raise ValueError('Post not found. It may have been deleted.')

        response.raise_for_status()
        return data

    except requests.RequestException as e:
        print(f'Failed to update post: {e}')
        raise
```

## Errors

| Status | Error | Cause |
|--------|-------|-------|
| 400 | `"At least one of status, scheduledTime, content, platforms, platformSettings, or mediaUrls must be provided"` | None of the six fields was provided (see note below) |
| 400 | `"Content must be a string"` — code `INVALID_CONTENT` | `content` was sent as something other than a string. Response includes `field: "content"`. |
| 400 | `"Platforms must be an array"` / `"Invalid platforms JSON format"` / `"Invalid platform ID format: {id}"` / `"Platforms must not contain duplicates"` — code `INVALID_PLATFORMS` | `platforms` failed shape validation. Response includes `field: "platforms"`. |
| 400 | `"Invalid platform connection(s): {ids}. Check connected accounts or call GET /platform-connections."` — code `INVALID_PLATFORM_CONNECTION` | A newly added platform ID is not a connection owned by the acting user. Response includes `invalidPlatforms`. |
| 400 | `"At least one platform is required to schedule a post. Add a platform or keep the post as a draft."` — code `PLATFORMS_REQUIRED` | The effective final state is `scheduled` with an empty `platforms` array |
| 400 | `"Cannot edit a post with a child in {status} status."` — code `POST_NOT_EDITABLE` | A platform post under this group has already reached a terminal state |
| 409 | `"Post publishing has already started; the post can no longer be edited."` / `"…retrying this edit could publish stale or duplicate content."` / `"A platform upload or publish operation has already started; editing now could publish stale content."` — code `POST_PUBLISH_IN_PROGRESS` | The group or one of its platform posts is publishing. Re-read with `GET /get-post` before retrying. |
| 409 | `"Post group state changed concurrently; please retry."` — code `POST_GROUP_VERSION_CONFLICT` | Another write committed first; this request changed nothing. Re-read and retry. |
| 400 | `"Unknown platformSettings path: {path}"` — code `PLATFORM_SETTING_UNKNOWN` | An unrecognized top-level platform or nested settings key. The response includes `field` with the exact dotted path. Nothing is persisted. |
| 400 | `"platformSettings.linkedin.*"` validation errors | Invalid LinkedIn repost settings (bad URN shape, bad visibility, `repostEnabled`/`repostParentUrn` mismatch) — see [Create Post → LinkedIn Repost Settings](./create-post.md#linkedin-repost-settings) |
| 400 | `"LinkedIn company-page reposts cannot use CONNECTIONS visibility; choose PUBLIC"` | `repostVisibility: "CONNECTIONS"` on a scheduled group that targets a LinkedIn company-page connection |
| 400 | `"Scheduled time is in the past. Server time is {t} UTC."` — code `SCHEDULED_TIME_IN_PAST` | The requested or stored time is 5+ minutes in the past while strict mode is active (scheduled for **2026-08-25** unless configuration overrides it). Includes `serverTime`. |
| 400 | `"Idempotency-Key request body is too deeply nested"` — code `IDEMPOTENCY_BODY_TOO_COMPLEX` | The request body sent with an `Idempotency-Key` exceeds the nesting limit and cannot be hashed |
| 409 | `"A request with this idempotency key is still in flight"` — code `IDEMPOTENCY_IN_FLIGHT` | Another request with the same `Idempotency-Key` is still being processed. Retry shortly. |
| 422 | `"Idempotency key was already used with a different request body"` — code `IDEMPOTENCY_KEY_CONFLICT` | The same `Idempotency-Key` was reused with a different body. Use a new key. |
| 400 | `"Status must be either 'draft' or 'scheduled'"` | Invalid status value |
| 400 | `"Invalid scheduled time format"` | Malformed datetime string |
| 400 | `"Cannot update post: post is currently in {status} status"` — code `POST_NOT_EDITABLE` | Post is in any status other than `draft` or `scheduled` |
| 400 | `"Invalid x-publora-user-id"` | The `x-publora-user-id` header value is not a valid ObjectId format |
| 401 | `"API key is required"` | Missing `x-publora-key` header |
| 401 | `"Invalid API key"` | `x-publora-key` value is incorrect or revoked |
| 401 | `"Invalid API key owner"` | The API key exists but its owner account could not be found |
| 403 | `"API access is not enabled for this account"` | No active subscription or API access not enabled |
| 403 | `"MCP access is not enabled for this account"` | The account does not have MCP access enabled (MCP-only keys) |
| 403 | `"Workspace access is not enabled for this key"` | The API key does not have workspace/managed-user permissions |
| 403 | `"User is not managed by key"` | The `x-publora-user-id` references a user not managed by this API key |
| 403 | `LimitExceededError` (structured JSON) | Rescheduling would exceed the account's posting limits for the target time slot (see below) |
| 404 | `"Post group not found"` | Invalid ID or post belongs to another user |
| 500 | `"Failed to update post"` | Malformed post group ID or internal server error |
| 500 | `"Internal server error"` | Unexpected server error in middleware |

> **Note:** The `status` field must be a **non-empty string**. The server uses a JavaScript falsy check, so empty strings (`""`), `null`, and `0` are all treated as absent. `content` and `platforms` are detected by **key presence**, not truthiness — sending `"content": ""` or `"platforms": []` counts as a real edit. If `status` and `scheduledTime` are both falsy and none of `content`, `platforms`, `platformSettings`, or `mediaUrls` is present, you will receive the "At least one of status, scheduledTime, content, platforms, platformSettings, or mediaUrls must be provided" error.

> **Note:** If `x-publora-user-id` matches the API key owner, no workspace check is triggered — the header is effectively a no-op in that case.

> **Note:** If the `postGroupId` is not a valid MongoDB ObjectId format (e.g., too short, contains invalid characters), the server returns a **500** error (`"Failed to update post"`) instead of a **400** validation error. Ensure you pass only valid ObjectId strings received from the create-post or list-posts endpoints.

### Limit Exceeded Error Format

When a **403** limit error is returned, the response body is a structured JSON object (not a simple string):

```json
{
  "error": "Post limit reached",
  "code": "POST_LIMIT_REACHED",
  "metric": "posts.platform_monthly",
  "message": "Monthly post limit reached. Your Pro plan allows 50 platform posts per month.",
  "limit": 50,
  "used": 48,
  "requested": 3,
  "remaining": 2,
  "periodStart": "2026-03-01T00:00:00.000Z",
  "periodEnd": "2026-04-01T00:00:00.000Z",
  "planName": "Pro"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `error` | string | Descriptive error string (see values below) |
| `code` | string | Error code identifying which limit was hit (see values below) |
| `metric` | string | Which limit metric was exceeded (see values below) |
| `message` | string | Human-readable description of the limit violation |
| `limit` | number | Maximum allowed value for this metric |
| `used` | number/null | How many have been used in the current period (`null` for date-based limits) |
| `requested` | number/null | How many were requested in this operation (`null` for date-based limits) |
| `remaining` | number/null | How many are still available (`null` for date-based limits) |
| `periodStart` | string | ISO 8601 start of the current billing/limit period |
| `periodEnd` | string | ISO 8601 end of the current billing/limit period |
| `planName` | string | The user's current plan name |

**Error codes and their corresponding `error` and `metric` values:**

| `code` | `error` | `metric` |
|--------|---------|----------|
| `POST_LIMIT_REACHED` | `"Post limit reached"` | `"posts.platform_monthly"` |
| `SCHEDULED_POST_LIMIT_REACHED` | `"Scheduled post limit reached"` | `"posts.scheduled_active"` |
| `SCHEDULE_HORIZON_REACHED` | `"Schedule horizon reached"` | `"posts.schedule_horizon_days"` |
| `PLATFORM_NOT_AVAILABLE` | `"Platform not available"` | `"posts.platform_monthly"` |
| `CONNECTIONS_OVER_LIMIT` | `"Account over channel limit"` | `"connections.total"` |

> **Note:** For `SCHEDULE_HORIZON_REACHED`, the `used`, `requested`, and `remaining` fields are `null` since this limit is date-based rather than count-based.

> **Note:** Additional top-level context depends on the code: `scheduledTime`/`maxScheduledDate` for the schedule horizon; `limitScope` and (for connection scope) `blockedPlatforms` for monthly posts; workspace fields for the scheduled queue; and platform allowlist fields for availability checks.

> **Note (low priority):** When a `LimitExceededError` is triggered, the API also sends a limit-reached notification email to the account owner as a side effect. This is an internal behavior and does not affect the API response.


---

*[Publora](https://publora.com) is built by [Creative Content Crafts, Inc.](https://cccrafts.ai) Need AI-powered content creation for LinkedIn, Threads, and X? Try [Co.Actor](https://co.actor) — the best AI service for authentic thought leadership at scale.*
