Error Handling

View as Markdown

This guide covers how to handle errors from the Publora API, including HTTP status codes, error response formats, retry strategies, and dealing with partial failures.

For a code-by-code lookup covering top-level, validation, media-ingestion, and workspace errors, see the API Error Code Catalog.

How It Works

The Publora API uses standard HTTP status codes to indicate success or failure. When an error occurs, the response body contains a JSON object with details about what went wrong.

HTTP Status Codes

Code Meaning Description
200 OK Request succeeded (GET, PUT, DELETE, and most POST endpoints including create-post)
201 Created Resource created successfully (used by specific POST endpoints such as workspace user creation)
400 Bad Request Invalid input, missing required fields, or malformed data
401 Unauthorized Missing or invalid API key
403 Forbidden Valid API key but insufficient permissions or plan limits reached
404 Not Found Resource does not exist or does not belong to your account
409 Conflict Duplicate or conflicting operation, or an Idempotency-Key request that is still in flight
422 Unprocessable Entity An Idempotency-Key was reused with a different request body
500 Internal Server Error Something went wrong on Publora's side

Note: Most POST endpoints (including create-post) return 200 on success, but workspace user creation (POST /workspace/users) returns 201. Check for both 200 and 201 as success codes in your error handling logic.

Error Response Format

Errors are returned as JSON in one of two formats:

{
  "error": "Human-readable error message"
}

or

{
  "message": "Detailed description of what went wrong"
}

Both formats may also carry a machine-readable code field. Prefer branching on code over matching error text — messages may be reworded, codes are stable.

The API tells you, or refuses — it never silently rewrites your request

Publora no longer quietly changes a request it cannot take literally. It either warns (2xx + warnings[]) or refuses (4xx + code).

warnings[] on successful responses

A 200 from create-post or update-post may include a warnings array. It is omitted entirely when empty — never assume the key exists. Each entry has at least code and message. Surface warnings to your users; do not treat them as failures.

{
  "success": true,
  "postGroupId": "664f1a2b3c4d5e6f7a8b9c0d",
  "scheduledTime": "2026-03-15T14:00:00.000Z",
  "warnings": [
    {
      "code": "SCHEDULED_TIME_COERCED",
      "message": "Requested scheduled time 2026-03-15T13:59:00.000Z was in the past and was changed to server time 2026-03-15T14:00:00.000Z.",
      "requested": "2026-03-15T13:59:00.000Z",
      "effective": "2026-03-15T14:00:00.000Z"
    }
  ]
}

scheduledTime in the past

Situation Behaviour
Less than 5 minutes in the past Always clamped to server time + SCHEDULED_TIME_COERCED warning (tolerates clock skew). This tolerance is permanent — it does not change after the sunset.
5 minutes or more in the past, before the strict sunset (2026-08-25) Clamped to server time + SCHEDULED_TIME_COERCED warning
5 minutes or more in the past, on or after the configured sunset (default 2026-08-25) Rejected: 400 SCHEDULED_TIME_IN_PAST

Production configuration can force strict mode on or off, or move the sunset, so the calendar date is not unconditional.

Migrate now: SCHEDULED_TIME_COERCED means the request will fail whenever strict mode becomes active. Compare requested against serverTime/effective to detect clock skew.

Common Errors

