PocketPass Developer
api.pocketpass.xyz

Build with PocketPass

Let people connect their PocketPass account to your website or app, then read their profile, list their friends, read and send their messages and see their notifications — with their permission, on their behalf.

PocketPass is a friend-finding and messaging app for handheld consoles and phones. The public API is a curated JSON API behind standard OAuth 2.1: register an app in the developer portal, send users through the consent flow, and call POST https://api.pocketpass.xyz/v1/<resource>.<action> with the token you get back.

Overview

Everything runs against one host, https://api.pocketpass.xyz:

WhatWhere
Authorization endpointGET /auth/v1/oauth/authorize
Token endpointPOST /auth/v1/oauth/token
OpenID userinfoGET /auth/v1/oauth/userinfo
Discovery documentGET /auth/v1/.well-known/openid-configuration
PocketPass APIPOST /v1/<resource>.<action>
Media (avatars, attachments)/storage/v1/object/…
Realtime (push)wss://api.pocketpass.xyz/realtime/v1/websocket
Consent screen (shown to users)https://links.pocketpass.xyz/oauth/consent
Connected apps (users manage grants)https://links.pocketpass.xyz/oauth/apps

The flow in one paragraph: your app sends the user to the authorization endpoint with a PKCE challenge. PocketPass signs the user in (email code or Discord) and shows a consent screen listing your app and the permissions you registered. On approval the user is redirected back to you with a one-time code, which you exchange for an access token (valid one hour) and a refresh token. From then on you call the API with Authorization: Bearer <access token>.

A few things that make this API different from a generic backend:

  • Permissions are app-level. You choose the PocketPass scopes when you register the app; every user of your app grants the same set. The OAuth scope= parameter only covers OpenID claims (see Scopes).
  • Every request is a POST with a JSON object body, even reads, and every response is a JSON object. Errors are JSON objects too, with a stable code and hint.
  • The token only works on the public API. A connected app cannot reach the first-party endpoints PocketPass itself uses or change the account. What you can do is exactly what is listed in the endpoint reference, plus media and Realtime with the same token and the same scopes.
  • Users can disconnect you at any time and PocketPass can suspend an app. Your code needs to handle a token that suddenly stops working (see Tokens).

Getting started

1. Create a PocketPass account

The portal signs you in with a normal PocketPass account — there is no separate developer login. If you do not have one yet, download PocketPass, create the account in the app (email code or Discord), then sign in at developer.pocketpass.xyz with the same email or Discord account.

2. Register an app

Every account can register up to five apps. An app has:

  • Name, description, website and logo URL — shown on the consent screen so users know who is asking. Names cannot contain "PocketPass". Website and logo must be https:// URLs.
  • Redirect URIs — where users are sent back after consent. Exact-match only; see Redirect URIs.
  • Client type — public or confidential (below). Fixed at registration.
  • Permissions — the PocketPass scopes your app needs. Pick the smallest set that works; adding a scope later disconnects your existing users (see Changing scopes).

Registration gives you a client id (a UUID) and, for confidential clients, a client secret that is shown exactly once. Copy it straight into your server's secret store. If you lose it, rotate it from the app page; the old secret stops working the moment you do.

3. Choose the right client type

TypeUse it forToken endpoint authentication
Public Websites and single-page apps, browser extensions, desktop and mobile apps, CLI tools — anything that ships to the user's device and therefore cannot keep a secret. None. PKCE protects the code exchange. Send client_id in the request body.
Confidential Server-side apps where the token exchange happens on a machine you control (a web backend, a bot, a worker). HTTP Basic with client_id as the username and the client secret as the password, on every token request. PKCE is still required.

When in doubt pick public: a confidential client that leaks its secret is worse than a public one that never had it. PKCE is mandatory for both.

4. Test it

The app page in the portal has a Test connect button. It generates a PKCE verifier and state in your browser, opens the consent flow for your first redirect URI in a new tab, and shows you the exact curl commands to exchange the code and call session.get. Use it before writing any code so you know what the redirect looks like.

Connecting a user

The connect flow is a standard OAuth 2.1 authorization code flow with PKCE (S256). Requests without a PKCE challenge are refused at the gateway.

Step 1 — generate a verifier, a challenge and a state

Create 32 random bytes and base64url-encode them: that is your code_verifier. Hash it with SHA-256 and base64url-encode the digest: that is your code_challenge. Generate a second random string as state. Keep both the verifier and the state somewhere tied to this user's session (a cookie, sessionStorage, a row in your database) — you need them when the user comes back.

Step 2 — send the user to the authorization endpoint

GET https://api.pocketpass.xyz/auth/v1/oauth/authorize
    ?client_id=<your client id>
    &redirect_uri=<one of your registered redirect URIs>
    &response_type=code
    &scope=openid
    &code_challenge=<challenge>
    &code_challenge_method=S256
    &state=<state>

Open this URL in the user's browser (a full navigation on the web, the system browser or a custom tab on mobile and desktop — never a web view you control). PocketPass redirects to the consent screen at https://links.pocketpass.xyz/oauth/consent, where the user signs in if needed and sees your app's name, website, description and the PocketPass permissions you registered. If the user already has a live grant for your app the screen is skipped and they land straight back on your redirect URI.

