Upload Media

View as Markdown

Upload images, videos, or PDF documents to attach to posts. Uses pre-signed S3 URLs for direct uploads.

Endpoint

POST https://api.publora.com/api/v1/get-upload-url

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 Set to mcp for MCP tool access
Content-Type Yes application/json

MCP access: If the x-publora-client: mcp header is sent, the server validates entitlements.features.mcpAccess and returns 403 "MCP access is not enabled for this account" if the feature is not enabled.

Request Body

Parameter Type Required Description
fileName string Yes Name of the file (e.g., photo.jpg). The filename is sanitized before use: whitespace is trimmed, spaces are replaced with underscores, and special characters (except underscores, dots, and hyphens) are removed. For example, my photo (1).jpg becomes my_photo_1.jpg.
contentType string Yes MIME type. Must begin with image/ or video/, or equal application/pdf; otherwise the API returns 400 "Only video, image, and PDF files are allowed".
postGroupId string Yes The post group to attach this media to. The attach helper verifies that it belongs to the effective API user.
type string No "image", "video", or "document". When omitted, the API infers it from contentType (including PDF → document).

Response

{
  "success": true,
  "uploadUrl": "https://brandcraft-media.s3.amazonaws.com/images/...",
  "fileUrl": "https://brandcraft-media.s3.amazonaws.com/images/1710500000000-product-photo.jpg",
  "mediaId": "65f8a1b2c3d4e5f6a7b8c9d0"
}
Field Description
success true if the upload URL was generated successfully.
uploadUrl Pre-signed S3 URL. Upload your file here via HTTP PUT. Expires in 1 hour.
fileUrl The public URL where the file will be accessible after upload.
mediaId The unique ID of the created media record. Use this to reference or delete the media.

Internal fields: Media records also store additional internal fields (urlPure, mimeType, addedAt, filePath, uploadUrl, fileName (S3 key), metadata) that are not returned by the API. Note that mimeType is a top-level field on the media record when created via the API get-upload-url endpoint, but the process-video endpoint stores mimeType inside the metadata subdocument instead. The metadata object contains: size, width, height, aspectRatio, format (defaults to "unknown" — never populated by any endpoint), frameRate, codecName, bitRate, and duration.

Dashboard vs API Differences

The dashboard actively uses a session-authenticated endpoint (/media/generate-upload-url) with a closely related response:

