- API Reference
- Admin API
Linked Repos
Admin API endpoints for managing linked repos and their invites. The whole lifecycle is available here, so the feature can be driven headlessly without the dashboard. For a conceptual overview, see Linked Repos.
The repo write operations themselves are not part of this API. The admin API manages grants; Lua scripts use them.
List grants
Section titled “List grants”GET /admin/linked-reposReturns every grant, newest first. Never includes session data or tokens.
Permission: linked-repos:read
Response:
{ "linked_repos": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "did": "did:plc:abc123", "handle": "partner.example.com", "reason": "Mirror published notes", "scopes": "atproto repo:com.example.note?action=create&action=update", "status": "active", "last_error": null, "last_refreshed_at": "2026-07-30T12:00:00Z", "authorized_at": "2026-07-29T09:14:00Z", "created_by": "did:plc:admin", "created_at": "2026-07-29T09:12:00Z" } ]}did is null for an open grant that hasn’t been authorized yet. status is one of pending, active, or needs_reauth.
Create a grant
Section titled “Create a grant”POST /admin/linked-reposPermission: linked-repos:create
Body:
| Field | Type | Description |
|---|---|---|
handle | string | Optional. A handle or DID to pin the grant to. Omit for an open grant. |
reason | string | Optional. Why access is being requested; shown to the recipient of an invite. |
scopes | string | Required. Whitespace-separated scope string. |
{ "handle": "partner.example.com", "reason": "Mirror published notes", "scopes": "repo:com.example.note?action=create&action=update"}Returns the created grant, in the same shape as the list endpoint.
Scopes are validated on the way in: a malformed scope or an invalid NSID is rejected with a 400. atproto is added automatically if the string doesn’t contain it, so the stored scopes may differ from what you sent. Scopes are immutable afterwards; there is no update endpoint.
Pinning to a DID that already has a grant returns a 409.
Start an authorization
Section titled “Start an authorization”POST /admin/linked-repos/{id}/authorizeReturns a URL to send the browser to. This only works for a grant that names a handle or DID. An open grant has no identity to authorize against, so it returns a 400 telling you to use an invite link instead.
Permission: linked-repos:create
Response:
{ "authorize_url": "https://pds.example.com/oauth/authorize?..."}Mint an invite
Section titled “Mint an invite”POST /admin/linked-repos/{id}/inviteMints a single-use invite link for the grant.
Permission: linked-repos:create
Body: optional.
| Field | Type | Description |
|---|---|---|
expires_ | number | Optional. Lifetime in seconds, 60 to 2592000 (30 days). Default 7 days. |
Response:
{ "invite_url": "https://appview.example.com/auth/linked-repo/start?token=...", "expires_at": "2026-08-06T09:12:00Z"}List invites
Section titled “List invites”GET /admin/linked-repos/{id}/invitesReturns the grant’s outstanding, unexpired invites. Expired invites are not listed.
Permission: linked-repos:read
Response:
{ "invites": [ { "invite_id": "a5e55c20844abe8068d142a815c6730d4e4ae73309c145dc1c5b451d9e7e45c8", "expires_at": "2026-08-06T09:12:00Z" } ]}invite_id is the stored SHA-256 of the token. It’s safe to display and is what you pass to the revoke endpoint, but it cannot be turned back into a usable link.
Revoke an invite
Section titled “Revoke an invite”DELETE /admin/linked-repos/{id}/invites/{invite_id}Permission: linked-repos:create
Revoking uses the same permission as minting, since they’re one lifecycle: whoever can send a link should be able to take it back.
Response:
{ "revoked": true }Returns 404 if no matching invite exists for that grant, which includes an invite that has already been used, revoked, or expired.
Delete a grant
Section titled “Delete a grant”DELETE /admin/linked-repos/{id}Deletes the grant and its stored OAuth session together.
Permission: linked-repos:delete
Response:
{ "deleted": true }Scopes are immutable, so this is also how you change what a grant can do: delete it and create a new one. Deleting on HappyView’s side does not revoke the authorization at the PDS.
Headless flow
Section titled “Headless flow”Creating a grant and getting it authorized without the dashboard:
# 1. Create an open grantGRANT=$(curl -s -X POST https://appview.example.com/admin/linked-repos \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"reason":"Mirror notes","scopes":"repo:com.example.note?action=create"}' \ | jq -r .id)
# 2. Mint an invite, capturing the URL — it can't be read back latercurl -s -X POST "https://appview.example.com/admin/linked-repos/$GRANT/invite" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"expires_in":3600}' | jq -r .invite_url
# 3. Send that URL to the repo owner. Poll until they've completed it.curl -s https://appview.example.com/admin/linked-repos \ -H "Authorization: Bearer $ADMIN_TOKEN" \ | jq --arg id "$GRANT" '.linked_repos[] | select(.id == $id) | .status'See also
Section titled “See also”- Linked Repos guide: Concepts, scopes, and the invite flow
- Linked Repos Lua API: Writing to a linked repo from a script
- Permissions: The full permission list
© HappyProto