Status Error Message Cause Resolution
401 "API key is required" The x-publora-key header is missing Include a valid API key in the x-publora-key header
401 "Invalid API key" The x-publora-key header contains an invalid or revoked key Verify your API key is correct
401 "Invalid API key owner" Workspace resolution failed for the API key (the key exists but its owner account could not be resolved) Verify the API key is associated with a valid account, or regenerate the key
403 "API access is not enabled for this account" API access is disabled for this account (a custom plan with apiAccess turned off — standard plans, including the free Starter plan, include API access) Contact support if you expect API access
403 "Workspace access is not enabled for this key" The API key does not have workspace-level access enabled Verify the API key has the correct permissions, or generate a new key with workspace access
403 "Subscription required" Your account does not have an active subscription. Response includes message: "No active plan entitlements found for this account." Note: This error is currently unreachable in practice because resolveEntitlementsForOwner() always falls back to the Starter plan when no paid subscription exists. The middleware code exists and is documented here for completeness, but with the current entitlement resolution logic this error cannot occur. Subscribe to a paid plan
403 "Account on hold" Your account has been placed on hold. Response includes message: "Your account is temporarily on hold. Please contact support.", holdExpiresAt (ISO 8601 timestamp), and holdReason fields. Note: This error only applies to dashboard routes, not the external REST API. The checkHold middleware is not applied to external API endpoints. Wait until the hold expires or contact support to resolve the issue
403 "Account inactive" Your account has been deactivated. Response includes message: "Your account is inactive." Note: This error is currently unreachable. The checkSubscription middleware is imported but never mounted on any route (dead code), so this error cannot occur on either dashboard or external API endpoints. It is documented here for completeness. Contact support@publora.com to reactivate your account
403 Limit exceeded (structured) You have exceeded a usage limit for your plan. Response uses LimitExceededError format with fields: code, error (short label), message (long text), metric, limit, used, requested, remaining, periodStart, periodEnd, planName, plus context-specific fields (see below) Wait for the current period to reset, reduce usage, or upgrade your plan
403 "MCP access is not enabled for this account" Your account does not have MCP access enabled Contact support or upgrade your plan to enable MCP access
400 "Invalid x-publora-user-id" The x-publora-user-id header contains a malformed user ID (not a valid ObjectId format). A valid-format but non-existent user ID returns 403 "User is not managed by key" instead Verify the user ID is a valid ObjectId and belongs to the account associated with the MCP API key
400 "Invalid scheduled time format" The scheduledTime is not a valid ISO 8601 string Use a valid time in the format YYYY-MM-DDTHH:mm:ss.sssZ
404 "Post group not found" The post group ID does not exist or does not belong to your account Verify the ID and ensure you are using the correct API key
400 "Cannot update post: post is currently in {status} status" Attempting to modify a post that is in a non-editable status (e.g., published, failed) Only draft and scheduled posts can be updated. The external API and dashboard have different update rules. The external API rejects any post not in draft or scheduled status. The dashboard controller rejects posts in published or failed status — meaning partially_published posts can be updated via the dashboard but not via the API. Neither checks processingStatus. Note: processing is not a value of the status field — it is tracked separately via the processingStatus field (values: pending, processing, finished). The status field values are: draft, scheduled, published, failed, partially_published.

Scheduled-Time Errors

