API reference
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
Content-Type: application/cbor
Body is a canonical CBOR claim record signed by the keypair that will own the handle. The registry verifies the signature and the regex shape before writing.
Response — 200 OK (JSON) on success:
{
"handle": "alice",
"url": "https://tpsocial.net/alice"
}
Common errors:
422 Unprocessable Entity— handle doesn't match the regex, is on the reserved list, or is already claimed.400 Bad Request— malformed CBOR or signature verification failed.
Rate-limited per IP via the tpsocial-claim limiter — see
config/throttle.php if you're running your own.
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.
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 is the account: no email, no password.
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.
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.