← palixo.media

Palixo API

Version 1.0 | Last updated: 2026-10-27

Overview

The Palixo API is a tag-based JSON API for building your own apps and integrations on Palixo. With it you can:

Every call acts as a signed-in Palixo user and sees exactly what that user can see in the Palixo apps.

Getting access

API access is available to Enterprise customers by arrangement. We issue a client ID for your app, and your users sign in with their own Palixo accounts. To get started, contact sales@palixo.media.

What this API does not do


Base URL

EnvironmentURL
Productionhttps://palixo.media/mobile-api
UAThttps://uat.palixo.media/mobile-api

UAT access for testing your integration is available on request.


Authentication

Every endpoint except GET /health requires a JWT ID token from Palixo sign-in, issued to the client ID we provide for your app. The user pool details are supplied with your client ID.

Authorization: Bearer <ID_TOKEN>

Trial gate

A user whose trial has ended gets 402 trial_expired from every endpoint except GET /me/plan. That endpoint stays available so your app can show the user their plan and how to continue.


Responses

Successful responses return the endpoint’s JSON body directly, with no wrapping envelope. Errors share a common shape; see Error handling.


Endpoints

MethodPathPurpose
GET/healthLiveness check (no auth)
GET/albumsList albums the user can access
GET/albums/:albumId/membersMembers and pending invitations for an album
GET/photosFind photos by tags (paginated)
POST/photosCreate a photo; returns an upload URL
PATCH/photos/:pictureIdUpdate photo details, or delete the photo
GET/photos/:pictureId/downloadDownload URL for the full-resolution photo
PATCH/photos/:pictureId/tagsReplace the tags on a photo
GET/me/profileThe user’s name and email
PATCH/me/profileChange the user’s name
GET/me/planPlan, usage and limits (no trial gate)
GET/me/invitationsPending invitations addressed to the user
POST/me/invitations/accept-by-tokenAccept an invitation from its link
POST/me/invitations/:invitationId/acceptAccept a pending invitation
POST/me/invitations/:invitationId/declineDecline a pending invitation
GET/me/collaboratorsThe user’s collaborators and their access per album
POST/me/collaborators/inviteInvite someone to albums
DELETE/me/collaborators/:emailRemove a collaborator from every album
POST/me/collaborators/:email/grantsGive a collaborator access to more albums
PATCH/me/collaborators/:email/grants/:albumIdChange a collaborator’s access to one album
DELETE/me/collaborators/:email/grants/:albumIdRemove a collaborator’s access to one album

Access levels, from least to most: VIEWER, CONTRIBUTOR, ADMIN, OWNER.


GET /health

Liveness check. No authentication.

Response (200):

{
  "status": "ok",
  "service": "mobile-api",
  "timestamp": "2026-04-20T10:00:00.000Z"
}

GET /albums

List the shareable albums the user can access.

Response (200):

{
  "albums": [
    { "albumId": "2PbE8KqF8CzqKd0EyPT4YJL0QiH", "name": "Site Photos 2026" }
  ]
}

Errors: 401 account_deleted, 402 trial_expired.


GET /albums/:albumId/members

Active members (with their access) and pending invitations for an album.

Response (200):

{
  "albumId": "2PbE8KqF8CzqKd0EyPT4YJL0QiH",
  "ownerId": "usr_abc123",
  "members": [
    {
      "userId": "usr_def456",
      "email": "alex@example.com",
      "name": "Alex Smith",
      "access": "ADMIN",
      "sourceType": "DIRECT",
      "joinedAt": "2026-03-12T00:00:00.000Z"
    }
  ],
  "pending": [
    {
      "invitationId": "inv_xyz789",
      "inviteeEmail": "newperson@example.com",
      "inviteeName": "New Person",
      "accessLevel": "CONTRIBUTOR",
      "expiresAt": "2026-04-27T00:00:00.000Z"
    }
  ]
}

Errors: 403 (the user has no access to the album), 404 (album not found), 401 account_deleted, 402 trial_expired.


GET /photos

Find photos that carry all of the given tags, a page at a time.

Query parameters:

NameTypeRequiredDefaultDescription
tagstring[]Yes—Repeat the parameter or comma-separate the values.
limitnumberNo50Maximum 200.
afterstringNo—The nextCursor from the previous page.

Response (200):

