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

# Upload a media asset

> Stores one file against the project, registers it with Meta for sending, and returns the `slug` your bot resolves it by at runtime (`sendMedia(to, "<slug>")` / `getMediaId("<slug>")`). Write the slug into a table row with `POST /v1/tables/{table}/rows` and the bot can send the asset later without ever touching the dashboard.

**Three transports, pick one.** Every one of them needs the file itself:

- `multipart/form-data` with a single part named **`file`**, plus optional `name` and `caption` text fields. Up to **20 MB**.
- JSON `{ "source_url": "https://..." }` — Paige downloads it for you. Up to **20 MB**. Only public http(s) URLs: private, loopback, link-local and other reserved addresses are refused (`400 blocked_source_url`), redirects are re-checked at every hop and capped, and a non-http(s) scheme is `400 invalid_source_url`. Add `filename` / `mime` to override what the remote server declares.
- JSON `{ "data": "<base64>", "filename": "...", "mime": "..." }` — inline bytes, capped at **8 MB decoded**, lower than the other two on purpose: base64 costs about 4/3 in transit and is buffered whole in memory, so large files belong on multipart or `source_url`. A `data:` URI prefix is accepted and stripped.

**`name` is the slug.** Send it to choose the bot-facing name; omit it and the filename is used. It is normalized to lowercase letters, digits and hyphens. **A slug that already exists is `409 SLUG_EXISTS`** — it is never overwritten, because the existing asset may already be referenced by deployed code or a published flow.

**Meta registration is best-effort, and the response tells you the truth about it.** `media_id` is `null` and `meta_status` is `"pending"` when the file was stored but not registered (typically because no WhatsApp number is connected). The asset is still usable and Paige retries registration on a schedule; a failed register is never reported as a success. `meta_status` is `"expired"` once the id passes Meta's 30-day lifetime.

**Size and type.** Oversize is `413 file_too_large`; a type Paige cannot ingest is `415`; an empty file is `400`. Images, video, audio, PDF, DOCX, plain text and markdown are accepted.

**Uploading an image costs credits.** Paige runs one AI vision call per image to write the knowledge description your bot answers from, and that call is billed to the project like any other AI usage — it shows up in Usage alongside builds. Out of credits is `402 insufficient_credits`. Send `describe_image: false` to skip it: the file is still stored, still registered with Meta and still usable, it just gets no AI-written description and costs nothing. Non-image uploads (PDF, DOCX, text) use no AI at all and are always free.

**Two limits apply beyond the per-request throttle.** Uploads are metered by BYTES as well as by request count, so a burst of large files can return `429 rate_limited` even while you are inside the request-per-minute budget — retry after the window or upload in smaller batches. And each project has a total media storage ceiling: an upload that would exceed it is refused with `413 storage_limit_exceeded` rather than quietly succeeding. `GET /v1/media` reports `storage.used_bytes` / `storage.remaining_bytes` so you can check before you push.

Send an `Idempotency-Key` header to make a retry safe. The fingerprint covers the **file bytes**, so retrying the same upload replays the original response, while reusing the key for a *different* file is `409 idempotency_key_mismatch` rather than a silently-dropped upload.

**Required scope:** `media:write`



## OpenAPI

````yaml /api-reference/openapi.json post /v1/media
openapi: 3.1.0
info:
  title: Paige API
  version: 1.0.0
  description: >-
    The Paige public REST API (`/v1`). Build WhatsApp automations against your
    Paige project: send messages, manage templates, read conversations, tag
    contacts, assemble broadcasts, edit bot code + flows, and register signed
    webhooks.


    ## Authentication

    Every `/v1` request authenticates with a project API key: `Authorization:
    Bearer pk_live_…`. Mint and scope keys in **Settings → API keys**. A key
    carries its own project context, so `/v1` paths never include a project id.


    ## Scopes

    Each endpoint requires one or more scopes (see each operation, and the
    `x-required-scopes` extension). Grant a key only the scopes it needs. A key
    missing a required scope gets `403 insufficient_scope`.


    ## Response envelope

    Success: `{ "success": true, "data": … }`. Error: `{ "success": false,
    "error": { "code", "message" }, "request_id" }`. Some errors add
    `error.details` with structured extras (e.g. `quota_exceeded` carries
    `limit` + `resetAt`) — read it defensively, its keys depend on the code. The
    `request_id` is also returned as the `X-Request-Id` header on every
    response.


    ## Rate limits & quota

    Requests are throttled per key (`RateLimit-*` headers; exceed → `429
    rate_limited`). WhatsApp sends also draw down a daily quota (exceed → `429
    quota_exceeded`). Both carry `Retry-After`.


    ## Idempotency

    Send an `Idempotency-Key` header on `POST /v1/messages` to make retries safe
    — a completed key replays the stored response and never re-sends.


    ## Webhook signatures

    Outbound webhook deliveries carry `X-Paige-Signature:
    t=<unix>,v1=<hmac_sha256>`. Verify with the reference `verifyPaigeSignature`
    helper (constant-time HMAC over `"<t>.<rawBody>"`, 300s tolerance).
  contact:
    name: Paige
    url: https://paigeme.dev
