Highport orbital control

XRPC, and building your own tools

Everything the Hub does, it does through methods you can call yourself. Orbital Control keeps no private entrance.

XRPC is AT Protocol's convention for calling a named operation over HTTP: every method has a name, an NSID like space.highport.sites.getSite, and that name is the last segment of the URL. Highport's methods live at https://highport.space/xrpc/<NSID>, and there are fifty-two of them plus two the protocol defines.

Six take no credential at all. Thirty are for a site owner acting on their own domains and records. Sixteen are the operator's. These are counts of methods that exist, not of methods that do something: space.highport.admin.setSpaceState is one of the sixteen and answers 501 MethodNotImplemented after authenticating, for the reason given in its row. Which group a method is in is the first thing to know about it, so that is how they are listed below.

Where the calls go

Base URL https://highport.space/xrpc/<NSID>
A query GET, parameters in the query string
A procedure POST, Content-Type: application/json
Wrong verb for the method 405
An NSID this deployment does not serve 501 {"error":"MethodNotImplemented"}
Failures {"error": "<Name>", "message": "<sentence>"}
CORS Access-Control-Allow-Origin: * on /xrpc/* and on /.well-known/did.json, methods GET, POST, OPTIONS, headers authorization, content-type. Credentials are never allowed, so a browser can call the public queries directly and cannot smuggle a cookie into any of them
Service identity did:web:highport.space, whose document is served at https://highport.space/.well-known/did.json

Every call is one request carrying one token. There is no session to establish and nothing to keep open.

Methods anyone may call

No credential is accepted on these six, and none is needed. They are the same reads the public directory pages make.

NSID Type What it answers Parameters
space.highport.sites.getSite query The site serving a domain: owner, active record, revision, bound tile domain (required)
space.highport.sites.resolveDomain query Which identity and which record serve a domain domain (required)
space.highport.sites.listSites query The directory of active sites limit, cursor, did, tile
space.highport.sites.getTile query One published tile, at its latest version or at a pinned cid uri (required), cid
space.highport.sites.listTiles query The directory of published tiles did, limit, cursor
space.highport.sites.getTileIcon query A tile icon's bytes, not JSON cid (required), the icon blob's CID and not the tile's

Two things about what these will and will not show you. A tile published inside a space is never on this surface, and neither is a tile version that has been validated but not yet written; both answer TileNotFound. And a tile's handle field is present only when the handle resolves back to the DID that claims it, so an author shown as a bare DID is a normal answer, not a fault, and a client should render the DID rather than an error.

These six are limited per client address instead of per identity, because there is no identity to key on. That limit belongs to the edge configuration and not to the service, so a refusal comes from the edge as a bare 429 with a Retry-After header and no error envelope, and the number behind it is an operator's to change.

Methods a site owner calls

Every method in this section needs a token, and authenticating is not the same as being permitted: whether the calling identity owns the domain is checked separately, inside the operation, against the current row. On the methods that name a domain, one that is not yours answers DomainNotFound, the same answer as a domain nobody has registered, so those cannot be used to find out who holds what. validate reports prepared: false with reason: DomainNotVerified for both cases, on a 200. getIndexStatus is the one that does distinguish: a domain registered to somebody else is NotOwner, 403, where an unregistered one is RecordNotFound, 404.

Domains and records

NSID Type What it does Caller must be
space.highport.manage.register procedure Claim a domain and get the two DNS records that prove and route it any identity
space.highport.manage.verify procedure Run a DNS check now instead of waiting for the poller the domain's owner
space.highport.manage.getDomain query One domain's registration, DNS records, last verification and certificate state the domain's owner
space.highport.manage.listDomains query Your own registrations any identity; scope comes from the token
space.highport.manage.releaseDomain procedure Stop serving the domain, stop authorizing its certificate, revoke it the domain's owner
space.highport.manage.validate procedure Check a record before writing it, and get back the record to submit verbatim any identity; preparing needs a verified domain
space.highport.manage.getIndexStatus query Per-blob progress on a publish. Exactly one of uri or domain the record's owner
space.highport.manage.reindex procedure Re-run the pipeline over the record Highport already holds. On a site record this reports success and rebuilds nothing; see Editing, deleting and moving on the record's owner
space.highport.manage.purge procedure Drop every origin replica's cached pointer for a domain the domain's owner

listDomains has no did parameter. The only registrations reachable through it are the ones belonging to the identity that signed the token.

Spaces

NSID Type What it does Caller must be
space.highport.manage.connectSpace procedure Connect a space so Highport reads and publishes from it the space's authority
space.highport.manage.grantSpaceAccess procedure Hand over a delegation token so Highport can keep reading any member; the token's own issuer
space.highport.manage.syncSpace procedure Pull the authority's repository now the space's authority
space.highport.manage.disconnectSpace procedure Forget the space and release its domains the space's authority
space.highport.manage.getSpace query What Highport knows about one connected space the authority, or a member with a recent grant
space.highport.manage.listSpaces query The spaces you are the authority of, not the ones you can read any identity
space.highport.manage.listSpaceSites query The domains registered to a space the authority, or a member with a recent grant
space.highport.manage.listSpaceTiles query A space's tiles, and the only directory of them the authority, or a member with a recent grant
space.highport.manage.listSpaceEvents query A space's audit trail, paged by sequence number the authority, or a member with a recent grant

All nine answer SpacesDisabled with a 501 on a deployment that has spaces switched off. Spaces explains what one is and what keeps access alive.

Names under your domain

NSID Type What it does Caller must be
space.highport.manage.enableDelegation procedure Start handing out names one label under a domain, and get the wildcard record that routes them the domain's owner, once it is verified
space.highport.manage.disableDelegation procedure Stop handing out names. Refused while any name is still reserved the domain's owner
space.highport.manage.setCertificateMode procedure Choose how names under the domain get their certificates: one wildcard for the whole domain, validated over DNS-01 through a challenge target of its own, or one certificate per name. Granting returns the record that delegates the challenge the domain's owner
space.highport.manage.reserveDomain procedure Reserve one name under the domain for another account's DID the domain's owner, once the wildcard has reached Highport
space.highport.manage.releaseReservation procedure Take one reserved name back the domain's owner
space.highport.manage.listReservations query The reserved names, who each is for, whether each still routes, and when each was taken up as a handle the domain's owner

There is no per-name DNS check on any of these: the domain proves its wildcard once. Handing out names under your domain walks through the order to call them in and the one DNS record that can still break a name.

Traffic and billing

NSID Type What it does Caller must be
space.highport.manage.getSiteTraffic query Hourly requests and bytes, busiest paths, status mix, for one domain the domain's owner
space.highport.manage.listSiteVisitors query Identities that signed in to one of your domains, and what each spent the domain's owner
space.highport.manage.getMyActivity query Your own browsing, across every domain you signed in to any identity; the subject is always the caller
space.highport.manage.getConsumption query Your allowance for a period: granted, spent, remaining any identity
space.highport.manage.listDomainBilling query The payer policy of every domain you own any identity
space.highport.manage.setDomainBilling procedure Set whose allowance a domain's traffic draws on the domain's owner

getMyActivity has no did parameter and never will. It is the one query in the system that draws a picture across sites, and the only thing that makes it acceptable is that nobody but its subject can ask it.

Three of the six declare AnalyticsDisabled with a 501 for a deployment holding no analytics store: getSiteTraffic, listSiteVisitors and getMyActivity. That is the honest answer where it applies — nothing was recorded — and no deployment answers it now that the request events live in the same database as everything else. A deployment that meters nothing answers zero instead. The other three read the ledger and answer either way.

What the permission set covers

An OAuth client holding space.highport.authManage can call twenty-nine of the thirty: the eight domain and record methods other than purge, all nine space methods, all six methods for names under your domain, and all six traffic and billing methods. purge alone sits outside it, and a tool that needs it has to ask for it as a bare rpc: scope. The permission set sets out what that grant covers.

Methods a tile author calls

There is no separate namespace for tiles. A tile author uses three of the methods above, pointed at a tile record instead of a domain, and reads the results back through the public queries.

NSID Type What it does for a tile
space.highport.manage.validate procedure Validate a space.highport.sites.tile record. rkey is an existing key for a new version, a TID you hold, or absent, in which case Highport mints one and hands it back, and that is the key you must use
space.highport.manage.getIndexStatus query Progress on a tile, by uri. Poll this to active before anyone binds the version
space.highport.manage.reindex procedure Re-run the pipeline over the tile record Highport already holds
space.highport.sites.getTile query Read a version back, including its state and its parameter declarations
space.highport.sites.listSites query Who is serving your tile. Pass tile=<the AT-URI>

Because Highport may mint the record key, a tile is written with putRecord and never createRecord, even the first time. Building a tile is the rest of it.

Methods reserved to the operator

Sixteen methods answer only to the identities a deployment names as its operators. A caller with no token gets 401 AuthenticationRequired like anywhere else; a caller whose token verified and who is not on the list gets 403 Forbidden. A deployment that has named nobody refuses all sixteen to everybody, the correct resting state.

NSID Type What it does
space.highport.admin.getDomain query The full internal state of any domain: registration, event history, job log, certificate
space.highport.admin.setDomainState procedure Suspend, unsuspend, or force-release a domain
space.highport.admin.getQuota query Every per-identity quota for one DID: the limit in force, the deployment default, and the operator override that replaced it, if there is one
space.highport.admin.setQuota procedure Replace one per-identity quota for one DID, or clear the override so the deployment default applies again. A lowered count refuses the next creation and takes nothing away
space.highport.admin.createDenylist procedure Deny a subject. This is the takedown mechanism
space.highport.admin.removeDenylist procedure Lift a denial
space.highport.admin.listDenylist query Read the denylist
space.highport.admin.listFindings query Malware detections, newest first. The evidence a quarantine is argued from, and the only record of why once the sample itself is gone
space.highport.admin.rescanBlob procedure Re-judge stored content against the scanner's current signatures, without waiting for the sweep to reach it
space.highport.admin.getQueueStats query Job queue depth, oldest job age, failure counts
space.highport.admin.listTopSites query Domains ranked by successful, non-crawler requests over a trailing window. A popularity signal and nothing more
space.highport.admin.listUserAgents query The user agents that reached the network over a trailing window of up to 30 days, with request counts and how each was classified. For reviewing crawlers and automation; it names no visitor
space.highport.admin.rebuildSnapshot procedure Re-materialize a snapshot from stored content, without refetching
space.highport.admin.listTileBindings query Which sites are serving a given tile or tile version
space.highport.admin.setTileState procedure Suspend or unsuspend a tile
space.highport.admin.listSpaces query Every connected space and its sync state
space.highport.admin.setSpaceState procedure Not implemented; it answers 501 MethodNotImplemented after authenticating, so the refusal is not a way to learn which operator methods exist

Operator tokens are held to a tighter standard than ordinary ones: at most sixty seconds between issue and expiry, and a jti is mandatory, not optional.

Two further procedures, com.atproto.space.notifyWrite and com.atproto.space.notifySpaceDeleted, are served so a space host can deliver write notifications. They belong to the protocol and not to Highport, their tokens are addressed to did:web:highport.space#bard_space_syncer instead of to the control-plane entry, they must be signed by the authority of the space named in the body, and they are not something a publishing tool calls.

Authenticating

From a browser

An interactive tool signs a person in with OAuth and asks for atproto blob:*/* include:space.highport.authManage?aud=did:web:highport.space#bard_control. The person's own server resolves the permission set off the network the same way it resolves a schema, builds the consent screen from it, and afterwards mints the per-call tokens below on their behalf. The permission set covers what the grant reaches and what it cannot.

