Media

Upload, retrieve, update, and delete media files. Manage media placeholders (organizational groupings for media items).

All endpoints require authentication via the X-API-Key header. See Authentication.


List media

GET /media

List media with filtering and offset-based pagination. Returns non-deleted media only.

Query parameters

ParameterTypeDefaultNotes
pagenumber—0-indexed.
perPagenumber—Items per page.
typestring—Comma-separated list of: image, video, pdf, share, vcard.
placeholderIdstring—Filter by media placeholder group.
contactIdstring—Filter by associated contact.
isPlaceholderboolean—Filter media with or without a placeholder.
archiveStatusstringactiveOne of: archived, active, all.

Example request

curl "https://<host>/api/media?page=0&perPage=25&type=image,video&archiveStatus=active" \
  -H "X-API-Key: your-api-key"

Response

{
  "success": true,
  "data": [
    {
      "_id": "670a...",
      "orgId": "...",
      "name": "team-photo.jpg",
      "type": "image/jpeg",
      "size": 245000,
      "url": {
        "thumbnail": "https://storage.googleapis.com/...",
        "original": "https://storage.googleapis.com/..."
      },
      "archived": false,
      "deleted": false,
      "dimensions": { "width": 1920, "height": 1080 },
      "owner": { "_id": "...", "firstName": "John", "lastName": "Doe" },
      "associatedTo": null,
      "placeholder": null,
      "createdAt": 1716000000000
    }
  ],
  "pagination": {
    "page": 0,
    "perPage": 25,
    "totalPages": 4,
    "totalItems": 92,
    "hasMore": true
  }
}

Get a media item

GET /media/:id

Returns a single media item with populated relationships (owner, associated contact, share, placeholder).

Path parameters

ParameterTypeDescription
idstringMedia ID.

Example request

curl https://<host>/api/media/670a... \
  -H "X-API-Key: your-api-key"

Response

{
  "success": true,
  "data": {
    "_id": "670a...",
    "name": "team-photo.jpg",
    "type": "image/jpeg",
    "size": 245000,
    "url": {
      "thumbnail": "https://storage.googleapis.com/...",
      "original": "https://storage.googleapis.com/..."
    },
    "archived": false,
    "owner": { "_id": "...", "firstName": "John", "lastName": "Doe" },
    "associatedTo": null,
    "placeholder": null,
    "createdAt": 1716000000000
  }
}

Errors

StatusMessageCause
404Media not foundNo media with that ID, or it has been deleted.

Update media metadata

PUT /media/:id

Update a media item's metadata. All fields are optional — only provided fields are updated.

Path parameters

ParameterTypeDescription
idstringMedia ID.

Request body

FieldTypeRequiredNotes
namestringNoDisplay name.
userIdstring | nullNoOwner user ID. Must exist in your org.
contactIdstring | nullNoAssociated contact ID. Must exist in your org.
placeholderIdstring | nullNoPlaceholder group ID. Must exist in your org. Moves media between groups and updates counts.
archivedbooleanNoArchive or unarchive.
summarystringNoSummary text.
transcriptionstringNoTranscription text.

Example request

curl -X PUT https://<host>/api/media/670a... \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Spring Training Highlights", "archived": false }'

Response

{
  "error": false,
  "success": true,
  "message": "Media updated successfully"
}

Errors

StatusMessageCause
400Validation errorInvalid user, contact, or placeholder ID.

Delete a media item

DELETE /media/:id

Soft-deletes a media item: marks the record as deleted and removes files from Google Cloud Storage (both original and thumbnail).

Cannot delete media that a live message still needs, which means a message that is currently sending or building, or one with a future send time that has not been cancelled. Media reached through a placeholder counts too: if a message is attached to the placeholder this media belongs to, the media is protected.

Path parameters

ParameterTypeDescription
idstringMedia ID.

Example request

curl -X DELETE https://<host>/api/media/670a... \
  -H "X-API-Key: your-api-key"

Response

{
  "success": true,
  "message": "Media deleted successfully"
}

Errors

