{
  "openapi": "3.1.0",
  "info": {
    "title": "PostPen Agent API",
    "version": "1.0.0",
    "summary": "Schedule and publish social posts (LinkedIn today) from AI agents and bots.",
    "description": "The same eight tools as the PostPen MCP server (https://mcp.postpen.ai), over plain HTTPS for bots that do not speak MCP. Create an API key at https://app.postpen.ai/agents and send it as Authorization: Bearer pp_.... Every operation is POST with a JSON body. Destinations come from list_destinations; each has a network (linkedin today). publish_now, delete_draft, and unschedule_post require confirm: true. Never send or expect a LinkedIn token.",
    "contact": {
      "name": "PostPen",
      "email": "hello@postpen.ai",
      "url": "https://postpen.ai"
    },
    "termsOfService": "https://postpen.ai/terms-of-service"
  },
  "externalDocs": {
    "description": "Connect an agent",
    "url": "https://app.postpen.ai/agents"
  },
  "servers": [
    {
      "url": "https://pdb.postpen.ai/functions/v1/agent-api"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Read",
      "description": "Does not change anything."
    },
    {
      "name": "Write",
      "description": "Creates or changes posts. publish_now sends to LinkedIn."
    }
  ],
  "paths": {
    "/list_destinations": {
      "post": {
        "operationId": "list_destinations",
        "summary": "List destinations",
        "description": "List destinations this account may post to. Each has an id, a network (linkedin today; more networks later), a type, and a name: the LinkedIn personal profile (id \"personal\") and each connected company page allowed by the plan. Call this before posting to a company page. Never returns LinkedIn tokens.",
        "tags": [
          "Read"
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {},
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. JSON object; never contains LinkedIn tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Invalid input, or a confirm: true guard was not met.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Plan limit reached (for example the Free plan's monthly post cap).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Destination not allowed for this account or plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/create_upload": {
      "post": {
        "operationId": "create_upload",
        "summary": "Create media upload",
        "description": "Get a private, short-lived upload URL for a file you created or have locally (a carousel PDF, an image, or an mp4). Use this when the file has no public HTTPS link. Steps: 1) call create_upload with type (and content_type/size_bytes if known), 2) PUT the raw bytes to upload_url with the returned headers, for example curl -X PUT -H \"Content-Type: application/pdf\" --data-binary @carousel.pdf \"<upload_url>\", 3) call save_draft with media: [{ upload_id }] (include id to attach to an existing draft or scheduled post). The URL expires in 2 hours. Limits: images 5MB, video 75KB–200MB mp4, PDF 100MB.",
        "tags": [
          "Write"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "image",
                      "video",
                      "document"
                    ],
                    "description": "What you will upload: image (jpeg, png, webp, gif), video (mp4), or document (pdf)."
                  },
                  "content_type": {
                    "type": "string",
                    "description": "Optional MIME type, for example image/png, video/mp4, or application/pdf. Defaults from type."
                  },
                  "size_bytes": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "Optional file size in bytes, checked against the limit before you upload."
                  }
                },
                "required": [
                  "type"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. JSON object; never contains LinkedIn tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Invalid input, or a confirm: true guard was not met.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Plan limit reached (for example the Free plan's monthly post cap).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Destination not allowed for this account or plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/save_draft": {
      "post": {
        "operationId": "save_draft",
        "summary": "Save draft",
        "description": "Save LinkedIn post text, or update an existing unpublished post. Omit id to create a draft. Pass id to update a draft OR a scheduled post that has not been sent — the schedule time and destination stay as they are (nothing is sent to LinkedIn). content is required to create; on an update pass content and/or media (and optional destination). Optional destination selects personal profile or a company page from list_destinations. Optional media attaches images, a video, or a PDF. Each media item uses exactly one source: { url } for a public HTTPS file, { upload_id } for a file sent through create_upload (use this for files you generated, such as a carousel PDF or an image), or { data } for a small base64 file up to 4MB when you cannot run an upload. PostPen checks each file and copies it into its own storage. Do not send media_urls or a LinkedIn token. Omit media to keep existing attachments on an update; pass media: [] to remove them. schedule_post and publish_now use media already stored on the post.",
        "tags": [
          "Write"
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "content": {
                    "type": "string",
                    "description": "The post text the agent already wrote. Required to create a draft; optional when id is set if you are only changing media or destination."
                  },
                  "id": {
                    "type": "string",
                    "description": "Draft or scheduled post id to update in place. Omit to create a new draft."
                  },
                  "destination": {
                    "type": "string",
                    "description": "Where to post: an id from list_destinations. Each destination has a network (linkedin today). Use \"personal\" (or omit on a personal draft) for the personal LinkedIn profile. For a company page, pass an id from list_destinations (usually the organization URN). On schedule_post and publish_now, omit to keep a company destination already saved on the draft; pass \"personal\" to force the profile."
                  },
                  "media": {
                    "type": "array",
                    "maxItems": 9,
                    "description": "Optional attachments, one source per item: { url }, { upload_id }, or { data, content_type }. Images: jpeg, png, webp, gif, max 5MB, up to 9. Video: mp4, 75KB–200MB, one per post. Document: pdf, max 100MB, one per post (LinkedIn shows it as a swipeable carousel). Do not mix types in one post.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "url": {
                          "type": "string",
                          "description": "Public HTTPS URL of the image, mp4, or PDF. PostPen downloads it and stores a copy."
                        },
                        "upload_id": {
                          "type": "string",
                          "description": "The upload_id returned by create_upload, after you PUT the file to its upload_url."
                        },
                        "data": {
                          "type": "string",
                          "description": "Base64 file bytes (a data: URL is fine) for small files up to 4MB. Prefer create_upload for anything larger."
                        },
                        "content_type": {
                          "type": "string",
                          "description": "MIME type for data, for example image/png or application/pdf. Optional; PostPen also reads the file header."
                        },
                        "file_name": {
                          "type": "string",
                          "description": "Optional file name. For a PDF, LinkedIn shows it as the document title."
                        },
                        "type": {
                          "type": "string",
                          "enum": [
                            "image",
                            "video",
                            "document"
                          ],
                          "description": "Optional hint. Inferred from the file if omitted. Stored as photo, video, or pdf."
                        }
                      },
                      "additionalProperties": false
                    }
                  }
                },
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. JSON object; never contains LinkedIn tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Invalid input, or a confirm: true guard was not met.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Plan limit reached (for example the Free plan's monthly post cap).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Destination not allowed for this account or plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/schedule_post": {
      "post": {
        "operationId": "schedule_post",
        "summary": "Schedule post",
        "description": "Schedule a saved draft, including any media already stored on it by save_draft. scheduled_for is an ISO-8601 timestamp in the future and must include a timezone. Optional destination selects personal profile or a company page from list_destinations. Do not send media here.",
        "tags": [
          "Write"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "description": "Post id returned by save_draft."
                  },
                  "scheduled_for": {
                    "type": "string",
                    "description": "Future ISO-8601 time with a timezone, for example 2026-09-28T15:00:00Z."
                  },
                  "destination": {
                    "type": "string",
                    "description": "Where to post: an id from list_destinations. Each destination has a network (linkedin today). Use \"personal\" (or omit on a personal draft) for the personal LinkedIn profile. For a company page, pass an id from list_destinations (usually the organization URN). On schedule_post and publish_now, omit to keep a company destination already saved on the draft; pass \"personal\" to force the profile."
                  }
                },
                "required": [
                  "id",
                  "scheduled_for"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. JSON object; never contains LinkedIn tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Invalid input, or a confirm: true guard was not met.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Plan limit reached (for example the Free plan's monthly post cap).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Destination not allowed for this account or plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/unschedule_post": {
      "post": {
        "operationId": "unschedule_post",
        "summary": "Move scheduled post to drafts",
        "description": "Move a scheduled post that has not been sent back to Drafts and clear its schedule time, like \"Move to draft\" on the PostPen calendar. Does not delete the post and does not call LinkedIn. Refused unless confirm is true. Published posts cannot be unscheduled. save_draft can still edit a scheduled post in place without calling this.",
        "tags": [
          "Write"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "description": "Id of the scheduled post, from list_posts status scheduled."
                  },
                  "confirm": {
                    "type": "boolean",
                    "description": "Must be true. Clears the schedule and leaves a draft."
                  }
                },
                "required": [
                  "id",
                  "confirm"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. JSON object; never contains LinkedIn tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Invalid input, or a confirm: true guard was not met.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Plan limit reached (for example the Free plan's monthly post cap).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Destination not allowed for this account or plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/delete_draft": {
      "post": {
        "operationId": "delete_draft",
        "summary": "Delete draft",
        "description": "Permanently delete an unpublished draft only (is_draft, not scheduled, not sent). Refused unless confirm is true. Scheduled and published posts cannot be deleted. Confirm with the user which draft to delete before calling this.",
        "tags": [
          "Write"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "description": "Id of the draft, from list_posts status draft."
                  },
                  "confirm": {
                    "type": "boolean",
                    "description": "Must be true. Deleting cannot be undone."
                  }
                },
                "required": [
                  "id",
                  "confirm"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. JSON object; never contains LinkedIn tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Invalid input, or a confirm: true guard was not met.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Plan limit reached (for example the Free plan's monthly post cap).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Destination not allowed for this account or plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-destructive": true
      }
    },
    "/list_posts": {
      "post": {
        "operationId": "list_posts",
        "summary": "List posts",
        "description": "List this account’s drafts, scheduled posts, or sent posts. Each row includes has_media, media_count, media_type (photo, video, or pdf), and media_urls stored on the content row so you can confirm an image is attached.",
        "tags": [
          "Read"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "draft",
                      "scheduled",
                      "sent"
                    ],
                    "description": "Which queue to read."
                  },
                  "limit": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100,
                    "description": "How many rows to return. Defaults to 50."
                  }
                },
                "required": [
                  "status"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. JSON object; never contains LinkedIn tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Invalid input, or a confirm: true guard was not met.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Plan limit reached (for example the Free plan's monthly post cap).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Destination not allowed for this account or plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/publish_now": {
      "post": {
        "operationId": "publish_now",
        "summary": "Publish now",
        "description": "Publish a saved post to LinkedIn immediately, including any media already stored on the draft by save_draft. Refused unless confirm is true. Optional destination selects personal profile or a company page from list_destinations. This sends the post. Do not send media here.",
        "tags": [
          "Write"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "string",
                    "description": "Post id to publish."
                  },
                  "confirm": {
                    "type": "boolean",
                    "description": "Must be true. Publishing sends the post to LinkedIn now."
                  },
                  "destination": {
                    "type": "string",
                    "description": "Where to post: an id from list_destinations. Each destination has a network (linkedin today). Use \"personal\" (or omit on a personal draft) for the personal LinkedIn profile. For a company page, pass an id from list_destinations (usually the organization URN). On schedule_post and publish_now, omit to keep a company destination already saved on the draft; pass \"personal\" to force the profile."
                  }
                },
                "required": [
                  "id",
                  "confirm"
                ],
                "additionalProperties": false
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success. JSON object; never contains LinkedIn tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Invalid input, or a confirm: true guard was not met.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Plan limit reached (for example the Free plan's monthly post cap).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "Destination not allowed for this account or plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-destructive": true
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "PostPen API key (pp_...) from https://app.postpen.ai/agents"
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          }
        },
        "required": [
          "error"
        ]
      }
    }
  }
}
