registry.afauth.org/v1. Write endpoints require proof of control of the discovery host (a one-time challenge + ~30-min token + 7-day session); read endpoints are unauthenticated and CORS-open for browser-based aggregators.
This page is a reference. For the step-by-step walkthrough, see
List your service on registry.afauth.org.
Base URL
application/json). All URLs are HTTPS.
Endpoints at a glance
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /listings/challenge | none | Request a host-control challenge. |
POST | /listings | challenge token in body | Submit (or re-register) a listing; returns a session token. |
PATCH | /listings/{service_did} | Bearer <session_token> | Update writeable fields (title, description, tags). |
DELETE | /listings/{service_did} | Bearer <session_token> | Soft-delete the listing. |
GET | /listings | none | Paginated list with filters. |
GET | /listings/{service_did} | none | Fetch a single listing. |
POST /v1/listings/challenge
Request a one-time challenge bound to a discovery host.
Request
| Field | Type | Notes |
|---|---|---|
discovery_url | string | Must be https://. Host is derived from this URL. |
Response — 200 OK
POST /v1/listings
Verify the challenge proof and create (or re-register) a listing.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
discovery_url | string | yes | Must match the URL the challenge was issued for. |
challenge_token | string | yes | The unused, unexpired token from /challenge. |
title | string | no | ≤120 chars. |
description | string | no | ≤500 chars. |
tags | string[] | no | ≤20 entries, ≤40 chars each. |
Server-side flow
- Validate that the challenge exists, is unexpired, and was issued for the same
discovery_url. - Consume the challenge (it cannot be reused).
- Fetch
proof_url = https://{host}/.well-known/afauth-registry-proofover HTTPS. The body must betext/plainand equal thechallenge_tokenbyte-for-byte. - Fetch
discovery_urlover HTTPS and validate againstschemas/well-known.json. - If the discovery doc’s
service_didis adid:web:host, the host portion must equal the discovery host. - If a listing already exists for that
service_didfrom the same host, this is the re-challenge path: prior session tokens are revoked and a new one issued. - If a listing already exists from a different host, the request is rejected as a
conflict.
Response — 201 Created (new) / 200 OK (re-challenge)
PATCH /v1/listings/{service_did}
Update the writeable fields of a listing.
Request
| Field | Type | Writeable | Notes |
|---|---|---|---|
title | string | yes | ≤120 chars. |
description | string | yes | ≤500 chars. |
tags | string[] | yes | ≤20 entries. |
| Anything else | — | no | Unknown fields rejected with invalid_request. |
{}) is rejected with invalid_request.
To change discovery_url, soft-delete the existing listing and re-register from the new host.
Response — 200 OK
The full listing record after the update (same shape asGET /v1/listings/{service_did}).
DELETE /v1/listings/{service_did}
Soft-delete the listing. The record’s status becomes deleted and it disappears from default list responses, but persists so mirrors converge.
Response — 204 No Content
Soft-deleted listings reappear inGET /v1/listings?include_deleted=true with status: "deleted". Hard-erase (removing the record entirely) is reserved for unlawful content under the take-down policy.
GET /v1/listings
Cursor-paginated list of listings.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
cursor | string | — | Opaque server-issued continuation token. Submit unmodified. |
limit | integer (1–100) | 50 | Max page size. |
search | string | — | Case-insensitive substring match on title and description. |
tag | string | — | Returns listings whose tags include this exact tag. |
updated_since | RFC 3339 datetime | — | Returns listings with updated_at >= updated_since. |
status | active/stale/deleted | — | Filter by status. |
include_deleted | boolean | false | When true, soft-deleted listings are included. |
Response — 200 OK
next_cursor is null when there are no more pages. The cursor encoding is unspecified and may change without notice — submit it unmodified.
Cache headers
Cache-Control: public, max-age=30, s-maxage=120Access-Control-Allow-Origin: *
updated_since and to browser-based aggregators.
GET /v1/listings/{service_did}
Single listing. The path parameter is the full DID (e.g. did:web:api.example.com). Colons are valid in URL path segments; no encoding is required.
Response — 200 OK
Same shape as a single entry in the list response above.Response — 404 Not Found
If no listing exists for thatservice_did. Soft-deleted listings still return 200 with status: "deleted" — unlike the list endpoint (which hides deleted records unless include_deleted=true), a single fetch by DID always returns the record regardless of status.
Cache headers
Cache-Control: public, max-age=60, s-maxage=300Access-Control-Allow-Origin: *
Errors
Every non-2xx response uses a structured envelope:| Code | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | Body or query parameters failed validation. |
invalid_challenge | 400 | Challenge token unknown, expired, or already used. |
proof_fetch_failed | 400 | Could not reach proof_url over HTTPS, or it did not return a text/plain body. |
proof_mismatch | 400 | Challenge discovery_url doesn’t match, or proof body doesn’t equal the token. |
discovery_fetch_failed | 400 | Could not reach the submitted discovery_url. |
discovery_invalid | 400 | Discovery document failed schema validation. |
discovery_did_mismatch | 400 | did:web:host does not match the discovery host. |
unauthorized | 401 | Missing or invalid bearer token. |
forbidden | 403 | Session token is bound to a different service_did. |
not_found | 404 | Listing doesn’t exist. |
conflict | 409 | Host or DID already taken by a different listing. |
rate_limited | 429 | See rate-limit table below. The Retry-After header tells you how long to wait. |
challenge_already_used | 409 | Concurrent reuse race — request a fresh challenge. |
internal_error | 500 | Unexpected. Retry, then report. |
Rate limits
Per-IP, fixed-window. The directory returns429 rate_limited with a Retry-After header (in seconds) when a bucket overflows.
| Endpoint group | Limit |
|---|---|
POST /v1/listings/challenge | 30 / minute |
POST /v1/listings | 10 / minute |
PATCH/DELETE /v1/listings/… | 30 / minute |
GET /v1/listings* | 600 / minute |
GET /sitemap.xml endpoint (outside /v1) is separately capped at 60 / minute per IP.
Federation
The schema and the surface described above are public — anyone may run a mirror or a private directory implementing the same contract. The schema is atschemas/listing.json; the reference implementation is at AFAuthHQ/registry.