Status Code Error Message Cause Resolution
400 SCHEDULED_TIME_IN_PAST "Scheduled time is in the past. Server time is {ISO} UTC." scheduledTime was 5+ minutes in the past and strict mode is active (see the sunset table above). Response also includes serverTime (ISO 8601, the server's clock at the moment of the request). Send a future time. If your time looked correct, compare it to serverTime — you likely have clock skew or a timezone/UTC bug.
200 SCHEDULED_TIME_COERCED (not an error — a warnings[] entry) A past scheduledTime was clamped to server time. Entry fields: code, message, requested, effective. None required, but fix the sending clock: after the strict sunset this same request returns 400.

platformSettings Errors

Status Code Error Message Cause Resolution
400 PLATFORM_SETTING_UNKNOWN "Unknown platformSettings path: {path}" An unknown platform key or an unknown nested field inside platformSettings. Previously such paths were silently dropped and the post published without the setting. The response includes field with the exact offending dotted path (e.g. instagram.coverurl). Read field, fix the typo or remove the key. See the platformSettings schema for the supported paths per platform.

Idempotency Errors

Applies to POST /create-post and PUT /update-post when you send an Idempotency-Key header.

The code field is the contract — branch on it, not on the message text.

Status Code Cause Resolution
422 IDEMPOTENCY_KEY_CONFLICT The same Idempotency-Key was reused with a different request body. Bodies are compared by a canonical hash, so key order and formatting do not matter — only actual values do. Use a new key for a new request, or resend the equivalent original body to replay the stored response.
409 IDEMPOTENCY_IN_FLIGHT A request with this key is still being processed, or your claim on it was superseded by a concurrent request. Several distinct messages map to this one code — match on code. Wait briefly and retry the identical request; once the original completes you receive its stored response. Do not switch to a new key — that risks a duplicate post.
400 IDEMPOTENCY_BODY_TOO_COMPLEX The request body nests deeper than 200 levels and cannot be hashed. Flatten the payload. No real platformSettings / mediaUrls body reaches this depth — it usually signals a serialization bug.

Replays are not errors. Resending an identical request with the same Idempotency-Key returns the original response — same status code, same body (including any warnings[]) — without performing the mutation again.

Post Creation Errors

Status Error Message Cause Resolution
400 "Content is required" The content field is empty or missing in a create-post request Provide a non-empty content string
400 "Platforms are required" The platforms field is missing from the request body Include a platforms field in the request
400 "At least one platform is required" The platforms array is present but empty Add at least one platform ID to the platforms array
400 "Invalid platform ID format: {platformId}" A platform ID in the platforms array does not match the expected {platform}-{id} format Use the correct format, e.g., "twitter-123456"
400 "Invalid platforms JSON format" The platforms field could not be parsed as valid JSON Ensure platforms is a valid JSON array
400 "Platforms must be an array" The platforms field is not an array (e.g., a string or object) Pass platforms as a JSON array of platform ID strings
400 "Invalid platformSettings JSON" The platformSettings field could not be parsed as valid JSON Ensure platformSettings is a valid JSON object
400 "platformSettings must be an object" The platformSettings field is not a JSON object Pass platformSettings as a JSON object, not an array or primitive
400 "Unknown platformSettings path: {path}" An unknown platform or nested key in platformSettings (code PLATFORM_SETTING_UNKNOWN, with the exact field) See platformSettings Errors

Post Update Errors

Status Error Message Cause Resolution
400 "At least one of status, scheduledTime, platformSettings, or mediaUrls must be provided" An update-post request omitted all four accepted fields Include at least one of status, scheduledTime, platformSettings, or mediaUrls in the request body
400 "Status must be either 'draft' or 'scheduled'" The status field in an update-post request contains an invalid value Set status to either "draft" or "scheduled"
422 / 409 / 400 Idempotency errors An Idempotency-Key was reused with a different body, is still in flight, or the body is too deeply nested See Idempotency Errors

Media Upload Errors

Status Error Message Cause Resolution
400 "fileName, contentType, and postGroupId are required" A get-upload-url request is missing one or more required fields Include all three fields: fileName, contentType, and postGroupId

LimitExceededError Codes

The code field in a LimitExceededError response identifies the specific limit that was exceeded:

Code Description
POST_LIMIT_REACHED Monthly post limit for the plan has been reached
SCHEDULED_POST_LIMIT_REACHED Maximum number of pending scheduled posts reached
SCHEDULE_HORIZON_REACHED Scheduled time exceeds the plan's maximum scheduling horizon
PLATFORM_NOT_AVAILABLE The target platform is not available on the current plan
CONNECTIONS_OVER_LIMIT Number of connected accounts exceeds the plan limit
CHANNEL_LIMIT_REACHED Maximum number of channels for the plan has been reached

Note on CHANNEL_LIMIT_REACHED: The checkConnectionLimit middleware returns a different response format from the standard LimitExceededError. When canExpand: true, the response includes planKey, planName, currentQuantity, targetQuantity, connectionCount, and pricing (expansion pricing details). When canExpand: false, the response varies depending on whether the user's billing is managed by a workspace owner. For billing-managed users, only success, code, error, and canExpand are returned. For non-managed users, connectionCount is also included in the response. If you are handling connection limit errors specifically, parse these fields rather than the standard LimitExceededError shape.

LimitExceededError Context Fields

In addition to the standard fields (code, error, message, metric, limit, used, requested, remaining, periodStart, periodEnd, planName), the response spreads ...this.context into the top-level response object. This means context-specific fields appear conditionally depending on the error code -- they are not guaranteed to be present on every LimitExceededError response:

Context Field Appears With Description
overLimitBy CONNECTIONS_OVER_LIMIT How many units over the limit the request would go
disallowedPlatforms PLATFORM_NOT_AVAILABLE Array of platforms in the request that are not available on the current plan
allowedPlatforms PLATFORM_NOT_AVAILABLE Array of platforms that are available on the current plan
projectedUsed CHANNEL_LIMIT_REACHED The projected usage count if the operation were to proceed
blockedPlatforms POST_LIMIT_REACHED Per-connection { platformSelection, used, remaining } entries; present only for connection-scoped monthly-post limits
limitScope POST_LIMIT_REACHED Monthly-post scope: "account" or "connection"
scheduledTime SCHEDULE_HORIZON_REACHED The requested scheduled time that exceeded the horizon
maxScheduledDate SCHEDULE_HORIZON_REACHED The latest allowed scheduled date for the current plan

Threads-Specific Errors

Error Message Cause Resolution
"Multi-part threads (nested replies) are temporarily blocked while we wait for Meta to approve additional permissions for our app." Multi-part threads (content >500 chars or with --- separators) are temporarily disabled due to Threads API access requirements Keep content under 500 characters without --- separators. Use carousel posts for multiple images. Contact support@publora.com for updates.

Note: Single posts (under 500 characters) and carousel posts on Threads continue to work normally. Multi-part requests are blocked, and no automatic split occurs while supportsThreading is false.

Post-Level Errors (Partial Failures)

A post group can target multiple platforms. Even if some platforms succeed, others may fail. This results in a partially_published status on the post group.

When checking a post group's status, each individual platform post has its own status field:

{
  "success": true,
  "postGroupId": "664f1a2b3c4d5e6f7a8b9c0d",
  "status": "partially_published",
  "scheduledTime": "2026-03-15T14:00:00.000Z",
  "platformSettings": {},
  "platforms": ["twitter-123456", "tiktok-789012", "linkedin-ABCDEF"],
  "posts": [
    {
      "_id": "664f1a2b3c4d5e6f7a8b9c01",
      "platform": "twitter",
      "platformId": "123456",
      "status": "published",
      "content": "Check out our latest product launch!",
      "postedId": "1234567890123456789",
      "permalink": null
    },
    {
      "_id": "664f1a2b3c4d5e6f7a8b9c02",
      "platform": "tiktok",
      "platformId": "789012",
      "status": "failed",
      "content": "Check out our latest product launch!",
      "postedId": null,
      "permalink": null,
      "error": {
        "code": "RATE_LIMITED",
        "message": "Rate limit exceeded",
        "platformStatusCode": 429,
        "platformError": null,
        "failedAt": "2026-03-15T14:01:12.000Z",
        "retryable": true
      }
    },
    {
      "_id": "664f1a2b3c4d5e6f7a8b9c03",
      "platform": "linkedin",
      "platformId": "ABCDEF",
      "status": "published",
      "content": "Check out our latest product launch!",
      "postedId": "urn:li:share:7000000000000000000",
      "permalink": null
    }
  ],
  "media": []
}

Note: Top-level status is the aggregate post-group status. Each posts[].status is the outcome for one platform target. The top-level response also includes success, postGroupId, scheduledTime, platformSettings, platforms, and media; content remains per-platform inside posts[].

error field format: The error field on individual platform posts is a structured object, not a plain string. The get-post endpoint returns the full error object with fields: code, message, platformStatusCode, platformError, failedAt, and retryable.

platformId vs platform ID string: In the get-post response, platform and platformId are separate fields. The platform field contains the platform name (e.g., "twitter"), and platformId contains just the account identifier (e.g., "123456"). This is different from the {platform}-{id} combined format (e.g., "twitter-123456") used in the platforms array when creating posts.

Platform-Specific Validation Errors

Some platforms have strict media requirements that are checked before publishing:

  • TikTok: Minimum FPS requirement, specific video format requirements, duration limits
  • Instagram: Video aspect ratio and duration requirements for Reels
  • YouTube: Video format and size requirements

These errors appear at the individual platform post level, not at the HTTP response level.

Examples

Error Handling Wrapper (JavaScript)

class PubloraApiError extends Error {
  constructor(status, message, body) {
    super(message);
    this.name = 'PubloraApiError';
    this.status = status;
    this.body = body;
  }
}
 
async function publoraRequest(endpoint, options = {}) {
  const url = `https://api.publora.com/api/v1${endpoint}`;
 
  const response = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      'x-publora-key': 'YOUR_API_KEY',
      ...options.headers
    }
  });
 
  const body = await response.json();
 
  if (!response.ok) {
    const message = body.error || body.message || 'Unknown API error';
    throw new PubloraApiError(response.status, message, body);
  }
 
  return body;
}
 