From a server

Every authenticated method on this surface takes AT Protocol inter-service authentication: a short-lived JWT signed by a key in the calling identity's DID document, sent as Authorization: Bearer <token>. There is no OAuth flow here, no session, and no shared secret. Highport verifies these tokens and never issues one.

One token authorizes one method. The lxm claim is compared against the last segment of the path you called, so a token minted for getIndexStatus cannot register a domain. Mint a fresh one per call.

There are three ways to get one.

Way How
App-password session com.atproto.server.createSession at your PDS, then com.atproto.server.getServiceAuth
OAuth session Sign in with the permission set — with ?aud=did:web:highport.space#bard_control on its include:, or the set grants no methods — then getServiceAuth under its rpc grant. The grant covers twenty-nine of the thirty; purge needs its own rpc: scope
Sign it yourself ES256, ES256K or ES384 over a private key whose public half is in your DID document

The third way needs a real crypto library. The signature is raw r‖s, not the DER that openssl dgst -sign produces, and a token with a DER signature is refused with no indication of whether the key or the encoding was wrong.

The claims, and the rule on each:

Claim Value Rule
iss your DID did:plc, did:web or did:webvh
aud did:web:highport.space or did:web:highport.space#bard_control compared exactly; no other fragment is accepted
lxm the NSID you are calling must equal the path's last segment; absent is a refusal
iat, exp epoch seconds both required. exp - iat at most 300 seconds, or 60 on an operator method
jti a nonce single-use. Optional on ordinary methods, required on operator ones. A token sent without one is accepted, and is replayable for the rest of its lifetime by anyone who saw it, so send one

