> ## Documentation Index
> Fetch the complete documentation index at: https://docs.postbreeze.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Get a media upload URL

> Uploading an image or video is a three-step process. This is step one.

**How it works:**

1. Call this endpoint with the file name and content type. The server gives you back an `uploadUrl` (where to send the bytes), a `publicUrl` (where the file will live once uploaded), and the exact `headers` to use on the upload request.
2. Send the file's bytes directly to `uploadUrl` using a `PUT` request with the headers from step 1. **Don't include an `Authorization` header on this request** — the URL itself is already signed, and adding one will break the upload.
3. Attach the file to a post in one of two ways: include `publicUrl` in `mediaItems: [{ url: publicUrl, type: "image" }]`, or pass the `mediaAsset.id` you got back in `mediaIds: ["med_…"]`. Both work identically — pick whichever feels cleaner in your code.

**A few things to know:**

- The `uploadUrl` expires after 15 minutes. If you miss the window, just call this endpoint again for a fresh one.
- Uploaded media is shared across all your workspaces — one upload can be attached to posts on any account you can reach.
- If you're using the Node SDK, `postbreeze.media.upload(...)` handles all three steps for you.



## OpenAPI

````yaml /openapi.json post /media/presign
openapi: 3.0.0
info:
  title: Postbreeze API
  description: >-
    Public REST API for scheduling and managing social posts. Authenticate every
    request with `Authorization: Bearer pb_live_…`. All endpoints are scoped to
    a single workspace; an API key issued for workspace A cannot access
    workspace B.
  version: 1.0.0
  contact: {}
servers:
  - url: http://localhost:4100/api/v1
security:
  - bearer: []