StatusMessageCause
400Media is being used in a message that is scheduled or currently sendingA live message still needs this media, directly or through its placeholder.
404Media does not existNo media with that ID in your org, or it was already deleted.

Bulk delete media

POST /media/bulk-delete

Soft-deletes multiple media items in one request. Body carries the target IDs (preferred over DELETE with a body, which many proxies strip).

Partial by design: this always returns 200, and every requested ID comes back in exactly one of deletedIds, skippedIds or notFoundIds. Media still needed by a message that is scheduled or currently sending is skipped rather than deleted, including media reached through a placeholder that a message is attached to.

Request body

FieldTypeDescription
mediaIdsstring[]Media IDs to delete (1 to 1000).

Example request

curl -X POST https://<host>/api/media/bulk-delete \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"mediaIds":["670a...","670b..."]}'

Response

{
  "success": true,
  "message": "Media deleted",
  "data": {
    "deletedCount": 2,
    "deletedIds": ["670a...", "670b..."],
    "skippedIds": [],
    "notFoundIds": []
  }
}
FieldTypeDescription
deletedCountnumberHow many were soft-deleted. Equals deletedIds.length.
deletedIdsstring[]Soft-deleted; their files were removed from storage.
skippedIdsstring[]Still needed by a scheduled or in-flight message, so left untouched.
notFoundIdsstring[]Not in your org, or already deleted.

Errors

StatusMessageCause
400Validation errormediaIds is empty, has more than 1000 entries, or contains a malformed ID.

Upload a media file

POST /media

Upload a media file using multipart form data (not JSON). Metadata is passed as a JSON string in the data field.

File size limit: effectively 32 MiB. Cloud Run caps an HTTP/1 request at that size and refuses anything larger before it reaches this service. For larger files, use the direct upload flow below (POST /media/upload/init), which accepts up to 512MB.

Supported file types

CategoryMIME types
Imagesimage/gif, image/jpeg, image/jpg, image/png, image/webp
Videosvideo/mp4, video/quicktime (.mov)
Documentsapplication/pdf
vCardstext/vcard, text/x-vcard

The file's content is verified against its declared type. A request whose bytes do not match (for example an HTML document declared as image/png) is refused with 400 and nothing is stored.

Form fields

FieldTypeRequiredNotes
filefileYesThe media file to upload.
thumbnailfileNoCustom thumbnail image (PNG or JPEG). If omitted, the original file is used as thumbnail for images.
datastringYesJSON string with metadata: { "name": string, "userId?": string, "contactId?": string, "placeholderId?": string }.

Image compression

Images over 3.5MB are automatically compressed server-side:

  • JPEG: Compressed with MozJPEG at quality 30.
  • PNG: Compressed with PNGQuant at quality 30, 256 colors.

The stored file may differ from what was uploaded due to this compression.

Example request

curl -X POST https://<host>/api/media \
  -H "X-API-Key: your-api-key" \
  -F "file=@team-photo.jpg" \
  -F 'data={"name": "Team Photo", "placeholderId": "671a..."}'

Response

{
  "success": true,
  "message": "Media uploaded successfully",
  "data": {
    "_id": "670a...",
    "name": "Team Photo",
    "type": "image/jpeg",
    "size": 245000,
    "url": {
      "thumbnail": "https://storage.googleapis.com/...",
      "original": "https://storage.googleapis.com/..."
    },
    "createdAt": 1716000000000
  }
}

Errors

StatusMessageCause
400Validation errorInvalid file type, missing data field, or invalid metadata.

Initialize direct upload

POST /media/upload/init

Create a signed upload URL and temporary uploadId for direct-to-storage upload flows.

Direct upload flow

  1. Call POST /media/upload/init to get a signed url, uploadId, and expiresAt.
  2. Upload the raw file bytes with PUT to the signed url (outside your API domain).
  3. Call POST /media/upload/finalize with the uploadId to create the media record.

Request body

FieldTypeRequiredNotes
namestringYesOriginal file name.
sizenumberYesFile size in bytes. Must be greater than 0.
typestringYesMIME type. Must be one of the supported file types: image/jpeg (or image/jpg), image/png, image/gif, image/webp, video/mp4, video/quicktime (or video/mov), application/pdf, text/vcard (or text/x-vcard).