// Usage
try {
  const post = await publoraRequest('/create-post', {
    method: 'POST',
    body: JSON.stringify({
      content: 'Hello from Publora!',
      platforms: ['twitter-123456'],
      scheduledTime: '2026-03-15T14:00:00.000Z'
    })
  });
 
  console.log('Post created:', post.postGroupId);
} catch (error) {
  if (error instanceof PubloraApiError) {
    switch (error.status) {
      case 401:
        console.error('Authentication failed. Check your API key.');
        break;
      case 403:
        console.error('Access denied:', error.message);
        // Could be "Subscription required", limit exceeded, or account hold
        break;
      case 400:
        console.error('Bad request:', error.message);
        // Could be "Invalid scheduled time" or validation errors
        break;
      case 404:
        console.error('Resource not found:', error.message);
        break;
      case 409:
        console.error('Conflict:', error.message);
        break;
      case 500:
        console.error('Server error. Retry later.');
        break;
      default:
        console.error(`API error (${error.status}):`, error.message);
    }
  } else {
    console.error('Network or unexpected error:', error.message);
  }
}

Error Handling Wrapper (Python)

import requests
 
 
class PubloraApiError(Exception):
    def __init__(self, status_code, message, body):
        super().__init__(message)
        self.status_code = status_code
        self.body = body
 
 