tags: []
paths:
  /media/presign:
    post:
      tags:
        - Media
      summary: Get a media upload URL
      description: >-
        Uploading an image or video is a three-step process. This is step one.


        **How it works:**


        1. Call this endpoint with the file name and content type. The server
        gives you back an `uploadUrl` (where to send the bytes), a `publicUrl`
        (where the file will live once uploaded), and the exact `headers` to use
        on the upload request.

        2. Send the file's bytes directly to `uploadUrl` using a `PUT` request
        with the headers from step 1. **Don't include an `Authorization` header
        on this request** — the URL itself is already signed, and adding one
        will break the upload.

        3. Attach the file to a post in one of two ways: include `publicUrl` in
        `mediaItems: [{ url: publicUrl, type: "image" }]`, or pass the
        `mediaAsset.id` you got back in `mediaIds: ["med_…"]`. Both work
        identically — pick whichever feels cleaner in your code.


        **A few things to know:**


        - The `uploadUrl` expires after 15 minutes. If you miss the window, just
        call this endpoint again for a fresh one.

        - Uploaded media is shared across all your workspaces — one upload can
        be attached to posts on any account you can reach.

        - If you're using the Node SDK, `postbreeze.media.upload(...)` handles
        all three steps for you.
      operationId: MediaV1Controller_presign
      parameters: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PresignDto'
      responses:
        '201':
          description: >-
            Upload URL is ready. Send the bytes to `uploadUrl` within 15
            minutes, then attach the result to a post using `publicUrl` or
            `mediaAsset.id`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  uploadUrl:
                    type: string
                    description: >-
                      Presigned R2 PUT URL valid for 15 minutes. PUT the raw
                      bytes to this URL with the headers from `headers`. Do NOT
                      send an `Authorization` header on the PUT — the signature
                      is embedded in the URL's query string, and adding
                      `Authorization` breaks the signature.
                  publicUrl:
                    type: string
                    description: >-
                      Long-lived public URL of the uploaded object. Stable
                      across the asset's lifetime — store it and reuse it across
                      workspaces. Pass it in `mediaItems: [{ url: publicUrl,
                      type: 'image' }]` when creating posts via the flat
                      URL-based attach path.
                  expiresAt:
                    type: string
                    format: date-time
                    description: >-
                      When `uploadUrl` expires (ISO 8601). After this instant
                      the presigned PUT will be rejected by R2; if you haven't
                      uploaded by then, request a fresh presign.
                  headers:
                    type: object
                    additionalProperties:
                      type: string
                    description: >-
                      HTTP headers you MUST include on the PUT to satisfy the
                      signature. Always contains `Content-Type` matching the
                      `contentType` you sent in the request body. Send these
                      verbatim — mismatching even one byte invalidates the
                      signature.
                  mediaAsset:
                    description: >-
                      The pending `MediaAsset` row the server created up-front.
                      Use `mediaAsset.id` to attach the upload via `mediaIds:
                      [...]` on a later post-create call (id-based attach), or
                      ignore it and use `publicUrl` instead (URL-based attach —
                      both produce identical results).
                    allOf:
                      - type: object
                        properties:
                          id:
                            type: string
                            description: >-
                              Prefixed cuid of the media asset row, e.g.
                              `med_…`. Use this value when attaching the asset
                              to a post via `mediaIds`.
                          ownerUserId:
                            type: string
                            description: >-
                              User id that owns the asset. Media is
                              account-global, so this is the calling user (or
                              the API key's owner).
                          folderId:
                            type: string
                            nullable: true
                            description: >-
                              Dashboard library folder id, or `null` when the
                              asset lives in the library root.
                          kind:
                            type: string
                            enum:
                              - IMAGE
                              - VIDEO
                              - GIF
                              - DOCUMENT
                            description: >-
                              Coarse type inferred from `mimeType`. Drives
                              per-platform validation downstream — e.g. only
                              `IMAGE` is valid in an Instagram carousel.
                          mimeType:
                            type: string
                            description: MIME type echoed back from the presign request.
                          bytes:
                            type: integer
                            description: >-
                              Size in bytes. `0` when the presign request
                              omitted `bytes` — the normalize worker patches
                              this in after the PUT lands.
                          width:
                            type: integer
                            nullable: true
                            description: >-
                              Image / video width in pixels. Populated by the
                              normalize worker after the PUT, so `null` on a
                              fresh presign response.
                          height:
                            type: integer
                            nullable: true
                            description: >-
                              Image / video height in pixels. Populated by the
                              normalize worker after the PUT.
                          durationMs:
                            type: integer
                            nullable: true
                            description: >-
                              Video duration in milliseconds. `null` for stills
                              and PDFs.
                          storageKey:
                            type: string
                            description: >-
                              R2 object key the bytes will live under. Internal
                              — prefer `publicUrl` for everything except
                              debugging.
                          thumbnailKey:
                            type: string
                            nullable: true
                            description: >-
                              R2 key of the 256px WebP thumbnail. Produced by
                              the normalize worker, so `null` until
                              normalization finishes.
                          previewKey:
                            type: string
                            nullable: true
                            description: >-
                              R2 key of the ~1080px WebP preview variant.
                              Produced by the normalize worker.
                          altText:
                            type: string
                            nullable: true
                            description: >-
                              Default alt text remembered for this asset.
                              Re-used as the pre-fill when the asset is attached
                              to a new post; can be overridden per-post.
                          normalizationStatus:
                            type: string
                            enum:
                              - PENDING
                              - PROCESSING
                              - DONE
                              - FAILED
                              - SKIPPED
                            description: >-
                              Server-side normalization status. Images start
                              `PENDING`, flip to `PROCESSING` while the worker
                              runs, then `DONE` or `FAILED`. Videos and PDFs go
                              straight to `SKIPPED`. **The publish pipeline
                              refuses to publish assets still in `PENDING` or
                              `PROCESSING`** — wait for `DONE` or `SKIPPED`
                              before attaching to a scheduled post.
                          publicUrl:
                            type: string
                            nullable: true
                            description: >-
                              Long-lived public URL for the original bytes. Use
                              this value in `mediaItems[].url` when attaching to
                              a post by URL.
                          previewUrl:
                            type: string
                            nullable: true
                            description: >-
                              Public URL of the normalized preview (~1080px
                              WebP). `null` until normalization finishes.
                          thumbnailUrl:
                            type: string
                            nullable: true
                            description: >-
                              Public URL of the thumbnail (256px WebP). `null`
                              until normalization finishes.
                          createdAt:
                            type: string
                            format: date-time
                            description: >-
                              When the asset row was created (i.e. when the
                              presign URL was minted).
                          updatedAt:
                            type: string
                            format: date-time
                            description: >-
                              Last write to the row — bumped by the normalize
                              worker when it patches dimensions / status.
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  statusCode:
                    type: integer
                    example: 400
                  code:
                    type: string
                    example: VALIDATION_FAILED
                  message:
                    type: string
                  requestId:
                    type: string
        '401':
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                type: object
                properties:
                  statusCode:
                    type: integer
                    example: 401
                  message:
                    type: string
                    example: Unauthorized
                  requestId:
                    type: string
components:
  schemas:
    PresignDto:
      type: object
      properties:
        filename:
          type: string
          minLength: 1
          maxLength: 255
          example: hero-image.jpg
          description: >-
            Original file name. Persisted on the `MediaAsset` row and used to
            derive the R2 storage key. Sanitized server-side, so unsafe
            characters (`/`, `\`, control chars) are stripped before storage.
        contentType:
          type: string
          maxLength: 100
          example: image/jpeg
          description: >-
            MIME type of the bytes you're about to PUT. Supported — Images:
            `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/heic`,
            `image/heif`. Videos: `video/mp4`, `video/quicktime`,
            `video/x-msvideo`, `video/webm`. Documents: `application/pdf`
            (LinkedIn document carousels only). The same value MUST be set as
            the `Content-Type` header on the PUT — R2 binds the signature to it.
        bytes:
          type: integer
          minimum: 1
          description: >-
            File size in bytes. When provided, the server enforces the per-kind
            size cap (images, video, PDF) and your account-wide storage quota
            before signing the URL — failures return `400` (limit) or `403
            STORAGE_LIMIT` (quota). When omitted, both limits are reconciled
            lazily on the PUT itself; R2 still rejects oversize uploads.
        folderId:
          type: string
          nullable: true
          maxLength: 64
          description: >-
            Internal — dashboard media-library folder id. Public API
            integrations can omit this; assets land in the library root.
      required:
        - filename
        - contentType
  securitySchemes:
    bearer:
      scheme: bearer
      bearerFormat: API key
      type: http
      description: Your Postbreeze API key

````