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:
- Register the domain with
space.highport.manage.register, create the two DNS records it returns, and wait for the registration to reachverified. Bringing your domain alongside has the DNS detail. - 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. - Assemble the site record, whose fields are covered in The site record. The collection is
space.highport.sites.siteand the record key is the hostname, in its canonical lowercase form. - Call
space.highport.manage.validatewith the record and therkeyyou 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. - Write back exactly the record it returned, with
com.atproto.repo.putRecordat 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. - Poll
space.highport.manage.getIndexStatusevery couple of seconds until it settles.activeis the one that means the site is serving;failed,rejected,expiredandsupersededare 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