Overview
The Palixo API is a tag-based JSON API for building your own apps and integrations on Palixo. With it you can:
- Upload photos with tags, and find photos by tag
- List the albums a user can access, and who is on each one
- Read a user’s plan and usage
- Accept and decline album invitations
- Manage a user’s collaborators and their access to each album
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
- Albums are created in the Palixo web app; this API does not create or rename albums.
- It is not an offline sync protocol. Each call works against the server directly.
Base URL
| Environment | URL |
|---|---|
| Production | https://palixo.media/mobile-api |
| UAT | https://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>
- ID tokens expire after 1 hour.
- Refresh tokens expire after 30 days.
- Refresh before expiry to avoid a 401 part-way through a call.
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
| Method | Path | Purpose |
|---|---|---|
| GET | /health | Liveness check (no auth) |
| GET | /albums | List albums the user can access |
| GET | /albums/:albumId/members | Members and pending invitations for an album |
| GET | /photos | Find photos by tags (paginated) |
| POST | /photos | Create a photo; returns an upload URL |
| PATCH | /photos/:pictureId | Update photo details, or delete the photo |
| GET | /photos/:pictureId/download | Download URL for the full-resolution photo |
| PATCH | /photos/:pictureId/tags | Replace the tags on a photo |
| GET | /me/profile | The user’s name and email |
| PATCH | /me/profile | Change the user’s name |
| GET | /me/plan | Plan, usage and limits (no trial gate) |
| GET | /me/invitations | Pending invitations addressed to the user |
| POST | /me/invitations/accept-by-token | Accept an invitation from its link |
| POST | /me/invitations/:invitationId/accept | Accept a pending invitation |
| POST | /me/invitations/:invitationId/decline | Decline a pending invitation |
| GET | /me/collaborators | The user’s collaborators and their access per album |
| POST | /me/collaborators/invite | Invite someone to albums |
| DELETE | /me/collaborators/:email | Remove a collaborator from every album |
| POST | /me/collaborators/:email/grants | Give a collaborator access to more albums |
| PATCH | /me/collaborators/:email/grants/:albumId | Change a collaborator’s access to one album |
| DELETE | /me/collaborators/:email/grants/:albumId | Remove 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:
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
tag | string[] | Yes | — | Repeat the parameter or comma-separate the values. |
limit | number | No | 50 | Maximum 200. |
after | string | No | — | 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
}
pictureId: a KSUID your app generates.sizeBytes: strongly recommended. It counts toward the user’s storage limit; without it, the user’s storage usage may lag until it is next recalculated.location: optional free-form text, such as"lat,lng"or a place name.
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:
- 400: missing fields or an invalid
pictureId - 401
account_deleted - 402
trial_expired - 402
limit_exceededwithlimitKind: "photos": the user is at their photo limit - 402
limit_exceededwithlimitKind: "bytes": the photo would take the user over their storage limit
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"
}
nameOverride(string ornull): a display name for the photo;nullclears it.description(string).timestamp(KSUID): when the photo was taken, which sets its place in date order.location(string): free-form, such as"lat, lng".imageVersion(string): a whole-number counter sent as a string ("0","1","2"…). Increase it after replacing the image so cached copies refresh. Anything other than a whole number is a 400.tags(string[]): replaces all of the photo’s tags; stored in lowercase. Removing a tag someone else added requires ADMIN or OWNER.deleted(KSUID): the time of deletion, chosen by your app so deletes made offline keep their real time. Requires ADMIN or OWNER, or CONTRIBUTOR for a photo the user added themselves. Deleted photos go to the album’s recycle bin. Deleting an already-deleted photo returns 200 withupdated: []and the original deletion time. This endpoint can’t restore a deleted photo.
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
}
}
limits.*:nullmeans the plan has no limit.percentages.*: usage as a fraction of the limit, from 0 upward. Over 1 means the user is past the limit.0means no usage or no limit. A limit of 0 with any usage returns 1.trial: only present while the user is on a trial.daysRemainingnever goes below 0.
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" }
| HTTP | errorCode | When |
|---|---|---|
| 404 | INVALID_TOKEN | No invitation has this token |
| 410 | EXPIRED | The invitation has expired |
| 410 | ALREADY_ACCEPTED | The invitation was already accepted |
| 410 | CANCELLED | The sender cancelled it, or the invitee declined it |
| 500 | VALIDATION_ERROR | The token couldn’t be checked; try again |
Other errors:
- 400:
tokenmissing or empty. - 409
already_owner: the user owns this album already. Nothing changes and the invitation stays pending, so it can still be declined. - 401
account_deleted, 402trial_expired.
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:
| HTTP | errorCode | When |
|---|---|---|
| 404 | NOT_FOUND | No open invitation with this id is addressed to the user |
| 410 | ALREADY_ACCEPTED | It was accepted at the same moment, for example by a second tap |
| 410 | CANCELLED | It was cancelled or declined at the same moment |
| 410 | EXPIRED | It 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"
}
accessLevel:VIEWER,CONTRIBUTORorADMIN. Only an album’s owner can invite someone asADMIN.albumIds: at least one album the user owns or is an Admin on. Albums that don’t qualify are listed inskipped, and the rest of the request goes ahead.
Response (200):
{ "success": true, "sent": 2, "skipped": [], "alreadyMember": [] }
sent: the number of invitations sent.skipped: albums not invited to, because the album doesn’t exist, isn’t shareable, the user can’t invite to it, orADMINwas asked for on an album the user doesn’t own.alreadyMember: albums the invitee can already access or already has a pending invitation for.successistrueonly when at least one invitation was sent.
Errors:
- 400: missing
email, missing or emptyalbumIds, or an invalidaccessLevel - 409
already_collaborator: nothing was sent, because the invitee already has access or a pending invitation on every album the user could invite to - 402
limit_exceededwithlimitKind: "collaborators" - 401
account_deleted, 402trial_expired
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" }
accessLevel:VIEWER,CONTRIBUTORorADMIN(any case). Only an album’s owner can grantADMIN.
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" }
]
}
status | reason | Meaning |
|---|---|---|
applied | — | Access granted at the requested level. |
skipped | not_grantable | The user can’t share this album, or asked for ADMIN on an album they don’t own. |
skipped | target_is_owner | The collaborator owns this album; an owner’s access never changes. |
skipped | target_is_admin | The collaborator is an Admin on an album the user administers but doesn’t own. Only the owner can change an Admin’s access. |
failed | grant_failed | The 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):
- 400: malformed body, empty
albumIds, or an invalidaccessLevel - 422
user_not_registered: the email has no Palixo account yet; send an invitation instead - 503
shareable_albums_unavailable: temporary; try again - 401
account_deleted, 402trial_expired
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.
| HTTP | Code | Extra fields | When |
|---|---|---|---|
| 400 | (varies) | — | Invalid or missing parameters |
| 401 | (varies) | — | Missing, invalid or expired token |
| 401 | account_deleted | — | The user’s account has been deactivated |
| 402 | trial_expired | contactEmail | The user’s trial has ended |
| 402 | limit_exceeded | limitKind: "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) | errorCode | The invitation is no longer open |
| 413 | payload_too_large | maxBytes | The 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."
}
limitKind | Returned 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
- Generate a KSUID for
pictureId. POST /photoswithalbumId,pictureId,fileName,tagsandsizeBytes.PUTthe image file to the returneduploadUrl.- The photo is now findable with
GET /photos?tag=....
Find photos for a job
- Tag photos with the job’s identifier when you upload them, e.g.
job-1042. GET /photos?tag=job-1042, followingnextCursorwhilehasMoreis true.GET /photos/:pictureId/downloadfor each photo you need to show.
Accept an invitation
GET /me/invitationsto list them.POST /me/invitations/:invitationId/accept, or, when your app opened an invitation link,POST /me/invitations/accept-by-token.- 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.