class PubloraClient:
    BASE_URL = 'https://api.publora.com/api/v1'
 
    def __init__(self, api_key):
        self.session = requests.Session()
        self.session.headers.update({
            'Content-Type': 'application/json',
            'x-publora-key': api_key
        })
 
    def _request(self, method, endpoint, **kwargs):
        url = f'{self.BASE_URL}{endpoint}'
        response = self.session.request(method, url, **kwargs)
 
        try:
            body = response.json()
        except ValueError:
            body = {}
 
        if not response.ok:
            message = body.get('error') or body.get('message') or 'Unknown API error'
            raise PubloraApiError(response.status_code, message, body)
 
        return body
 
    def create_post(self, content, platform_ids, scheduled_time=None):
        payload = {
            'content': content,
            'platforms': platform_ids
        }
        if scheduled_time:
            payload['scheduledTime'] = scheduled_time
 
        return self._request('POST', '/create-post', json=payload)
 
    def get_post(self, post_group_id):
        return self._request('GET', f'/get-post/{post_group_id}')
 
 
# Usage
client = PubloraClient('YOUR_API_KEY')
 
try:
    post = client.create_post(
        content='Hello from Publora!',
        platforms=['twitter-123456'],
        scheduled_time='2026-03-15T14:00:00.000Z'
    )
    print(f"Post created: {post['postGroupId']}")
 
except PubloraApiError as e:
    if e.status_code == 401:
        print('Authentication failed. Check your API key.')
    elif e.status_code == 403:
        print(f'Access denied: {e}')
    elif e.status_code == 400:
        print(f'Bad request: {e}')
    elif e.status_code == 404:
        print(f'Resource not found: {e}')
    elif e.status_code == 500:
        print('Server error. Retry later.')
    else:
        print(f'API error ({e.status_code}): {e}')
 
except requests.RequestException as e:
    print(f'Network error: {e}')

cURL with Error Checking

#!/bin/bash
 
API_KEY="YOUR_API_KEY"
BASE_URL="https://api.publora.com/api/v1"
 
# Make a request and capture both HTTP status and body
HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$BASE_URL/create-post" \
  -H "Content-Type: application/json" \
  -H "x-publora-key: $API_KEY" \
  -d '{
    "content": "Hello from Publora!",
    "platforms": ["twitter-123456"],
    "scheduledTime": "2026-03-15T14:00:00.000Z"
  }')
 
# Split response body and status code
HTTP_BODY=$(echo "$HTTP_RESPONSE" | sed '$d')
HTTP_STATUS=$(echo "$HTTP_RESPONSE" | tail -1)
 
case $HTTP_STATUS in
  200|201)
    echo "Success:"
    echo "$HTTP_BODY" | jq .
    ;;
  400)
    echo "Bad request:"
    echo "$HTTP_BODY" | jq '.error // .message'
    ;;
  401)
    echo "Authentication failed. Check your x-publora-key header."
    ;;
  403)
    echo "Access denied:"
    echo "$HTTP_BODY" | jq '.error // .message'
    ;;
  404)
    echo "Not found:"
    echo "$HTTP_BODY" | jq '.error // .message'
    ;;
  500)
    echo "Server error. Retry later."
    ;;
  *)
    echo "Unexpected status: $HTTP_STATUS"
    echo "$HTTP_BODY"
    ;;
esac

Node.js (axios) with Error Handling

const axios = require('axios');
 
const api = axios.create({
  baseURL: 'https://api.publora.com/api/v1',
  headers: {
    'Content-Type': 'application/json',
    'x-publora-key': 'YOUR_API_KEY'
  }
});
 
