Skip to content
Sideverse

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": "[email protected]",
  "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 skewed issued_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 {"ffi": "unavailable"} when neither works. A browser signed in to the operator console also sees the runtime details (SAPI, ffi.enable, library and program paths) and, on failure, the cause and the fix.

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 as og:image when there is one) and schema.org Event data. Unlisted events are noindex; 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, 404 without 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

Protocol 2 links account-owned WhatsApp connections to Sideverse communities. A user can connect a personal number, or an existing number for one community. Sideverse does not create phone numbers. A separate Go worker holds each linked device session and calls the registry. Both endpoints return 404 when TPSOCIAL_WHATSAPP_BRIDGE_SECRET is not set.

Signing. Each request carries X-TpSocial-Bridge-Timestamp (Unix seconds) and X-TpSocial-Bridge-Signature: hex HMAC-SHA256 with the shared secret over timestamp \n METHOD \n /path \n body. Requests outside a five-minute window are refused. The signed body is limited to 12 MiB. Signatures authenticate the worker; account, session, group, mapping and message checks also apply to each report. A timestamp window alone is not an exactly-once delivery guarantee.

POST /api/v1/bridge/whatsapp/poll accepts {limit?, protocol_version: 2, capabilities: [...]} and returns {protocol_version, capabilities, data: [{id, account_id, type, payload, attempt}]}. A poll records worker availability. It does not prove that a phone is connected or that WhatsApp received a message. Claims expire after five minutes; eligible commands retry up to five attempts. Legacy member invitation commands are not automatically retried. Protocol 1 cannot claim protocol 2 connection commands.

Pending group refresh requests are combined. Manual full inventories are spaced by five minutes. Provider rate limits delay read-only refreshes with bounded backoff; sends and member changes are not automatically retried for that error.

Every command payload has session_generation. Group commands also have group_ref and mapping_version. The registry checks current ownership, membership, moderation rights, room access and mapping state before dispatch. Generation zero is refused. The upgrade disables old transports and requires a fresh link while preserving existing content and opaque references.

Commands Purpose
link.start, link.logout, groups.sync Pair, disconnect, or refresh a connection.
group.sync Refresh one mapped group when the worker advertises group-refresh.
group.settings Change name, description, admin-only messages, optional JPEG photo and optional join_approval_required boolean.
group.configure Announce a group link and enable its selected sharing options.
group.history Request up to 50 earlier messages from one mapped group with explicit consent and the group-history capability.
event.post, event.update, event.cancel, event.remind Send a new event poll, a change, a cancellation or a reminder.
chat.send, chat.edit, chat.delete Send or change text that this bridge owns in the linked group.
group.member.remove, group.member.promote, group.member.demote Change a group member after provider admin checks.
mirror.announce, members.invite Retained command handlers; member invitations require an explicit action.

The ordinary-group join-approval option requires group-join-approval in the current poll. Omit it or send null to keep the setting; false explicitly disables approval. Community containers use a different provider setting and reject this option. Snapshots report join_approval_required; an omitted field preserves the last known value. A failed settings command stays failed and queues a refresh because an earlier setting may have changed.

Settings, member changes, join decisions, moderation and history requests include requested_by_side_id. The registry checks that Side's current rights before dispatch and completion. Earlier queued mutations without this identity cannot run.

Earlier messages. A group.history request requires consent for one mapped group and a period of 1 to 30 days. Its payload includes group_ref, mapping_version, session_generation, requested_by_side_id, since (Unix seconds), limit (maximum 50), anchor_message_id, anchor_sent_at (Unix seconds), anchor_sender_ref and anchor_from_me. A known message from the same mapping anchors the request. The worker checks its stored sender evidence. The provider can return fewer messages or no messages.

Completion supplies result.messages. The registry imports eligible text and available attachments into the group room. It stores only {imported, skipped} in the command result. Deleted messages, opted-out senders and disappearing content are excluded. History does not execute chat commands or collect event photos. Unsupported content uses system_notice=content_unavailable; missing attachments use attachment_unavailable. Stored sender text stays separate from these notices. An unconfirmed history request does not retry automatically.

A group.sync result can also contain announcement_group when the worker supports community-announcements. It must be the requested community's default announcement group, with confirmed membership for the connected number. Both snapshots are validated before either is stored. This result is incremental: it does not delete other groups, import discussion groups, enable sharing or change existing mappings. Older results containing only group remain valid.

