Attach Media

View as Markdown

Attach one image or video to an existing draft or scheduled post by passing a file reference: an HTTPS download URL plus a stable file ID. Publora downloads the file right away, checks the actual bytes and attaches it. You don't need a presigned PUT or a complete-media call.

This endpoint backs the MCP attach_media tool. ChatGPT fills that tool's file argument from a file in the conversation through OpenAI's openai/fileParams; see MCP Tools Reference. Any API client can call the endpoint directly.

The post is left in draft. This endpoint always sets the status to draft, so a post that was scheduled goes back to draft. (By contrast, mediaUrls on update-post keeps a scheduled post scheduled and re-validates it.) To publish it, schedule it again with update-post.

Endpoint

POST https://api.publora.com/api/v1/attach-media/:postGroupId

Headers

Header Required Description
x-publora-key Yes Your API key
x-publora-user-id No Managed user ID (workspace only)
x-publora-client No Client identifier (e.g., "mcp")
Content-Type Yes application/json
Idempotency-Key No Recommended. With it, a retry replays the first result instead of attaching the file a second time. See Idempotency. The MCP tool always sends one.

Path Parameters

Parameter Type Description
postGroupId string ID of an existing draft or scheduled post (24 hexadecimal characters). If you need a new one, first create a draft with create-post and leave out scheduledTime.

Request Body

The body can contain only file and fileName. Any other top-level field, such as status, scheduledTime or mediaUrls, returns 400 INVALID_MEDIA_FILE: this endpoint attaches exactly one file and never schedules or publishes.

Parameter Type Required Description
file object Yes The file reference described below. A string is rejected, including a local path such as /mnt/data/image.png, base64 data or a bare URL.
fileName string No A descriptive file name with an extension, 1–255 characters, e.g. spring-launch-banner.png. Takes precedence over file.file_name. See File name.

file object. It must contain only these keys. Any other key is rejected.

Field Type Required Description
download_url string Yes The HTTPS URL Publora downloads the file from. Temporary signed URLs work. It goes through the same checks as mediaUrls: https:// only, no embedded credentials, only the default port 443, and no private or loopback hosts.
file_id string Yes A stable identifier for the file, up to 512 characters. Together with the post ID and the final file name, it lets Publora recognise a retry. See Idempotency.
mime_type string No Only a hint. Publora determines the real type from the file's bytes.
file_name string No The original file name. Used when fileName is absent.

File name

Publora takes the name from fileName if it is present, otherwise from file.file_name, and uses chatgpt-media only when both are absent. It keeps only the last path segment, trims it, replaces each run of whitespace with _, and removes every character other than ASCII letters, digits, _, . and -, so accented and non-Latin letters are dropped. If the result is empty or only dots, the request is rejected, even when the name came from file.file_name: an unusable file.file_name is not replaced by chatgpt-media. Pass an ASCII fileName to avoid this.

get-post returns the cleaned name as media[].sourceFileName. The stored object name and the media URL end with it after a unique prefix, so two files with the same name never overwrite each other. Social networks may rename the file again when they re-host it.

What happens

  1. Ownership and state checks run before anything is downloaded. A post that doesn't exist or belongs to another user returns 404. A published, failed or partially published post returns 400 POST_NOT_EDITABLE. A post that is publishing, or that already has a live platform post or one with an unknown publish outcome, returns 409 POST_PUBLISH_IN_PROGRESS.
  2. Rate limit. Each attempt that gets this far uses one URL from the allowance it shares with mediaUrls: 60 URLs per fixed one-hour window. Idempotent replays don't count. Going over it returns 429 MEDIA_URL_RATE_LIMITED with Retry-After.
  3. Download and validation. The same pipeline as mediaUrls: redirects are followed with the same host checks, the type is detected from the bytes, and the file is probed. Accepted formats: JPEG, PNG, GIF, WebP, TIFF and AVIF images up to 25 MB, and MP4, MOV and WebM videos up to 150 MB. Anything else fails without changing the post.
  4. Attach. The file is appended to the post's existing media with status ready, and the post's status is set to draft. Publora keeps its own copy, so the temporary download URL isn't needed afterwards.

As with any other media, platform rules such as media count, format and duration for each target are checked when you schedule the post.