Clocks are allowed thirty seconds of disagreement in either direction. One identity may make 300 calls per sixty seconds across the authenticated surface, and four methods carry a second budget on top of that: reindex and purge share 30 per minute, verify allows one forced check per domain every 30 seconds, and a validate that prepares is capped at 60 an hour with 5 preparations live at once. Limits and quotas covers the rest.

A call you can run

Three requests: sign in to your own PDS, ask it for a token addressed to Highport, then call the method.

PDS=https://bsky.social
      HANDLE=alice.example.com
      APP_PASSWORD=xxxx-xxxx-xxxx-xxxx     # example values; use your own

      ACCESS=$(curl -s -X POST "$PDS/xrpc/com.atproto.server.createSession" \
        -H 'Content-Type: application/json' \
        -d "{\"identifier\":\"$HANDLE\",\"password\":\"$APP_PASSWORD\"}" | jq -r .accessJwt)

      TOKEN=$(curl -s --get "$PDS/xrpc/com.atproto.server.getServiceAuth" \
        -H "Authorization: Bearer $ACCESS" \
        --data-urlencode 'aud=did:web:highport.space' \
        --data-urlencode 'lxm=space.highport.manage.listDomains' \
        --data-urlencode "exp=$(( $(date +%s) + 60 ))" | jq -r .token)

      curl -s "https://highport.space/xrpc/space.highport.manage.listDomains?limit=50" \
        -H "Authorization: Bearer $TOKEN"
      

