Developers
The NANDA Index has a REST API. Public reads need no key; writes use the session token from sign-in.
Reference
API base URL: https://api.nandaindex.org. Every route is described in the API reference (Swagger UI), generated from the server's own route schemas; the OpenAPI document behind it can feed a code generator. The source and README cover self-hosting. Identifiers follow the ARD form urn:air:<publisher-FQDN>:<namespace...>:<short-name>; see How it works for the model.
Quickstart: read the index
Resolve an identifier
Returns the entry registered under that identifier, or the publisher's catalog entry when the publisher fronts its agents with an AI Catalog (match is "exact" or "publisher").
curl "https://api.nandaindex.org/api/v1/resolve?locator=urn:air:moonbakery39.com:agent:orders"
{ "locator": "urn:air:moonbakery39.com:agent:orders", "identifier": "orders", "match": "exact",
"entry": { "identifier": "...", "displayName": "...", "type": "application/a2a-agent-card+json",
"url": "...", "publisher": { ... }, "extensions": { "org.projectnanda": { ... } } } }List the index
A JSON array of live entries in the AI Catalog entry shape. Pages with limit (1 to 500, default 100) and offset; the total is in the X-Total-Count response header.
curl -i "https://api.nandaindex.org/api/v1/index?limit=20&offset=0"
X-Total-Count: 105
[ { "identifier": "urn:air:...", "displayName": "...", "type": "...", "url": "...", "tags": [...], ... } ]Get one entry by org ID
The same shape as a list item. Pending, suspended, unproven and taken-down entries answer 404, the same as an unknown org ID.
curl "https://api.nandaindex.org/api/v1/index/moon-bakery"
Search by keyword
Matches org IDs, domains, names, identifiers, tags and descriptions; at most 50 results. A query that starts with urn:air: is resolved like the resolve endpoint. Results use the owner record shape with snake_case fields (org_id, display_name, registry_url, ...), not the entry shape above.
curl "https://api.nandaindex.org/api/v1/search?q=bakery"
{ "query": "bakery", "count": 1, "results": [ { "org_id": "moon-bakery", "display_name": "Moon Bakery", ... } ] }Describe what you need
Ranks entries for a plain-language request and asks enterprise registries for matching agents. limit is 1 to 50 (default 20). Slower, and limited more tightly than the other reads.
curl "https://api.nandaindex.org/api/v1/agentic-search?q=order%20a%20custom%20cake&limit=5"
{ "query": "...", "count": 1, "candidates": [ ... ], "resolved": { ... } | null,
"referrals": [], "orgsQueried": 1, "orgsUnreachable": [], "tookMs": 1140 }Every public response is JSON. Entries from resolve and index use camelCase AI Catalog fields; search results use the snake_case owner record.
Register, verify, update, delete
The same flow the dashboard uses, as API calls. The web app at Register is the easier path if you only have one entry.
Create an account
Returns a session token right away and emails a verification link. Until you confirm the link, every write to the index answers 403 ACCOUNT_NOT_VERIFIED.
curl -X POST "https://api.nandaindex.org/auth/register" -H "Content-Type: application/json" \ -d '{"email":"[email protected]","password":"a-long-password"}' # 201 {"token":"eyJ..."} curl -X POST "https://api.nandaindex.org/auth/verify-account" -H "Content-Type: application/json" \ -d '{"token":"<token from the email link>"}' # 200 {"ok":true}Sign in
Every write needs the token in an Authorization header. Tokens expire after a day by default; sign in again to get a new one.
curl -X POST "https://api.nandaindex.org/auth/login" -H "Content-Type: application/json" \ -d '{"email":"[email protected]","password":"a-long-password"}' # 200 {"token":"eyJ..."} export TOKEN="eyJ..."Register an entry
Required: org_id, display_name, contact_email and registry_url (HTTP or HTTPS; use HTTPS). hosting_path is registry (default), dns-svcb, smb or personal; every path except personal needs a domain, and personal must not send one. The server derives the identifier, media type and publisher from the path and domain. A personal entry has no domain: its identifier is urn:air:host39.org:personal:<contact email, lowercased>, so that email is public once the entry is live. The entry starts as pending.
curl -X POST "https://api.nandaindex.org/api/v1/orgs" -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" -d '{ "org_id": "acme", "display_name": "Acme Corp", "hosting_path": "registry", "domain": "acme.com", "contact_email": "[email protected]", "registry_url": "https://registry.acme.com", "description": "Acme enterprise AI Catalog.", "tags": ["enterprise"] }' # 201 {"org_id":"acme","status":"pending","identifier":"urn:air:acme.com:catalog:root", ...}Verify and go live
An entry with a domain is published once a DNS TXT record proves the domain. Request the challenge, publish the record, then ask the index to check it; the same challenge is returned until it expires (7 days). A personal entry has no domain and goes live when its contact email is confirmed through the emailed link. Domain entries should confirm their contact email too, so the index can reach them.
curl -X POST "https://api.nandaindex.org/api/v1/orgs/acme/domain-challenge" -H "Authorization: Bearer $TOKEN" # 200 {"domain":"acme.com","record_name":"_nanda-challenge.acme.com","record_type":"TXT", # "record_value":"nanda-verify=<token>","expires_at":"..."} # publish that TXT record at your DNS host, then: curl -X POST "https://api.nandaindex.org/api/v1/orgs/acme/verify-domain" -H "Authorization: Bearer $TOKEN" # 200 {... "domain_verified":true,"status":"active" ...} # 400 DOMAIN_NOT_VERIFIED while the record is not visible yet # personal entries instead confirm the emailed link: curl -X POST "https://api.nandaindex.org/api/v1/verify-email" -H "Content-Type: application/json" -d '{"token":"<token from the email link>"}'Update
Editable fields: display_name, domain, registry_url, ttl_seconds (3600 to 604800), description, tags, version, publisher.displayName, extensions, entry_data and trust_manifest. Anything else, including org_id, contact_email, hosting_path and the identifier, answers 400 UNKNOWN_FIELDS. Changing the domain means proving the new one.
curl -X PUT "https://api.nandaindex.org/api/v1/orgs/acme" -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" -d '{"description":"Acme agents and tools.","ttl_seconds":21600}' # 200 {... the updated record ...}Suspend, reactivate or delete
Suspending hides the entry from the public index and keeps it; reactivating puts it back. Deleting is permanent and answers 204 with no body.
curl -X DELETE "https://api.nandaindex.org/api/v1/orgs/acme/suspend" -H "Authorization: Bearer $TOKEN" curl -X POST "https://api.nandaindex.org/api/v1/orgs/acme/reactivate" -H "Authorization: Bearer $TOKEN" curl -X DELETE "https://api.nandaindex.org/api/v1/orgs/acme" -H "Authorization: Bearer $TOKEN" # 204
Rate limits
Defaults for this index; an operator can change them. Each limited route has its own per-IP budget, so hitting the limit on one route does not block the others. Over a route limit, the API answers 429 with a Retry-After header (seconds) and X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers; the body's error text says how long to wait, for example "retry in 1 minute". The 429 codes below that the application raises itself (TOO_MANY_ATTEMPTS, TOO_MANY_UNVERIFIED, RESEND_COOLDOWN) carry no Retry-After header; the claim route's per-account limit does. Request bodies are capped at 64 KB (413).
| Routes | Limit |
|---|---|
| Public reads: /api/v1/index, /api/v1/index/:org_id, /api/v1/search, /api/v1/resolve, GET /api/ard/agents, POST /api/ard/explore | 120 requests per minute per IP address, counted separately for each route |
| Agentic search: /api/v1/agentic-search, /api/ard/search | 30 requests per minute per IP address, counted separately for each route |
| Account routes: /auth/register, /auth/login, password reset, account verification, resend | 10 requests per minute per IP address, counted separately for each route; sign-in locks for 15 minutes after 5 failed attempts for one email from one network, doubling on further failures |
| Entry writes: POST /api/v1/orgs, PUT /api/v1/orgs/:org_id, POST /api/v1/orgs/:org_id/claim | 30 requests per hour per IP address, counted separately for each route; claim also has a 30 per hour budget per account across all IP addresses |
Errors
Errors are JSON: { "error": "<code>", "detail": "<message>" }. Server failures answer 500 with internal_server_error and no detail. Codes are quoted exactly as the API sends them.
| Status | Code | Meaning |
|---|---|---|
| 400 | FST_ERR_VALIDATION | The request does not match the route's schema; detail names the field. |
| 400 | VALIDATION | A registration rule was broken: domain, hosting_path, registry_url or identifier. |
| 400 | UNKNOWN_FIELDS | PUT named a field that cannot be changed; detail lists the editable ones. |
| 400 | PAYLOAD_TOO_LARGE | extensions, entry_data or trust_manifest is over 16 KB or nested deeper than 6 levels. |
| 400 | RESERVED_ORG_ID | That org_id is reserved; choose another. |
| 400 | NO_DOMAIN | Domain verification was requested for a personal entry, which has no domain. |
| 400 | NO_ACTIVE_CHALLENGE | verify-domain was called before domain-challenge, or the challenge expired. |
| 400 | DOMAIN_NOT_VERIFIED | The TXT record was not found yet; detail says what was expected and what was seen. |
| 400 | INVALID_TOKEN | An account-verification or password-reset token is invalid or expired. |
| 400 | invalid_locator | The resolve locator is not a valid urn:air: identifier. |
| 401 | UNAUTHORIZED | Wrong email or password on sign-in, or no valid session token (missing, expired or revoked); sign in again. |
| 403 | ACCOUNT_NOT_VERIFIED | Confirm your account email before writing to the index. |
| 403 | FORBIDDEN | You are not an admin of that entry. |
| 403 | MODERATED | An index operator took the entry down; it cannot be suspended or reactivated by its owner (edits and deletion still work). |
| 404 | NOT_FOUND | No such entry, or it is not live (index reads), or the verify-email token was not found, already used or expired. |
| 404 | not_found | Resolve found no live entry for the locator. |
| 409 | CONFLICT | Already taken: the account email, an org_id held by another registration, or a domain or personal identity that another registration has already proven. |
| 409 | ALREADY_VERIFIED | The contact email is already verified. |
| 409 | NOT_SUSPENDED | Only a suspended entry can be reactivated. |
| 422 | query_too_short | Search needs at least 2 non-space characters. |
| 429 | TOO_MANY_ATTEMPTS | Sign-in is locked for this email and network after repeated failures. |
| 429 | TOO_MANY_UNVERIFIED | You already hold 5 unverified registrations from the last 48 hours; verify or delete one first. |
| 429 | RESEND_COOLDOWN | A verification email was sent less than 60 seconds ago. |
| 503 | db_unreachable | /health could not reach the database. |