{
  "pictures": [
    {
      "pictureId": "2PbE...",
      "fileName": "IMG_1234.jpg",
      "tags": ["before", "deck-repair"],
      "systemTags": ["wood", "outdoor"],
      "created": "2PbE...",
      "modified": "2PbE...",
      "albumId": "2PbE..."
    }
  ],
  "hasMore": true,
  "nextCursor": "2PbE..."
}

tags are the tags people added; systemTags are added by Palixo’s AI tagging.

Errors: 401 account_deleted, 402 trial_expired.


POST /photos

Create a photo and receive a presigned upload URL. Your app then PUTs the image file directly to that URL.

Request:

{
  "albumId": "2PbE...",
  "pictureId": "2PbE...",
  "fileName": "IMG_1234.jpg",
  "tags": ["before", "deck-repair"],
  "location": "-33.865,151.209",
  "sizeBytes": 4823011
}

Response (201):

{
  "pictureId": "2PbE...",
  "uploadUrl": "https://s3.ap-southeast-2.amazonaws.com/...",
  "expiresIn": 3540
}

expiresIn is how many seconds the upload URL stays valid, usually just under an hour. Read it rather than assuming a fixed value.

Once the PUT succeeds the photo is immediately findable by tag. There is no separate “finish upload” call.

Errors:


GET /photos/:pictureId/download

A presigned download URL for the full-resolution photo.

Response (200):

{
  "downloadUrl": "https://s3.ap-southeast-2.amazonaws.com/...",
  "expiresIn": 3600
}

Errors: 403 (no access to the album), 404 (photo not found), 401 account_deleted, 402 trial_expired.


PATCH /photos/:pictureId

Update some of a photo’s details, or delete it. Only the fields you send are changed. Requires CONTRIBUTOR access or higher.

Request (every field optional):

{
  "nameOverride": "Beach panorama",
  "description": "South-facing dunes",
  "timestamp": "2v1m...",
  "location": "-33.8568, 151.2153",
  "imageVersion": "2",
  "tags": ["beach", "dunes"],
  "deleted": "2v9k0AbCdEfGhIjKlMnOpQrStUv"
}

An empty body ({}) is valid and changes nothing.

Response (200):

{
  "pictureId": "2PbE...",
  "updated": ["description", "tags"],
  "deleted": "2v9k0AbCdEfGhIjKlMnOpQrStUv"
}

updated lists the fields that actually changed. deleted appears only when the request deleted the photo.

Errors: 400 (a field has the wrong type), 403 (the user’s access is too low for the change), 404 (photo not found or already deleted, for any change other than deleting), 405 (a method other than PATCH), 401 account_deleted, 402 trial_expired.


PATCH /photos/:pictureId/tags

Replace the tags on a photo.

Request:

{ "tags": ["before", "deck-repair", "site-a"] }

Tags are stored in lowercase. Removing a tag someone else added requires ADMIN or OWNER.

Response (200):

{ "pictureId": "2PbE...", "tags": ["before", "deck-repair", "site-a"] }

Errors: 403 (the user can’t remove one of the tags), 404 (photo not found), 401 account_deleted, 402 trial_expired.


GET /me/profile

The signed-in user’s name and email.

Response (200):

{ "email": "alex@example.com", "firstName": "Alex", "lastName": "Smith" }

Errors: 401 account_deleted, 402 trial_expired.


PATCH /me/profile

Change the signed-in user’s name. The email address is the user’s sign-in identity and can’t be changed here.

Request:

{ "firstName": "Alex", "lastName": "Smith" }

Both fields are required and can’t be blank.

Response (200): the updated profile, in the same shape as GET /me/profile.

Errors: 400 (a name is missing or blank), 405 (a method other than GET or PATCH), 401 account_deleted, 402 trial_expired.


GET /me/plan (no trial gate)

The user’s plan, usage and limits. This works even after the trial has ended, so your app can show the user where they stand.

Response (200):

{
  "userId": "usr_abc123",
  "tier": "PERSONAL",
  "billingMode": "TRIAL",
  "signupSource": "MANUAL",
  "usage": {
    "photos": 412,
    "bytes": 2147483648,
    "collaborators": 0,
    "pendingInvites": 0,
    "computedAt": "2026-04-20T10:00:00.000Z"
  },
  "limits": {
    "maxPhotos": 1000,
    "maxBytes": 5368709120,
    "maxCollaborators": 0
  },
  "percentages": {
    "photos": 0.412,
    "bytes": 0.4,
    "collaborators": 1
  },
  "trial": {
    "expiresAt": "2026-05-05T00:00:00.000Z",
    "daysRemaining": 14
  }
}