POST /api/v1/bridge/whatsapp/reports accepts {reports: [{type, account_id, data}]} (at most 200) and returns {accepted, failed: [{index, error, retryable}]}. Each item is applied separately. Reports include session_generation; reports for a linked group also include mapping_version. The worker keeps reports in a durable outbox until the registry accepts them or refuses them permanently. Temporary failures retry.

Upgraded command reports include the claimed attempt; stale attempts are refused. A commanded full inventory is returned as command.done.result.groups, and one-group refresh uses result.group. Snapshot updates and completion are applied in one transaction. A targeted refresh does not import other communities. Automatic inventories retain their separate report type.

Report types include command.done, command.failed, command.progress, link.qr, link.connected, link.logged_out, groups.synced, poll.vote, chat.message, chat.edit, chat.delete and chat.photo. Group inventory has opaque refs, names, counts, admin status, is_community, parent_ref and is_announcement. Linked groups can include member refs, display names and admin roles. Member snapshots also include is_self for the connected number and is_super_admin for the group creator. Applications show these roles and do not offer member changes for them. The worker checks current provider roles again before it changes any member. A report cannot act on another account or mapping. Edits and removals must match the original message author. Removal tombstones prevent a late photo report from restoring a removed image.

Community structure. The WhatsApp community is the parent Verse. Its announcement group uses a room in that Verse. Other groups use child Verses. The community container is not a chat. Whole-community linking opts in to current and future groups that the connected number can access. Each actual chat gets a sharing notice before forwarding starts. Revoked access, an archived room or a changed link stops sharing. Earlier messages require a separate history request. Sideverse does not import unrelated private chats.

Events and photos. Enabled mappings receive event creation, changes and cancellation automatically. Repeated states use a monotonic event revision; retrying one transition does not suppress a later return to an earlier state. Poll answers stop after cancellation. Text commands include !going, !paid, !tab and !settle. A selected event fixes their context; the UI explains the fallback event selection when no event is selected.

Photos use the selected event or !photo EVENT_ID in the caption. The registry checks the event and group, decodes and re-encodes JPEG/PNG/WebP images up to 5 MiB, and stores them on its private disk. Photos wait for review before they appear in the event album. GET /e/{event}/whatsapp-photos/{photo} serves an allowed photo with private, no-store response headers; unauthorized requests return 404. WhatsApp contributors keep their source label and are not presented as Sideverse members.

Supported group attachments use the private media path below. Unsupported attachments have an open-in-WhatsApp placeholder. View-once and disappearing content is not copied. Provider permissions still limit member changes and message removal. A queued command is not a delivery receipt.

Phone data. Group and member identifiers are HMAC references made with a key held only by the worker. Reports do not include raw WhatsApp JIDs or phone numbers. The account keeps only the last four number digits as a connection hint. The session, reference key and report queue remain in the private bridge state directory. Use a separate state directory and secret for staging.

Private WhatsApp chat attachments

POST /api/v1/bridge/whatsapp/media uses the same signed request middleware as poll and reports. The body supplies command_id, account_id and session_generation. Only an open, current chat.send command can fetch its own delivery attachment. The response contains media_base64, verified mime and filename, with a 5 MiB decoded limit. The command does not contain file bytes or an arbitrary fetch URL.

Inbound chat.media uses the current account, group mapping, message ID and sender reference. It follows member opt-outs and message deletion records. Unsupported content retains a notice without storing its bytes. Normal group attachments do not require an event. Event-album publication still requires organiser review. View-once and disappearing content are not imported.

New media records keep the sender caption separate from interface text. A missing caption stays empty. Unsupported files use the server-controlled attachment_unavailable notice. The browser translates the attachment label and notice in the viewer language. The room-message API retains a readable body for older clients. Existing stored captions are not rewritten.

GET /app/attachments/{attachment}?side=… requires the current Side to have access to the active community and room. Hidden messages and archived rooms/communities return 404. Files are private and are served with no-store and nosniff headers. Bulk deletion retains file references for the hourly attachment cleanup task.

Participant photos use GET /app/photos/whatsapp/members/{member}?side=… with current mapped-room and opt-out checks. Provider privacy can make a photo unavailable. The worker fetches visible member pages without treating an unavailable photo as a broken image.