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
  1. Basics
  2. Authentication
  3. Errors
  4. Public routes
  5. Sign-in and the account
  6. Teams
  7. Registering a name
  8. Records and activation
  9. Managing a name
  10. Owner grants and succession
  11. Automatic renewal and the card on file
  12. Domain proof
  13. Recovery
  14. Support, reports and hosted servers
  15. For the Mesh servers
  16. Files, payments and staff
  17. The name view
  18. The key file: pm.name-key/1
  19. The rotation proof: pm.key-rotation/1
  20. Email

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_required without it.
  • The Origin check. A state-changing request (POST, PUT, DELETE) that carries an Origin header must carry one of the registry’s own origins, or it is refused with 403 origin_refused, “This request did not come from the Portal Mesh website.” A request with no Origin is allowed only if it carries no pm_session cookie, 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.

json
{
  "code": "required_records_missing",
  "message": "Still needed before activation: PERSONA, ENDPOINT.",
  "detail": {
    "missing": [
      "PERSONA",
      "ENDPOINT"
    ]
  }
}
codeHTTPMeans
invalid_name400The name breaks the label rules.
invalid_address400The input is not a Mesh address.
unknown_namespace400The namespace is not one of the seventeen.
not_offered403The namespace is not open for registration.
reserved_name403The name is reserved and cannot be requested.
requires_review409The request needs a reviewer first.
name_taken409Someone already holds the name.
unauthorized401Not signed in, or a two-factor code did not match.
two_factor_required403This step needs two-factor authentication on the session.
forbidden403Signed in, but not allowed to do this.
not_found404No such thing, or not one you may see.
cap_reached429A registration limit was reached.
payment_required402Payment comes first.
verification_required403Verification or review must be approved first.
invalid_request400The request is malformed or incomplete.
invalid_record422A record failed validation or verification.
signature_invalid422A signature, or another activation check, failed.
required_records_missing422Records the namespace requires are missing.
status_conflict409The name is not in a state that allows this.
transfer_refused403This name can never be transferred.
key_proof_required403A key rotation must be signed by the current key; without it, start a recovery.
identity_required403The account must be identity-verified before its first paid registration.
account_review409The account holds many names and is waiting for a Jubilee review before it registers more.
charter_required409A realm is an application: file its charter before checkout.
rate_limited429Too many requests; wait and try again.
turnstile_failed403The human check did not pass.
origin_refused403A write came from a site other than the registry's own.
config_error503The registry is missing configuration. Fails closed.
upstream_error502A service the registry depends on failed.
internal500Something 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 }. term is { months, renewalMode }, the registration term (12 months, renewed yearly, by default). stripeMode is test or live; root is { kid, publicKey, x }, or null if the root key is not configured; devLogin is 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), verificationLevel and rules are 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/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; address is the corrected one, and certificate is the current signed certificate.
Errors
400 for 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. certificate is 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 307 redirect 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/config reports it as devLogin.

GET/v1/me

Auth
Signed in (session).
Answer
{ account, mfa: { enrolled, satisfied } }, where account is { 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_conflict if 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 unauthorized as 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, with data in 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": "staff@example.com", "role": "admin" | "editor" | "viewer" }
Answer
201 { id, acceptUrl, token, emailed: true }. The invitation is emailed to that address; acceptUrl is 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 forbidden for 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:

nextThe name waits for
reviewA reviewer: a look-alike, a reserved name someone asked for, a tragedy, or any figure.
charterA realm’s charter (below).
checkoutPayment.
decisionJubilee's decision on a realm's charter, with its fee held on the card.
verifyThe owner's identity or organization verification, or a figure's documented reason.
characterA .character registered from its questionnaire: its checkout and its birth are on its draft (Registering a character).
birthThe owner’s key (birth).
activateRecords and activation.
manageNothing: 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, female or other) and affirmResponsibility: true, the affirmation in the registry’s words. A person adds registrantRole, father or mother, and a Family relationshipLabel (the certificate’s label is set from the Character DNA at birth); an organization adds its title as relationshipLabel (AI Employee when none is given) and responsibleGuardian, a person’s name. Any name may add a displayName (up to 120 characters), which need not be unique. See Characters.