Errors: 401 account_deleted. Never returns 402 trial_expired.


GET /me/invitations

Pending invitations addressed to the signed-in user’s email.

Response (200):

{
  "invitations": [
    {
      "invitationId": "inv_xyz789",
      "albumId": "2PbE...",
      "albumName": "Site Photos 2026",
      "ownerId": "usr_def456",
      "inviterName": "Alex Smith",
      "accessLevel": "CONTRIBUTOR",
      "sentAt": "2026-04-18T00:00:00.000Z",
      "expiresAt": "2026-04-25T00:00:00.000Z"
    }
  ]
}

Errors: 401 account_deleted, 402 trial_expired.


POST /me/invitations/accept-by-token

Accept an invitation from the link in the invitation email, for when your app opens that link. The token is the last part of the link, https://palixo.media/app/invite/<token>.

Holding a valid token for an open invitation is enough: the signed-in user’s email does not have to match the invitee’s. Accepting has the same effect as POST /me/invitations/:invitationId/accept.

Request:

{ "token": "<token from the invitation link>" }

Response (200):

{
  "success": true,
  "albumId": "2PbE...",
  "albumName": "Site Photos 2026",
  "accessLevel": "CONTRIBUTOR"
}

Invitation errors carry a machine-readable errorCode, with the human-readable message in error:

{ "success": false, "error": "This invitation has expired.", "errorCode": "EXPIRED" }
HTTPerrorCodeWhen
404INVALID_TOKENNo invitation has this token
410EXPIREDThe invitation has expired
410ALREADY_ACCEPTEDThe invitation was already accepted
410CANCELLEDThe sender cancelled it, or the invitee declined it
500VALIDATION_ERRORThe token couldn’t be checked; try again

Other errors:


POST /me/invitations/:invitationId/accept

Accept a pending invitation. A user on a trial moves to the free Personal plan when they accept.

Response (200):

{ "success": true, "albumId": "2PbE..." }

Errors use the same shape as accept-by-token:

HTTPerrorCodeWhen
404NOT_FOUNDNo open invitation with this id is addressed to the user
410ALREADY_ACCEPTEDIt was accepted at the same moment, for example by a second tap
410CANCELLEDIt was cancelled or declined at the same moment
410EXPIREDIt expired at the same moment

Also 409 already_owner (as above), 401 account_deleted and 402 trial_expired.


POST /me/invitations/:invitationId/decline

Decline a pending invitation.

Response (200):

{ "success": true }

Errors: 404 NOT_FOUND (no open invitation with this id is addressed to the user), 401 account_deleted, 402 trial_expired.


GET /me/collaborators

Everyone with access to the albums the user owns, with their access per album, plus pending invitations. Albums the user administers but doesn’t own aren’t included; use GET /albums/:albumId/members for those.

Response (200):

{
  "pool": [
    {
      "email": "alex@example.com",
      "userId": "usr_def456",
      "grants": [
        { "albumId": "2PbE...", "access": "ADMIN", "source": "DIRECT" },
        { "albumId": "2PbF...", "access": "VIEWER", "source": "DIRECT" }
      ]
    },
    {
      "email": "pending@example.com",
      "grants": [],
      "pending": [
        { "invitationId": "inv_xyz789", "albumId": "2PbE...", "accessLevel": "CONTRIBUTOR" }
      ]
    }
  ]
}

userId is present once the collaborator has a Palixo account and has accepted an invitation.

Errors: 401 account_deleted, 402 trial_expired.


POST /me/collaborators/invite

Invite someone to one or more albums. Inviting a new person uses one of the user’s collaborator places; inviting someone who is already a collaborator does not.

Request:

{
  "email": "newperson@example.com",
  "name": "New Person",
  "albumIds": ["2PbE...", "2PbF..."],
  "accessLevel": "CONTRIBUTOR"
}

Response (200):

{ "success": true, "sent": 2, "skipped": [], "alreadyMember": [] }

Errors:


DELETE /me/collaborators/:email

Remove a collaborator from all of the user’s albums and cancel any pending invitations to them. Access that comes through an organisation, and the user’s own Owner access, are never removed.

Response (204): empty body.

Errors: 400 cannot_remove_self, 401 account_deleted, 402 trial_expired.


POST /me/collaborators/:email/grants

