Skip to content
TP Social
Sections

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.

Responseapplication/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.

Response200 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.

Response200 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.