The answer:

{
        "domains": [
          {
            "domain": "example.com",
            "did": "did:plc:z72i7hdynmk6r22z27h6tvur",
            "status": "active",
            "tls": "issued",
            "activeUri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/space.highport.sites.site/example.com",
            "activeRev": "3lrevaaaaaa2z",
            "activeCid": "bafkreia4w54blzdgvovgbdc7baqhnp5aohygjqt36g6ak5inwiidzlkno4",
            "sourceKind": "inline",
            "entryCount": 12,
            "totalBytes": 40960,
            "verifiedAt": "2026-08-23T12:00:00.000Z",
            "activatedAt": "2026-08-23T12:00:00.000Z",
            "createdAt": "2026-08-23T12:00:00.000Z"
          }
        ],
        "cursor": "example.com"
      }
      

The domain, DID and timestamps there stand in for yours; the field names are exact.

Send the exp yourself, as the example does. The three-hundred-second ceiling on exp - iat is Highport's, not your PDS's, and a token minted with a wider window is refused as TokenLifetimeTooLong with nothing else about the request wrong.

Every listing takes limit (1 to 100, default 50) and cursor. Pass back the cursor a response returned, and stop when a response has none. The domain, site, tile and billing listings refuse a limit outside the range with InvalidRequest; the space and operator listings clamp it instead. space.highport.admin.listDenylist is the exception to all of it — it ignores a cursor and returns none, so it shows at most its first hundred rows.

When a token is refused

error HTTP What it means
AuthenticationRequired 401 No bearer token; a token that is not a decodable JWT; a signature that did not verify against the issuer's DID document; an iss whose DID method is not did:plc, did:web or did:webvh; or an operator token with no jti
InvalidAudience 401 aud names a different service, or a different service entry of this one
InvalidLexiconMethod 401 lxm is not the method at the path you called, or the token carries none
TokenLifetimeTooLong 401 iat or exp missing, exp before iat, the token expired, or its window is wider than the ceiling
TokenReplayed 401 That jti has already been used, or replay protection is unavailable. Mint a fresh token and retry
AccountSuspended 403 The issuing identity is suspended or denylisted
Forbidden 403 Authenticated, and not an operator of this deployment
NotSpaceAuthority 403 On the two notify procedures only: the signer is not the authority of the space named in the body
RateLimitExceeded 429 300 calls in sixty seconds from one identity
InternalError 500 Ours. The message is always An internal error occurred