Step 3 — handle the redirect

On approval the browser lands on your redirect_uri with ?code=…&state=…. On denial it lands there with ?error=access_denied&state=…. Always check that state matches the value you stored before doing anything with the code.

Step 4 — exchange the code for tokens

Codes are single-use and expire ten minutes after they were issued. Send the exchange as application/x-www-form-urlencoded (JSON with Content-Type: application/json is accepted too):

POST https://api.pocketpass.xyz/auth/v1/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<code from the redirect>
&redirect_uri=<the same redirect_uri as in step 2>
&code_verifier=<verifier from step 1>
&client_id=<your client id>            (public clients only)

Confidential clients leave client_id out of the body and send Authorization: Basic base64(client_id:client_secret) instead. The response:

{
  "access_token": "eyJhbGciOiJIUzI1NiIs…",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "…",
  "id_token": "eyJhbGciOiJIUzI1NiIs…"
}

Store the access token and the refresh token per user. You can ignore id_token; the user id it carries is also returned by session.get and me.get, which is the reliable way to know who just connected.

Step 5 — check the connection

curl -X POST https://api.pocketpass.xyz/v1/session.get \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

session.get needs no scope and returns the user id, your client id, the scopes the user granted and your remaining rate-limit budget. It is the right first call after every connect and a good health check afterwards.

Website example (public client, browser fetch)

Two functions: one that starts the flow, one that runs on your callback page.

const AUTH = "https://api.pocketpass.xyz/auth/v1";
const CLIENT_ID = "your-client-id";
const REDIRECT_URI = "https://example.com/pocketpass/callback";

const base64url = (bytes) =>
  btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

async function startConnect() {
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
  sessionStorage.setItem("pp_connect", JSON.stringify({ verifier, state }));
  const params = new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    response_type: "code",
    scope: "openid",
    code_challenge: base64url(new Uint8Array(digest)),
    code_challenge_method: "S256",
    state,
  });
  location.assign(`${AUTH}/oauth/authorize?${params}`);
}

async function finishConnect() {
  const query = new URLSearchParams(location.search);
  const saved = JSON.parse(sessionStorage.getItem("pp_connect") || "null");
  sessionStorage.removeItem("pp_connect");
  if (query.get("error")) throw new Error(query.get("error_description") || query.get("error"));
  if (!saved || query.get("state") !== saved.state) throw new Error("state mismatch");
  const response = await fetch(`${AUTH}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      client_id: CLIENT_ID,
      code: query.get("code"),
      redirect_uri: REDIRECT_URI,
      code_verifier: saved.verifier,
    }),
  });
  const tokens = await response.json();
  if (!response.ok) throw new Error(tokens.error_description || tokens.error);
  return tokens;
}

The API sends CORS headers, so a browser app can call /v1/… directly with the access token. Remember that anything in the browser is visible to the user, which is why a website is a public client.

Native app example (public client, loopback redirect)

Desktop and CLI apps listen on a loopback port and register http://127.0.0.1:<port>/callback as the redirect URI. Node.js, using only built-in modules; openBrowser is whatever your platform uses to open a URL in the default browser.

import http from "node:http";
import crypto from "node:crypto";

const AUTH = "https://api.pocketpass.xyz/auth/v1";
const CLIENT_ID = "your-client-id";
const REDIRECT_URI = "http://127.0.0.1:8765/callback";

const verifier = crypto.randomBytes(32).toString("base64url");
const state = crypto.randomBytes(16).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");

const server = http.createServer(async (request, response) => {
  const url = new URL(request.url, REDIRECT_URI);
  if (url.pathname !== "/callback") {
    response.writeHead(404).end();
    return;
  }
  response.end("PocketPass is connected. You can close this tab.");
  server.close();
  if (url.searchParams.get("state") !== state) throw new Error("state mismatch");
  const exchange = await fetch(`${AUTH}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      client_id: CLIENT_ID,
      code: url.searchParams.get("code"),
      redirect_uri: REDIRECT_URI,
      code_verifier: verifier,
    }),
  });
  const tokens = await exchange.json();
  if (!exchange.ok) throw new Error(tokens.error_description || tokens.error);
  saveTokens(tokens);
});

server.listen(8765, "127.0.0.1", () => {
  const params = new URLSearchParams({
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    response_type: "code",
    scope: "openid",
    code_challenge: challenge,
    code_challenge_method: "S256",
    state,
  });
  openBrowser(`${AUTH}/oauth/authorize?${params}`);
});

Mobile apps do the same with a custom scheme (com.example.app:/callback) or an https App Link / Universal Link, opened in the system browser or an in-app browser tab.

Server-side example (confidential client)

The browser part is identical; only the exchange changes. Run it on your server with the secret from your secret store:

curl -X POST https://api.pocketpass.xyz/auth/v1/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d "code=$CODE" \
  -d "redirect_uri=$REDIRECT_URI" \
  -d "code_verifier=$VERIFIER"

Redirect URIs

