Documentation
API reference
HTTP endpoints exposed by this registry.
All endpoints below live at the registry's root domain. Request bodies are CBOR; responses are CBOR for protocol endpoints and JSON for everything else.
Resolve a handle
GET /.well-known/tpsocial/resolve/{handle}
Accept: application/cbor
Returns the public key and attestation wire for a claimed handle.
Response — application/cbor:
{
"handle": "alice",
"pubkey": h'…32 bytes…',
"attestation_wire": h'…64 bytes…',
"attested_at": 1700000000
}
Headers
ETag: "<blake3-of-attestation>"— cache validator.Cache-Control: public, max-age=300— short cache so handle rotations propagate within five minutes.
A 404 means the handle is not in the registry. A 410 Gone means it
was claimed and later revoked.
Claim a handle
POST /handles/claim?category=person
Content-Type: application/cbor
Body is a signed HandleAttest (canonical CBOR) binding handle_local and
this registry's domain to the side that will own the name. The registry
verifies the signature, that the domain inside the signed bytes is its own
(an attestation minted for another registry is refused), the name's shape,
the reserved list, and that issued_at is within 300 seconds of its clock.
Query: category — person (default), business, directory or
institution; dial_addr — an optional hint for reaching the side.
Response — 201 Created:
{
"handle": "alice@tpsocial.net",
"handle_local": "alice",
"domain": "tpsocial.net",
"side": "tp1…",
"category": "person",
"attested_at": 1790000000
}
Errors:
409 Conflict— the name is already claimed here.422 Unprocessable Entity— a bad or tampered attestation, another registry's domain, a name that is not a TP Social handle or is reserved, an unknown category, or a skewedissued_at.
Rate-limited per IP by the tpsocial-claim limiter (AppServiceProvider).
A browser account claims at /app/claim instead: it has no device to sign
on, so this registry signs the same attestation with the key it holds for
the account, under the same rules. Each browser Side may hold three names.
Publish a page
POST /pages
Content-Type: application/cbor
Body is a canonical CBOR page wire signed by the handle's keypair.
Response — 200 OK (JSON):
{
"handle": "alice",
"slug": "hello",
"url": "https://tpsocial.net/alice/hello",
"wire_blake3": "…hex…"
}
Re-publishing under the same (handle, slug) overwrites the previous
version. The registry keeps only the latest.
Errors:
403 Forbidden— signature did not match the claimed handle's pubkey.404 Not Found— handle has not been claimed.413 Payload Too Large— wire exceeded the registry's per-page cap (currently 256 KiB; configurable per operator).
Render a page
GET /{handle}/{slug}
Returns the rendered HTML for the page — markdown sanitised inline,
HTML iframed against the sandbox origin. ETag is the BLAKE3 of the
signed wire, so 304s are cheap.
GET /{handle}/{slug}.cbor
Returns the raw signed wire bytes for clients that want to verify locally.
Health check
GET /up
A Laravel-default health endpoint that returns 200 when the app boots and the database is reachable. Useful behind a load balancer.
GET /__health/ffi
Verifies a committed HandleAttest fixture through libtpsocial and reports
how this node signs: runtime.transport is library when libtpsocial is
loaded in-process, or process when the runtime has no FFI and the same
library runs as the tpsocial-ffi-proc program — Laravel Cloud's case.
503 with a remediation when neither works.
Names on the trust web
The registry is the namespace authority for sa (protocol §11.12). Two
read-only endpoints let resolvers and clients verify names without trusting
the transport:
GET /.well-known/trustplane/authority
JSON: namespace, domain, authority (the authority's tp1… side
address), authority_pk_hex, uri (trust://sa/<tp1…>), name_ttl_seconds.
Pin authority in a resolver's or client's names table
(sa=<domain>=<base url>=<authority>).
GET /.well-known/trustplane/names/{handle}
application/cbor: a NameRecord — the authority's signature over the
DirectoryEntry served at /.well-known/tpsocial/resolve/{handle}, with
issued_at and expires_at. Two signatures: the side's own attestation
inside the entry says the side claims the name; the authority's says it
assigned it. 404 for an unknown, hidden or foreign-domain handle; 501
when the registry has no authority key configured.
The app: pairing and what a side sees
Everything below is what the TrustPlane app uses (protocol §11.12). A Side key authenticates one identity. The private owner account may hold several Sides; a Side credential never authenticates that owner account.
Pair a device. POST /api/v1/auth/challenge → {nonce, domain, label, expires_at}. The device signs blake3(label ‖ domain ‖ nonce) with its side
key and posts POST /api/v1/auth/pair {nonce, side_pk, signature, device_name} (base64url) → 201 {token, token_id, address, side_hex, user_id, created}. The token carries the read and publish abilities.
404 unknown nonce, 409 used, 410 expired, 401 bad signature. DELETE /api/v1/auth/token revokes the calling token.
Independent Sides: version 2 pairing. Use POST /api/v2/auth/challenge
for {nonce, domain, label, expires_at}. The label is
tpsocial/v2/api-pair; sign blake3(label ‖ domain ‖ nonce) and send
POST /api/v2/auth/pair {nonce, side_pk, signature, device_name?}. Nonce,
key and signature are base64url. Only an existing, active Side may pair;
this endpoint does not create accounts or identities. The response is
201 {token, token_id, address, side_hex, scope: "side"} with read and
publish abilities. It contains no owner ID, email or sibling identities.
DELETE /api/v2/auth/token revokes the calling token.
Version 1 remains compatible with the original Side. Attempting to pair an
independent Side through version 1 returns 409; use version 2 instead.
Existing tokens remain restricted to their original Side. A side query
parameter cannot move a token to another identity. /api/user now returns
the same Side-only representation as /api/v1/me, rather than an account
record. Browser QR authentication likewise grants a Side-only session,
bound to the browser that created its challenge. Account settings require
owner authentication by password or passkey.
Who am I. GET /api/v1/me → {address, side_hex, has_side, handles[], token{name, abilities}}.
Groups. GET /api/v1/verses (mine), GET /api/v1/verses/{slug}
(adds ranks, contract, owner handle), POST /api/v1/verses {title, description, owner_handle}. A verse you are not an active member of is a
404, never a 403.
Invites and joining. POST /api/v1/verses/{slug}/invites {max_uses?, expires_in_days?, note?, grants_rank?} → 201 {code, prefix, join_url, expires_at, max_uses} (the code is shown once; 403 without the invite
grant). POST /api/v1/join/{code} {display_name} → the verse as you now see
it; expired, revoked, exhausted and never-existed codes are all 404.
Members. GET /api/v1/verses/{slug}/members → the side and name each
member chose there, their rank, who invited them, and a public handle if
that side holds one.
Sides graph. GET /api/v1/graph → {me, verses, people}: for each
person, exactly what they let you see — the side and display name they used
in each verse you share, their rank there, a public handle, the places they
verified presence at in rooms you can read, and who invited whom. The same
person with two sides in two verses is two people; nothing is inferred.
Discovery. GET /api/v1/discover lists public, active groups for
everyone (a token, if present, marks is_member). GET /api/v1/discover/{slug}
previews a public or unlisted one: rooms open to newcomers, ranks, what it
asks for and never asks for, the founder's handle. POST /api/v1/verses/{slug}/join {display_name} admits the caller to an open community; an invite-only one
answers 403 with what to do instead; a hidden one is 404. Creating a verse
takes visibility (public, unlisted, hidden) and join_mode (open,
invite); an open door on a hidden space is corrected to invite.
Experiences, people, groups inside a group
The social layer (protocol §11.14). One rule shapes all of it: your
peers are the active members of the active groups you are in, and
nothing about a person reaches anyone who is not their peer. A profile, a
post or a photo you may not see is a 404.
Post an experience. POST /api/v1/experiences (multipart, publish,
30/hour): body (≤ 2000), verse? (a slug: only that group sees it, and
tags must be its members), presence[credential] + presence[manifest]
(+ presence[issued_at] when the check-in is older than a minute — the
registry verifies at that instant), with[] (side hex of peers, ≤ 10),
photos[] (≤ 4, ≤ 8 MB each, JPEG/PNG/WebP; re-encoded to JPEG ≤ 1600 px
with EXIF dropped, kept on the private disk). A presence credential must be
the poster's own (its subject is the poster's side) or the post is 422.
→ 201 with the feed item.
The feed. GET /api/v1/feed?before=<id> → {data[], next_before}, 50
per page, newest first: posts by your peers (unscoped, or scoped to a
group you share), your own, and those you are tagged in. Each item:
{id, author{side_hex, name, handle}, body, verse{slug,title}|null, place{entity, name, level, verified_at, issued_at}|null, with[{side_hex, name, status, verified_together}], photos[{position, width, height, url}], created_at, mine, tagged_me, my_status}. Names are the ones you know —
the post's group first, else the first one you share; you for
yourself; someone when you share nothing (a tag on a post you may see).
A post and its photos. GET /api/v1/experiences/{id};
GET /api/v1/experiences/{id}/photos/{n} streams the JPEG to anyone who may
see the post. DELETE /api/v1/experiences/{id}: the author deletes (files
too), a member with the moderate grant in the post's group hides it,
anyone else gets 404.
Tags. A tag is the poster's claim (tagged). The tagged person answers
once: POST /api/v1/experiences/{id}/confirm {presence?} or
POST …/decline (409 once answered, 404 for anyone else). Confirming
with your own presence credential for the same place issued within two
hours of the post's makes the tag verified_together; a credential for
another place or too far apart is 422 and nothing is recorded. Confirming
without a credential is "says so": confirmed, not verified.
People. GET /api/v1/people/{side_hex} (404 unless a peer or
yourself) → {side, side_hex, self, names{slug: name}, handle, bio, shared_verses[{slug, title, their_rank, their_display_name}], places[{entity, name, level, first, last, count}], experiences[], next_before, brought_you_into[], you_brought_into[], member_since}. Places come from
their verified posts you may see and their presence-attested messages in
rooms you can read. PATCH /api/v1/me {bio} (≤ 300) edits your own.
Groups inside a group. POST /api/v1/verses/{slug}/verses {title, description?} → 201 the child, for a parent member whose rank
grants create_room (trusted members and the founder; 403 otherwise,
409 on a child — one level only). A child is always hidden with
join_mode: parent, inherits the parent's contract rules under its own
title, and admits its creator and the parent's founder at the top rank.
GET /api/v1/verses/{slug} adds parent{slug,title}, children[{slug, title, description, address, member_count, is_member}] and
me.can_create_inside. Parent members preview it at
GET /api/v1/discover/{slug} (parent and inside name the parent) and
join with POST /api/v1/verses/{slug}/join — no display_name needed, the
one they use in the parent carries over. To everyone else it does not exist.
Leaving the parent leaves its children.
A group's look
Every group chooses how it looks, and the app wears that look whenever
you are inside it or posting to it — like a server with its own colours.
The look is presentation, not contract: the founder and moderators (the
moderate grant) change it without anyone re-consenting.
Read. theme rides on every verse shape: GET /api/v1/verses and
/verses/{slug} (with me.can_style), the children[] of a verse,
GET /api/v1/discover cards and /discover/{slug}, the verse of a feed
item, and the graph's verses[]. Shape: {accent, bg, panel, ink} as
#rrggbb; font sans|serif|mono|rounded|display; corners
sharp|soft|round; wallpaper none|dots|grid|lines|waves; bubbles
round|square; density cozy|compact; icon (≤ 8 chars, an emoji or a
letter or two); motto (≤ 120); rooms_word, people_word (≤ 20, what
that group calls them); banner {width, height} or null;
banner_position top|center|bottom; icon_image {width, height} or null (a
square picture that replaces the emoji); backdrop flat|gradient|banner
(what sits behind the page); glow none|soft|strong (how much the accent
glows on what is active); title_font same|sans|serif|mono|rounded|display
(names and headings); chat_style bubbles|rows (a group chat, or rows with
avatars like a server); css (custom CSS, sanitised — see below);
preset (the look it started from);
inherited (a group inside another wears its parent's look until it has
its own); light (whether the background is light). Defaults fill every
missing value, so a client never sees a hole.
Write. PUT /api/v1/verses/{slug}/theme (publish, 403 without the
moderate grant) with any subset of the fields above, or {preset: "coffee"} to start from a preset (trustplane, paper, coffee,
forest, sunset, neon, slate), or {reset: true} to go back to the
TrustPlane look. Fields not mentioned keep their value. Everything is
normalised before it is stored: a colour that is not #rrggbb (#rgb
and a missing # are accepted) is dropped, a value outside the vocabulary
falls back to the default, words lose control characters and angle
brackets and are cut to length. Nothing a founder types reaches a
stylesheet as typed.
Custom CSS. css (≤ 8000 characters after sanitising, ≤ 200 rules)
is for the founders who want every little detail. The registry keeps only
what can change how things look: no at-rules (@import, @font-face,
@media…), no url(), image-set() or src(), no expression(),
javascript:, behavior, -moz-binding, no backslash escapes, no < or
& — any of those refuses the whole thing with 422 and a reason. Of the
rest, only known properties survive (colours, backgrounds without images,
borders, radius, shadows, type, spacing, opacity, filters, transforms,
transitions, display/flex, custom properties); every selector is kept as
written and the app prefixes it with the group's page scope, so the CSS
never reaches another group's page.
Pictures. POST /api/v1/verses/{slug}/banner (multipart banner,
JPEG/PNG/WebP ≤ 8 MB; re-encoded to JPEG ≤ 2000 px, EXIF dropped, private
disk) and POST …/icon (multipart icon; centre-cropped to a 512 px
square) → 201 with the theme; DELETE …/banner / …/icon remove them; a
colour change or a reset keeps them. GET /api/v1/verses/{slug}/banner and
…/icon stream them to members and, for a public or unlisted group, to
anyone; a hidden one's pictures are 404 to outsiders like everything else
about it.
Events
An event belongs to the person who made it, is signed by no one, and can be answered by anyone with a name — the link that gets posted in a WhatsApp group. Pages, not API, for now.
GET /e/{id}— the page. Speaks the organiser's language unless the visitor picked one (?lang=en|ar). Carries Open Graph tags (the cover asog:imagewhen there is one) and schema.orgEventdata. Unlisted events arenoindex; listed ones that are still ahead are in the sitemap.GET /e/{id}/calendar.ics— iCalendar (RFC 5545), times in UTC.GET /e/{id}/cover.jpg— the cover,404without one.
An answer is going, maybe or declined, keyed by exactly one of: the
account, a token in a guest's browser (stored hashed), or a WhatsApp voter
reference. Who else is coming is shown to the organiser and to people who
have answered, never to someone only holding the link.
The WhatsApp bridge
A separate process holds an organiser's linked WhatsApp session and speaks to
this registry with two signed calls. It only ever calls out, so it runs behind
NAT. Both answer 404 when no bridge secret is configured
(TPSOCIAL_WHATSAPP_BRIDGE_SECRET).
Signing. Each request carries X-TpSocial-Bridge-Timestamp (unix seconds)
and X-TpSocial-Bridge-Signature: hex HMAC-SHA256, keyed with the shared
secret, over timestamp \n METHOD \n /path \n body. A timestamp more than five
minutes off is refused, so a captured request cannot be replayed.
POST /api/v1/bridge/whatsapp/poll {limit?} → {data: [{id, account_id, type, payload, attempt}]}. Hands over waiting commands and marks them
claimed. A claim with no report for five minutes is offered again — except
members.invite, which never runs twice. Command types: link.start,
link.logout, groups.sync, event.post {post_id, group_ref, text, poll: {name, options[3]}}, event.remind {post_id, group_ref, reply_to, text},
chat.send {group_ref, text}, mirror.announce {group_ref, mirror, text}
(the bridge forwards messages only from groups last announced with
mirror: true),
members.invite {group_ref, text, pace_seconds: [min, max], daily_cap, skip_member_refs[]}.
POST /api/v1/bridge/whatsapp/reports {reports: [{type, account_id, data}]} (≤ 200) → {accepted, failed: [{index, error}]}. Each report is
applied on its own and is safe to resend. Types: command.done {command_id, result} (for event.post, result.poll_message_id), command.failed
{command_id, error}, command.progress {command_id, result}, link.qr
{code, expires_in}, link.connected {push_name, number_hint},
link.logged_out, groups.synced {groups: [{ref, name, participants, is_admin}]}, poll.vote {poll_message_id, voter_ref, voter_name, selected: [option indexes]} (empty takes the vote back), chat.message
{group_ref, sender_ref, sender_name, text, message_id, sent_at}. A report
may only speak about commands and groups belonging to its account_id.
No phone numbers. Groups and people are named by references the bridge
makes with its own HMAC key — group_ref, sender_ref, voter_ref,
member_ref — never a JID, since older group JIDs embed the creator's
number. The one exception is number_hint, of which only the last four
digits are kept, so an organiser can tell which WhatsApp they linked.