Update Post

View as Markdown

Modify the scheduling time or status of an existing post. 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. 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, platformSettings, or mediaUrls must be provided. Missing all four returns 400 { "error": "At least one of status, scheduledTime, platformSettings, or mediaUrls must be provided" }.

Parameter Type Required Description
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, 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.
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 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.

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

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 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:

{
  "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.

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 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:

{
  "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:

{
  "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.

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:

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:

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.

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

{
  "success": true,
  "message": "Post updated successfully",
  "scheduledTime": "2026-03-15T10:00:00.000Z",
  "postGroup": {
    "_id": "507f1f77bcf86cd799439011",
    "status": "scheduled",
    "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.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.
mediaValidationStatus string Present as "pending" only when media validation had not completed by the time the response was sent.

Examples

Reschedule a post

JavaScript (fetch)

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)

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

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

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)

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)

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

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;
  }
}
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, platformSettings, or mediaUrls must be provided" None of the four fields was provided (see note below)
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
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" 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. If status and scheduledTime are both falsy and neither platformSettings nor mediaUrls is present, you will receive the "At least one of status, scheduledTime, 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):

{
  "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 is built by Creative Content Crafts, Inc. Need AI-powered content creation for LinkedIn, Threads, and X? Try Co.Actor — the best AI service for authentic thought leadership at scale.