// Add a response interceptor for centralized error handling
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response) {
      const { status, data } = error.response;
      const message = data.error || data.message || 'Unknown error';
 
      switch (status) {
        case 401:
          console.error('Authentication failed. Check your API key.');
          break;
        case 403:
          console.error('Access denied:', message);
          break;
        case 400:
          console.error('Bad request:', message);
          break;
        case 404:
          console.error('Not found:', message);
          break;
        case 500:
          console.error('Server error. Retry later.');
          break;
        default:
          console.error(`API error (${status}):`, message);
      }
    } else if (error.request) {
      console.error('No response received. Check your network connection.');
    } else {
      console.error('Request setup error:', error.message);
    }
 
    return Promise.reject(error);
  }
);
 
// Usage
try {
  const { data: post } = await api.post('/create-post', {
    content: 'Hello from Publora!',
    platforms: ['twitter-123456'],
    scheduledTime: '2026-03-15T14:00:00.000Z'
  });
 
  console.log('Post created:', post.postGroupId);
} catch (error) {
  // Error already logged by the interceptor
  // Add any additional handling here
}

Retry Logic with Exponential Backoff

Server errors (500) and network failures are usually transient. Implement retry logic for these cases.

JavaScript (fetch)

async function publoraRequestWithRetry(endpoint, options = {}, maxRetries = 3) {
  const url = `https://api.publora.com/api/v1${endpoint}`;
 
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, {
        ...options,
        headers: {
          'Content-Type': 'application/json',
          'x-publora-key': 'YOUR_API_KEY',
          ...options.headers
        }
      });
 
      const body = await response.json();
 
      if (response.ok) {
        return body;
      }
 
      // Do not retry client errors (4xx) -- they will not succeed on retry
      if (response.status >= 400 && response.status < 500) {
        throw new Error(`Client error ${response.status}: ${body.error || body.message}`);
      }
 
      // Retry on server errors (5xx)
      if (attempt < maxRetries) {
        const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
        console.log(`Server error (${response.status}). Retrying in ${delay}ms...`);
        await new Promise(resolve => setTimeout(resolve, delay));
        continue;
      }
 
      throw new Error(`Server error ${response.status} after ${maxRetries} retries`);
 
    } catch (error) {
      // Retry on network errors
      if (error.name === 'TypeError' && attempt < maxRetries) {
        const delay = Math.pow(2, attempt) * 1000;
        console.log(`Network error. Retrying in ${delay}ms...`);
        await new Promise(resolve => setTimeout(resolve, delay));
        continue;
      }
 
      throw error;
    }
  }
}
 
// Usage
try {
  const post = await publoraRequestWithRetry('/create-post', {
    method: 'POST',
    body: JSON.stringify({
      content: 'Retryable post!',
      platforms: ['twitter-123456'],
      scheduledTime: '2026-03-15T14:00:00.000Z'
    })
  });
 
  console.log('Post created:', post.postGroupId);
} catch (error) {
  console.error('Failed after retries:', error.message);
}

Python (requests)

import time
import requests
 
 
def publora_request_with_retry(method, endpoint, max_retries=3, **kwargs):
    url = f'https://api.publora.com/api/v1{endpoint}'
    headers = {
        'Content-Type': 'application/json',
        'x-publora-key': 'YOUR_API_KEY'
    }
 
    for attempt in range(max_retries + 1):
        try:
            response = requests.request(method, url, headers=headers, **kwargs)
            body = response.json()
 
            if response.ok:
                return body
 
            # Do not retry client errors (4xx)
            if 400 <= response.status_code < 500:
                message = body.get('error') or body.get('message') or 'Unknown error'
                raise Exception(f'Client error {response.status_code}: {message}')
 
            # Retry on server errors (5xx)
            if attempt < max_retries:
                delay = (2 ** attempt)  # 1s, 2s, 4s
                print(f'Server error ({response.status_code}). Retrying in {delay}s...')
                time.sleep(delay)
                continue
 
            raise Exception(f'Server error {response.status_code} after {max_retries} retries')
 
        except requests.ConnectionError:
            if attempt < max_retries:
                delay = (2 ** attempt)
                print(f'Network error. Retrying in {delay}s...')
                time.sleep(delay)
                continue
            raise
 
 
# Usage
try:
    post = publora_request_with_retry(
        'POST', '/create-post',
        json={
            'content': 'Retryable post!',
            'platforms': ['twitter-123456'],
            'scheduledTime': '2026-03-15T14:00:00.000Z'
        }
    )
    print(f"Post created: {post['postGroupId']}")
except Exception as e:
    print(f'Failed after retries: {e}')

Node.js (axios)

const axios = require('axios');
 
