Upload Media
Upload images and videos to attach to posts. Uses pre-signed S3 URLs for direct uploads.
Endpoint
POST https://api.postpost.dev/api/v1/get-upload-urlHeaders
| Header | Required | Description |
|---|---|---|
x-api-key | Yes | Your API key |
x-postpost-user-id | No | Managed user ID (workspace only) |
x-postpost-client | No | Set to mcp for MCP tool access |
Content-Type | Yes | application/json |
MCP access: If the
x-postpost-client: mcpheader is sent, the server validatesentitlements.features.mcpAccessand returns403 "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 (e.g., image/jpeg, video/mp4). The API route does not validate this parameter — any MIME type string is accepted. |
postGroupId | string | Yes | The post group to attach this media to. Security note: The API does not verify that the postGroupId belongs to the requesting user. This is a known limitation. |
type | string | No | Media type: "image" or "video". Determines the S3 key prefix. Warning: Omitting type means the field will be absent from the media record, which causes the AWS SDK PutObjectCommand to fail with an error. The S3 key prefix is also derived from this field, so omitting it means neither the "image" nor "video" branch executes. Always include this parameter. |
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 thatmimeTypeis a top-level field on the media record when created via the APIget-upload-urlendpoint, but theprocess-videoendpoint storesmimeTypeinside themetadatasubdocument instead. Themetadataobject contains:size,width,height,aspectRatio,format(defaults to"unknown"— never populated by any endpoint),frameRate,codecName,bitRate, andduration.
Dashboard vs API Differences
The dashboard uses a different endpoint (/media/generate-upload-url) with different behavior:
Note: The
/media/generate-upload-urlendpoint is deprecated and not actively used in the current codebase. It is documented here for reference only.
| Aspect | API (/api/v1/get-upload-url) | Dashboard (/media/generate-upload-url) |
|---|---|---|
| Required fields | fileName, contentType, postGroupId | fileName, contentType (no postGroupId required) |
| Optional fields | type | metadata (object), type ("image" or "video") |
| Validation | No contentType validation | Validates contentType starts with image/ or video/; returns "Only video and image files are allowed" (400) if invalid |
| Error message | "fileName, contentType, and postGroupId are required" | "fileName and contentType are required" |
| Response format | { success, uploadUrl, fileUrl, mediaId } | { uploadUrl, key } (no success, fileUrl, or mediaId) |
| Auth | API key (x-api-key) | Session cookie |
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.
Recommended workflow (with media):
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 scheduledTimeWhy 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.
Quick workflow (text-only posts):
1. POST /create-post → Create with scheduledTime (no media needed)Supported Formats
Note: The formats listed below are what the publishing platforms accept, not what the upload endpoint enforces. The
get-upload-urlAPI route accepts anycontentTypevalue without validation.
| Type | Formats | Max Size |
|---|---|---|
| Image | JPEG, PNG, GIF, WebP | No enforced limit (pre-signed URL) |
| Video | MP4, MOV, AVI, MKV, WebM | No enforced limit (pre-signed URL) |
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: WebP images are automatically converted to JPEG for platforms that don't support WebP (LinkedIn, Telegram, Bluesky).
Examples
Complete workflow: Post with image
JavaScript (fetch)
const API_KEY = 'YOUR_API_KEY';
const BASE_URL = 'https://api.postpost.dev/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-api-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-api-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-api-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.postpost.dev/api/v1'
headers = {'Content-Type': 'application/json', 'x-api-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.postpost.dev/api/v1/create-post \
-H "Content-Type: application/json" \
-H "x-api-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.postpost.dev/api/v1/get-upload-url \
-H "Content-Type: application/json" \
-H "x-api-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.postpost.dev/api/v1/update-post/$POST_GROUP_ID" \
-H "Content-Type: application/json" \
-H "x-api-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.postpost.dev/api/v1/get-upload-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-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.postpost.dev/api/v1/get-upload-url',
headers={
'Content-Type': 'application/json',
'x-api-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)Upload multiple images for a carousel
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.postpost.dev/api/v1/get-upload-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-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 groupErrors
| Status | Error | Cause |
|---|---|---|
| 400 | "fileName, contentType, and postGroupId are required" | Missing fileName, contentType, or postGroupId |
| 400 | "Invalid x-postpost-user-id" | The x-postpost-user-id header value is not a valid ID |
| 401 | "API key is required" | x-api-key header is missing entirely |
| 401 | "Invalid API key" | x-api-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-postpost-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 PostPost dashboard.
Endpoint
POST https://api.postpost.dev/media/process-videoHeaders
| 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-videoendpoint now correctly passestype: "video"to the presigned URL generator. This was previously a bug where the missingtypeparameter resulted in anundefinedS3 key, but it has been fixed.Note: The multer
fileFilterrejection (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. |
Delete Media
Remove a media file attached to a post.
Dashboard-only endpoint. This endpoint uses session authentication (cookies) and is not available via API key auth. It is accessible only from the PostPost dashboard.
Endpoint
DELETE https://api.postpost.dev/media/post/media/:mediaIdAuthentication
This endpoint requires an active dashboard session (cookie-based auth). It cannot be called with an API key.
The endpoint verifies media ownership — it filters by the authenticated user's ID, so a user can only delete their own media files.
Response
{
"success": true
}On success, the endpoint returns { success: true }.
Errors
| Status | Error | Cause |
|---|---|---|
| 400 | "Cannot delete media from a post that has already been published or failed" | The post has already been published or is in a failed state |
| 401 | Unauthorized | No active session |
| 404 | "Media file not found" | No media record found for the given mediaId |
| 500 | "Failed to delete media file" | Internal server error during media deletion |
Note: If S3 deletion fails, the database record is still deleted. This could leave orphaned files in S3.
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.jpgInternal 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/...) andurlPure(the CDN URL viamedia.postpost.dev, e.g.,https://media.postpost.dev/...). Onlyurlis returned in API responses. TheurlPurefield 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 |
| Multiple | 1 per post | WebP auto-converted to JPEG | |
| Carousel (10) | Reels or Stories | Business account required | |
| Threads | Carousel | 1 per post | WebP auto-converted |
| TikTok | -- | 1 per post | MP4 only, min 23 FPS |
| YouTube | -- | 1 per post | MP4, streaming upload |
| Multiple | 1 per post | Carousel support | |
| Bluesky | Up to 4 | 1 per post | WebP auto-converted, alt text |
| Mastodon | Up to 4 | 1 per post | Standard limits |
| Telegram | Multiple | 1 per post | 1024 char caption max (bot) |