Give an existing collaborator access to more albums, at one access level for all of them. Doesn’t use a collaborator place.

Request:

{ "albumIds": ["2PbG...", "2PbH..."], "accessLevel": "VIEWER" }

Response (200): a result for each album, so you don’t need to fetch again.

{
  "success": true,
  "granted": 1,
  "requestedAccessLevel": "VIEWER",
  "results": [
    { "albumId": "2PbG...", "status": "applied", "accessLevel": "VIEWER" },
    { "albumId": "2PbH...", "status": "skipped", "reason": "target_is_owner" }
  ]
}
statusreasonMeaning
applied—Access granted at the requested level.
skippednot_grantableThe user can’t share this album, or asked for ADMIN on an album they don’t own.
skippedtarget_is_ownerThe collaborator owns this album; an owner’s access never changes.
skippedtarget_is_adminThe collaborator is an Admin on an album the user administers but doesn’t own. Only the owner can change an Admin’s access.
failedgrant_failedThe album changed while the request ran. The other albums’ results stand.

A 200 is returned even when no album was granted (success: false).

Errors (nothing has changed):


PATCH /me/collaborators/:email/grants/:albumId

Change a collaborator’s access to one album the user owns. A pending invitation is updated in place.

Request:

{ "accessLevel": "ADMIN" }

Response (200):

{ "success": true }

Errors: 404 (collaborator or access not found), 401 account_deleted, 402 trial_expired.


DELETE /me/collaborators/:email/grants/:albumId

Remove a collaborator’s access to one album the user owns, and cancel any pending invitation to it. They stay a collaborator on the user’s other albums.

Response (204): empty body.

Errors: 404 (not found), 401 account_deleted, 402 trial_expired.


Error handling

Errors return success: false with a machine-readable code in error:

{ "success": false, "error": "<code>", "message": "<description>" }

The invitation endpoints are the exception: there, error holds the message and the code is in errorCode.

HTTPCodeExtra fieldsWhen
400(varies)—Invalid or missing parameters
401(varies)—Missing, invalid or expired token
401account_deleted—The user’s account has been deactivated
402trial_expiredcontactEmailThe user’s trial has ended
402limit_exceededlimitKind: "photos", "bytes" or "collaborators"The action would take the user over a plan limit
403(varies)—The user’s access is too low
404(varies)—Not found
409(varies)—Conflicts with the current state
410(message)errorCodeThe invitation is no longer open
413payload_too_largemaxBytesThe request body is over 256 KB
500(varies)—Server error

Don’t rely on message text in code; it may change.

account_deleted (401)

{ "success": false, "error": "account_deleted", "message": "This account has been deactivated." }

Final. Sign the user out and direct them to support. Don’t retry.

trial_expired (402)

{
  "success": false,
  "error": "trial_expired",
  "message": "Your trial has ended. Contact sales@palixo.media to continue.",
  "contactEmail": "sales@palixo.media"
}

The user’s sign-in is still valid. Call GET /me/plan to show their trial status, and offer contactEmail. Calls succeed again as soon as the user’s plan changes or they accept an invitation.

limit_exceeded (402)

{
  "success": false,
  "error": "limit_exceeded",
  "limitKind": "photos",
  "message": "You've reached your photo limit."
}
limitKindReturned by
"photos"POST /photos when the user is at their photo limit
"bytes"POST /photos when the photo would take the user over their storage limit
"collaborators"POST /me/collaborators/invite for a new person when every collaborator place is used

Tell the user which limit they hit, and call GET /me/plan for the exact numbers.


Common workflows

Upload a photo

  1. Generate a KSUID for pictureId.
  2. POST /photos with albumId, pictureId, fileName, tags and sizeBytes.
  3. PUT the image file to the returned uploadUrl.
  4. The photo is now findable with GET /photos?tag=....

Find photos for a job

  1. Tag photos with the job’s identifier when you upload them, e.g. job-1042.
  2. GET /photos?tag=job-1042, following nextCursor while hasMore is true.
  3. GET /photos/:pictureId/download for each photo you need to show.

Accept an invitation

  1. GET /me/invitations to list them.
  2. POST /me/invitations/:invitationId/accept, or, when your app opened an invitation link, POST /me/invitations/accept-by-token.
  3. The album appears in GET /albums.

Versioning

The API is not versioned in the path. Changes are additive only; a breaking change would come with a new /mobile-api/v2/ path.


Support

For API support or questions, email support@palixo.media.