<!-- 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 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.

### 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.*