Aspect API (/api/v1/get-upload-url) Dashboard (/media/generate-upload-url)
Required fields fileName, contentType, postGroupId fileName, contentType, postGroupId
Optional fields type (inferred when omitted) metadata (object), type (inferred when omitted)
Validation Accepts image/*, video/*, or application/pdf Accepts image/*, video/*, or application/pdf
Missing-field errors "fileName, contentType, and postGroupId are required" "fileName and contentType are required", or "postGroupId is required"
Response format { success, uploadUrl, fileUrl, mediaId } { uploadUrl, key, mediaId }
Auth API key (x-publora-key) Session cookie

Either route may also return postGroupDemoted and message when attaching the new asset demotes a scheduled group to draft.

Upload Flow

Important: When uploading media, always create the post as a draft first, upload media, then schedule. This prevents the scheduler from processing the post before media upload completes.

1. POST /create-post                → Create draft (no scheduledTime), get postGroupId
2. POST /get-upload-url        → Get pre-signed URL
3. PUT {uploadUrl}                   → Upload file to S3
4. PUT /update-post/:postGroupId     → Set status="scheduled" and scheduledTime

Why this matters

If you create a post with scheduledTime set immediately, the scheduler may attempt to publish before your media upload completes — resulting in a failed post or missing media.

⚠ Attaching media demotes a scheduled post to draft. If you call get-upload-url against a post that is already scheduled, the post is automatically demoted back to draft so the new media set gets re-validated. The response carries postGroupDemoted: true and a message. You must re-schedule it afterward with PUT /update-post/:postGroupId (status: "scheduled" + scheduledTime). Skipping the re-schedule is the most common cause of media that "uploaded fine" but never published — the post silently sits in draft. The same demote happens when you remove media with DELETE /media/:mediaId.

Quick workflow (text-only posts):

1. POST /create-post              → Create with scheduledTime (no media needed)

Supported Formats

The upload endpoint validates the broad MIME family; platform-specific formats and limits are enforced when the post is scheduled.

Type Formats Max Size
Image Any image/* at upload; platform validator decides publishability No shared presigned-upload cap
Video Any video/* at upload; platform validator decides publishability No shared presigned-upload cap
Document application/pdf Platform/document rules apply

Note: The 512 MB per file limit applies only to the server-side multipart upload endpoint (process-video), not to pre-signed URL uploads. Individual platforms may impose their own size limits at publish time.

Note: Upload acceptance does not promise that a platform publisher will accept the same file; consult the platform limits before scheduling.

Examples

Complete workflow: Post with image

JavaScript (fetch)

const API_KEY = 'YOUR_API_KEY';
const BASE_URL = 'https://api.publora.com/api/v1';
 
// Step 1: Create draft post (no scheduledTime)
const postRes = await fetch(`${BASE_URL}/create-post`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'x-publora-key': API_KEY },
  body: JSON.stringify({
    content: 'Check out our new product! 🚀',
    platforms: ['twitter-123456789', 'linkedin-ABC123']
    // No scheduledTime = draft
  })
});
const { postGroupId } = await postRes.json();
 
// Step 2: Get upload URL
const urlRes = await fetch(`${BASE_URL}/get-upload-url`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'x-publora-key': API_KEY },
  body: JSON.stringify({
    fileName: 'product-photo.jpg',
    contentType: 'image/jpeg',
    type: 'image',
    postGroupId
  })
});
const { uploadUrl, fileUrl, mediaId } = await urlRes.json();
 
// Step 3: Upload file to S3
const fileBuffer = await fs.promises.readFile('./product-photo.jpg');
await fetch(uploadUrl, {
  method: 'PUT',
  headers: { 'Content-Type': 'image/jpeg' },
  body: fileBuffer
});
console.log(`Uploaded: ${fileUrl} (mediaId: ${mediaId})`);
 
// Step 4: Schedule the post
await fetch(`${BASE_URL}/update-post/${postGroupId}`, {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json', 'x-publora-key': API_KEY },
  body: JSON.stringify({
    status: 'scheduled',
    scheduledTime: '2026-03-01T14:00:00.000Z'
  })
});
console.log('Post scheduled!');

Python (requests)

import requests
 
API_KEY = 'YOUR_API_KEY'
BASE_URL = 'https://api.publora.com/api/v1'
headers = {'Content-Type': 'application/json', 'x-publora-key': API_KEY}
 
# Step 1: Create draft post
post_res = requests.post(f'{BASE_URL}/create-post', headers=headers, json={
    'content': 'Check out our new product! 🚀',
    'platforms': ['twitter-123456789', 'linkedin-ABC123']
})
post_group_id = post_res.json()['postGroupId']
 
# Step 2: Get upload URL
url_res = requests.post(f'{BASE_URL}/get-upload-url', headers=headers, json={
    'fileName': 'product-photo.jpg',
    'contentType': 'image/jpeg',
    'type': 'image',
    'postGroupId': post_group_id
})
data = url_res.json()
 
# Step 3: Upload file to S3
with open('product-photo.jpg', 'rb') as f:
    requests.put(data['uploadUrl'], headers={'Content-Type': 'image/jpeg'}, data=f)
print(f"Uploaded: {data['fileUrl']} (mediaId: {data['mediaId']})")
 
# Step 4: Schedule the post
requests.put(f'{BASE_URL}/update-post/{post_group_id}', headers=headers, json={
    'status': 'scheduled',
    'scheduledTime': '2026-03-01T14:00:00.000Z'
})
print('Post scheduled!')

cURL

API_KEY="YOUR_API_KEY"
 
# Step 1: Create draft post
POST_GROUP_ID=$(curl -s -X POST https://api.publora.com/api/v1/create-post \
  -H "Content-Type: application/json" \
  -H "x-publora-key: $API_KEY" \
  -d '{
    "content": "Check out our new product! 🚀",
    "platforms": ["twitter-123456789", "linkedin-ABC123"]
  }' | jq -r '.postGroupId')
 
# Step 2: Get upload URL
UPLOAD_RESPONSE=$(curl -s -X POST https://api.publora.com/api/v1/get-upload-url \
  -H "Content-Type: application/json" \
  -H "x-publora-key: $API_KEY" \
  -d "{
    \"fileName\": \"product-photo.jpg\",
    \"contentType\": \"image/jpeg\",
    \"type\": \"image\",
    \"postGroupId\": \"$POST_GROUP_ID\"
  }")
UPLOAD_URL=$(echo "$UPLOAD_RESPONSE" | jq -r '.uploadUrl')
FILE_URL=$(echo "$UPLOAD_RESPONSE" | jq -r '.fileUrl')
MEDIA_ID=$(echo "$UPLOAD_RESPONSE" | jq -r '.mediaId')
 
# Step 3: Upload file to S3
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/jpeg" \
  --data-binary @product-photo.jpg
 
# Step 4: Schedule the post
curl -X PUT "https://api.publora.com/api/v1/update-post/$POST_GROUP_ID" \
  -H "Content-Type: application/json" \
  -H "x-publora-key: $API_KEY" \
  -d '{
    "status": "scheduled",
    "scheduledTime": "2026-03-01T14:00:00.000Z"
  }'

Upload a video

JavaScript (fetch)

// Step 1: Get upload URL for video
const urlResponse = await fetch('https://api.publora.com/api/v1/get-upload-url', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-publora-key': 'YOUR_API_KEY'
  },
  body: JSON.stringify({
    fileName: 'promo-video.mp4',
    contentType: 'video/mp4',
    type: 'video',
    postGroupId: '507f1f77bcf86cd799439011'
  })
});
const { uploadUrl, fileUrl, mediaId } = await urlResponse.json();
 
// Step 2: Upload video to S3
const videoBuffer = await fs.promises.readFile('./promo-video.mp4');
await fetch(uploadUrl, {
  method: 'PUT',
  headers: { 'Content-Type': 'video/mp4' },
  body: videoBuffer
});

Python (requests)

# Step 1: Get upload URL for video
url_response = requests.post(
    'https://api.publora.com/api/v1/get-upload-url',
    headers={
        'Content-Type': 'application/json',
        'x-publora-key': 'YOUR_API_KEY'
    },
    json={
        'fileName': 'promo-video.mp4',
        'contentType': 'video/mp4',
        'type': 'video',
        'postGroupId': '507f1f77bcf86cd799439011'
    }
)
upload_url = url_response.json()['uploadUrl']
 
# Step 2: Upload video to S3
with open('promo-video.mp4', 'rb') as f:
    requests.put(upload_url, headers={'Content-Type': 'video/mp4'}, data=f)
const images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg'];
 
for (const image of images) {
  // Get upload URL for each image
  const urlRes = await fetch('https://api.publora.com/api/v1/get-upload-url', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-publora-key': 'YOUR_API_KEY'
    },
    body: JSON.stringify({
      fileName: image,
      contentType: 'image/jpeg',
      type: 'image',
      postGroupId: '507f1f77bcf86cd799439011'
    })
  });
  const { uploadUrl } = await urlRes.json();
 
  // Upload each image
  const fileBuffer = await fs.promises.readFile(`./${image}`);
  await fetch(uploadUrl, {
    method: 'PUT',
    headers: { 'Content-Type': 'image/jpeg' },
    body: fileBuffer
  });
}
// All 4 images now attached to the post group

Errors

Status Error Cause
400 "fileName, contentType, and postGroupId are required" Missing fileName, contentType, or postGroupId
400 "Invalid x-publora-user-id" The x-publora-user-id header value is not a valid ID
401 "API key is required" x-publora-key header is missing entirely
401 "Invalid API key" x-publora-key is present but invalid
401 "Invalid API key owner" API key exists but the associated user account was not found
403 "API access is not enabled for this account" The user's account does not have API access enabled
403 "MCP access is not enabled for this account" The x-publora-client: mcp header was sent but entitlements.features.mcpAccess is not enabled
403 "Workspace access is not enabled for this key" API key does not have workspace access enabled
403 "User is not managed by key" Managed user does not belong to the API key owner's workspace
500 "Failed to create upload URL" Internal server error during URL generation

Server-Side Video Upload

For video files, you can use the server-side upload endpoint which handles the upload and extracts video metadata (resolution, codec, frame rate, bitrate, duration).

Dashboard-only endpoint. This endpoint uses session authentication (cookies) and is not available via API key auth. It is accessible only from the Publora dashboard.

Endpoint

POST https://api.publora.com/media/process-video

Headers

Header Required Description
Cookie Yes Active dashboard session cookie
Content-Type Yes multipart/form-data

Request Body (multipart/form-data)

Field Type Required Description
video file Yes The video file to upload
postGroupId string Yes The post group to attach this video to

This endpoint accepts multipart/form-data with a single video file attached (field name: video) and a postGroupId. The server uploads the file to S3 and extracts metadata automatically. Processing is asynchronous -- the endpoint returns a sessionId which can be used to track progress via SSE (Server-Sent Events).

Resolved issue: The process-video endpoint now correctly passes type: "video" to the presigned URL generator. This was previously a bug where the missing type parameter resulted in an undefined S3 key, but it has been fixed.

Note: The multer fileFilter rejection (for unsupported video formats) throws an error inside multer's callback. The resulting HTTP response format depends on Express's global error handler and may not produce a clean JSON 400 response.

Response

{
  "sessionId": "1710500000000"
}

The sessionId is returned for future use, but the SSE progress endpoint (/processing-progress/:id) is currently disabled (commented out in source). There is no working SSE endpoint to subscribe to yet.

Limits: 1 video file per request, 512 MB max. Accepted formats: MP4, MOV, AVI, MKV, and WebM.

Errors

Status Error Cause
400 "No video file uploaded" No file is attached to the request
400 "Unsupported video format. Allowed: MP4, MOV, AVI, MKV, WebM" The uploaded file format is not one of the accepted video formats. Note: This error is thrown inside multer's fileFilter callback, so it may not be returned as a clean JSON 400 response depending on Express's error handler.

Cleaning Up Abandoned Uploads

If a POST /get-upload-url call succeeds but the subsequent PUT to the presigned S3 URL is cancelled or fails, the MediaFile record is persisted to the post group. An abandoned upload would otherwise leave a broken reference that blocks re-scheduling with MEDIA_NOT_READY.

Two REST endpoints handle this by API key:

  • DELETE /api/v1/media/:mediaId — delete a single media file. Detaches it from its post group and hard-deletes the row + S3 object. Returns { "success": true }. If the parent post was scheduled, the response also includes postGroupDemoted: true and the group is demoted to draft (re-schedule with update-post). Errors: 400 "Invalid mediaId" (malformed), 404 "Media file not found", 409 if the group is mid-publish, 400 if the group is already published/failed.
  • DELETE /api/v1/delete-post/:postGroupId — removes the whole post group and all attached media (works in draft or scheduled).
  • Or use the Publora dashboard.

Finalizing an Upload (optional)

POST https://api.publora.com/api/v1/complete-media/:mediaFileId

After you PUT the bytes to the presigned URL, you may call complete-media to probe the file server-side immediately: it extracts authoritative metadata and advances the media from uploadingready (or failed), giving you fast "is my media valid?" feedback. It is optional — the update-post scheduling gate probes any still-uploading media lazily when you schedule. Auth is the same x-publora-key.

Response:

{
  "success": true,
  "mediaFile": { "_id": "...", "type": "image", "mimeType": "image/jpeg", "fileName": "images/...", "url": "https://...", "status": "ready", "metadata": { } }
}

Note: complete-media only probes an already-attached file — it does not attach media and does not demote a scheduled post. Only get-upload-url (attach) and DELETE /media (detach) trigger the demote-to-draft behavior.

File URLs

Uploaded file URLs use the S3 format and are returned directly as fileUrl in the response from the get-upload-url endpoint. For example:

https://your-bucket.s3.amazonaws.com/images/1710500000000-product-photo.jpg

Internal note: The API internally stores two URL fields on each media record: url (the S3 domain URL, e.g., https://your-bucket.s3.amazonaws.com/...) and urlPure (the CDN URL via media.publora.com, e.g., https://media.publora.com/...). Only url is returned in API responses. The urlPure field is used internally for CDN-served media.

Platform Media Limits

Platform Images Videos Notes
X / Twitter Up to 4 1 per post PNG preferred for images
LinkedIn Multiple 1 per post WebP auto-converted to JPEG
Instagram Carousel (10) Reels or Stories Publora connects Instagram accounts through Instagram Login for Business and requests instagram_business_basic plus instagram_business_content_publish; personal accounts are unsupported. Whether Meta accepts a particular Creator account is determined by Meta, not Publora's code.
Threads Carousel 1 per post WebP auto-converted
TikTok Up to 35 1 per post Images: JPEG, PNG, WebP; video: MP4, MOV, WebM, min 23 FPS
YouTube -- 1 per post MP4, streaming upload
Facebook Multiple 1 per post Carousel support
Bluesky Up to 4 1 per post WebP auto-converted; alt text is not settable through REST
Mastodon Up to 4 1 per post Standard limits
Telegram Multiple 1 per post 1024 char caption max (bot)

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.