async function publoraRequestWithRetry(method, endpoint, data = null, maxRetries = 3) {
  const config = {
    method,
    url: `https://api.publora.com/api/v1${endpoint}`,
    headers: {
      'Content-Type': 'application/json',
      'x-publora-key': 'YOUR_API_KEY'
    },
    data
  };
 
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await axios(config);
      return response.data;
    } catch (error) {
      const status = error.response?.status;
 
      // Do not retry client errors
      if (status && status >= 400 && status < 500) {
        const message = error.response.data?.error || error.response.data?.message;
        throw new Error(`Client error ${status}: ${message}`);
      }
 
      // Retry on server errors or network errors
      if (attempt < maxRetries) {
        const delay = Math.pow(2, attempt) * 1000;
        const reason = status ? `Server error (${status})` : 'Network error';
        console.log(`${reason}. Retrying in ${delay}ms...`);
        await new Promise(resolve => setTimeout(resolve, delay));
        continue;
      }
 
      throw error;
    }
  }
}
 
// Usage
try {
  const post = await publoraRequestWithRetry('POST', '/create-post', {
    content: 'Retryable post!',
    platforms: ['twitter-123456'],
    scheduledTime: '2026-03-15T14:00:00.000Z'
  });
 
  console.log('Post created:', post.postGroupId);
} catch (error) {
  console.error('Failed after retries:', error.message);
}

Checking for Partial Failures

After a post group has been processed, check whether all platforms succeeded or if some failed.

JavaScript (fetch)

async function checkPostGroupStatus(postGroupId) {
  const response = await fetch(
    `https://api.publora.com/api/v1/get-post/${postGroupId}`,
    {
      headers: { 'x-publora-key': 'YOUR_API_KEY' }
    }
  );
 
  const postGroup = await response.json();
 
  // Determine overall status from individual posts
  const posts = postGroup.posts || [];
  const statuses = posts.map(p => p.status);
  const allPublished = statuses.every(s => s === 'published');
  const allFailed = statuses.every(s => s === 'failed');
  const overallStatus = allPublished ? 'published' : allFailed ? 'failed' : 'partially_published';
 
  console.log(`Post group ${postGroup.postGroupId}: ${overallStatus}`);
 
  if (overallStatus === 'partially_published') {
    console.log('Some platforms failed:');
 
    const failed = postGroup.posts.filter(p => p.status === 'failed');
    const succeeded = postGroup.posts.filter(p => p.status === 'published');
 
    console.log(`  Succeeded: ${succeeded.length}`);
    for (const p of succeeded) {
      console.log(`    - ${p.platform}: ${p.postedId}`);
    }
 
    console.log(`  Failed: ${failed.length}`);
    for (const p of failed) {
      console.log(`    - ${p.platform}: ${p.error}`);
    }
 
    return { status: 'partial', succeeded, failed };
  }
 
  if (overallStatus === 'failed') {
    console.log('All platforms failed.');
    for (const p of posts) {
      console.log(`  - ${p.platform}: ${p.error}`);
    }
    return { status: 'failed', succeeded: [], failed: posts };
  }
 
  if (overallStatus === 'published') {
    console.log('All platforms succeeded.');
    return { status: 'success', succeeded: posts, failed: [] };
  }
 
  // Still processing or scheduled (posts array may be empty or have pending statuses)
  console.log('Post is still being processed');
  return { status: 'pending', succeeded: [], failed: [] };
}
 
// Usage
const result = await checkPostGroupStatus('664f1a2b3c4d5e6f7a8b9c0d');
if (result.failed.length > 0) {
  // Take action: notify team, retry failed platforms, etc.
}

Python (requests)

import requests
 
