Lexicon Publishing
Admin API endpoints for publishing lexicons as com.atproto.lexicon.schema records and managing the rules that decide where they go. For the concepts, DNS requirements and dashboard, see Publishing Lexicons.
All endpoints are under /admin/lexicon-publishing. Reads need lexicons:read and writes need lexicons:publish. Choosing a linked repo as a rule’s target also needs linked-repos:read; for a caller without it, every linked-repo target in a response keeps kind, available and can_unpublish and has linked_repo_id, did, handle and unavailable_reason set to null, as does a linked-repo rule’s own linked_repo_id. Errors use the usual body, {"error": "<message>"}.
# All examples assume $TOKEN is an API key (hv_...)AUTH="Authorization: Bearer $TOKEN"Shared shapes
Section titled “Shared shapes”A target is a place a lexicon can be published:
{ "kind": "linked_repo", "linked_repo_id": "550e8400-e29b-41d4-a716-446655440000", "did": "did:plc:abc123", "handle": "partner.example.com", "available": true, "unavailable_reason": null, "can_unpublish": true}kind is instance or linked_repo. available is false when nothing can be written there now, and unavailable_reason says why. can_unpublish is true when the target can also delete records.
A rule:
| Field | Type | Description |
|---|---|---|
id | string | Rule ID |
pattern | string | *, a lowercased prefix such as com.example.*, or an exact NSID |
target_kind | string | instance or linked_repo |
linked_repo_id | string/null | The linked repo; null for the instance, or after its linked repo is removed |
auto_publish | boolean | Publish matching lexicons without a click |
created_by | string | DID of the admin who created it |
created_at | string | RFC 3339 |
updated_at | string | RFC 3339 |
Rules returned by the API also carry a target object beside these fields.
Get the overview
Section titled “Get the overview”GET /admin/lexicon-publishingPermission: lexicons:read
Response:
{ "usable": true, "targets": [], "rules": [], "lexicons": [ { "nsid": "com.example.notes.getNote", "layer": "manual", "rule_id": "7c1e0f3a-0f7e-4c35-9f43-6d2b8d1a9f10", "rule_pattern": "com.example.*", "auto_publish": true, "target": {}, "status": "outdated", "publication": { "nsid": "com.example.notes.getNote", "did": "did:plc:abc123", "uri": "at://did:plc:abc123/com.atproto.lexicon.schema/com.example.notes.getNote", "cid": "bafyrei...", "published_at": "2026-10-13T09:00:00+00:00", "last_error": null, "last_attempt_at": "2026-10-13T09:00:00+00:00", "withdrawn_at": null } } ], "orphans": []}-
usable: whether any target can publish, or can remove a record HappyView put there. While it’sfalse, the dashboard’s publishing page shows what to set up instead of its rules. -
targets: the instance first, then one per linked repo. -
rules: most specific first. -
lexicons: every publishable lexicon, sorted by NSID.layerismanualorproject; network lexicons never appear.rule_id,rule_patternandtargetarenullwhen no rule matches, andauto_publishis thenfalse.statusis one ofno_rule,target_unavailable,unpublished,withdrawn,moved,outdatedorcurrent.publicationisnulluntil something has been attempted; it carries only the error columns when every attempt has failed.withdrawn_atis set when the lexicon was unpublished and has not been published since; such a lexicon reportswithdrawn(unless no rule matches or its target is unavailable) and auto-publish skips it. -
orphans: publication rows, in the same shape aspublication, for records still in a repo whose lexicon is no longer publishable, such as one deleted after it was published. Unpublish works on them as on any other.
The overview computes each lexicon’s CID locally, so it makes no network calls.
Check DNS
Section titled “Check DNS”GET /admin/lexicon-publishing/dnsPermission: lexicons:read
Looks up _lexicon.<authority> for every distinct authority among the publishable lexicons, eight at a time with a five-second timeout each. It’s separate from the overview because DNS is slow.
[ { "authority": "notes.example.com", "record_name": "_lexicon.notes.example.com", "did": "did:plc:abc123", "error": null }]did is null when there’s no TXT record. error is set when the lookup itself failed, such as a timeout. Compare did with each lexicon’s target DID to see what needs adding.
Check one lexicon’s DNS
Section titled “Check one lexicon’s DNS”GET /admin/lexicon-publishing/dns/{nsid}Permission: lexicons:read
Looks up the _lexicon record for one lexicon’s authority, with the same five-second timeout, and says which DID it should name.
{ "nsid": "com.example.notes.getNote", "authority": "notes.example.com", "record_name": "_lexicon.notes.example.com", "did": null, "error": null, "expected_did": "did:plc:abc123"}expected_did is the DID of the account the lexicon’s rule publishes to, or failing that the account already holding its record. It’s null for a lexicon HappyView doesn’t publish, or when neither is known. Without linked-repos:read, a rule’s linked repo isn’t named, so expected_did then comes only from a publication. An invalid NSID is a 400.
Create a rule
Section titled “Create a rule”POST /admin/lexicon-publishing/rulesPermission: lexicons:publish
Body:
| Field | Type | Description |
|---|---|---|
pattern | string | Required. *, a prefix ending in .*, or an exact NSID |
target_kind | string | Required. instance or linked_repo |
linked_repo_id | string | Required for linked_repo; ignored for instance |
auto_publish | boolean | Optional, defaults to false |
{ "pattern": "com.example.*", "target_kind": "linked_repo", "linked_repo_id": "550e8400-e29b-41d4-a716-446655440000", "auto_publish": true}Returns 201 with the rule and its target.
| Status | Cause |
|---|---|
400 | A malformed pattern, an unknown target_kind, a missing or unknown linked_repo_id, or a target that is unavailable (the body gives its reason) |
403 | The target is a linked repo and the caller lacks linked-repos:read |
409 | A rule for this pattern already exists. Patterns are compared after normalisation, so COM.EXAMPLE.* collides with com.example.* |
After a successful create, HappyView starts an auto-publish run in the background.
Update a rule
Section titled “Update a rule”PATCH /admin/lexicon-publishing/rules/{id}Permission: lexicons:publish
Any of target_kind, linked_repo_id and auto_publish. The pattern can’t change; delete the rule and add another. The target is re-validated only when it changes, so you can still turn off auto-publish on a rule whose linked repo has gone away. Switching to instance clears linked_repo_id.
Returns 200 with the rule and its target, 403 when it changes the target to a linked repo without linked-repos:read, or 404 for an unknown ID.
Delete a rule
Section titled “Delete a rule”DELETE /admin/lexicon-publishing/rules/{id}Permission: lexicons:publish
Returns 204, or 404 for an unknown ID. Records already published are left alone, and an auto-publish run starts in the background, since a broader rule may now cover the lexicons this one did.
Publish
Section titled “Publish”POST /admin/lexicon-publishing/publishPermission: lexicons:publish
Body: { "nsids": ["com.example.notes.getNote"] }, between 1 and 500 NSIDs. An empty or longer list is a 400.
NSIDs are published one after another, in the order given. A success clears withdrawn_at, so a lexicon unpublished earlier returns to auto-publish. The response is 200 even when some fail:
{ "results": [ { "nsid": "com.example.notes.getNote", "ok": true, "uri": "at://did:plc:abc123/com.atproto.lexicon.schema/com.example.notes.getNote", "cid": "bafyrei...", "error": null }, { "nsid": "com.example.other", "ok": false, "uri": null, "cid": null, "error": "not a lexicon HappyView can publish" } ]}Unpublish
Section titled “Unpublish”POST /admin/lexicon-publishing/unpublishPermission: lexicons:publish
Body: { "nsid": "com.example.notes.getNote" }
Deletes the record from the account it was published to, clears the stored did, uri, cid and published_at, and sets withdrawn_at. Auto-publish then leaves the lexicon alone until a publish succeeds for it. Returns 204.
| Status | Cause |
|---|---|
404 | The NSID isn’t published |
409 | No linked repo or attached account holds the DID the record was published to; nothing is changed |
401 | The attached account needs to sign in again, or a linked repo needs reauthorization |
403 | The linked repo’s grant lacks the delete scope, or the attached account’s PDS refused the delete for lack of scope (re-authenticate it from Service Identity) |
502 | The attached account’s PDS refused the delete for another reason |
Events
Section titled “Events”These actions are written to the event log: lexicon_publishing.rule_created, lexicon_publishing.rule_updated, lexicon_publishing.rule_deleted, lexicon.published, lexicon.publish_failed and lexicon.unpublished. A failed publish whose error matches the last one recorded for that lexicon updates last_attempt_at without logging another lexicon.publish_failed.