Answer
201 { name, next, priority? }, where name is the name’s view (below). priority is { 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 forbidden for 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’s autoRenew says 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 }. next is as above. records are the stored signed envelopes. recovery is the open key recovery of the name, { id, kind, state, createdAt, expiresAt, readyAt }, or null (see Recovery). keyId is the key id every new owner record must carry (the current key), or null before birth; nextKeyId is an older name for the same value. versionFloors is 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 is false: 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 to url, a Stripe Checkout page. autoRenew says whether the card will be saved. The hold is extended to 24 hours. Stripe returns to https://theportalmesh.com/register/<name>/?paid=1&pid=<pid>, and the name becomes paid when 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 to url all 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_required before an account’s first paid registration, with detail.verify set to identity or organization: “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_required for 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 }, where charter is { purpose, rules, contentRating, responsibleGuardian, state, decidedAt, note, createdAt } and state is submitted, approved or rejected. 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_conflict once 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 }, or 200 with 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 }. records are the new ID, STATUS (born) and CERT records; keyId is <pid>#k1; personaProfileUrl is where the persona package was published, or null.
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_conflict if already born, and always for a .character, with detail.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 in detail.problems; 422 required_records_missing when 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.” with detail.missing; 422 signature_invalid for any other problem (a signature, a pending review, an ALIAS target that is not live), with detail.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"?: "..." }. proof is required: the current key’s signature over a pm.key-rotation/1 statement (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_required without a proof, with the statement to sign in detail.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 with detail.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_invalid for a record that does not verify against the new key; 422 invalid_record for one that is malformed or not newer than the slot’s current version.
  • 409 status_conflict unless 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.

POST /v1/names/pid:01JBX3Q8Z9M4T7K2V5N6R8W0YC/rotate-keyjson
{
  "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": "new.owner@example.com", "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; acceptUrl is the recipient’s link, for sharing another way. The transfer is open for 14 days; one at a time per name.
Errors
400 invalid_request without affirmNoSale: 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. feeCents is what the recipient will pay, if anything.
Errors
400 invalid_request without affirmNoSale: true, “Names are never resold on the Mesh. Affirm that you paid nothing for this name to continue.”; 403 transfer_refused for 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[] }: 200 once all four PNS servers have confirmed the retirement, 202 when 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 is false.
Answer
{ url, sessionId, autoRenew }: a Stripe Checkout for the next term, which returns to https://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 with free: true. Either way a receipt is emailed. A sleeping character renews like an active one, and a dormant name 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 }: a pmg1 token, 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": "ann@example.com", "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 saying successorDesignated: 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 says succeeded, 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_conflict when 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[] }. card is { brand, last4, expMonth, expYear, label } or null; daysBefore is how many days before expiry the card is charged; creditCents is the account credit. Each of names is { pid, name, expiresAt, autoRenew, eligible } for the account’s own top-level names, where eligible is 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 to https://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); card is null when switching it off.
Errors
402 payment_required, “Save a card first; automatic renewal charges it shortly before the name expires.”; 409 status_conflict for 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:

NameTypeValue
_portalmesh.<domain>TXTportalmesh-verify=<token>

For example, for the claim of starwars.com:

text
_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):

TypedClaimed domainMesh label or refusal
https://www.StarWars.com/aboutstarwars.comstarwars
starwars.netstarwars.netstarwars
starwars.co.ukstarwars.co.ukstarwars
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 } }, where state is pending, verified or lapsed, record is what to publish, and lastError says 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, or 200 with the existing claim when the account already has it.
Errors
400 invalid_request with 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 }: found is true when the record is there (the claim is now verified), false when it is not, and null when the DNS lookup itself failed, so try again later. domain is 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 levelNamespaces at this level by defaultRecovery
Standard: verified email, two-factor authentication, payment.character, .agent, .history, .event, .gameConfirm by email, then wait 72 hours.
Standard plus SOURCE review.realm, .destinationConfirm by email, then wait 72 hours.
Standard plus rights check.movie, .music, .bookConfirm by email, then wait 72 hours.
Identity verified (human).avatar, .homeA reviewer decides, on a written statement and fresh evidence.
Organization verified.business, .ministry, .educationA reviewer decides, on a written statement and fresh evidence.
Reviewed by Jubilee.figureA 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 needs statement and, 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 forbidden when 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 }, where kind is key or account and role is requester or holder.
stateMeans
confirmingWaiting for the link in the confirmation email.
waitingConfirmed; the new key takes over at readyAt unless the recovery is canceled first.
reviewWith a reviewer.
completedThe new key took over (and, for an account recovery, the name moved).
cancelledCanceled, or overtaken by a rotation with the current key; outcome says which.
rejectedA reviewer did not approve it.
expiredNot 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 }, now waiting. The email’s link opens https://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 }, now cancelled. 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, where state is open, waiting or closed.

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. A pid counts 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 }: registered says 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. download is the server’s self-host package, { version, file, sha256, bytes, builtAt, url }, or null until one is published. The url is on the files host (/downloads/); check the file against sha256 before 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. state is running (on), paused or requested (asked for before hosting opened). names is { total, live, namespaces: [{ tld, count, names }], truncated }: the account’s paid, born, active, sleeping, suspended and dormant names in that server’s namespaces. usage is the Character Server’s latest storage report, { storageBytes, characters, measuredAt, includedBytesPerCharacter, overageBytes }, or null for the other servers. health is { state: up | down | unreachable | unknown, checkedAt, message }, fetched from the server when you ask, with a 2.5-second limit. pendingAction is a restart, pause or resume support has recorded, or null; 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 answers 200 with 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.js and the source map, and anything under certificates/, personas/, packages/, worlds/ and media/. 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:

FieldMeaning
pid, name, address, tld, familyWho it is.
statusThe registry’s state: pending, expired, paid, born, active, sleeping, suspended, dormant, dead, resting, released, retired, terminated. See every status.
publicStateWhat PNS publishes: born, active, sleeping, suspended, dormant, dead, resting, released, retired, terminated, or null before birth.
verificationStatenot_required, required, submitted, approved or rejected.
relationshipLabel, registrantRoleThe label and, for a Family character, how its person is shown: father, mother, owner, sponsor or guardian.
displayNameThe display name, or null. Stored apart from the address; need not be unique.
priceCentsWhat was paid, or the current price.
serialThe current serial, as in the bundle.
keyVersionThe n of the current key id, <pid>#k<n>; 0 before birth.
createdAt, paidAt, bornAt, activatedAt, expiresAtMilestones, ISO 8601 UTC, or null.
autoRenewWhether the card on file renews the name before expiresAt. See Automatic renewal.
modes, directionsSet at activation.
missingEverything still needed before activation, before and after birth, with TXT kinds as TXT:<kind>.
roleThe 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.

oasis.character.key.json (shape)json
{
  "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"
}
FieldMeaning
schemapm.name-key/1
name, pid, kidWhich name and key the file holds, in the clear, to tell files apart.
publicKeyThe public key, as a JWK.
kdfPBKDF2 with SHA-256, 600,000 iterations, and a 16-byte random salt (base64url). The passphrase is NFKC-normalized first.
cipherAES-256-GCM, with a 12-byte random iv (base64url).
seedThe 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.
createdWhen 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):

mesh-keyfile.jsjs
// 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.

FieldValue
schemapm.key-rotation/1
pidThe name's permanent ID.
nameThe name, such as oasis.character.
fromKidThe key being replaced: the current key id, <pid>#k<n>, as keyId on the name.
toKidThe key taking over: <pid>#k<n+1>.
newKeyThe 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:

pm.key-rotation/1 statementjson
{
  "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:

text
{"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:

text
4u7awgL7_fIZPFMHx5PMokcbs3KYZaOSpQev3Svbxb-0Ayfk3X38zZWK0PRwHo0lo1ooNvhF4thB6rvyK06gAg

During 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:

mesh-rotate.jsjs
// 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:

json
{
  "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"
    }
  }
}

Email

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.

WhenWho is emailed
An organization invites someone to its teamThe invited address, with the link to accept.
A transfer is startedThe recipient, with the link to accept; and the owner, with a notice in case it was not them.
A transfer completesThe previous owner.
A transfer is accepted and waits for Jubilee's approval, or its fee is dueBoth people; the recipient with the link to pay.
A name is 30, 7 and 1 days from expiryThe owner. With automatic renewal on, the reminder says when, and which card, will be charged.
A name is renewed, by hand, automatically or from creditThe owner, with a receipt.
An automatic renewal failsThe 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 dormantThe owner.
A name dies after a year dormantThe owner, with the date by which to export the character's memory.
A released address opens to anyoneThe former owner.
A name is 30 and 10 days from its live deadline, or lapses without going liveThe owner.
A realm's charter is received with its fee held, or decidedThe applicant.
An account passes the review threshold, and when the review is decidedThe account.
A character is born on its DNA approvalThe owner: "Congratulations, your AI character has been born", with the birth certificate PDF and the DNA attached.
A character's DNA is returnedThe owner, with what needs to change.
A character falls asleep because its model key failedThe owner.
A death report pauses a characterThe designated successor, or else the backup, with the link to step in.
A Parent has not signed in for about a yearThe 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 claimedThe name's owner. The server never sees their address.
A key is rotatedThe owner.
An identity or organization verification is decidedThe account.
A name's review is decidedThe registrant.
A key recovery is startedThe account: a confirmation link and a cancel link, or, on the review path, a cancel link.
An account recovery is asked forThe account that holds the name, without a cancel link.
A recovery completes, or is declinedThe requester; and, when an account recovery completes, the previous holder.
A verified domain owner takes a name from an unfinished registrationThe registrant whose registration was released.
A domain proof lapsesThe owner.
Support replies to a requestThe customer.
Support resets two-factor authentication, or closes or merges an accountThe account.
A name is reportedThe 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, and emailed: true), so it can be shared another way if the mail is slow. It is the same one-time link.