Error Handling
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) return200on success, but workspace user creation (POST /workspace/users) returns201. Check for both200and201as 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-Keyreturns the original response — same status code, same body (including anywarnings[]) — 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: ThecheckConnectionLimitmiddleware returns a different response format from the standardLimitExceededError. WhencanExpand: true, the response includesplanKey,planName,currentQuantity,targetQuantity,connectionCount, andpricing(expansion pricing details). WhencanExpand: false, the response varies depending on whether the user's billing is managed by a workspace owner. For billing-managed users, onlysuccess,code,error, andcanExpandare returned. For non-managed users,connectionCountis also included in the response. If you are handling connection limit errors specifically, parse these fields rather than the standardLimitExceededErrorshape.
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[].
errorfield format: Theerrorfield on individual platform posts is a structured object, not a plain string. Theget-postendpoint returns the full error object with fields:code,message,platformStatusCode,platformError,failedAt, andretryable.
platformIdvs platform ID string: In theget-postresponse,platformandplatformIdare separate fields. Theplatformfield contains the platform name (e.g.,"twitter"), andplatformIdcontains just the account identifier (e.g.,"123456"). This is different from the{platform}-{id}combined format (e.g.,"twitter-123456") used in theplatformsarray 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"
;;
esacNode.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.
passBest Practices
-
Always check the HTTP status code. Do not assume every response is successful. Parse the error body for details.
-
Do not retry most 4xx responses unchanged. They usually indicate a problem with the request. The exception is
429 MEDIA_URL_RATE_LIMITEDfrommediaUrlsingestion: wait the number of seconds inRetry-After(also returned asretryAfterSec), then retry with the sameIdempotency-Keydescribed in item 4. -
Use exponential backoff for retries. Start at 1 second and double with each attempt. Cap at 3-5 retries to avoid infinite loops.
-
Send an
Idempotency-Keyon everycreate-post/update-postyou might retry. A timeout does not tell you whether the post was created. Without a key, the retry double-posts (and onupdate-postwithmediaUrls, double-appends the media). With one, the retry replays the original response. -
Read
warnings[]on successful responses. A200can still mean the API changed something about your request — most oftenSCHEDULED_TIME_COERCED. Log warnings; do not discard them. -
Monitor for partial failures. A
200response when creating a post does not guarantee all platforms will succeed. Poll the post group status after the scheduled time to detect partial failures. -
Log error responses in full. When debugging, log the entire response body, not just the error message. Additional fields may provide context.
-
Handle
403errors gracefully in your UI. If a user hits a plan limit (returned as a structuredLimitExceededError), parse themetric,limit,used, andremainingfields to show actionable guidance rather than a raw error. -
Validate inputs before sending. Check that
scheduledTimeis in the future and in ISO 8601 format, platform IDs follow the{platform}-{id}format, and media counts are within limits. This avoids unnecessary400errors.
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.