Idempotency

Send an Idempotency-Key header whose value stays the same across retries of one attachment. Publora recognises a retry by the post ID, file_id and the final file name. download_url and mime_type are left out, so a retry with a refreshed temporary URL counts as the same attachment.

Situation Result
Same key, same post, same file_id and file name, even with a new download_url The first response is replayed. The file is attached only once.
Same key, but a different post, file_id or file name 422 IDEMPOTENCY_KEY_CONFLICT
Same key while the first request is still running 409 IDEMPOTENCY_IN_FLIGHT
Same key after the download failed Processed again. A failed download frees the key, so retry with a fresh file reference.
No key Every call attaches another copy of the file.

Keys are scoped to the acting user and kept for 24 hours. A replay returns the earlier result even if you have since removed the file from the post; to attach the same file again within that window, use a new key. If you don't know whether an attachment went through after the window has passed, check get-post before retrying.

Response

The response has the same shape as update-post:

{
  "success": true,
  "message": "Post updated successfully",
  "scheduledTime": null,
  "postGroup": {
    "_id": "507f1f77bcf86cd799439011",
    "status": "draft",
    "content": "Our spring collection is here.",
    "platforms": ["linkedin-ABC123"]
  }
}

postGroup.status is always draft. A post that was scheduled keeps its stored scheduledTime as a draft, so the top-level scheduledTime and postGroup.scheduledTime can be non-null. Unlike get-upload-url, this endpoint returns no postGroupDemoted flag and sends no post.demoted webhook. postGroup.status is the signal.

The response does not include the new media ID. To see the attachment, call get-post. Its media array lists the file with status: "ready":

{
  "mediaId": "6a0000000000000000000099",
  "sourceFileName": "spring-launch-banner.png",
  "fileName": "images/1790000000000-0-1a2b3c4d-spring-launch-banner.png",
  "type": "image",
  "mimeType": "image/png",
  "status": "ready",
  "failureReason": null,
  "url": "https://media.publora.com/images/1790000000000-0-1a2b3c4d-spring-launch-banner.png"
}

Examples

cURL

POST_GROUP_ID="507f1f77bcf86cd799439011"
 
curl -sS -X POST "https://api.publora.com/api/v1/attach-media/$POST_GROUP_ID" \
  -H "x-publora-key: $PUBLORA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: attach-$POST_GROUP_ID-file-abc123" \
  -d '{
    "file": {
      "download_url": "https://files.example.com/download/abc123?signature=TEMPORARY",
      "file_id": "file-abc123",
      "mime_type": "image/png",
      "file_name": "image.png"
    },
    "fileName": "spring-launch-banner.png"
  }'

Then, when the post should go out, schedule it:

FUTURE_TIME=$(date -u -v+1H +%Y-%m-%dT%H:%M:%S.000Z 2>/dev/null || date -u -d '+1 hour' +%Y-%m-%dT%H:%M:%S.000Z)
 
curl -sS -X PUT "https://api.publora.com/api/v1/update-post/$POST_GROUP_ID" \
  -H "x-publora-key: $PUBLORA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: schedule-$POST_GROUP_ID-$FUTURE_TIME" \
  -d "{\"status\":\"scheduled\",\"scheduledTime\":\"$FUTURE_TIME\"}"

JavaScript (fetch)

const postGroupId = '507f1f77bcf86cd799439011';
const file = {
  download_url: 'https://files.example.com/download/abc123?signature=TEMPORARY',
  file_id: 'file-abc123',
  mime_type: 'image/png'
};
 
const response = await fetch(`https://api.publora.com/api/v1/attach-media/${postGroupId}`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-publora-key': process.env.PUBLORA_API_KEY,
    // One key per logical attachment; reuse it for every retry.
    'Idempotency-Key': `attach-${postGroupId}-${file.file_id}`
  },
  body: JSON.stringify({ file, fileName: 'spring-launch-banner.png' })
});
 
const result = await response.json();
if (!response.ok) {
  // mediaResults explains a failed download, e.g. MEDIA_URL_HTTP_ERROR for an expired URL.
  throw new Error(`${result.code || result.error}: ${JSON.stringify(result.mediaResults || [])}`);
}
console.log(result.postGroup.status); // "draft"