servers:
  - url: https://api.paigeme.dev
    description: Production
security:
  - ApiKeyAuth: []
tags:
  - name: Meta
    description: Smoke / diagnostics.
  - name: Messages
    description: Send messages and read delivery status + media.
  - name: Conversations
    description: List conversations, read messages, set state.
  - name: Contacts
    description: Read contact messages; update names, tags, attributes.
  - name: Templates
    description: WhatsApp message template CRUD.
  - name: Broadcasts
    description: Segments + broadcast assembly (always pending_approval).
  - name: Build
    description: Read/edit bot code, deploy, read/generate/update flows.
  - name: Tables
    description: >-
      Read and write the project's own database tables. Paige platform tables
      are denied, and an update or delete must always be filtered.
  - name: Media
    description: >-
      Upload, list, retrieve and delete the project's media assets. The slug an
      upload returns is what the bot sends by.
  - name: Webhooks
    description: Register signed outbound webhooks.
paths:
  /v1/media:
    post:
      tags:
        - Media
      summary: Upload a media asset
      description: >-
        Stores one file against the project, registers it with Meta for sending,
        and returns the `slug` your bot resolves it by at runtime
        (`sendMedia(to, "<slug>")` / `getMediaId("<slug>")`). Write the slug
        into a table row with `POST /v1/tables/{table}/rows` and the bot can
        send the asset later without ever touching the dashboard.


        **Three transports, pick one.** Every one of them needs the file itself:


        - `multipart/form-data` with a single part named **`file`**, plus
        optional `name` and `caption` text fields. Up to **20 MB**.

        - JSON `{ "source_url": "https://..." }` — Paige downloads it for you.
        Up to **20 MB**. Only public http(s) URLs: private, loopback, link-local
        and other reserved addresses are refused (`400 blocked_source_url`),
        redirects are re-checked at every hop and capped, and a non-http(s)
        scheme is `400 invalid_source_url`. Add `filename` / `mime` to override
        what the remote server declares.

        - JSON `{ "data": "<base64>", "filename": "...", "mime": "..." }` —
        inline bytes, capped at **8 MB decoded**, lower than the other two on
        purpose: base64 costs about 4/3 in transit and is buffered whole in
        memory, so large files belong on multipart or `source_url`. A `data:`
        URI prefix is accepted and stripped.


        **`name` is the slug.** Send it to choose the bot-facing name; omit it
        and the filename is used. It is normalized to lowercase letters, digits
        and hyphens. **A slug that already exists is `409 SLUG_EXISTS`** — it is
        never overwritten, because the existing asset may already be referenced
        by deployed code or a published flow.


        **Meta registration is best-effort, and the response tells you the truth
        about it.** `media_id` is `null` and `meta_status` is `"pending"` when
        the file was stored but not registered (typically because no WhatsApp
        number is connected). The asset is still usable and Paige retries
        registration on a schedule; a failed register is never reported as a
        success. `meta_status` is `"expired"` once the id passes Meta's 30-day
        lifetime.


        **Size and type.** Oversize is `413 file_too_large`; a type Paige cannot
        ingest is `415`; an empty file is `400`. Images, video, audio, PDF,
        DOCX, plain text and markdown are accepted.


        **Uploading an image costs credits.** Paige runs one AI vision call per
        image to write the knowledge description your bot answers from, and that
        call is billed to the project like any other AI usage — it shows up in
        Usage alongside builds. Out of credits is `402 insufficient_credits`.
        Send `describe_image: false` to skip it: the file is still stored, still
        registered with Meta and still usable, it just gets no AI-written
        description and costs nothing. Non-image uploads (PDF, DOCX, text) use
        no AI at all and are always free.


        **Two limits apply beyond the per-request throttle.** Uploads are
        metered by BYTES as well as by request count, so a burst of large files
        can return `429 rate_limited` even while you are inside the
        request-per-minute budget — retry after the window or upload in smaller
        batches. And each project has a total media storage ceiling: an upload
        that would exceed it is refused with `413 storage_limit_exceeded` rather
        than quietly succeeding. `GET /v1/media` reports `storage.used_bytes` /
        `storage.remaining_bytes` so you can check before you push.


        Send an `Idempotency-Key` header to make a retry safe. The fingerprint
        covers the **file bytes**, so retrying the same upload replays the
        original response, while reusing the key for a *different* file is `409
        idempotency_key_mismatch` rather than a silently-dropped upload.


        **Required scope:** `media:write`
      operationId: uploadMedia
      parameters:
        - schema:
            type: string
            description: >-
              Target project id, for an MCP OAuth bearer (`mcp_at_…`) attached
              to more than one project — get ids from `GET /v1/projects`.
              Matched case-insensitively.


              Omit it and a READ falls back to the connection's default project;
              a **mutation** (any non-GET) on a connection with 2+ projects is
              rejected with `400 project_required` — a write is never defaulted
              to a guessed project. A connection with exactly one project never
              needs the header.


              Scopes are checked against the SELECTED project only, never a
              union across the connection.


              For a `pk_` API key the header selects nothing — one key is one
              project's context — but it IS validated: omit it and the key's own
              project is used, send it and it must name that project, otherwise
              the call is rejected with `403 project_not_attached` (a blank
              value is `400 invalid_project_header`, as above).
          required: false
          name: X-Paige-Project
          in: header
      requestBody:
        required: true
        content:
          application/json:
            schema:
              anyOf:
                - type: object
                  properties:
                    source_url:
                      type: string
                      format: uri
                      description: >-
                        Public http(s) URL Paige downloads the file from.
                        Private, loopback and link-local addresses are refused,
                        redirects are re-checked at every hop.
                    name:
                      type: string
                      description: >-
                        Optional bot-facing name for the asset. Normalized to a
                        slug; blank falls back to the filename.
                    caption:
                      type:
                        - string
                        - 'null'
                      maxLength: 2000
                    filename:
                      type: string
                      minLength: 1
                      maxLength: 255
                      description: >-
                        Overrides the filename derived from the URL. The slug
                        still comes from `name` when you send one.
                    mime:
                      type: string
                      minLength: 1
                      maxLength: 255
                      description: >-
                        Overrides the server's `Content-Type`. Use it when the
                        host serves the file as `application/octet-stream`.
                    describe_image:
                      anyOf:
                        - type: boolean
                        - type: string
                          enum:
                            - 'true'
                            - 'false'
                            - '1'
                            - '0'
                            - ''
                      description: >-
                        Run the AI vision description on an image upload
                        (default true). It costs credits, so set false to store
                        and register the file without it. Ignored for non-image
                        files. Accepts a boolean, or `true`/`false`/`1`/`0` as a
                        multipart form field.
                  required:
                    - source_url
                  additionalProperties: false
                - type: object
                  properties:
                    data:
                      type: string
                      minLength: 1
                      description: >-
                        The file bytes, base64-encoded. Plain base64 or a
                        `data:` URI; both are accepted.
                    filename:
                      type: string
                      minLength: 1
                      maxLength: 255
                      description: >-
                        Original filename, including its extension. Also the
                        slug fallback when `name` is omitted.
                    mime:
                      type: string
                      minLength: 1
                      maxLength: 255
                      description: >-
                        The file's mime type, e.g. `image/png`. Checked against
                        the types Paige can ingest.
                    name:
                      type: string
                      description: >-
                        Optional bot-facing name for the asset. Normalized to a
                        slug; blank falls back to the filename.
                    caption:
                      type:
                        - string
                        - 'null'
                      maxLength: 2000
                    describe_image:
                      anyOf:
                        - type: boolean
                        - type: string
                          enum:
                            - 'true'
                            - 'false'
                            - '1'
                            - '0'
                            - ''
                      description: >-
                        Run the AI vision description on an image upload
                        (default true). It costs credits, so set false to store
                        and register the file without it. Ignored for non-image
                        files. Accepts a boolean, or `true`/`false`/`1`/`0` as a
                        multipart form field.
                  required:
                    - data
                    - filename
                    - mime
                  additionalProperties: false
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  description: The file bytes. Exactly one part, named `file`.
                  format: binary
                name:
                  type: string
                  description: >-
                    Optional bot-facing name for the asset. Normalized to a
                    slug; blank falls back to the filename.
                caption:
                  type:
                    - string
                    - 'null'
                  maxLength: 2000
                describe_image:
                  anyOf:
                    - type: boolean
                    - type: string
                      enum:
                        - 'true'
                        - 'false'
                        - '1'
                        - '0'
                        - ''
                  description: >-
                    Run the AI vision description on an image upload (default
                    true). It costs credits, so set false to store and register
                    the file without it. Ignored for non-image files. Accepts a
                    boolean, or `true`/`false`/`1`/`0` as a multipart form
                    field.
      responses:
        '201':
          description: Success.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    enum:
                      - true
                  data:
                    type: object
                    properties:
                      asset:
                        type: object
                        properties:
                          slug:
                            type: string
                            description: >-
                              The bot-facing name. `sendMedia(to, "<slug>")` and
                              `getMediaId("<slug>")` resolve by this.
                          media_id:
                            type:
                              - string
                              - 'null'
                            description: >-
                              Meta's id for the registered copy, used when the
                              bot sends the file. **Null when registration did
                              not happen** (usually no WhatsApp number
                              connected) — the asset is still stored and Paige
                              retries on a schedule.
                          meta_status:
                            type: string
                            enum:
                              - registered
                              - pending
                              - expired
                            description: >-
                              `registered` — ready to send. `pending` — stored
                              but not registered with Meta yet. `expired` — the
                              id passed Meta's 30-day lifetime and is being
                              refreshed.
                          meta_uploaded_at:
                            type:
                              - string
                              - 'null'
                            description: >-
                              When the file was registered with Meta (ISO 8601),
                              or null.
                          mime:
                            type:
                              - string
                              - 'null'
                            description: Stored mime type, e.g. `image/png`.
                          bytes:
                            type:
                              - number
                              - 'null'
                            description: Size of the stored original.
                          filename:
                            type:
                              - string
                              - 'null'
                            description: The original filename it was uploaded under.
                          kind:
                            type: string
                            enum:
                              - image
                              - video
                              - audio
                              - knowledge
                            description: >-
                              What Paige did with it. `knowledge` means it was
                              text-extracted into the bot's knowledge base (PDF,
                              DOCX, text, markdown).
                          caption:
                            type:
                              - string
                              - 'null'
                            description: The caption supplied at upload, if any.
                          source:
                            type: string
                            enum:
                              - chat_upload
                              - conversation_inbound
                            description: >-
                              `chat_upload` is your own upload.
                              `conversation_inbound` is a file a customer sent
                              in over WhatsApp — only ever returned to a key
                              that also holds `conversations:read`.
                          created_at:
                            type:
                              - string
                              - 'null'
                            description: When the asset was created (ISO 8601).
                        required:
                          - slug
                          - media_id
                          - meta_status
                          - meta_uploaded_at
                          - mime
                          - bytes
                          - filename
                          - kind
                          - caption
                          - source
                          - created_at
                    required:
                      - asset
                required:
                  - success
                  - data
        '400':
          description: >-
            Validation / bad request (e.g. `invalid_request`, `invalid_cursor`,
            `invalid_template` for a Meta 4xx rejection of a template payload,
            which carries Meta's own detail as the message; note
            `outside_24h_window` is 409, not 400). Also `invalid_project_header`
            (any credential: `X-Paige-Project` was sent but blank) and, for
            multi-project MCP bearers, `project_required` (a mutation, or a read
            with no default, and no `X-Paige-Project`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: >-
            Missing or invalid API key (`api_key_required` / `invalid_api_key` /
            `invalid_token` for a revoked or expired OAuth bearer).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '402':
          description: Out of credits (`insufficient_credits`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: >-
            Key lacks the required scope, project inactive, or a write targets a
            platform-protected file (`insufficient_scope` / `project_inactive` /
            `subscription_inactive` / `protected_file`). Also
            `project_not_attached` (the `X-Paige-Project` project is not one
            this credential may act on — not attached to the MCP connection, or
            not the `pk_` key's own project; identical response whether or not
            it exists) and, for MCP bearers, `no_project_access` (the connection
            has no projects attached).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '409':
          description: >-
            Conflict — 24h window closed (`outside_24h_window`) or idempotency
            (`idempotency_in_progress` / `idempotency_key_mismatch`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '413':
          description: Error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '415':
          description: Error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: >-
            Rate limit (`rate_limited`) or daily send quota (`quota_exceeded`)
            exceeded. Carries `RateLimit-*` + `Retry-After` headers.
          headers:
            RateLimit-Limit:
              description: Requests permitted in the current window.
              schema:
                type: integer
            RateLimit-Remaining:
              description: Requests remaining in the current window.
              schema:
                type: integer
            RateLimit-Reset:
              description: Seconds until the window resets.
              schema:
                type: integer
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Unexpected server error (`server_error`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      security:
        - ApiKeyAuth: []
components:
  schemas:
    ErrorResponse:
      type: object
      properties:
        success:
          type: boolean
          enum:
            - false
        error:
          $ref: '#/components/schemas/ApiError'
        request_id:
          type: string
          description: Per-request id, also returned as the X-Request-Id header.
      required:
        - success
        - error
        - request_id
    ApiError:
      type: object
      properties:
        code:
          type: string
          description: Stable, machine-readable error code.
          example: invalid_request
        message:
          type: string
          description: Human-readable message (safe to surface).
          example: Invalid request body
        details:
          type: object
          properties: {}
          description: >-
            Structured extras for codes that carry them — the keys depend on
            `code`. `quota_exceeded` carries `limit` + `resetAt`; most errors
            omit this field entirely.
      required:
        - code
        - message
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: API key
      description: >-
        Project API key issued in Settings → API keys. Send it as
        `Authorization: Bearer pk_live_…`.

````