الوثائق
مرجع واجهة البرمجة
نقاط اتصال HTTP التي يوفّرها هذا السجل.
هذه المقالة متاحة باللغة الإنجليزية حاليًا.
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 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 {"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 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
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.