Example request

curl -X POST https://<host>/api/media/upload/init \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "name": "meeting.mp4", "size": 2450000, "type": "video/mp4" }'

Response

{
  "success": true,
  "message": "Success",
  "data": {
    "url": "https://storage.googleapis.com/...",
    "uploadId": "671a...",
    "expiresAt": "2026-05-06T13:15:00.000Z"
  }
}

expiresAt is the timestamp when this upload session expires. Complete the PUT upload and /upload/finalize before this time.

File size limit: 512MB, enforced at init (declared size) and at finalize (actual object size). This differs from POST /media, which is effectively capped at 32 MiB: Cloud Run refuses a larger HTTP/1 request before it reaches the service. Use the signed-URL flow for anything bigger. That platform limit is the reason this flow exists.

The response may include requiredHeaders, an object of headers to send verbatim on the PUT to the signed URL. It is optional and only present when the server has that feature enabled, but when present the headers must be included: they are part of the URL's signature, and a PUT without them is refused by the storage bucket.

Step 2 example: PUT file to signed URL

Use the url returned by init and upload the file bytes directly. The Content-Type should match the type sent in init.

curl -X PUT "https://storage.googleapis.com/...signed-url..." \
  -H "Content-Type: video/mp4" \
  --data-binary @meeting.mp4

If the upload succeeds, Google Cloud Storage returns 200 OK (or 201 Created) with an empty body.


Finalize direct upload

POST /media/upload/finalize

Finalize a direct upload using the uploadId returned by upload init and create the media record.

Request body

FieldTypeRequiredNotes
uploadIdstringYesUpload session ID returned by init.
namestringNoOptional display name.
contactIdstring | nullNoAssociated contact ID.
placeholderIdstring | nullNoPlaceholder group ID.
userIdstring | nullNoOwner user ID. Defaults to authenticated user when omitted.

Example request

curl -X POST https://<host>/api/media/upload/finalize \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "uploadId": "671a...", "name": "Meeting Recording", "placeholderId": "672b..." }'

Response

{
  "success": true,
  "message": "Media uploaded successfully",
  "data": {
    "_id": "670a...",
    "name": "Meeting Recording",
    "type": "video/mp4",
    "size": 2450000,
    "url": {
      "thumbnail": "https://storage.googleapis.com/...",
      "original": "https://storage.googleapis.com/..."
    }
  }
}

Upload a thumbnail

POST /media/upload/thumbnail/:mediaId

Upload a thumbnail image for an existing media item. Sends raw binary data (not multipart). Typically used for video thumbnails.

Path parameters

ParameterTypeDescription
mediaIdstringMedia ID to attach the thumbnail to.

Required headers

HeaderValueNotes
Content-Typeimage/png or image/jpgMust be a supported image type.
Content-LengthnumberFile size in bytes.

Example request

curl -X POST https://<host>/api/media/upload/thumbnail/670a... \
  -H "X-API-Key: your-api-key" \
  -H "Content-Type: image/png" \
  -H "Content-Length: 48000" \
  --data-binary @thumbnail.png

Response

{
  "success": true,
  "message": "Thumbnail uploaded successfully",
  "data": {
    "_id": "670a...",
    "name": "training-video.mp4",
    "url": {
      "thumbnail": "https://storage.googleapis.com/...",
      "original": "https://storage.googleapis.com/..."
    }
  }
}

Errors

StatusMessageCause
401Media not foundNo media with that ID in your org.
500Invalid media IDInvalid ObjectId format.

Start media transcription

POST /media/transcribe/:id

Trigger on-demand transcription for a media item. Only video media is supported.

Path parameters

ParameterTypeDescription
idstringMedia ID.

Example request

curl -X POST https://<host>/api/media/transcribe/670a... \
  -H "X-API-Key: your-api-key"

Response

{
  "success": true,
  "message": "Success",
  "data": {
    "_id": "670a...",
    "transcriptionStatus": {
      "status": "pending",
      "timestamp": "2026-05-06T12:00:00.000Z"
    }
  }
}

