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:
| What | Where |
|---|---|
| Authorization endpoint | GET /auth/v1/oauth/authorize |
| Token endpoint | POST /auth/v1/oauth/token |
| OpenID userinfo | GET /auth/v1/oauth/userinfo |
| Discovery document | GET /auth/v1/.well-known/openid-configuration |
| PocketPass API | POST /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
codeandhint. - 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
| Type | Use it for | Token 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.1and[::1]. Because the match is exact, register the port you will actually listen on —http://127.0.0.1:8765/callback, nothttp://127.0.0.1/callback— and pin that port in your app. If you are unsure which name the platform will use, register bothhttp://localhost:8765/callbackandhttp://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.
| Scope | Shown to the user as | Unlocks |
|---|---|---|
profile:read | See your profile (name, bio, avatar, age, country) | me.get, profiles.get, profiles.get_many, avatar downloads |
friends:read | See your friends list and friend requests | friends.list, friends.requests_list, the friends: Realtime topic |
friends:write | Add and remove friends and answer friend requests as you | friends.request_send, friends.request_respond, friends.request_cancel, friends.remove, friends.code_get, friends.code_resolve |
messages:read | Read your conversations and messages | conversations.list, conversations.get, messages.list, attachment downloads, the conversation: Realtime topics |
messages:write | Send, edit and delete messages as you | conversations.open, conversations.mark_read, messages.send (text and images), messages.edit, messages.delete, image uploads |
notifications:read | See and clear your notifications | notifications.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 scope | Shown to the user as | Userinfo claims |
|---|---|---|
openid | (always) | sub — the PocketPass user id |
email | your email address | email, email_verified |
profile | your account name and picture | name, picture, preferred_username, updated_at |
phone | your phone number | phone, phone_verified |
Tokens
Lifetimes
- Access token: a JWT valid for one hour (
expires_in: 3600). Send it asAuthorization: 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 answerPT401 CONSENT_REVOKED, refresh fails withinvalid_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_SUSPENDEDand 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(andOPTIONSfor CORS preflight). AGETis 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 a4xx/5xxstatus and a body{ "code", "message", "hint" }— branch onhint, showmessageto a developer, never to an end user. - Lists return
{ "items": [...], "next_cursor": null | "…" }. Passnext_cursorback ascursorto get the next page;nullmeans you have everything. Cursors are opaque strings — do not build or parse them. limitis 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/answers404with{ "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.
| Endpoint | Scope | Body | Returns |
|---|---|---|---|
session.get | none | {} |
{ user_id, client_id, app: { name }, scopes: [...], rate_limit: { per_minute, remaining, reset_at }, unread_total } |
session.revoke | none | {} |
{ 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.get | profile:read | {} |
{ profile } — the connected user's own profile, including setup_complete |
profiles.get | profile: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_many | profile:read | { user_ids: [...] } (1…100) |
{ items: [profile, ...] } — same visibility rule; ids the user may not see are simply left out |
friends.list | friends:read | {} |
{ items: [friend, ...] } — the whole list, no paging |
friends.requests_list | friends:read | {} |
{ incoming: [friend_request, ...], outgoing: [friend_request, ...] } — pending requests only, newest first |
friends.request_send | friends: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_respond | friends: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_cancel | friends: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.remove | friends: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_get | friends:write | {} |
{ code } — the user's own 8-digit friend code, the same one PocketPass shows them |
friends.code_resolve | friends: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.list | messages:read | { limit?, cursor?, updated_after? } |
{ items: [conversation, ...], next_cursor } — most recently updated first; updated_after (timestamp) keeps only conversations that changed since then |
conversations.get | messages:read | { conversation_id } |
{ conversation }; PT404 CONVERSATION_NOT_FOUND when the user is not a member |
conversations.open | messages: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_read | messages:write | { conversation_id } |
{ last_read_at } |
messages.list | messages: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.send | messages: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.edit | messages:write | { message_id, body } |
{ message } — own messages only (PT403 NOT_SENDER); deleted messages cannot be edited (PT409 MESSAGE_DELETED) |
messages.delete | messages:write | { message_id } |
{ message } with deleted_at set — own messages only |
notifications.list | notifications:read | { limit?, cursor? } |
{ items: [notification, ...], next_cursor } — most recently updated first |
notifications.mark_read | notifications:read | { notification_id } |
{ ok: true } |
notifications.delete | notifications: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 assubin 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
avatarsbucket, ornull. See Media. age- Integer or
null. country_code- Two-letter ISO 3166-1 code or
null. created_at,updated_at- Timestamps.
setup_completeme.getonly: 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_idon 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.getworks for the other person.created_at,responded_at- Timestamps;
responded_atisnullwhile pending.
conversation
id- UUID.
kind"direct"today; groups may appear later, so do not assume exactly two members.titlenullfor 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
messageobject, ornull. 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 themessage-mediabucket), ornull. 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, ornull.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_atisnulluntil 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 / code | Hint | Meaning | What to do |
|---|---|---|---|
400 PT400 | MISSING_FIELD | A required body key is absent. | Fix the request. |
INVALID_FIELD | A key has the wrong type or value (not a UUID, not a timestamp, list too long…). | Fix the request. | |
UNKNOWN_FIELD | The body contains a key the endpoint does not know. | Check the spelling against the reference. | |
INVALID_CURSOR | The cursor was not one this endpoint issued. | Restart from the first page. | |
BODY_LENGTH | Message body is empty or longer than 4000 characters after trimming. | Validate before sending. | |
REPLY_TARGET | reply_to_id is not a message of that conversation. | Only reply to messages from the same messages.list. | |
SELF_TARGET | A friend endpoint was called with the user's own id. | Hide the user from their own friend actions. | |
INVALID_CODE | A friend code that is not eight digits. | Validate before sending. | |
UNSUPPORTED_MEDIA | attachment.mime_type is not image/jpeg, image/png or image/webp. | Convert the image first. | |
INVALID_ATTACHMENT_PATH | attachment.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 PT401 | API_TOKEN_REQUIRED | No bearer token, an expired one, or a token that is not an app token. | Refresh and retry once; then reconnect. |
CONSENT_REVOKED | The user disconnected your app, or its scopes changed since they approved it. | Delete the stored tokens and offer the connect flow again. | |
403 PT403 | APP_SUSPENDED | PocketPass suspended your app. | Stop calling; contact PocketPass. |
SCOPE_REQUIRED | Your 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_MEMBER | The user is not an active member of that conversation. | Refresh your conversation list. | |
BLOCKED | One of the two users blocked the other. | Show "unavailable". | |
NOT_FRIENDS | conversations.open between users who are not friends. | Only open conversations with entries from friends.list. | |
NOT_SENDER | Editing or deleting someone else's message. | Only offer edit/delete on messages whose sender_id is the user. | |
NOT_ADDRESSEE | Answering a friend request that was not sent to the user. | Only offer accept/decline on incoming requests. | |
NOT_REQUESTER | Cancelling a friend request the user did not send. | Only offer cancel on outgoing requests. | |
404 PT404 | APP_NOT_FOUND | The app in the token no longer exists. | The app was deleted; nothing to retry. |
PROFILE_NOT_FOUND | No such user, or the connected user may not see them. | Treat as unavailable. | |
CONVERSATION_NOT_FOUND | No such conversation, or the user is not in it. | Refresh your conversation list. | |
MESSAGE_NOT_FOUND | No such message in a conversation the user can see. | Refresh the message list. | |
NOTIFICATION_NOT_FOUND | No such notification for this user. | Refresh the notification list. | |
REQUEST_NOT_FOUND | No such friend request involving the user. | Refresh friends.requests_list. | |
CODE_NOT_FOUND | No account the user may see uses that friend code. | Tell the user to check the code. | |
ATTACHMENT_NOT_FOUND | attachment.path was never uploaded. | Upload first, then send. | |
409 PT409 | DUPLICATE_OPERATION_ID | A 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_DELETED | Editing a message that has been deleted. | Remove it from your UI. | |
FRIEND_REQUEST_PENDING | The notification is a friend request the user has not answered yet. | Answer it with friends.request_respond or leave it in place. | |
ALREADY_FRIENDS | friends.request_send to someone who is already a friend. | Refresh friends.list. | |
REQUEST_CLOSED | The friend request was already accepted, declined or cancelled. | Refresh friends.requests_list. | |
429 PT429 | API_RATE_LIMITED | Too 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_LIMITED | More than 50 friends.code_resolve lookups for this user in an hour. | Wait an hour; do not resolve codes on every keystroke. | |
500 PT500 | INTERNAL | Something 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
429whose body has nocodefield (it looks like{ "message": "API rate limit exceeded" }). This is the per-IP backstop in front of the API; back off exactly as forPT429. - 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 gets403with{ "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
PT429withRetry-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.getalways reports the limits that apply to your app. - Failed requests count. A
PT400, aPT403, aPT401 CONSENT_REVOKEDand 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
PT429response carries aRetry-Afterheader in seconds.session.getreturnsrate_limit.remainingandrate_limit.reset_atfor 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-Resetheaders for it; a shared egress IP that trips it gets the code-less429described 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:
- Poll
conversations.listwithupdated_afterset to the newestupdated_atyou have seen. The response contains only conversations that received a message since then, together with theirlast_messageandunread_count. An emptyitemsarray is the common, cheap case. - For the conversation that is open on screen, poll
messages.listwithchanged_sinceset to the newest ofcreated_at,edited_atanddeleted_atyou have seen. This returns new messages and messages that were edited or deleted after that instant, whichupdated_afteron the conversation does not surface because edits and deletions do not bump a conversation'supdated_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:
| Topic | Scope | Events | Fires when |
|---|---|---|---|
conversation:<conversation id> | messages:read | INSERT, UPDATE, DELETE | A message in that conversation is sent, edited or deleted. The user must be an active member. |
notifications:<user id> | notifications:read | INSERT, UPDATE, DELETE | A notification for the connected user is created, changes (marked read, folded, answered) or is removed. |
friends:<user id> | friends:read | INSERT, UPDATE, DELETE | A 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: trueis mandatory. A public channel with the same name joins happily and never receives anything.- The join is refused (a
phx_replywith"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 likePT403/PT401on the API: do not retry in a loop. recordandold_recordare 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.listwithchanged_since,conversations.listwithupdated_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_tokenmessage, 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.
expiresInis 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_idfromsession.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/pngandimage/webpup to 10 MiB. Storage refuses other types and larger files;messages.sendrefuses amime_typeoutside that list withPT400 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.senddouble-checks the path (PT400 INVALID_ATTACHMENT_PATH) and that the file exists (PT404 ATTACHMENT_NOT_FOUND). - Omit
bodyfor 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_REQUIREDimmediately; 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.sendthe same message (itsidis derived from the operation id, so there is never a duplicate row); forconversations.openthe same conversation; for the friend endpoints the same request or the sameremovedanswer. - 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 answers403or404to 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)