Python (requests)

import os
import requests
 
post_group_id = "507f1f77bcf86cd799439011"
file_ref = {
    "download_url": "https://files.example.com/download/abc123?signature=TEMPORARY",
    "file_id": "file-abc123",
}
 
response = requests.post(
    f"https://api.publora.com/api/v1/attach-media/{post_group_id}",
    headers={
        "x-publora-key": os.environ["PUBLORA_API_KEY"],
        "Idempotency-Key": f"attach-{post_group_id}-{file_ref['file_id']}",
    },
    json={"file": file_ref, "fileName": "spring-launch-banner.png"},
)
response.raise_for_status()
print(response.json()["postGroup"]["status"])  # draft

MCP tool

The MCP attach_media tool takes postGroupId, file, an optional fileName and an optional idempotencyKey. It calls this endpoint and, when you don't pass a key, derives one from the post ID and file_id. The tool declares _meta["openai/fileParams"]: ["file"], so ChatGPT supplies download_url and file_id itself. See the MCP Tools Reference.

Errors

Status Error Cause
400 "Only file and fileName are accepted; attaching media leaves the post in draft" — code INVALID_MEDIA_FILE The body has another top-level field, such as status or scheduledTime. Nothing was downloaded.
400 "file must be a file object with download_url and file_id, not a local path or base64 string", "file.download_url must be a non-empty string", "file only accepts download_url, file_id, mime_type and file_name" and similar — code INVALID_MEDIA_FILE file is missing, isn't an object, lacks a required field, has an unknown key or a non-string value, or file_id is over 512 characters
400 "Only https:// media URLs are allowed (got http://)" and other URL-check messages — code INVALID_MEDIA_FILE download_url is not a valid URL, is not HTTPS, embeds credentials, uses a non-443 port, or its host is a private or loopback IP address or a localhost, .local or .internal name. Nothing was downloaded. A host name that only resolves to a private address is refused during the download instead, in mediaResults[].
400 "fileName must be a non-empty string of at most 255 characters" / "fileName must contain a valid file name" — code INVALID_MEDIA_FILE fileName is null or not a string, or the name used (fileName, else file.file_name) is over 255 characters or is empty or only dots after cleaning. An unusable file.file_name is not replaced by chatgpt-media.
400 "Invalid post group ID" postGroupId is not a valid post ID. Use the 24-character ID from create-post or list-posts.
400 "Media ingestion failed" with mediaResults[] Download or validation failed. The entry for the URL has ok: false, a code such as MEDIA_URL_HTTP_ERROR (often an expired temporary URL), MEDIA_URL_TOO_LARGE or MEDIA_URL_UNSUPPORTED_FORMAT, and an error message. Nothing was attached. See mediaUrls per-URL codes.
400 "Cannot update post: post is currently in {status} status" — code POST_NOT_EDITABLE The post is published, failed or partially published
400 "Invalid platform connection(s): …" — code INVALID_PLATFORM_CONNECTION The post targets a YouTube connection that is no longer yours. YouTube targets are re-checked when the attachment is saved, and the downloaded file is discarded. The response includes invalidPlatforms.
401 "API key is required" / "Invalid API key" Missing or invalid x-publora-key
403 "API access is not enabled for this account" / "MCP access is not enabled for this account" The account lacks API access, or MCP access when called through MCP
404 "Post group not found" No such post, or it belongs to another user
409 "Publishing has already started; media cannot be changed. Duplicate the post to recover its content." — code POST_PUBLISH_IN_PROGRESS The post is publishing, or one of its platform posts is publishing, already live or has an unknown publish outcome. Re-read it with get-post before retrying.
409 code POST_GROUP_VERSION_CONFLICT Another write finished first, and this request changed nothing. Re-read the post and retry.
409 code IDEMPOTENCY_IN_FLIGHT A request with the same Idempotency-Key is still running. Retry the same request shortly.
422 code IDEMPOTENCY_KEY_CONFLICT The key was already used for a different post, file_id or file name
429 code MEDIA_URL_RATE_LIMITED Over the limit of 60 media URLs per hour. Wait for Retry-After seconds.
500 "Failed to update post" Internal server error

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.