Errors

StatusMessageCause
400Media is not a videoRequested media is not a video MIME type.
400Transcription already doneMedia already has completed transcription.
400Transcription is already in progressA pending transcription was started less than one hour ago.
404Media not foundNo media with that ID exists.

List media placeholders

GET /media/placeholders

List media placeholders (organizational groupings). Each placeholder includes a preview array with up to 3 media items.

Query parameters

ParameterTypeDefaultNotes
searchstring—Text search on placeholder name.
includeEmptybooleanfalseInclude placeholders with no media.
pagenumber00-indexed.
perPagenumber50Items per page.

Example request

curl "https://<host>/api/media/placeholders?search=spring&includeEmpty=false" \
  -H "X-API-Key: your-api-key"

Response

{
  "success": true,
  "data": [
    {
      "_id": "671a...",
      "orgId": "...",
      "name": "Spring Campaign",
      "mediaCount": 12,
      "archived": false,
      "createdAt": 1716000000000,
      "preview": [
        {
          "name": "banner.png",
          "type": "image/png",
          "url": {
            "thumbnail": "https://storage.googleapis.com/...",
            "original": "https://storage.googleapis.com/..."
          }
        }
      ]
    }
  ],
  "pagination": {
    "page": 0,
    "perPage": 50,
    "totalPages": 1,
    "totalItems": 3
  }
}

Get a media placeholder

GET /media/placeholders/:id

Returns a single media placeholder with a preview of up to 3 media items.

Path parameters

ParameterTypeDescription
idstringPlaceholder ID.

Example request

curl https://<host>/api/media/placeholders/671a... \
  -H "X-API-Key: your-api-key"

Response

{
  "success": true,
  "data": {
    "_id": "671a...",
    "name": "Spring Campaign",
    "mediaCount": 12,
    "archived": false,
    "createdAt": 1716000000000,
    "preview": [
      {
        "name": "banner.png",
        "type": "image/png",
        "url": {
          "thumbnail": "https://storage.googleapis.com/...",
          "original": "https://storage.googleapis.com/..."
        }
      }
    ]
  }
}

Errors

StatusMessageCause
403Placeholder not foundNo placeholder with that ID in your org.

Response envelope

All endpoints use the standard envelope:

{
  "error": false,
  "success": true,
  "message": "...",
  "code": 200,
  "data": "...",
  "pagination": {}
}

Error responses:

{
  "error": true,
  "success": false,
  "message": "...",
  "code": 400,
  "data": null
}

data is null for a general failure, such as a database error or an upstream timeout. When the request is rejected because of a specific field, data carries the field(s) at fault instead:

{
  "error": true,
  "success": false,
  "message": "phone: Phone parse failed: The string supplied did not seem to be a phone number (input: <masked>)",
  "code": 400,
  "data": {
    "issues": [
      {
        "path": "phone",
        "message": "Phone parse failed: The string supplied did not seem to be a phone number (input: <masked>)"
      }
    ]
  }
}

Each issue carries a path (the dotted path into the request body) and a message. A label is added when the path is not self-describing, which today means custom fields only, for example "path": "fields.68b0a1f39c2e4d0012ab77e5", "label": "Class Year". issues is an array, but treat it as holding at most one entry: validation stops at the first failure.


mediaDTO schema

Used by endpoints that return media with populated relationships, such as GET /media/:id and GET /media.

type MediaWithDetails = {
  _id: string
  orgId: string
  userId?: string | null
  contactId?: string | null
  placeholderId?: string | null
  shareId?: string
  name: string
  url: {
    thumbnail: string
    original: string
  }
  type: string
  size: number
  createdAt: number
  updatedAt?: number
  archived: boolean
  deleted: boolean
  dimensions?: { width: number, height: number } | null
  transcription?: string | null
  summary?: string | null
  tags?: string[] | null
  transcriptionStatus?: {
    status: 'pending' | 'done' | 'error'
    timestamp: string
  }
  owner: User | null
  associatedTo: Contact | null
  share: Share | null
  placeholder: MediaPlaceholder | null
}

On this page