The message is Highport's own sentence and not a copy of what you sent, though a few name the value back at you: limit must be between 1 and 100; got 500. A rejected cursor is the one that never is, because a cursor is a token the method issued. Three kinds of failure do not use the envelope at all, and a client must not assume every non-2xx has one: a request body over 2 MiB answers a plain-text 413; a malformed query string or unparseable JSON answers a plain-text 4xx from the HTTP layer before any handler sees it; and the edge's own 429 above never reaches a handler to be shaped.

Authentication runs before the body is read, so an over-size request from a caller with no token is a 401 and not a 413.

Writing a record yourself

The Hub has no privileged path. It uploads files to the signed-in person's own PDS, calls the same operations, and writes the same records. To do it without a browser:

  1. Register the domain with space.highport.manage.register, create the two DNS records it returns, and wait for the registration to reach verified. Bringing your domain alongside has the DNS detail.
  2. Upload your files to your own PDS with com.atproto.repo.uploadBlob. They stay in your account; Highport fetches them later and verifies every one against its content hash.
  3. Assemble the site record, whose fields are covered in The site record. The collection is space.highport.sites.site and the record key is the hostname, in its canonical lowercase form.
  4. Call space.highport.manage.validate with the record and the rkey you mean to use. It answers with the AT-URI, the CID the record will have, and the record itself, and it starts fetching your files before you have written anything.
  5. Write back exactly the record it returned, with com.atproto.repo.putRecord at exactly the key it returned. Any edit changes the hash, and the work done in step 4 no longer applies to what you wrote. That is never an error. The commit takes the ordinary path and is indexed like any other. It is just slower.
  6. Poll space.highport.manage.getIndexStatus every couple of seconds until it settles. active is the one that means the site is serving; failed, rejected, expired and superseded are the other four states it stops at.

Step 4 is the one worth reading about before you build against it. Validate before you write covers what preparation buys, why the record must go back unchanged, and how long a preparation lasts. How the station notices is what runs between step 5 and step 6.

Publishing a tile is the same shape against space.highport.sites.tile, with the record key a TID instead of a hostname.

The schemas

There is no schema appendix in this manual. Every method and record type here is itself a record published on the network, and a definition copied into a document is one that goes stale the moment the record changes.

A lexicon is a JSON schema document describing one thing: a record's fields, or a method's parameters, output and named errors. Its identity is its NSID. To resolve one, a client reverses everything but the last segment of the NSID to get the authority, looks up _lexicon.<authority> in DNS for the DID that owns that namespace, resolves that DID to its PDS, and fetches one record:

space.highport.sites.site
        → authority: sites.highport.space
        → DNS TXT  _lexicon.sites.highport.space   → did=<publisher>
        → that DID's document                      → its PDS
        → com.atproto.repo.getRecord   collection=com.atproto.lexicon.schema
                                       rkey=space.highport.sites.site
      

The record key is the full NSID, which is what makes resolution one fetch instead of a search. Four authorities cover the whole namespace:

Authority What lives under it
highport.space space.highport.defs, the shared views and status vocabularies every method answers with
sites.highport.space The two record types, the manifest and resource definitions they share, the six public queries, the space declaration, and the permission set
manage.highport.space The thirty owner methods
admin.highport.space The sixteen operator methods

The two collections a publisher writes to are space.highport.sites.site, keyed by hostname, and space.highport.sites.tile, keyed by TID.

To read the schemas instead of resolving them, browse the publisher's identity at lexicon.garden/identity/lexicons.highport.space.

Two properties are worth building against. There is no version number anywhere on this surface. Not in a path, not in a header, not in a parameter. Versioning is the schema's, and a client branches on the fields and error names it finds. And every open vocabulary tolerates a value you have not seen: domain status, certificate status, index state, sync state, event kind and job state are all open, so a value added tomorrow arrives at a client built today as a string it does not recognize rather than as a parse failure. Pass it through and say so.

The last page

That is the manual. Twenty-seven pages, most of which nobody needs to read, and one or two that somebody eventually will at an unreasonable hour.

Everything in it describes a service that holds copies and no originals. Your files are in your account, your site is a record you wrote and can delete, your domain is yours and points here only while you say so. Highport fetches, checks, stores and serves. If it stopped tomorrow, what you would have lost is the serving.

Orbital Control, standing by.

Advanced · 7 of 7