Build on the Mesh
Registry API
Every public and owner route of the Portal Mesh Registry API: who may call it, what to send, what comes back, and what can go wrong.
On this page
- Basics
- Authentication
- Errors
- Public routes
- Sign-in and the account
- Teams
- Registering a name
- Records and activation
- Managing a name
- Owner grants and succession
- Automatic renewal and the card on file
- Domain proof
- Recovery
- Support, reports and hosted servers
- For the Mesh servers
- Files, payments and staff
- The name view
- The key file: pm.name-key/1
- The rotation proof: pm.key-rotation/1
Basics
The Registry API is at https://api.theportalmesh.com. It speaks JSON in both directions, and every path starts with /v1. A PID is used in paths as it is, colon included: /v1/names/pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC.
- Request bodies are limited to 256 KB, except record uploads and key rotation (512 KB) and verification and recovery evidence (24 MB).
- The registry website calls it with credentials. Read routes that need no session are also open to any origin, without credentials.
- Search and registration are rate limited.
- Answers are not cached unless a route says so.
Authentication
People sign in with their Jubilee ID. The API then sets a session cookie, pm_session: HttpOnly, Secure, SameSite=Lax, on .theportalmesh.com, for 14 days. The cookie holds a random token; the registry stores only its SHA-256. Tools may send the same token as Authorization: Bearer <token> instead.
- Two-factor authentication is a property of the session. It is satisfied when Jubilee ID reports a second factor at sign-in, or by the registry’s own step-up: a TOTP code (RFC 6238: six digits, 30-second steps, SHA-1), each code usable once. Routes marked two-factor answer
403 two_factor_requiredwithout it. - The Origin check. A state-changing request (POST, PUT, DELETE) that carries an
Originheader must carry one of the registry’s own origins, or it is refused with403 origin_refused, “This request did not come from the Portal Mesh website.” A request with noOriginis allowed only if it carries nopm_sessioncookie, so it can authenticate only by a Bearer token, as a server-side tool does. In practice, only the registry’s own sites can make changes from a browser. - Roles on a name. The owner may do everything. An organization’s team members act by role: a viewer reads the name and its records; an editor also uploads and changes records; an admin also pays, verifies, births, activates, rotates keys, renews and switches automatic renewal; only the owner files a realm charter, transfers, retires, names a successor and recovers a lost key. Too low a role is
403 forbidden, “This needs the editor role on oasis.character; you are a viewer.” A name the account cannot see at all is reported as missing, so PIDs cannot be probed: “There is no portal name with that permanent ID.”
Errors
Every error is JSON with a code for programs and a message written for people, which a client should show as it is. Some add a detail, such as the full list of problems.
{
"code": "required_records_missing",
"message": "Still needed before activation: PERSONA, ENDPOINT.",
"detail": {
"missing": [
"PERSONA",
"ENDPOINT"
]
}
}| code | HTTP | Means |
|---|---|---|
invalid_name | 400 | The name breaks the label rules. |
invalid_address | 400 | The input is not a Mesh address. |
unknown_namespace | 400 | The namespace is not one of the seventeen. |
not_offered | 403 | The namespace is not open for registration. |
reserved_name | 403 | The name is reserved and cannot be requested. |
requires_review | 409 | The request needs a reviewer first. |
name_taken | 409 | Someone already holds the name. |
unauthorized | 401 | Not signed in, or a two-factor code did not match. |
two_factor_required | 403 | This step needs two-factor authentication on the session. |
forbidden | 403 | Signed in, but not allowed to do this. |
not_found | 404 | No such thing, or not one you may see. |
cap_reached | 429 | A registration limit was reached. |
payment_required | 402 | Payment comes first. |
verification_required | 403 | Verification or review must be approved first. |
invalid_request | 400 | The request is malformed or incomplete. |
invalid_record | 422 | A record failed validation or verification. |
signature_invalid | 422 | A signature, or another activation check, failed. |
required_records_missing | 422 | Records the namespace requires are missing. |
status_conflict | 409 | The name is not in a state that allows this. |
transfer_refused | 403 | This name can never be transferred. |
key_proof_required | 403 | A key rotation must be signed by the current key; without it, start a recovery. |
identity_required | 403 | The account must be identity-verified before its first paid registration. |
account_review | 409 | The account holds many names and is waiting for a Jubilee review before it registers more. |
charter_required | 409 | A realm is an application: file its charter before checkout. |
rate_limited | 429 | Too many requests; wait and try again. |
turnstile_failed | 403 | The human check did not pass. |
origin_refused | 403 | A write came from a site other than the registry's own. |
config_error | 503 | The registry is missing configuration. Fails closed. |
upstream_error | 502 | A service the registry depends on failed. |
internal | 500 | Something unexpected; the message quotes a reference. |
An unexpected failure answers 500 internal, “Something went wrong on our side. Please try again; if it keeps happening, quote reference <id>.” Nothing about the failure leaks into the answer.
Public routes
GET/v1/health
- Auth
- None.
- Answer
{ "ok": true, "service": "registry-api", "environment": "production" }
GET/v1/config
- Auth
- None.
- Answer
{ siteOrigin, apiOrigin, filesOrigin, pnsOrigins[], turnstileSiteKey, stripeMode, term, root, devLogin }.termis{ months, renewalMode }, the registration term (12 months, renewed yearly, by default).stripeModeistestorlive;rootis{ kid, publicKey, x }, ornullif the root key is not configured;devLoginis true only on a development registry.
GET/v1/root-key
- Auth
- None.
- Answer
{ kid, publicKey: { kty: "OKP", crv: "Ed25519", x }, algorithm: "Ed25519" }, cacheable for an hour. The same as a PNS server’s/root-key.- Errors
503 config_error, “The registry signing key is not configured.”
GET/v1/namespaces
- Auth
- None.
- Answer
{ namespaces: [...] }, all 17 in family order, cacheable for a minute. Each is{ tld, family, scheme, defaultDirection, verificationLevel, verificationLabel, offered, priceCents, required, requiredTxtKinds, ownerSupplied, persona, transferable, relationshipLabels, example, whoMayRegister, rules }.offered,priceCents(the yearly fee in cents: 0 for.avatar, 100000 for.realm),verificationLevelandrulesare live settings an administrator can change, and the registry enforces the level that is set. See Namespaces.
GET/v1/namespaces/:tld
- Auth
- None.
- Answer
- One namespace, as above. A leading dot is allowed:
/v1/namespaces/.character. - Errors
404 not_found, “.example is not a Mesh namespace.”
GET/v1/search?q=oasis
- Auth
- None. Signed in, the answer is the one the caller would get on registering: a holder of a reservation sees that name as available (except where review is required anyway: a tragedy, or any
.figure), and the verified owner of a matching web domain sees its priority. - Answer
{ query, label, valid, message?, results[], suggestions[] }.qmay beoasis,oasis.characteroractivate://oasis.character; its first label is checked. An invalid label still answers 200, withvalid: false, the reason inmessage, and no results.- Errors
429 rate_limited, “Too many searches at once. Please wait a moment and try again.”
results has one row per namespace, 17 in family order: { tld, family, name, address, state, priceCents, verificationLevel, ownerLabel?, reason?, reservedReason?, requestable?, priorityDomain?, heldByYou? }. Each row’s priceCents is that namespace’s yearly fee, and its address carries the locked scheme: activate://, portal:// or experience://. For a reserved name, reservedReason says why it is held and requestable whether it may be requested (the request goes to review). Two fields appear only for a signed-in caller: heldByYou: true when the reservation is the caller’s (held for the account, or for a domain it has proved), and priorityDomain when the caller’s verified domain gives it priority over someone else’s unfinished registration. The row then shows the name as if that registration were not there: available, say, with the reason “You have priority as the verified owner of starwars.com.” state is one of:
| state | Means |
|---|---|
available | Free to register. |
taken | Registered. ownerLabel is the public owner label only; never contact details. A retired name stays taken: “This name was retired. A retired name and its permanent ID are never reissued.” |
reserved | Held; reason says why. See reserved names. |
requires_rights | A movie, music or book: the registrant will state that they own or license the work. |
requires_review | A look-alike, a figure, or a tragedy: a reviewer checks it first. |
not_offered | The namespace is closed; priceCents is null. |
suggestions holds up to three { name, tld, state } alternatives, in the namespace that was typed (or .character), when that one is not available. Each has been checked and is available or requires rights.
GET/v1/lookup/:address
- Auth
- None.
- Answer
- A public view of any born name, with the status read live:
{ name, address, pid, tld, family, state, ownerLabel, relationshipLabel, displayName, bornAt, certificate, certificateHistory[], corrected, view, formerNames[] }. The address may use any scheme and may be percent-encoded;addressis the corrected one, andcertificateis the current signed certificate. - Errors
400for an invalid address;404 not_found, “oasis.character is not a registered portal name.”
formerNames lists the names that held a released address before the current one, each { pid, releasedAt, certificateUrl }, so anyone can see that the address now belongs to a different character and read the former one’s death certificate. When the address has not been registered again, the answer is the most recent former name itself, in its released state. See No resurrection.
GET/v1/certificates/:pid
- Auth
- None.
- Answer
{ certificate, fingerprint, pdfUrl, history[] }, cacheable for a minute.certificateis the current one (the death certificate once retired). Each history entry is{ certificateId, kind, version, issuedAt, supersededAt, fingerprint, certificate }. See Certificates.- Errors
404 not_found, “There is no certificate for that permanent ID.”
GET/v1/resolve/:address
- Auth
- None.
- Answer
- A
307redirect to the same address on the first PNS server, query included, so?time=and?dir=carry through. See Resolution.
Sign-in and the account
GET/v1/auth/login?return=/dashboard
- Auth
- None.
- Answer
- A redirect to Jubilee ID (OpenID Connect with PKCE). After sign-in the browser comes back to the registry website at
return(a path on the site; anything else becomes/dashboard), or to/welcome/?return=for a new account that has not yet chosen its type. The session cookie is set on the way.
GET/v1/auth/callback
- Auth
- Called by Jubilee ID only, never by a client.
- Answer
- Finishes the sign-in, sets the session cookie, and redirects to the site as above. A sign-in error from Jubilee ID redirects to
/signin/?error=. - Errors
400 invalid_request, “The sign-in response was incomplete. Please sign in again.”;403 forbidden, “This account is suspended. Contact support.”
POST/v1/auth/logout
- Auth
- None needed; the Origin check applies.
- Answer
{ "ok": true }. The session, if there is one, is revoked and the cookie cleared.
POST/v1/auth/dev-login
- Auth
- Development and test registries only; everywhere else it answers
404. - Body
{ "email": "...", "displayName"?: "...", "type"?: "individual" | "organization", "jubilee"?: true }- Answer
{ token, account }: a session without Jubilee ID, for local development and the test suite./v1/configreports it asdevLogin.
GET/v1/me
- Auth
- Signed in (session).
- Answer
{ account, mfa: { enrolled, satisfied } }, whereaccountis{ id, type, displayName, email, verification, twoFactor, needsAccountType }.- Errors
401 unauthorized, “Please sign in with your Jubilee ID.”
POST/v1/me/account-type
- Auth
- Signed in (session).
- Body
{ "type": "individual" | "organization", "displayName"?: "...", "contact"?: { "name", "email", "phone"? } }. The display name (up to 120 characters) is the public owner label on certificates. An organization must give an accountable point of contact, which is kept private.- Answer
{ account }- Errors
400 invalid_request, “Organizations give an accountable point of contact: a name and an email. It is kept private.”;409 status_conflict, “The account type cannot change once the account holds names.”
POST/v1/me/mfa/enroll
- Auth
- Signed in (session).
- Answer
{ secret, otpauthUri }. Show the URI as a QR code for an authenticator app, then confirm with a code.- Errors
409 status_conflictif two-factor is already set up.
POST/v1/me/mfa/confirm
- Auth
- Signed in (session).
- Body
{ "code": "123456" }- Answer
{ "ok": true, "twoFactor": true }. Confirms enrollment, and this session now has two-factor.- Errors
401 unauthorized, “That code did not match. Check your authenticator app's time and try again.”
POST/v1/me/mfa/verify
- Auth
- Signed in (session).
- Body
{ "code": "123456" }- Answer
{ "ok": true, "twoFactor": true }: a later session steps up to two-factor.- Errors
400 invalid_request, “Finish setting up two-factor authentication first.”;401 unauthorizedas above.
GET/v1/me/verifications
- Auth
- Signed in (session).
- Answer
{ verifications: [{ id, kind, level, state, result, decided_at, created_at, pid }] }. Note the field names in this answer are written with underscores.
POST/v1/me/verifications
- Auth
- Signed in, with two-factor authentication on the session.
- Body
{ "kind": "identity" | "organization", "evidence": [{ "name"?, "contentType", "data" }] }. Identity verification is for a person’s own (individual) account; organization verification for an organization account. One to four files, each a JPEG, PNG or WebP image or a PDF under 4 MB, withdatain base64 or base64url.- Answer
201 { id, state: "submitted" }. The account’s names waiting for this kind of verification now point at it.- Errors
409 status_conflict, “A verification of this kind is already waiting for review.”
Jubilee reviews the evidence and deletes it the moment it decides; only the result is kept. An approval is reused for every name the account registers that needs it.
Teams
An organization account can give staff a role on all of its names: admin, editor or viewer.
GET/v1/team
- Auth
- Signed in (session).
- Answer
{ members: [{ accountId, role, displayName, email }], invites: [{ id, email, role, createdAt, expiresAt }], memberships: [{ orgAccountId, role, displayName }] }: the account’s own team and pending invitations, and the organizations it belongs to.
POST/v1/team/invites
- Auth
- Signed in as the organization, with two-factor.
- Body
{ "email": "[email protected]", "role": "admin" | "editor" | "viewer" }- Answer
201 { id, acceptUrl, token, emailed: true }. The invitation is emailed to that address;acceptUrlis the same link, for sharing another way. It is valid for 14 days and must be accepted by the invited email address.
POST/v1/team/accept
- Auth
- Signed in, with two-factor authentication on the session.
- Body
{ "invite": "inv_...", "token": "..." }- Answer
{ "ok": true, "orgAccountId": "acc_...", "role": "editor" }- Errors
404 not_found, “That invitation is not valid or has expired.”;403 forbiddenfor another email address.
PUT/v1/team/members/:accountId
- Auth
- Signed in as the organization, with two-factor.
- Body
{ "role": "admin" | "editor" | "viewer" }- Answer
{ "ok": true }
DELETE/v1/team/members/:accountId
- Auth
- Signed in as the organization, with two-factor.
- Answer
{ "ok": true }
Registering a name
A registration moves through pending, paid and born to active. Starting a registration and reading one name both answer with next, the step the name waits for:
| next | The name waits for |
|---|---|
review | A reviewer: a look-alike, a reserved name someone asked for, a tragedy, or any figure. |
charter | A realm’s charter (below). |
checkout | Payment. |
decision | Jubilee's decision on a realm's charter, with its fee held on the card. |
verify | The owner's identity or organization verification, or a figure's documented reason. |
character | A .character registered from its questionnaire: its checkout and its birth are on its draft (Registering a character). |
birth | The owner’s key (birth). |
activate | Records and activation. |
manage | Nothing: the name is live, or past the point of registration. |
POST/v1/names
- Auth
- Signed in, with two-factor authentication on the session.
- Body
A Worker character, registered by an organization:
json { "name": "oasis.character", "relationshipLabel": "AI Employee", "responsibleGuardian": "Jordan Lee", "gender": "female", "affirmResponsibility": true, "displayName": "Oasis", "acceptRules": true, "acceptTerms": true, "turnstileToken": "<token from the human check>" }For any
.character:gender(male,femaleorother) andaffirmResponsibility: true, the affirmation in the registry’s words. A person addsregistrantRole,fatherormother, and a FamilyrelationshipLabel(the certificate’s label is set from the Character DNA at birth); an organization adds its title asrelationshipLabel(AI Employee when none is given) andresponsibleGuardian, a person’s name. Any name may add adisplayName(up to 120 characters), which need not be unique. See Characters.- Answer
201 { name, next, priority? }, wherenameis the name’s view (below).priorityis{ domain }when the caller’s verified domain released someone else’s unfinished registration of the same name (see Domain proof).- Errors
400 invalid_request: the account type is not chosen; the rules or terms are not accepted; the relationship label is missing or wrong; a character without its gender (“Choose the character's gender: male, female or other. It stays on the birth certificate.”), its affirmation, a Parent shown as father or mother (“Choose whether you are shown as the father or the mother. A Family character has exactly one Parent.”), or an organization’s Responsible Guardian; a Worker title with a family word (“A Worker character's label is "AI" and your title for it, such as AI Employee or AI Representative.”).403 verification_required, “Verify your email address with Jubilee ID first.”;403 turnstile_failed;403 not_offered;403 reserved_name;403 forbiddenfor the wrong kind of account.409 account_review, “This account is being reviewed because it holds many names. We will email you when the review is finished.” The first time an account passes the threshold, the review opens and the message says how many names it holds.409 name_taken, “oasis.character is already registered by Example Studio.”429 cap_reached, “You have reached the limit of 10 new .character names in 30 days.”;429 rate_limited.
A new name is held for 60 minutes while its registrant pays; an unpaid hold expires and gives the label back. A name that needs review (a look-alike, a reserved name someone asks for, a tragedy, or any .figure, subnames included) is held until the review decides; holding a reservation never skips a review the namespace requires. A subname is made by an admin of its parent, which must be born; it is paid at once, at no charge. A subname whose leaf is a reserved label is refused with 403 reserved_name, “"persona" is a reserved label: oasis.character already answers persona.oasis.character itself.” A person’s first avatar is their primary avatar.
GET/v1/names
- Auth
- Signed in (session).
- Answer
{ names: [...] }: the account’s own names, and those of every organization it is a member of, each with the account’s role. Each name’sautoRenewsays whether the card on file renews it.
GET/v1/names/:pid
- Auth
- Signed in; the viewer role or higher.
- Answer
{ name, next, records, certificate, certificateUrl, statusReason, reviews[], transfers[], recovery, versionFloors, keyId, nextKeyId }.nextis as above.recordsare the stored signed envelopes.recoveryis the open key recovery of the name,{ id, kind, state, createdAt, expiresAt, readyAt }, ornull(see Recovery).keyIdis the key id every new owner record must carry (the current key), ornullbefore birth;nextKeyIdis an older name for the same value.versionFloorsis explained under PUT records.
POST/v1/names/:pid/checkout
- Auth
- Signed in, with two-factor authentication; the admin role or higher on the name.
- Body
{ "autoRenew"?: true | false }. Automatic renewal is on unless this isfalse: Stripe saves the card for the registry to charge before each term ends (see Automatic renewal). Subnames, and registrations that do not expire, never save a card.- Answer
{ url, sessionId, autoRenew }: send the person tourl, a Stripe Checkout page.autoRenewsays whether the card will be saved. The hold is extended to 24 hours. Stripe returns tohttps://theportalmesh.com/register/<name>/?paid=1&pid=<pid>, and the name becomespaidwhen Stripe confirms the payment to the registry.Account credit is spent first. When it covers the whole price, nothing goes to Stripe: the answer is
{ url, sessionId: null, autoRenew: false, paidWithCredit: true, outcome }, the name is already paid, and the client goes tourlall the same.A free name, an
.avatar, answers{ url, sessionId: null, autoRenew: false, paidWithCredit: false, free: true, outcome: "paid" }with no Stripe at all. A realm answers{ url, sessionId, autoRenew, held: true }: Stripe holds the $1,000 on the card, and the card is kept, until Jubilee decides its charter (below). Credit is never spent on a realm.- Errors
403 identity_requiredbefore an account’s first paid registration, withdetail.verifyset toidentityororganization: “Verify your identity with Jubilee before your first paid registration. Every account on the Mesh is a verified person or organization, so names cannot be hoarded.”409 charter_requiredfor a realm without its charter: “File the realm charter for heaven.realm first: its purpose, rules, content rating and Responsible Guardian.”403 verification_required, “This name is waiting for review. You can pay once it is approved.”;409 status_conflict, “The hold on this name expired before payment. Please search again.”;429 cap_reached.
POST/v1/names/:pid/charter
- Auth
- Signed in, with two-factor authentication; the owner role or higher on the name.
- Body
json { "purpose": "A depiction of Heaven drawn from Revelation 21 and 22, for worship and teaching.", "rules": "Visitors come as their own verified avatars. No violence, no commerce, an exit that always works.", "contentRating": "all", "responsibleGuardian": "Jordan Lee" }- Answer
201 { charter }, wherecharteris{ purpose, rules, contentRating, responsibleGuardian, state, decidedAt, note, createdAt }andstateissubmitted,approvedorrejected. A realm only, and only before its fee is held; filing again replaces it.- Errors
400 invalid_request: “The charter needs its purpose (at least 20 characters).”, the same for its rules; a content rating that is not one of all, teen, mature, adult (any letter case) is refused with “Choose the realm's content rating: all (All ages), teen (Teen (13 and older)), mature (Mature (17 and older)), adult (Adult (18 and older)).”; and a Responsible Guardian of fewer than 3 characters.409 status_conflictonce the charter is with Jubilee for a decision.
The charter is decided by hand in the Customer Admin. Approval takes the held payment (or charges the saved card, if the hold has lapsed) and the realm is paid; rejection releases the hold. The applicant is emailed either way, and the Portal Server enforces the approved charter at the door. GET /v1/names/:pid/charter (any role on the name, with two-factor) answers { charter }, or { charter: null }.
POST/v1/names/:pid/verify
- Auth
- Signed in, with two-factor authentication; the admin role or higher on the name.
- Body
- For identity and organization namespaces,
{ "evidence": [...] }as for account verification. For a figure,{ "evidence"?: [...], "figure": { "reason": "...", "diedYear"?: 1799, "rightsHolderPermission"?: true, "notWorshipped": true } }, with a documented reason of at least 20 characters. - Answer
201 { state: "submitted", verificationId }, or200with the existing state when a verification is already approved or waiting. Other namespaces answer “This namespace needs no separate verification step.”- Errors
400 invalid_request, “Confirm that this person is not worshipped or venerated as divine, nor the founding prophet of a religion.”
POST/v1/names/:pid/birth
- Auth
- Signed in, with two-factor authentication; the admin role or higher on the name.
- Body
{ publicKey, personaPackage? }: the name’s Ed25519 public key as a JWK, generated on the registrant’s device, and for a persona namespace the persona package. The private key is never sent.json { "publicKey": { "kty": "OKP", "crv": "Ed25519", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" }, "personaPackage": "<the persona package, for .character, .avatar and .figure>" }- Answer
201 { pid, certificate, certificateFingerprint, certificateUrl, pdfUrl, jsonUrl, records, keyId, personaProfileUrl }.recordsare the new ID, STATUS (born) and CERT records;keyIdis<pid>#k1;personaProfileUrlis where the persona package was published, ornull.- Errors
402 payment_required, “Complete payment before birth.”;403 verification_required;400 invalid_request, “Send the Ed25519 public key as a JWK. The private key stays with you.” or “A persona package is needed: its fingerprint is written into the birth certificate.”;409 status_conflictif already born, and always for a.character, withdetail.reason: "born_by_engine": a character is born by the birth engine the moment its registration is paid for (The birth engine).
Records and activation
PUT/v1/names/:pid/records
- Auth
- Signed in, with two-factor authentication; the editor role or higher on the name.
- Body
{ "records": [...], "remove"?: [{ "type", "kind"? }], "personaPackage"? }: up to 20 signed owner records, slots to remove, and the new persona package when a PERSONA record changes the persona. Only while the name is born or active. See Records and signing for every rule.- Answer
{ ok, serial, records, recertified, missing, versionFloors }: the new serial, every stored record, whether the birth certificate was re-issued, what is still missing, and the version floors.- Errors
422 invalid_record, with the first problem as the message and all of them indetail.problems;422 required_records_missingwhen a change would leave an active name without a record it needs (“An active name must keep ENDPOINT.”);409 status_conflict.
Every new record must be numbered above both the slot’s stored version and its floor. When an owner removes a record, the registry keeps the highest version that slot held as its floor, so an older signed copy can never be put back. versionFloors lists the floors, keyed "TYPE" or "TXT:kind", for example { "ENDPOINT": 3, "TXT:rating": 1 }: a new ENDPOINT must then be version 4 or higher. Records dropped by a key rotation or a transfer leave no floor, because they can never verify again.
A character’s experience CONSENT is uploaded here like any owner record, in the slot CONSENT:<experience>; to remove one, send { "type": "CONSENT", "kind": "<experience>" } in remove. Removing it, or replacing it for another script, withdraws the consent: the registry tells the Character Server at once, and the character leaves that experience.
GET/v1/names/:pid/activation-check
- Auth
- Signed in; the viewer role or higher.
- Answer
{ ready, missing[], problems[] }: exactly what activation would check, without activating.
POST/v1/names/:pid/activate
- Auth
- Signed in, with two-factor authentication; the admin role or higher on the name.
- Body
{ "modes": ["text", "voice"], "directions": ["bring"] }. Each must be offered by the ENDPOINT record; empty lists take the ENDPOINT’s.- Answer
{ name, manifest, snippet }: STATUS becomes active and is published, and the answer carries the portal.json manifest and the portal.js snippet. Calling it again on an active name changes its modes and directions.- Errors
422 required_records_missing, “Still needed before activation: PERSONA, ENDPOINT.” withdetail.missing;422 signature_invalidfor any other problem (a signature, a pending review, an ALIAS target that is not live), withdetail.problems;400 invalid_request, “The ENDPOINT does not offer hologram.”;409 status_conflict.
Managing a name
POST/v1/names/:pid/rotate-key
- Auth
- Signed in, with two-factor authentication; the admin role or higher on the name. The request must also be signed by the name’s current key: the
proof. - Body
{ "publicKey": { JWK }, "proof": "...", "records"?: [...], "reason"?: "..." }.proofis required: the current key’s signature over apm.key-rotation/1statement (below), so the current key file must be opened. Records are signed with the new key, whose id is<pid>#k<n+1>.- Answer
{ keyId, keyVersion, certificateId, name, droppedRecords }: a new ID record and a re-issued birth certificate. The owner is emailed, and any open key recovery of the name is canceled: whoever holds the current key outranks a recovery request.- Errors
403 key_proof_requiredwithout aproof, with the statement to sign indetail.statement: “Sign the rotation with oasis.character's current key file. If the key is lost, start a recovery from your dashboard instead.”422 signature_invalid, “The rotation is not signed by oasis.character's current key (<pid>#k1).”, also withdetail.statement.422 required_records_missing: an active name must send every owner record re-signed with the new key in the same request, and the answer lists the ones missing.422 signature_invalidfor a record that does not verify against the new key;422 invalid_recordfor one that is malformed or not newer than the slot’s current version.409 status_conflictunless the name is born or active.
A born name that is not yet active may rotate without records; its old owner records are dropped and listed in droppedRecords.
{
"publicKey": {
"kty": "OKP",
"crv": "Ed25519",
"x": "_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU"
},
"proof": "4u7awgL7_fIZPFMHx5PMokcbs3KYZaOSpQev3Svbxb-0Ayfk3X38zZWK0PRwHo0lo1ooNvhF4thB6rvyK06gAg",
"records": [
"<every owner record of an active name, re-signed with the new key and numbered above its current version>"
],
"reason": "Scheduled key rotation"
}POST/v1/names/:pid/transfer
- Auth
- Signed in, with two-factor authentication; the owner role or higher on the name.
- Body
{ "toEmail": "[email protected]", "affirmNoSale": true }- Answer
201 { id, acceptUrl, token, emailed: true }. The recipient is emailed the offer, and the owner is emailed a notice in case the account is not in the owner’s hands;acceptUrlis the recipient’s link, for sharing another way. The transfer is open for 14 days; one at a time per name.- Errors
400 invalid_requestwithoutaffirmNoSale: true, “Names are never resold on the Mesh. Affirm that no money is changing hands for this name to continue.”;403 transfer_refused, “An avatar is bound to one person for life and can never be transferred.”;409 status_conflict, “A transfer of this name is already waiting. Cancel it first.”
Names cannot be resold: every transfer goes through Jubilee, at cost. The holder starts it, the recipient accepts it, and Jubilee approves it; when there is a fee, the recipient pays it from an emailed link, and the name moves once it is paid.
GET/v1/transfers/incoming
- Auth
- Signed in, with two-factor authentication on the session.
- Answer
{ transfers: [{ id, createdAt, expiresAt, name, pid, keyVersion, tld }] }: open transfers sent to the account’s email.
POST/v1/transfers/:id/accept
- Auth
- Signed in, with two-factor, as the account the transfer was sent to.
- Body
{ "token": "...", "publicKey": { JWK }, "affirmNoSale": true, "relationshipLabel"?: "...", "registrantRole"?: "..." }- Answer
{ transfer: { id, state: "approval", feeCents } }. Both people are emailed that the transfer waits for Jubilee’s approval.feeCentsis what the recipient will pay, if anything.- Errors
400 invalid_requestwithoutaffirmNoSale: true, “Names are never resold on the Mesh. Affirm that you paid nothing for this name to continue.”;403 transfer_refusedfor a Family character and an organization, “A Family character can never be transferred to an organization. An organization creates its own Worker character instead.”;404 not_found, “That transfer is not valid or has expired.”
Once Jubilee approves it, and any fee is paid, the new owner’s key takes over. The previous owner’s key, and every record it signed, stop counting: the birth certificate is re-issued for the new registrant, owner records are dropped, and an active name returns to born until its new owner activates it. The name arrives with automatic renewal off, because the previous owner’s card no longer pays for it. The previous owner is emailed when the transfer completes.
POST/v1/transfers/:id/cancel
- Auth
- Signed in, with two-factor, as the sender.
- Answer
{ "ok": true }. An open transfer, one waiting for approval, or one waiting for its fee.
POST/v1/names/:pid/retire
- Auth
- Signed in, with two-factor authentication; the owner role or higher on the name.
- Body
{ "confirmName": "oasis.character", "reason"?: "..." }- Answer
{ name, deathCertificateId, confirmed, servers[] }:200once all four PNS servers have confirmed the retirement,202when it is not yet confirmed (it keeps being published). A death certificate is issued and the permanent ID is never reused.- Errors
400 invalid_request, “Type oasis.character to confirm. Retirement is permanent and the permanent ID is never reissued.”
POST/v1/names/:pid/renew
- Auth
- Signed in, with two-factor authentication; the admin role or higher on the name.
- Body
{ "autoRenew"?: true | false }. As at checkout, paying by hand also saves the card and turns automatic renewal on, unless this isfalse.- Answer
{ url, sessionId, autoRenew }: a Stripe Checkout for the next term, which returns tohttps://theportalmesh.com/dashboard/?renewed=<name>. When account credit covers the price,{ url, sessionId: null, autoRenew: false, paidWithCredit: true, outcome }: the name is already renewed. A free name renews the same way withfree: true. Either way a receipt is emailed. A sleeping character renews like an active one, and adormantname is revived by the renewal at once, to the state it was in.- Errors
400 invalid_request, “Subnames renew with their parent.”;409 status_conflict, “A suspended name cannot be renewed until the suspension is lifted.”, and for a name that has died or ended: “oasis.character cannot be renewed while dead.” There is no resurrection; see The life of a name.
Owner grants and succession
Owner grants
An owner manages a name’s settings on the Mesh server that runs it, Jubilee’s or a self-hosted one, with a short-lived grant the registry signs. See How the Mesh is run for an example, verified while these pages were built.
POST/v1/names/:pid/grant
- Auth
- Signed in, with two-factor; any role on the name. The Origin check applies.
- Body
{ "server": "character" | "portal" | "experience" }- Answer
{ token, expiresAt }: apmg1token, good for five minutes at that server only, naming the account, the name, its PID and the caller’s role on it. The server checks it with the published grant key. The role limits what the server allows: owners and admins change settings, editors edit content, viewers read.- Errors
400 invalid_request, “Name the server: character, portal or experience.”, or for the wrong server: “oasis.character is served by the Character Server.”
GET/v1/grant-key
- Auth
- None.
- Answer
{ keys: [{ kid, x }] }, cacheable for an hour: the public half of the registry’s grant key, an Ed25519 key that is not the root key. A grant vouches for an account, never for a record.- Errors
503 config_error, “The owner grant key is not configured.”
Succession
POST/v1/names/:pid/succession
- Auth
- Signed in, with two-factor authentication; the owner role or higher on the name.
- Body
{ "successorName": "Ann Brown", "successorEmail": "[email protected]", "backupName"?: "...", "backupEmail"?: "..." }- Answer
{ ok: true, successorDesignated: true }. A.character’s guardianship designation, like a will: filing again replaces it. The emails are kept private, and the GUARDIAN record is re-issued sayingsuccessorDesignated: true.- Errors
400 invalid_request, “Name a successor: the person who becomes the character's Guardian if you no longer can.” or “Give the successor's email, so Jubilee can reach them. It is never published.”
POST/v1/succession/accept
- Auth
- Signed in, with two-factor, as the email the invitation was sent to.
- Body
{ "pid": "pid:...", "token": "...", "affirmResponsibility": true }- Answer
{ ok: true, name, status }. After a death report confirmed by Jubilee, the successor (or else the backup) is emailed a link,/succession/?pid=<pid>&token=<token>. Accepting it, with the guardianship affirmation, moves the character to their account: it runs again, GUARDIAN sayssucceeded, and the Parent stays on the birth certificate.- Errors
400 invalid_request, “Affirm: "I accept the duties and responsibilities of guardianship for this AI character."”;403 forbidden, “Sign in with the email this invitation was sent to.”;404 not_found, “That link is not valid any more.”;409 status_conflictwhen the character is not waiting for a Guardian.
With nobody stepping in within 180 days, the character is retired with a death certificate. See Succession.
Automatic renewal and the card on file
A registration runs for a term, 12 months by default (/v1/config reports it as term), and renews automatically unless its owner turns that off. Checkout saves the card at Stripe for the registry to charge before each term ends; renewing by hand does the same. A card can also be saved on its own, from the dashboard.
- The registry never sees a card number. It keeps Stripe’s references and the brand, last four digits and expiry Stripe returns, so it can say which card will be charged, for example “Visa ending 4242”.
- One card per account. Saving a new one replaces the old one. Removing it turns automatic renewal off for every name.
- The card is charged one day before the name expires. A declined charge is tried again three days later, at most three times per term, and the owner is emailed each time in plain words. After the last attempt the name follows the ordinary path: reminders, a 30-day grace period, then a year dormant, during which paying the renewal revives it at once (see When a renewal is missed).
- Account credit is spent first. When it covers the whole price, nothing is charged.
- Each term is charged at most once, however the scheduled runs and Stripe’s notices interleave.
- Subnames renew with their parent. A name that changes hands, by transfer or by an account recovery, arrives with automatic renewal off.
GET/v1/me/billing
- Auth
- Signed in (session).
- Answer
{ card, renewalMode, daysBefore, creditCents, names[] }.cardis{ brand, last4, expMonth, expYear, label }ornull;daysBeforeis how many days before expiry the card is charged;creditCentsis the account credit. Each ofnamesis{ pid, name, expiresAt, autoRenew, eligible }for the account’s own top-level names, whereeligibleis false for a name that cannot renew in its present state.
POST/v1/me/billing/card
- Auth
- Signed in, with two-factor authentication on the session.
- Answer
{ url, sessionId }: a Stripe Checkout in setup mode, which saves a card without charging it and returns tohttps://theportalmesh.com/dashboard/?card=saved. The new card replaces any earlier one.
DELETE/v1/me/billing/card
- Auth
- Signed in, with two-factor authentication on the session.
- Answer
{ ok: true, namesTurnedOff }: the card is never charged again, and automatic renewal is off for every name.- Errors
404 not_found, “There is no card on file.”
POST/v1/me/billing/auto-renew
- Auth
- Signed in, with two-factor authentication on the session.
- Body
{ "enabled": true | false }- Answer
{ ok: true, changed }: automatic renewal is switched for every eligible name the account holds itself.- Errors
400 invalid_request, “Say whether automatic renewal is on or off.”;402 payment_required, “Save a card first; automatic renewal charges it shortly before each name expires.”
PUT/v1/names/:pid/auto-renew
- Auth
- Signed in, with two-factor authentication; the admin role or higher on the name.
- Body
{ "enabled": true | false }- Answer
{ autoRenew, card }: the new setting and, when switching it on, the card that will be charged (the name owner’s);cardisnullwhen switching it off.- Errors
402 payment_required, “Save a card first; automatic renewal charges it shortly before the name expires.”;409 status_conflictfor a subname, “Subnames renew with their parent name.”, or a name that cannot renew in its present state.
Domain proof
The verified owner of a matching web domain gets the Mesh name first: the Registry Website Specification calls it matching-rights priority. An account proves a domain by publishing a DNS TXT record the registry gives it:
| Name | Type | Value |
|---|---|---|
_portalmesh.<domain> | TXT | portalmesh-verify=<token> |
For example, for the claim of starwars.com:
_portalmesh.starwars.com. TXT "portalmesh-verify=XCGeBLc6aPENwkeTLqV7Fug_"The registry looks the record up over DNS over HTTPS when the owner asks it to check, so the first check comes from the dashboard once the record is published. DNS changes can take a while to appear.
Which domain matches which name
A claim is for the registered domain itself, and the label it matches is the one just left of the public suffix: starwars.com, starwars.net and starwars.co.uk all match starwars, in every namespace. A subdomain is refused, because controlling blog.example.com proves nothing about example.com. Common two-part suffixes such as co.uk and com.au are known; a domain under one the registry does not list is refused as a subdomain rather than matched wrongly. What a person types is read this way (this ran during the build):
| Typed | Claimed domain | Mesh label or refusal |
|---|---|---|
https://www.StarWars.com/about | starwars.com | starwars |
starwars.net | starwars.net | starwars |
starwars.co.uk | starwars.co.uk | starwars |
blog.starwars.com | “Claim the registered domain itself, starwars.com, not a subdomain of it.” | |
co.uk | “co.uk is a public suffix, not a domain anyone owns.” | |
203.0.113.7 | “An IP address is not a domain.” | |
my_site.com | “That is not a web domain. Give one such as example.com.” | |
ab.com | “ab.com does not match a Mesh name: A name needs at least 3 characters.” |
What priority does, and does not do
- A verified matching domain comes before an unfinished registration of the same name (not yet paid, or waiting for review) by someone who has not proved a matching domain. Registering releases the other registration in the same step; its registrant is emailed, and a payment of theirs that lands afterward is refunded automatically.
- A reservation can be held for a domain, such as a protected brand’s; the verified owner of that domain registers it directly.
- Reviewers see the proven domains on requests for reserved names and on organization verifications.
- A paid name is never taken this way. A dispute over a name someone has already paid for goes to Jubilee’s staff.
- A second matching domain does not outrank the first: if the person already registering has proved starwars.net, the owner of starwars.com gains nothing over them.
- A look-alike domain gains nothing. Only the exact label matches.
- There is no automated trademark check; trademark claims are for staff to judge.
Rechecks and lapse
A verified proof is checked again every 30 days. When the record is missing it is checked daily, and after three misses in a row the proof lapses: it no longer gives priority, and the owner is emailed. A failed DNS lookup is the registry’s problem and never counts as a miss. Checking a lapsed claim again brings it back as soon as the record is found.
GET/v1/me/domains
- Auth
- Signed in (session).
- Answer
{ domains: [...] }, each{ id, domain, label, state, createdAt, verifiedAt, checkedAt, lastError, record: { name, type: "TXT", value } }, wherestateispending,verifiedorlapsed,recordis what to publish, andlastErrorsays what the last check found, such as “The TXT record was not found yet. DNS changes can take a while to appear.”
POST/v1/me/domains
- Auth
- Signed in, with two-factor authentication on the session.
- Body
{ "domain": "https://www.StarWars.com/about" }: anything a person types; it is read as shown above.- Answer
201 { domain }, the new claim with its record, or200with the existing claim when the account already has it.- Errors
400 invalid_requestwith the reason, such as “Claim the registered domain itself, starwars.com, not a subdomain of it.”;429 cap_reached, “An account can hold 20 domain claims. Withdraw one you no longer need.”
POST/v1/me/domains/:id/check
- Auth
- Signed in (session).
- Answer
{ found, domain }:foundistruewhen the record is there (the claim is now verified),falsewhen it is not, andnullwhen the DNS lookup itself failed, so try again later.domainis the claim as it now stands.- Errors
404 not_found, “There is no domain claim with that id on your account.”
DELETE/v1/me/domains/:id
- Auth
- Signed in, with two-factor authentication on the session.
- Answer
{ "ok": true }: the claim is withdrawn.
Recovery
Rotation or recovery
Rotation is for an owner who has the current key file: the current key signs its own replacement (the rotation proof), and the new key takes over at once. Recovery is for an owner who has lost the key, or someone who has lost the account that holds the name. Signing in is not enough to replace a key, so whoever takes over an account cannot swap a name’s key on the spot: a recovery takes the account’s email and a wait with a cancel link, or a reviewer.
A lost-key recovery asks for the same verification the name was registered with. There are two paths, chosen by the namespace’s verification level:
| Verification level | Namespaces at this level by default | Recovery |
|---|---|---|
| Standard: verified email, two-factor authentication, payment | .character, .agent, .history, .event, .game | Confirm by email, then wait 72 hours. |
| Standard plus SOURCE review | .realm, .destination | Confirm by email, then wait 72 hours. |
| Standard plus rights check | .movie, .music, .book | Confirm by email, then wait 72 hours. |
| Identity verified (human) | .avatar, .home | A reviewer decides, on a written statement and fresh evidence. |
| Organization verified | .business, .ministry, .education | A reviewer decides, on a written statement and fresh evidence. |
| Reviewed by Jubilee | .figure | A reviewer decides, on a written statement and fresh evidence. |
- Confirm by email. The registry emails the account a confirmation link, valid for 24 hours. Once it is confirmed, the new key takes over after a 72-hour wait. Every message carries a link that cancels the recovery, so an owner whose account was taken over has three days to stop it.
- Reviewed. The requester explains what happened (at least 20 characters) and, for identity and organization namespaces, sends fresh evidence of the same kind the name needed at registration. Nothing changes until a reviewer approves it, and the evidence is deleted when they decide. An undecided request lapses after 90 days.
When a recovery completes, the new key replaces the old one: a new ID record, a re-issued birth certificate, and the key id goes up by one. Every record the old key signed is dropped, because it can never verify again, and an active name returns to dormant. Its owner uploads records signed with the new key and activates it again. A name has one open key recovery at a time, and a rotation with the current key cancels it: whoever holds the key outranks a request.
Account recovery
Someone who says a name is theirs, but who has lost the account that holds it, asks from another account. This is always a reviewer’s decision, on a statement of at least 40 characters and evidence, whatever the namespace. The account holding the name is emailed and cannot cancel the request; its owner can answer through support. An approval moves the name to the requester with the requester’s new key, cancels any open transfer, and leaves automatic renewal off.
GET/v1/names/:pid/recovery
- Auth
- Signed in; the owner of the name.
- Answer
{ level, path: "confirm" | "review", waitHours, confirmHours, open }: what a lost-key recovery of this name will ask for, how long the confirmation link lasts and the wait after it, and the open recovery, if there is one.
POST/v1/names/:pid/recovery
- Auth
- Signed in, with two-factor authentication; the owner role or higher on the name.
- Body
{ "publicKey": { JWK }, "statement"?: "...", "evidence"?: [...] }. The new key is made like any other, with the next key id,<pid>#k<n+1>, and saved in a new key file. The review path needsstatementand, for identity and organization levels,evidence(as for verifications).- Answer
201 { recovery }. On the confirm path the account is emailed the confirmation link.- Errors
400 invalid_request, “Make a new key for the name and send its public key as a JWK.”; “That is the key the name already has. Make a new one.”; “Explain what happened in at least 20 characters, for the reviewer.”; “Attach fresh identity evidence: the same kind this name needed at registration.”409 status_conflict, “A recovery of oasis.character is already under way. Cancel it first to start again.”, or for a name that is not born or active: “oasis.character is suspended; recovery waits until it is restored.”
POST/v1/recoveries
- Auth
- Signed in, with two-factor authentication on the session.
- Body
{ "name": "oasis.character", "publicKey": { JWK }, "statement": "...", "evidence": [...] }- Answer
201 { recovery }. The account holding the name is emailed.- Errors
404 not_found, “That is not a registered portal name.”400 invalid_request, “oasis.character is already on this account. Recover its key from your dashboard instead.” or “Attach evidence that the name is yours: receipts, the key file's public key, correspondence, or identity documents.”403 forbiddenwhen the requesting account could not hold the name (an organization’s name is recovered from an organization account).409 status_conflict, “Your request to recover oasis.character is already with a reviewer.”
GET/v1/recoveries
- Auth
- Signed in (session).
- Answer
{ recoveries: [...] }: the recoveries the account asked for, and those of names it holds, newest first. Each is{ id, pid, name, kind, state, path, createdAt, expiresAt, readyAt, completedAt, outcome, role }, wherekindiskeyoraccountandroleisrequesterorholder.
| state | Means |
|---|---|
confirming | Waiting for the link in the confirmation email. |
waiting | Confirmed; the new key takes over at readyAt unless the recovery is canceled first. |
review | With a reviewer. |
completed | The new key took over (and, for an account recovery, the name moved). |
cancelled | Canceled, or overtaken by a rotation with the current key; outcome says which. |
rejected | A reviewer did not approve it. |
expired | Not confirmed in time, or no decision within 90 days. |
POST/v1/recoveries/:id/confirm
- Auth
- None: the token from the email is the proof. The Origin check applies.
- Body
{ "token": "..." }- Answer
{ recovery }, nowwaiting. The email’s link openshttps://theportalmesh.com/recovery/?id=<id>&confirm=<token>.- Errors
404 not_found, “That confirmation link is not valid or has expired. Start the recovery again from your dashboard.”
POST/v1/recoveries/:id/cancel
- Auth
- The token from any recovery email (
/recovery/?id=<id>&cancel=<token>), or signed in as the requester. - Body
{ "token"?: "..." }- Answer
{ recovery }, nowcancelled. Any evidence sent with it is deleted.- Errors
403 forbidden, “Only the person who started this recovery, or the link in its email, can cancel it.”;404 not_found, “There is no open recovery with that id.”
Support, reports and hosted servers
Support requests
GET/v1/support/tickets
- Auth
- Signed in (session).
- Answer
{ tickets: [{ id, subject, category, state, pid, createdAt, updatedAt }] }, most recently updated first, wherestateisopen,waitingorclosed.
POST/v1/support/tickets
- Auth
- Signed in (session).
- Body
{ "subject": "...", "body": "...", "category"?: "general" | "billing" | "registration" | "verification" | "technical", "pid"?: "pid:..." }. The subject is at least 3 characters and the message at least 10. Apidcounts only for a name the account holds.- Answer
201 { id }- Errors
400 invalid_request, “Write the subject (at least 3 characters).”;429 rate_limited, “You have many open requests. Please add to one of them instead.”
GET/v1/support/tickets/:id
- Auth
- Signed in (session).
- Answer
{ ticket, messages: [{ id, author: "customer" | "staff", from: "You" | "Jubilee support", body, createdAt }] }. Staff replies are signed “Jubilee support” and are also emailed; staff’s internal notes are never shown.- Errors
404 not_found, “No such request.”
POST/v1/support/tickets/:id/messages
- Auth
- Signed in (session).
- Body
{ "body": "..." }- Answer
201 { "ok": true }. A reply opens the request again if it was closed.
POST/v1/support/tickets/:id/close
- Auth
- Signed in (session).
- Answer
{ "ok": true }
Reporting a name
POST/v1/abuse
- Auth
- Anyone, signed in or not. The human check and the Origin check apply.
- Body
{ "name": "oasis.character", "category": "impersonation" | "harmful" | "rights" | "spam" | "other", "details": "...", "email"?: "...", "turnstileToken": "..." }. The details are at least 20 characters; the name may be written as an address.- Answer
201 { id, registered }:registeredsays whether the reported name is registered. With an email (or signed in), the reporter is emailed that the report arrived.- Errors
400 invalid_request, “Choose what is wrong: impersonation, harmful, rights, spam, other.”;403 turnstile_failed
Support staff read every report. One that breaks the namespace rules is passed to the people who run Portal Name Services, who can suspend a name on every server. See How the Mesh is run. The Report button on every character, place and experience lands here too; from a Mesh server it may also say a report involves a minor, which asks for the name’s suspension at once while it is reviewed.
Hosted servers
GET/v1/servers
- Auth
- None.
- Answer
{ servers: [{ kind, title, namespaces, summary, download }], hosting: "jubilee" }, cacheable for five minutes: the three Mesh servers, which Jubilee hosts for every customer.downloadis the server’s self-host package,{ version, file, sha256, bytes, builtAt, url }, ornulluntil one is published. Theurlis on the files host (/downloads/); check the file againstsha256before running it.
GET/v1/me/servers
- Auth
- Signed in (session).
- Answer
{ servers: [{ id, kind, title, label, state, pendingAction, createdAt, updatedAt, names, usage, health }] }: the servers hosting is on for, for this account’s names.stateisrunning(on),pausedorrequested(asked for before hosting opened).namesis{ total, live, namespaces: [{ tld, count, names }], truncated }: the account’s paid, born, active, sleeping, suspended and dormant names in that server’s namespaces.usageis the Character Server’s latest storage report,{ storageBytes, characters, measuredAt, includedBytesPerCharacter, overageBytes }, ornullfor the other servers.healthis{ state: up | down | unreachable | unknown, checkedAt, message }, fetched from the server when you ask, with a 2.5-second limit.pendingActionis a restart, pause or resume support has recorded, ornull; it is not carried out on the server yet.
POST/v1/me/servers
- Auth
- Signed in, with two-factor authentication on the session.
- Body
{ "kind": "character" | "portal" | "experience", "label"?: "..." }. The label (2 to 80 characters) defaults to the server’s title.- Answer
201 { id, state: "running", server }: hosting is on at once, since Jubilee’s servers host every customer and nothing is set up for one account. Asking again answers200with the same entry.- Errors
400 invalid_request, “Choose the Character, Portal or Experience Server.”
DELETE/v1/me/servers/:id
- Auth
- Signed in, with two-factor authentication on the session.
- Answer
{ "ok": true }: hosting is turned off. Jubilee’s server still answers for a live name whose ENDPOINT points at it; this records that you mean to run those names elsewhere.- Errors
404 not_found, “No such server.”
What support saw
GET/v1/me/access-log
- Auth
- Signed in (session).
- Answer
{ entries: [{ who, reason, at }] }, newest first: each time Jubilee support looked at the account as the customer sees it, and the reason they gave. That view is read only.
For the Mesh servers
The Character, Portal and Experience Servers reach the registry on routes under /v1/servers/. These are server-to-server, not public API: each needs the calling server’s own service token, Authorization: Bearer <token> with X-Mesh-Server: character, portal or experience, and anything else is refused with 403 forbidden, “This route is for the Mesh servers.” A self-hosted server gets its token from Jubilee.
- What a server needs to know about a name: its status, class, gender, display name, key version, DNA fingerprint and GUARDIAN, and for an avatar, whether its person is identity-verified and their birth year. Never contact details.
- A character’s birth on DNA approval, or the DNA returned to its owner with what needs to change (Character Server only).
- A character falling asleep when its model key fails, and waking when it works again; its daily memory storage, for overage billing.
- An email to a name’s owner, sent from the registry’s outbox, from a short list of notices.
- A realm’s charter (Portal Server only), and reports from a server’s own Report button.
Servers never write PNS: what they report becomes a change in the registry’s database, which PNS Sync publishes. How the servers call each other is on How the Mesh is run.
Files, payments and staff
GEThttps://files.theportalmesh.com/<path>
- Auth
- None.
- Answer
- Public files only:
portal.js,portal.min.jsand the source map, and anything undercertificates/,personas/,packages/,worlds/andmedia/. Everything else is a 404, whether or not it exists; verification evidence can never be fetched by address. Open to any origin; certificates and persona packages are cached as immutable, portal.js for five minutes.
POST/v1/stripe/webhook
- Auth
- Stripe only: every event is checked against its signature before anything in it is read.
Jubilee’s support staff use the Customer Admin, whose routes under /v1/admin/ are staff only, behind Cloudflare Access with two-factor authentication. They are not part of the public API. See How the Mesh is run.
The name view
Routes that answer with a name give this view:
| Field | Meaning |
|---|---|
pid, name, address, tld, family | Who it is. |
status | The registry’s state: pending, expired, paid, born, active, sleeping, suspended, dormant, dead, resting, released, retired, terminated. See every status. |
publicState | What PNS publishes: born, active, sleeping, suspended, dormant, dead, resting, released, retired, terminated, or null before birth. |
verificationState | not_required, required, submitted, approved or rejected. |
relationshipLabel, registrantRole | The label and, for a Family character, how its person is shown: father, mother, owner, sponsor or guardian. |
displayName | The display name, or null. Stored apart from the address; need not be unique. |
priceCents | What was paid, or the current price. |
serial | The current serial, as in the bundle. |
keyVersion | The n of the current key id, <pid>#k<n>; 0 before birth. |
createdAt, paidAt, bornAt, activatedAt, expiresAt | Milestones, ISO 8601 UTC, or null. |
autoRenew | Whether the card on file renews the name before expiresAt. See Automatic renewal. |
modes, directions | Set at activation. |
missing | Everything still needed before activation, before and after birth, with TXT kinds as TXT:<kind>. |
role | The caller's role on the name: owner, admin, editor or viewer. |
The key file: pm.name-key/1
At birth the registrant’s browser generates the name’s Ed25519 key pair and sends only the public key. The private key is saved in a key file the person downloads, encrypted with a passphrase they choose (at least 12 characters). Nothing in it is ever sent anywhere, and Jubilee cannot recover it.
{
"schema": "pm.name-key/1",
"name": "oasis.character",
"pid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC",
"kid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1",
"publicKey": {
"kty": "OKP",
"crv": "Ed25519",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
},
"kdf": {
"name": "PBKDF2",
"hash": "SHA-256",
"iterations": 600000,
"salt": "<16 random bytes, base64url>"
},
"cipher": {
"name": "AES-GCM",
"iv": "<12 random bytes, base64url>"
},
"seed": "<the 32-byte seed sealed with AES-256-GCM, with its 16-byte tag, base64url>",
"created": "2026-10-23T12:00:00.000Z"
}| Field | Meaning |
|---|---|
schema | pm.name-key/1 |
name, pid, kid | Which name and key the file holds, in the clear, to tell files apart. |
publicKey | The public key, as a JWK. |
kdf | PBKDF2 with SHA-256, 600,000 iterations, and a 16-byte random salt (base64url). The passphrase is NFKC-normalized first. |
cipher | AES-256-GCM, with a 12-byte random iv (base64url). |
seed | The 32-byte Ed25519 seed, encrypted, with the GCM tag, base64url. The additional data is <name>|<pid>|<kid>|<publicKey.x>, so a file whose name or key was edited will not open. |
created | When the file was made. |
The format is the registry’s own (decision D-28). The file stores the 32-byte seed rather than a PKCS#8 key, so any Ed25519 implementation can use it. To open one, in a browser, Node 20+ or a Worker (this code ran during the build and recovered the example seed; it uses b64urlDecode from mesh-core.js):
// Open a pm.name-key/1 file with its passphrase. Returns the 32-byte Ed25519 seed.
// PBKDF2-SHA-256 (the file says how many iterations) derives an AES-256-GCM key; the file's public
// context is bound as additional data, so an edited file will not open.
async function openKeyFile(file, passphrase) {
if (!file || file.schema !== "pm.name-key/1") throw new Error("This is not a Portal Mesh key file.");
const encoder = new TextEncoder();
const base = await crypto.subtle.importKey("raw", encoder.encode(passphrase.normalize("NFKC")), "PBKDF2", false, ["deriveKey"]);
const key = await crypto.subtle.deriveKey(
{ name: "PBKDF2", hash: "SHA-256", salt: b64urlDecode(file.kdf.salt), iterations: file.kdf.iterations },
base,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"],
);
const aad = encoder.encode(file.name + "|" + file.pid + "|" + file.kid + "|" + file.publicKey.x);
try {
const seed = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64urlDecode(file.cipher.iv), additionalData: aad }, key, b64urlDecode(file.seed));
return new Uint8Array(seed);
} catch {
throw new Error("The passphrase is wrong, or the key file has been changed.");
}
}A wrong passphrase, or an edited file, is refused with “The passphrase is wrong, or the key file has been changed.” The seed then signs records (mesh-sign.js), sessions (the reference endpoint) and key rotations (below).
The rotation proof: pm.key-rotation/1
A key rotation is signed by the key it replaces. The client builds a statement that names the name, both key ids and the new public key, and signs it with the current key. The registry builds the same statement itself, from the name and the publicKey sent, and accepts the rotation only if the proof verifies against the name’s current public key.
| Field | Value |
|---|---|
schema | pm.key-rotation/1 |
pid | The name's permanent ID. |
name | The name, such as oasis.character. |
fromKid | The key being replaced: the current key id, <pid>#k<n>, as keyId on the name. |
toKid | The key taking over: <pid>#k<n+1>. |
newKey | The new public key’s x, base64url, exactly as in the JWK sent as publicKey. |
- The signing input is the statement’s canonical JSON, as UTF-8 bytes: keys sorted, no whitespace, the same rules as for records.
- The signature is Ed25519 by the current key’s private key, the one in the current key file. The proof is its 64 bytes in base64url, without padding.
- Because the statement names both key ids and the new key, a proof is good for exactly one rotation of one name. It cannot be replayed for another new key, and it is spent once the rotation happens, because the current key id changes.
The example rotates oasis.character from its first key (the RFC 8032 test key used throughout these pages) to a second one (RFC 8032 test 3). The statement:
{
"schema": "pm.key-rotation/1",
"pid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC",
"name": "oasis.character",
"fromKid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1",
"toKid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k2",
"newKey": "_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU"
}What is signed, as canonical JSON:
{"fromKid":"pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1","name":"oasis.character","newKey":"_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU","pid":"pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC","schema":"pm.key-rotation/1","toKid":"pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k2"}The proof, signed with the current key:
4u7awgL7_fIZPFMHx5PMokcbs3KYZaOSpQev3Svbxb-0Ayfk3X38zZWK0PRwHo0lo1ooNvhF4thB6rvyK06gAgDuring this build, that proof verified against the current key; it did not verify for a statement naming another new key (7Bcrk61eVjv0kyxw4SRQNMNUZ-8u_U1k6_gZaDRn4r8), nor did a rotation signed by the new key itself; and the code below produced the same proof byte for byte. It uses canonicalize and b64urlEncode from mesh-core.js, signingKeyFromSeed from mesh-sign.js and ed25519Verify from mesh-verify.js:
// pm.key-rotation/1: the CURRENT key signs a statement naming the name, both key ids and the new
// public key, so the proof can never be replayed for another key or another name.
// newKeyX is the new public key's x (base64url), as in its JWK.
function keyRotationStatement(pid, name, fromKid, toKid, newKeyX) {
return { schema: "pm.key-rotation/1", pid, name, fromKid, toKid, newKey: newKeyX };
}
// currentSeed is the 32-byte seed from the CURRENT key file (openKeyFile). Returns the proof, base64url.
async function signKeyRotation(statement, currentSeed) {
const key = await signingKeyFromSeed(currentSeed);
const bytes = new TextEncoder().encode(canonicalize(statement));
return b64urlEncode(new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, key, bytes)));
}
// What the registry checks: the proof verifies against the current public key over this exact statement.
function verifyKeyRotation(statement, proof, currentKeyX) {
return ed25519Verify(currentKeyX, proof, canonicalize(statement));
}Without a proof the registry answers 403 key_proof_required and hands back the statement it expects, so a client can sign exactly that:
{
"code": "key_proof_required",
"message": "Sign the rotation with oasis.character's current key file. If the key is lost, start a recovery from your dashboard instead.",
"detail": {
"statement": {
"schema": "pm.key-rotation/1",
"pid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC",
"name": "oasis.character",
"fromKid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k1",
"toKid": "pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC#k2",
"newKey": "_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU"
}
}
}The registry emails the people a change concerns. Each message is written in the same database step as the change it reports, then sent straight after, and tried again later if the mail service is down, so mail is as reliable as the change itself and is never sent twice.
| When | Who is emailed |
|---|---|
| An organization invites someone to its team | The invited address, with the link to accept. |
| A transfer is started | The recipient, with the link to accept; and the owner, with a notice in case it was not them. |
| A transfer completes | The previous owner. |
| A transfer is accepted and waits for Jubilee's approval, or its fee is due | Both people; the recipient with the link to pay. |
| A name is 30, 7 and 1 days from expiry | The owner. With automatic renewal on, the reminder says when, and which card, will be charged. |
| A name is renewed, by hand, automatically or from credit | The owner, with a receipt. |
| An automatic renewal fails | The owner, with the reason in plain words and the date of the next attempt, if there is one. |
| A missed renewal's grace period ends and the name is dormant | The owner. |
| A name dies after a year dormant | The owner, with the date by which to export the character's memory. |
| A released address opens to anyone | The former owner. |
| A name is 30 and 10 days from its live deadline, or lapses without going live | The owner. |
| A realm's charter is received with its fee held, or decided | The applicant. |
| An account passes the review threshold, and when the review is decided | The account. |
| A character is born on its DNA approval | The owner: "Congratulations, your AI character has been born", with the birth certificate PDF and the DNA attached. |
| A character's DNA is returned | The owner, with what needs to change. |
| A character falls asleep because its model key failed | The owner. |
| A death report pauses a character | The designated successor, or else the backup, with the link to step in. |
| A Parent has not signed in for about a year | The Parent, and the designated successor. |
| A Mesh server asks: a robot was shut off, a consent was requested or withdrawn, an export is ready, an experience was published, a takedown was claimed | The name's owner. The server never sees their address. |
| A key is rotated | The owner. |
| An identity or organization verification is decided | The account. |
| A name's review is decided | The registrant. |
| A key recovery is started | The account: a confirmation link and a cancel link, or, on the review path, a cancel link. |
| An account recovery is asked for | The account that holds the name, without a cancel link. |
| A recovery completes, or is declined | The requester; and, when an account recovery completes, the previous holder. |
| A verified domain owner takes a name from an unfinished registration | The registrant whose registration was released. |
| A domain proof lapses | The owner. |
| Support replies to a request | The customer. |
| Support resets two-factor authentication, or closes or merges an account | The account. |
| A name is reported | The reporter, when they gave an email or were signed in. |
- Links in mail are one-time. Each carries a token that works once and expires: an invitation and a transfer after 14 days, a recovery confirmation after 24 hours. The registry keeps only a hash of each token.
- Once a message is delivered, its text is cleared from the registry’s outbox, so its links live only in the recipient’s inbox.
- Invitations and transfers also answer with the link (
acceptUrl, andemailed: true), so it can be shared another way if the mail is slow. It is the same one-time link.