The redirect_uri in the authorization request and in the token exchange must match one of the URIs registered for your app exactly — same scheme, host, port, path, and no extra query or fragment. Wildcards and prefix matching are not supported.

  • https URIs are allowed for any host.
  • Custom schemes (com.example.app:/callback) are allowed for native apps.
  • http is allowed only for localhost, 127.0.0.1 and [::1]. Because the match is exact, register the port you will actually listen on — http://127.0.0.1:8765/callback, not http://127.0.0.1/callback — and pin that port in your app. If you are unsure which name the platform will use, register both http://localhost:8765/callback and http://127.0.0.1:8765/callback.
  • Rejected: javascript:, data:, file:, blob:, about:, vbscript:; URIs with a fragment (#), a comma, whitespace or control characters; anything without a scheme and host.
  • At most 10 URIs per app, each at most 2048 characters.

You can add and remove redirect URIs at any time from the app page; the change is live immediately and does not disconnect anyone.

Scopes

There are two kinds of scopes and it is important not to mix them up.

PocketPass scopes (chosen in the portal)

These decide what your app may do through /v1/…. You pick them when you register the app; they apply to every user of the app and are listed on the consent screen. They are not sent in the scope= parameter — the authorization server ignores them there.

ScopeShown to the user asUnlocks
profile:readSee your profile (name, bio, avatar, age, country)me.get, profiles.get, profiles.get_many, avatar downloads
friends:readSee your friends list and friend requestsfriends.list, friends.requests_list, the friends: Realtime topic
friends:writeAdd and remove friends and answer friend requests as youfriends.request_send, friends.request_respond, friends.request_cancel, friends.remove, friends.code_get, friends.code_resolve
messages:readRead your conversations and messagesconversations.list, conversations.get, messages.list, attachment downloads, the conversation: Realtime topics
messages:writeSend, edit and delete messages as youconversations.open, conversations.mark_read, messages.send (text and images), messages.edit, messages.delete, image uploads
notifications:readSee and clear your notificationsnotifications.list, notifications.mark_read, notifications.delete, the notifications: Realtime topic

session.get and session.revoke need no scope. Calling an endpoint your app does not have the scope for returns PT403 SCOPE_REQUIRED.

OpenID scopes (the scope= parameter)

The scope= parameter of the authorization request is OpenID Connect only. Send scope=openid. Add email, profile or phone only if you intend to call /auth/v1/oauth/userinfo and need those claims — each one is shown to the user as an extra thing you will see ("your email address", "your account name and picture", "your phone number"), and the consent screen refuses the request if you ask for an OpenID scope the user cannot grant. For almost every app me.get gives you what you need without asking for any of them.

OpenID scopeShown to the user asUserinfo claims
openid(always)sub — the PocketPass user id
emailyour email addressemail, email_verified
profileyour account name and picturename, picture, preferred_username, updated_at
phoneyour phone numberphone, phone_verified

Tokens

Lifetimes

  • Access token: a JWT valid for one hour (expires_in: 3600). Send it as Authorization: Bearer …. Treat it as opaque; do not rely on its claims.
  • Refresh token: long-lived and rotating. Every refresh returns a new refresh token and invalidates the one you used. Always persist the newest pair before you use it; if two of your processes race to refresh the same token, the second one fails and you have to reconnect the user.
  • Authorization code: single-use, ten minutes.

Refreshing

POST https://api.pocketpass.xyz/auth/v1/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=<refresh token>
&client_id=<your client id>            (public clients; confidential clients use HTTP Basic instead)

Refresh a little before expiry (for example when fewer than 60 seconds remain) or on the first 401, then retry the request once. A refresh that answers 400 with "error": "invalid_grant" means the grant is gone — the user disconnected your app, PocketPass suspended it, you changed the app's scopes, or the refresh token was already rotated. Drop the stored tokens and offer the user the connect flow again. invalid_client means your client id, secret or client type is wrong.

Revocation

Three things end a connection, and all of them take effect within one request:

  • The user disconnects you on https://links.pocketpass.xyz/oauth/apps. Existing access tokens answer PT401 CONSENT_REVOKED, refresh fails with invalid_grant.
  • Your app calls session.revoke (the "disconnect" button in your own UI). Same effect, scoped to that user. Discard the tokens afterwards; the call is idempotent, so retrying is safe.
  • PocketPass suspends the app. Every call answers PT403 APP_SUSPENDED and the token endpoint refuses the client.

Connected apps cannot use the account endpoints PocketPass itself uses: /auth/v1/logout, /auth/v1/user, multi-factor and grant-management endpoints all answer 403 for an app token. Disconnecting is POST /v1/session.revoke, nothing else.

Never put an access token in a URL, a log line or client-side analytics. If you suspect a token or secret leaked, rotate the secret in the portal (confidential clients) and have affected users disconnect and reconnect.

Calling the API

Every endpoint is POST https://api.pocketpass.xyz/v1/<resource>.<action> with these headers and a JSON object body — {} when the endpoint takes no arguments:

POST /v1/conversations.list HTTP/1.1
Host: api.pocketpass.xyz
Authorization: Bearer <access token>
Content-Type: application/json

{ "limit": 20 }

Rules that hold everywhere:

  • Only POST (and OPTIONS for CORS preflight). A GET is a 404.
  • The body must be a JSON object. Unknown keys are rejected with PT400 UNKNOWN_FIELD, so typos fail loudly instead of silently doing nothing.
  • Every response body is a JSON object. Successful responses are 200. Errors carry a 4xx/5xx status and a body { "code", "message", "hint" } — branch on hint, show message to a developer, never to an end user.
  • Lists return { "items": [...], "next_cursor": null | "…" }. Pass next_cursor back as cursor to get the next page; null means you have everything. Cursors are opaque strings — do not build or parse them.
  • limit is clamped to 1…100 and defaults to 50.
  • Single objects come wrapped in their resource name: { "profile": {…} }, { "conversation": {…} }, { "message": {…} }.
  • Timestamps are ISO 8601 with a timezone offset; ids are UUIDs.
  • An unknown path under /v1/ answers 404 with { "code": "PT404", "hint": "UNKNOWN_ENDPOINT" }.

Paging through a conversation, newest first:

let cursor = null;
do {
  const page = await api("messages.list", { conversation_id, limit: 100, cursor });
  render(page.items);
  cursor = page.next_cursor;
} while (cursor);

A minimal client that handles the envelope:

async function api(endpoint, body = {}) {
  const response = await fetch(`https://api.pocketpass.xyz/v1/${endpoint}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const data = await response.json();
  if (!response.ok) {
    const error = new Error(data.message || `HTTP ${response.status}`);
    error.code = data.code;
    error.hint = data.hint;
    error.retryAfter = Number(response.headers.get("Retry-After")) || 0;
    throw error;
  }
  return data;
}

Endpoint reference

Optional body keys are marked with ?. Shapes of the returned objects are in Objects.

EndpointScopeBodyReturns
session.getnone{} { user_id, client_id, app: { name }, scopes: [...], rate_limit: { per_minute, remaining, reset_at }, unread_total }
session.revokenone{} { revoked: true | false } — disconnects your app for this user and deletes its sessions; false when there was nothing left to revoke. Discard the tokens afterwards.
me.getprofile:read{} { profile } — the connected user's own profile, including setup_complete
profiles.getprofile:read{ user_id } { profile } — only the user themself, their friends and people they share an active conversation with; anyone else is PT404 PROFILE_NOT_FOUND. There is no directory lookup.
profiles.get_manyprofile:read{ user_ids: [...] } (1…100) { items: [profile, ...] } — same visibility rule; ids the user may not see are simply left out
friends.listfriends:read{} { items: [friend, ...] } — the whole list, no paging
friends.requests_listfriends:read{} { incoming: [friend_request, ...], outgoing: [friend_request, ...] } — pending requests only, newest first
friends.request_sendfriends:write{ user_id, client_operation_id } { request: friend_request } — if that person already asked, their pending request is returned instead (check requester.user_id). PT409 ALREADY_FRIENDS, PT403 BLOCKED, PT400 SELF_TARGET. Get user_id from friends.code_resolve or from a shared conversation; there is no search.
friends.request_respondfriends:write{ request_id, accept, client_operation_id } { request: friend_request } with status accepted or declined; accepting creates the friendship. Only the addressee may answer (PT403 NOT_ADDRESSEE); an answered request is PT409 REQUEST_CLOSED.
friends.request_cancelfriends:write{ request_id } { request: friend_request } with status cancelled; idempotent. Only the requester may cancel (PT403 NOT_REQUESTER); the other person's notification disappears.
friends.removefriends:write{ user_id, client_operation_id } { removed: true | false }false when they were not friends. Existing conversations stay readable; conversations.open needs a new friendship.
friends.code_getfriends:write{} { code } — the user's own 8-digit friend code, the same one PocketPass shows them
friends.code_resolvefriends:write{ code } { profile } of the person behind an 8-digit code, ready for friends.request_send. PT404 CODE_NOT_FOUND covers unused codes, the user's own code and people either side has blocked. Limited to 50 lookups per user per hour (PT429 FRIEND_CODE_RATE_LIMITED).
conversations.listmessages:read{ limit?, cursor?, updated_after? } { items: [conversation, ...], next_cursor } — most recently updated first; updated_after (timestamp) keeps only conversations that changed since then
conversations.getmessages:read{ conversation_id } { conversation }; PT404 CONVERSATION_NOT_FOUND when the user is not a member
conversations.openmessages:write{ user_id, client_operation_id } { conversation } — the direct conversation with that user, created if needed. The two must be friends (PT403 NOT_FRIENDS) and not blocked (PT403 BLOCKED).
conversations.mark_readmessages:write{ conversation_id } { last_read_at }
messages.listmessages:read{ conversation_id, limit?, cursor?, changed_since? } { items: [message, ...], next_cursor } — newest first. With changed_since (timestamp) you get every message created, edited or deleted after that instant, which is how you pick up edits and deletions.
messages.sendmessages:write{ conversation_id, body?, client_operation_id, reply_to_id?, attachment? } { message }body is 1…4000 characters after trimming (PT400 BODY_LENGTH) and may be omitted only with an attachment; attachment is { path, mime_type } of an image you uploaded first (see Media); client_operation_id is required (see Idempotency)
messages.editmessages:write{ message_id, body } { message } — own messages only (PT403 NOT_SENDER); deleted messages cannot be edited (PT409 MESSAGE_DELETED)
messages.deletemessages:write{ message_id } { message } with deleted_at set — own messages only
notifications.listnotifications:read{ limit?, cursor? } { items: [notification, ...], next_cursor } — most recently updated first
notifications.mark_readnotifications:read{ notification_id } { ok: true }
notifications.deletenotifications:read{ notification_id } { ok: true }

Every endpoint checks, in this order: that the token is an app token (PT401 API_TOKEN_REQUIRED), the rate limit (PT429), that the app exists and is active (PT404 APP_NOT_FOUND, PT403 APP_SUSPENDED), that the app has the scope (PT403 SCOPE_REQUIRED) and that the user's grant is still live (PT401 CONSENT_REVOKED). Only then does it look at the body.

Objects

profile

user_id
UUID. The same id appears as sender_id, in members lists and as sub in the id token.
username
Lowercase handle, 3…32 characters, unique.
display_name
What PocketPass shows in the UI.
bio
Up to 280 characters; may be empty.
avatar_path
Path inside the avatars bucket, or null. See Media.
age
Integer or null.
country_code
Two-letter ISO 3166-1 code or null.
created_at, updated_at
Timestamps.
setup_complete
me.get only: whether the user finished account setup in the app.

friend

A profile as above, plus friends_since — when the friendship was accepted.

friend_request

id
UUID; the same id appears as friend_request_id on notifications.
status
"pending", "accepted", "declined" or "cancelled".
requester, addressee
{ user_id, username, display_name, avatar_path }. The connected user is one of the two. While a request is pending, profiles.get works for the other person.
created_at, responded_at
Timestamps; responded_at is null while pending.

conversation

id
UUID.
kind
"direct" today; groups may appear later, so do not assume exactly two members.
title
null for direct conversations — show the other member's display name.
members
Array of { user_id, display_name, avatar_path } for the active members, including the connected user.
last_message
The newest message object, or null.
unread_count
Messages the connected user has not read.
updated_at
Bumped when a message is sent. Edits and deletions do not bump it — see Keeping in sync.

message

id
UUID. For messages your app sent, it is derived from the client_operation_id.
conversation_id, sender_id
UUIDs.
body
The text. Empty once the message is deleted.
reply_to_id
Id of the message being replied to, or null.
attachment
{ path, mime_type } for an image attachment (path inside the message-media bucket), or null. An image-only message has the body "📷", which is what PocketPass itself sends.
created_at
Timestamp.
edited_at
Timestamp of the last edit, or null.
deleted_at
Timestamp when the sender deleted it, or null. Deleted messages stay in the list so you can remove them from your UI.

notification

id
UUID.
kind
"friend_request", "friend_accepted", "message" or "system".
title, body
Ready-to-display text.
actor
{ user_id, display_name, avatar_path } of the person who caused it, or null.
conversation_id
Set for "message" notifications.
event_count
How many events were folded into this notification (for example unread messages in one conversation).
created_at, updated_at, read_at
Timestamps; read_at is null until marked read.

A "friend_request" notification carries friend_request_id; answer it with friends.request_respond (needs friends:write) or leave it for the user to answer in PocketPass.

Errors

Every error is { "code": "PTxxx", "message": "…", "hint": "…" } with a matching HTTP status. The code tells you the class, the hint the exact reason. Both are stable; the message is not.

Status / codeHintMeaningWhat to do
400 PT400MISSING_FIELDA required body key is absent.Fix the request.
INVALID_FIELDA key has the wrong type or value (not a UUID, not a timestamp, list too long…).Fix the request.
UNKNOWN_FIELDThe body contains a key the endpoint does not know.Check the spelling against the reference.
INVALID_CURSORThe cursor was not one this endpoint issued.Restart from the first page.
BODY_LENGTHMessage body is empty or longer than 4000 characters after trimming.Validate before sending.
REPLY_TARGETreply_to_id is not a message of that conversation.Only reply to messages from the same messages.list.
SELF_TARGETA friend endpoint was called with the user's own id.Hide the user from their own friend actions.
INVALID_CODEA friend code that is not eight digits.Validate before sending.
UNSUPPORTED_MEDIAattachment.mime_type is not image/jpeg, image/png or image/webp.Convert the image first.
INVALID_ATTACHMENT_PATHattachment.path is not <user id>/<conversation id>/<file> for this user and conversation.Upload to the documented path and send that exact path back.
401 PT401API_TOKEN_REQUIREDNo bearer token, an expired one, or a token that is not an app token.Refresh and retry once; then reconnect.
CONSENT_REVOKEDThe user disconnected your app, or its scopes changed since they approved it.Delete the stored tokens and offer the connect flow again.
403 PT403APP_SUSPENDEDPocketPass suspended your app.Stop calling; contact PocketPass.
SCOPE_REQUIREDYour app was not registered with the scope this endpoint needs.Add the scope in the portal (this disconnects users) or stop calling the endpoint.
NOT_A_MEMBERThe user is not an active member of that conversation.Refresh your conversation list.
BLOCKEDOne of the two users blocked the other.Show "unavailable".
NOT_FRIENDSconversations.open between users who are not friends.Only open conversations with entries from friends.list.
NOT_SENDEREditing or deleting someone else's message.Only offer edit/delete on messages whose sender_id is the user.
NOT_ADDRESSEEAnswering a friend request that was not sent to the user.Only offer accept/decline on incoming requests.
NOT_REQUESTERCancelling a friend request the user did not send.Only offer cancel on outgoing requests.
404 PT404APP_NOT_FOUNDThe app in the token no longer exists.The app was deleted; nothing to retry.
PROFILE_NOT_FOUNDNo such user, or the connected user may not see them.Treat as unavailable.
CONVERSATION_NOT_FOUNDNo such conversation, or the user is not in it.Refresh your conversation list.
MESSAGE_NOT_FOUNDNo such message in a conversation the user can see.Refresh the message list.
NOTIFICATION_NOT_FOUNDNo such notification for this user.Refresh the notification list.
REQUEST_NOT_FOUNDNo such friend request involving the user.Refresh friends.requests_list.
CODE_NOT_FOUNDNo account the user may see uses that friend code.Tell the user to check the code.
ATTACHMENT_NOT_FOUNDattachment.path was never uploaded.Upload first, then send.
409 PT409DUPLICATE_OPERATION_IDA client_operation_id was reused with a different request.Generate a fresh id per user action; reuse it only for retries of the same action.
MESSAGE_DELETEDEditing a message that has been deleted.Remove it from your UI.
FRIEND_REQUEST_PENDINGThe notification is a friend request the user has not answered yet.Answer it with friends.request_respond or leave it in place.
ALREADY_FRIENDSfriends.request_send to someone who is already a friend.Refresh friends.list.
REQUEST_CLOSEDThe friend request was already accepted, declined or cancelled.Refresh friends.requests_list.
429 PT429API_RATE_LIMITEDToo many requests for this app + user (or for the app as a whole).Wait for the Retry-After seconds, then resume. See Rate limits.
FRIEND_CODE_RATE_LIMITEDMore than 50 friends.code_resolve lookups for this user in an hour.Wait an hour; do not resolve codes on every keystroke.
500 PT500INTERNALSomething failed on our side; the message is deliberately generic.Retry with backoff. If it persists, tell us the time and endpoint.

Errors without a code

  • Gateway rate limit: a 429 whose body has no code field (it looks like { "message": "API rate limit exceeded" }). This is the per-IP backstop in front of the API; back off exactly as for PT429.
  • Token endpoint: OAuth errors use the standard shape { "error": "invalid_grant", "error_description": "…" }.
  • Blocked endpoints: an app token used on anything outside /v1/…, the OAuth token and userinfo endpoints, Storage and Realtime gets 403 with { "error": "oauth_client_forbidden", "error_description": "…" }.
  • Media: Storage answers with its own error objects (for example { "statusCode": "404", "error": "not_found", "message": "Object not found" }). A file the user may not see is a 404 or 400, never a 403.

Rate limits

  • 120 requests per minute per app and user by default. This is the limit you will normally meet. It is a rolling one-minute bucket keyed on your client id plus the user id.
  • A per-app ceiling of 600 requests per minute across all users and a burst cap of 100 requests per second per app by default. The burst cap answers PT429 with Retry-After: 1.
  • Need more? Open the app in the portal and use Request higher limits under Limits: say what the app does, how many users you expect and which limit you are hitting. Requests are reviewed by hand; an approval applies to the app immediately and the decision, with any note, shows on the same page. session.get always reports the limits that apply to your app.
  • Failed requests count. A PT400, a PT403, a PT401 CONSENT_REVOKED and even a call for a suspended app all consume budget, and denied calls are reported as "denied" in the portal's usage table. A retry loop against a broken token will rate-limit itself.
  • A PT429 response carries a Retry-After header in seconds. session.get returns rate_limit.remaining and rate_limit.reset_at for the current user so you can pace yourself before hitting the limit.
  • The gateway also enforces a generous per-IP backstop (thousands of requests per minute) and exposes RateLimit-Remaining / RateLimit-Reset headers for it; a shared egress IP that trips it gets the code-less 429 described under Errors.

Media downloads and the token endpoint have their own limits (the token endpoint is 30 requests per minute per IP) and do not count against the API budget.

Keeping in sync

Realtime pushes changes to you the moment they happen; use it whenever your app can hold a websocket open. Polling is the fallback for servers, for the seconds after a reconnect and for apps that cannot keep a socket. Two parameters make polling cheap:

  1. Poll conversations.list with updated_after set to the newest updated_at you have seen. The response contains only conversations that received a message since then, together with their last_message and unread_count. An empty items array is the common, cheap case.
  2. For the conversation that is open on screen, poll messages.list with changed_since set to the newest of created_at, edited_at and deleted_at you have seen. This returns new messages and messages that were edited or deleted after that instant, which updated_after on the conversation does not surface because edits and deletions do not bump a conversation's updated_at.

Recommended cadence: every 5 seconds while your app is in the foreground and a chat is visible, every 60 seconds in the background or when only the conversation list is showing. At 5 seconds a user with one open conversation uses 24 requests per minute — well inside the 120 per minute budget, with room for their own actions. Stop polling entirely when the window is hidden or the device is idle, and after a PT401, PT403 or PT429.

Keep the timestamps you pass back exactly as the API returned them (do not round to seconds). Because changed_since and updated_after are strict "after" comparisons, a message you already have will not be returned again, and a message with the same timestamp as your watermark is impossible in practice.

Unread state: call conversations.mark_read when the user actually sees the conversation, not on every poll — it is a write, it needs messages:write, and PocketPass uses it to clear the user's badge.

Realtime

The same token opens PocketPass's Realtime websocket. Connected apps can join three families of private Broadcast topics, each behind the scope that guards the matching endpoints:

TopicScopeEventsFires when
conversation:<conversation id>messages:readINSERT, UPDATE, DELETEA message in that conversation is sent, edited or deleted. The user must be an active member.
notifications:<user id>notifications:readINSERT, UPDATE, DELETEA notification for the connected user is created, changes (marked read, folded, answered) or is removed.
friends:<user id>friends:readINSERT, UPDATE, DELETEA friendship or a friend request involving the connected user changes.

Connecting

Connect to wss://api.pocketpass.xyz/realtime/v1/websocket?apikey=<publishable key>&vsn=1.0.0. The publishable key is public and the same for every app: see https://developer.pocketpass.xyz/config.js. The user's access token goes into every join message, never into the URL.

With supabase-js (browser or Node):

import { createClient } from "@supabase/supabase-js";

const supabase = createClient("https://api.pocketpass.xyz", PUBLISHABLE_KEY, {
  accessToken: async () => currentAccessToken,
});

const channel = supabase
  .channel(`conversation:${conversationId}`, { config: { private: true } })
  .on("broadcast", { event: "INSERT" }, () => refreshMessages(conversationId))
  .on("broadcast", { event: "UPDATE" }, () => refreshMessages(conversationId))
  .on("broadcast", { event: "DELETE" }, () => refreshMessages(conversationId))
  .subscribe((status, error) => console.log(status, error));

Without a library, speak the Phoenix channel protocol over the socket: join, heartbeat every 30 seconds, and hand over each refreshed token before the previous one expires.

{ "topic": "realtime:conversation:<conversation id>", "event": "phx_join", "ref": "1", "join_ref": "1",
  "payload": { "config": { "broadcast": { "self": false }, "presence": { "key": "" }, "private": true },
               "access_token": "<access token>" } }

{ "topic": "phoenix", "event": "heartbeat", "payload": {}, "ref": "2" }

{ "topic": "realtime:conversation:<conversation id>", "event": "access_token", "ref": "3",
  "payload": { "access_token": "<refreshed access token>" } }

The join is answered with a phx_reply whose payload.status is "ok" or "error". Events then arrive as broadcast frames whose payload mirrors the database change:

{ "topic": "realtime:conversation:<conversation id>", "event": "broadcast", "ref": null,
  "payload": { "type": "broadcast", "event": "INSERT",
               "payload": { "operation": "INSERT", "schema": "public", "table": "messages",
                            "record": { "id": "…", "conversation_id": "…", "sender_id": "…", "body": "…", "created_at": "…" },
                            "old_record": null } } }
  • private: true is mandatory. A public channel with the same name joins happily and never receives anything.
  • The join is refused (a phx_reply with "status": "error") when your app lacks the scope, the user is not in that conversation, the topic belongs to someone else, or the user disconnected your app. Treat it like PT403 / PT401 on the API: do not retry in a loop.
  • record and old_record are the raw table rows, not the API objects, and their columns are not part of the API contract. The robust pattern is to treat an event as a signal and re-fetch through the API: messages.list with changed_since, conversations.list with updated_after, notifications.list, friends.list / friends.requests_list.
  • Send a heartbeat every 30 seconds or the server drops the socket. Access tokens last one hour: push the refreshed token with the access_token message, otherwise the channel closes when the old one expires.
  • Realtime does not count against the request budget, but it is a shared resource. Subscribe to the conversation on screen plus the user's notifications: topic, not to every conversation in the list; leave channels you no longer show.
  • Presence (who is online) and the first-party topics stay closed to connected apps.
  • While disconnected, poll as described in Keeping in sync, and re-fetch once after every reconnect — nothing is replayed.

Media

Avatars and image attachments are files in two private buckets, referenced by path: profile.avatar_path lives in avatars, message.attachment.path in message-media. The same token that calls the API can read them, subject to the same rules — an avatar is readable when the user could see that profile (profile:read), an attachment when the user is a member of its conversation (messages:read).

Direct download

curl https://api.pocketpass.xyz/storage/v1/object/authenticated/avatars/<avatar_path> \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  --output avatar.png

Use this from a server or a native app. The response is the file itself with its Content-Type. Files you may not read come back as a 404 or a 400, never a 403.

Signed URLs for browsers

An <img> tag cannot send a bearer token, so ask for short-lived signed URLs instead. Batch, up to a whole page of avatars in one call:

curl -X POST https://api.pocketpass.xyz/storage/v1/object/sign/avatars \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "expiresIn": 600, "paths": ["<path 1>", "<path 2>"] }'
[
  { "path": "<path 1>", "signedURL": "/object/sign/avatars/<path 1>?token=…", "error": null },
  { "path": "<path 2>", "signedURL": null, "error": "Object not found" }
]

Or one file at a time:

curl -X POST "https://api.pocketpass.xyz/storage/v1/object/sign/message-media/<attachment path>" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "expiresIn": 600 }'

{ "signedURL": "/object/sign/message-media/<attachment path>?token=…" }

signedURL is relative. Prefix it with https://api.pocketpass.xyz/storage/v1 before you use it. The full URL then works without any headers, in an <img> or anywhere else.

  • expiresIn is in seconds and is clamped to 15 minutes (900); ask for a larger value and you silently get 900. Keep it short — a minute or two for a screen you are rendering now is plenty — and re-sign when the user comes back.
  • Revocation caveat: a signed URL is a bearer credential for that one file. It keeps working until it expires even if the user disconnects your app, blocks the other person or the message is deleted in the meantime. That is the reason for the 15-minute cap; do not cache signed URLs beyond the screen that requested them and never store them.
  • Signing a file the user may not see returns an error for that path (batch) or a 400/404 (single). Treat it as "no image".

Sending images

Upload the file first, then send the message that references it. Both steps use the same token and need messages:write.

curl -X POST "https://api.pocketpass.xyz/storage/v1/object/message-media/<user id>/<conversation id>/<file name>" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg

{ "Key": "message-media/<user id>/<conversation id>/<file name>", "Id": "…" }
POST /v1/messages.send
{
  "conversation_id": "<conversation id>",
  "client_operation_id": "<uuid>",
  "body": "optional caption",
  "attachment": { "path": "<user id>/<conversation id>/<file name>", "mime_type": "image/jpeg" }
}
  • The path is exactly <user id>/<conversation id>/<file name>: the connected user's id (user_id from session.get), a conversation they are an active member of, and a file name of 1…128 characters made of letters, digits, ., _ and - that starts with a letter or digit. Use a fresh UUID as the name: an app can neither overwrite nor delete uploads, so a reused name fails.
  • image/jpeg, image/png and image/webp up to 10 MiB. Storage refuses other types and larger files; messages.send refuses a mime_type outside that list with PT400 UNSUPPORTED_MEDIA.
  • The upload is refused (a 400 or 403 from Storage) for another user's folder, for a conversation the user is not in, without messages:write, and after the user disconnects your app. messages.send double-checks the path (PT400 INVALID_ATTACHMENT_PATH) and that the file exists (PT404 ATTACHMENT_NOT_FOUND).
  • Omit body for an image-only message; it is stored as "📷", which is what PocketPass itself sends. A caption of 1…4000 characters is kept as the body.
  • An uploaded file that never ends up in a message is simply unused; there is nothing to clean up.
  • Avatars cannot be changed by apps.

Changing scopes

A user approved a specific list of permissions, so the rules are:

  • Adding a scope disconnects every connected user. The moment you save an app with a scope it did not have before, all live grants are revoked and all sessions for your app are deleted. Users stay disconnected until they go through the connect flow again and approve the new list. The portal warns you and tells you how many users are affected before it saves.
  • Removing a scope disconnects nobody. Calls that needed the removed scope start answering PT403 SCOPE_REQUIRED immediately; everything else keeps working.
  • Name, description, website, logo and redirect URIs can be changed freely.

From your code's point of view a scope expansion looks exactly like the user disconnecting you: API calls answer PT401 CONSENT_REVOKED, and refreshing answers 400 with "error": "invalid_grant". Handle both in one place — forget the stored tokens, mark the account as disconnected, and show a "Reconnect PocketPass" action that starts the flow again. Because the consent screen shows the new permissions, users see what changed. If your app has an audience, add the scope during a quiet hour and ship the client that needs it first, so the reconnect prompt makes sense when it appears.

Idempotency

Networks drop responses. To make retries safe, every endpoint that changes something a second time would duplicate requires a client_operation_id: messages.send, conversations.open, friends.request_send, friends.request_respond and friends.remove.

  • Generate a fresh UUID once per user action — when the user presses "Send", not when you build the HTTP request.
  • Keep it with the pending action and reuse the same id on every retry of that action, including after a restart if you queue outgoing messages.
  • A retry with the same id and the same content returns the same result: for messages.send the same message (its id is derived from the operation id, so there is never a duplicate row); for conversations.open the same conversation; for the friend endpoints the same request or the same removed answer.
  • The same id with different content — another body, another conversation — is refused with PT409 DUPLICATE_OPERATION_ID. That is a bug in the caller, not something to retry.

Retry on network failures and on PT500, PT429 (after Retry-After) and HTTP 502/503/504. Do not retry PT400, PT401, PT403, PT404 or PT409 — the answer will not change.

First-party only in v1

The public API is deliberately a subset of what the PocketPass app can do. The following remain first-party for now and are not reachable with an app token, however the app was registered:

  • Blocking and unblocking. A blocked person simply disappears from what the API returns; there is no way to tell "blocked" from "does not exist".
  • Encounters (meeting people nearby) and their confirmation.
  • Changing the avatar, and uploading anything other than image messages.
  • Group conversations (creating them or managing members) — reading works if they ever show up in the user's list.
  • Account settings, email and Discord links, deleting the account, tokens and the shop, achievements and Mii editing.
  • Presence (who is online) and the first-party Realtime topics (tokens, encounters, app updates).
  • The standard Supabase surface: tables under /rest/v1/, first-party RPCs, GraphQL, /auth/v1/user. All of it answers 403 or 404 to an app token by design.

New capabilities arrive as new endpoints and new scopes, so existing apps keep working unchanged.

PocketPass Developer Docs · Portal · Connected apps (for users)