API_URL = 'https://api.publora.com/api/v1'
HEADERS = {
    'Content-Type': 'application/json',
    'x-publora-key': 'YOUR_API_KEY'
}
 
 
def check_post_group_status(post_group_id):
    response = requests.get(
        f'{API_URL}/get-post/{post_group_id}',
        headers=HEADERS
    )
 
    post_group = response.json()
    posts = post_group.get('posts', [])
 
    # Determine overall status from individual posts
    statuses = [p['status'] for p in posts]
    all_published = all(s == 'published' for s in statuses)
    all_failed = all(s == 'failed' for s in statuses)
 
    if all_published:
        overall_status = 'published'
    elif all_failed:
        overall_status = 'failed'
    elif posts:
        overall_status = 'partially_published'
    else:
        overall_status = 'pending'
 
    print(f"Post group {post_group['postGroupId']}: {overall_status}")
 
    if overall_status == 'partially_published':
        print('Some platforms failed:')
 
        failed = [p for p in posts if p['status'] == 'failed']
        succeeded = [p for p in posts if p['status'] == 'published']
 
        print(f"  Succeeded: {len(succeeded)}")
        for p in succeeded:
            print(f"    - {p['platform']}: {p.get('postedId', 'N/A')}")
 
        print(f"  Failed: {len(failed)}")
        for p in failed:
            print(f"    - {p['platform']}: {p.get('error', 'Unknown error')}")
 
        return {'status': 'partial', 'succeeded': succeeded, 'failed': failed}
 
    if overall_status == 'failed':
        print('All platforms failed.')
        for p in posts:
            print(f"  - {p['platform']}: {p.get('error', 'Unknown error')}")
        return {'status': 'failed', 'succeeded': [], 'failed': posts}
 
    if overall_status == 'published':
        print('All platforms succeeded.')
        return {'status': 'success', 'succeeded': posts, 'failed': []}
 
    print(f'Post is still being processed')
    return {'status': 'pending', 'succeeded': [], 'failed': []}
 
 
# Usage
result = check_post_group_status('664f1a2b3c4d5e6f7a8b9c0d')
if result['failed']:
    # Take action: notify team, retry failed platforms, etc.
    pass

Best Practices

  1. Always check the HTTP status code. Do not assume every response is successful. Parse the error body for details.

  2. Do not retry most 4xx responses unchanged. They usually indicate a problem with the request. The exception is 429 MEDIA_URL_RATE_LIMITED from mediaUrls ingestion: wait the number of seconds in Retry-After (also returned as retryAfterSec), then retry with the same Idempotency-Key described in item 4.

  3. Use exponential backoff for retries. Start at 1 second and double with each attempt. Cap at 3-5 retries to avoid infinite loops.

  4. Send an Idempotency-Key on every create-post / update-post you might retry. A timeout does not tell you whether the post was created. Without a key, the retry double-posts (and on update-post with mediaUrls, double-appends the media). With one, the retry replays the original response.

  5. Read warnings[] on successful responses. A 200 can still mean the API changed something about your request — most often SCHEDULED_TIME_COERCED. Log warnings; do not discard them.

  6. Monitor for partial failures. A 200 response when creating a post does not guarantee all platforms will succeed. Poll the post group status after the scheduled time to detect partial failures.

  7. Log error responses in full. When debugging, log the entire response body, not just the error message. Additional fields may provide context.

  8. Handle 403 errors gracefully in your UI. If a user hits a plan limit (returned as a structured LimitExceededError), parse the metric, limit, used, and remaining fields to show actionable guidance rather than a raw error.

  9. Validate inputs before sending. Check that scheduledTime is in the future and in ISO 8601 format, platform IDs follow the {platform}-{id} format, and media counts are within limits. This avoids unnecessary 400 errors.

Common Issues

Problem Cause Solution
401 on every request API key not set or incorrect Ensure x-publora-key header is present with a valid key
403 but key is valid Account has no active subscription, hit a usage limit, or account is on hold/inactive Check subscription status, review the structured error response for limit details, or contact support
400 when scheduling scheduledTime is malformed, or (code SCHEDULED_TIME_IN_PAST) 5+ minutes in the past with strict mode active Always send a future UTC time as YYYY-MM-DDTHH:mm:ss.sssZ. Compare your clock against the serverTime in the error body.
Post scheduled "now" instead of my requested time The time was in the past and was clamped — the response carried a SCHEDULED_TIME_COERCED warning Send a future time; strict mode may turn this into a hard 400 (scheduled for 2026-08-25 unless configuration overrides it).
422 on retry Same Idempotency-Key reused with a different body Generate a fresh key per distinct request; reuse a key only to retry the identical request
409 on retry The original request with that key is still processing Wait and retry the identical request — do not issue a new key
404 when fetching a post Post group ID is wrong or belongs to another account Verify the ID and that you are using the correct API key
Post group shows partially_published Some platforms failed while others succeeded Inspect individual platform post statuses for error details
TikTok post fails with FPS error Video frame rate below TikTok's minimum requirement Re-encode the video with at least 23 FPS before uploading
500 intermittent errors Temporary server issues Implement retry logic with exponential backoff
Threads post fails with nested thread error Multi-part nested threads are temporarily disabled pending Meta permissions approval Keep content under 500 characters or use carousel posts. Contact support@publora.com for updates.

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.