Hyperindex
Hyperindex is the hosted GraphQL read API for Hypercerts and Certified AT Protocol records. Use it when you want to build applications, profile pages, discovery views, dashboards, analytics, or curated experiences over indexed Hypercerts data.
Hyperindex does not replace AT Protocol repositories. Records are created, updated, and deleted in users' repositories. Hyperindex indexes those records and exposes a queryable read model.
Endpoints#
| Environment | GraphQL endpoint | Playground |
|---|---|---|
| Production | https://api.indexer.hypercerts.dev/graphql | https://api.indexer.hypercerts.dev/graphiql |
| Staging | https://dev.api.indexer.hypercerts.dev/graphql | https://dev.api.indexer.hypercerts.dev/graphiql |
Use production for normal application traffic. Production and staging both index data from the same AT Protocol network. Staging is mainly for earlier Hyperindex API, schema, and indexing features; it may expose new functionality before production does.
Public GraphQL queries do not require an API key.
What Hyperindex gives you#
Hyperindex turns AT Protocol records into a GraphQL API with:
- typed queries for Hypercerts and Certified collections
- field-level selection, filtering, sorting, and pagination
- record metadata such as
uri,cid,did, andrkey - generic JSON access for lower-level workflows
- text search over indexed record JSON
Architecture#
At a high level, Hyperindex is a read-side indexer and GraphQL API.
AT Protocol repositories
│
│ repo commits and record events
▼
AT Protocol Relay
│
│ network event stream
▼
Tap / AT Protocol ingestion
│
│ normalized records for indexed collections
▼
Hyperindex record store
│
│ dynamically generated GraphQL schema
▼
Public GraphQL API
Source of truth#
The source of truth is the AT Protocol repository that owns a record. Hyperindex stores an indexed copy so consumers can query across records efficiently.
Use these identifiers carefully:
| Field | Meaning | When to use it |
|---|---|---|
uri | The AT-URI of a record, usually at://<did>/<collection>/<rkey> | Stable record identity and links between records |
cid | The CID of the indexed record version | Version-sensitive reads and cache validation |
author | Structured author identity with non-null did and nullable current handle | Rendering and linking the account that owns the record |
did | Deprecated direct DID output; use author.did instead | Compatibility with existing consumers; where.did filters remain supported |
rkey | The record key, the last segment of the AT-URI | Low-level AT Protocol workflows |
createdAt | Timestamp declared inside the record | User-facing chronology |
indexed_at | Hyperindex arrival order | Indexer-facing chronology |
If you only need to link to a logical record, store the uri. If your application cares about the exact content version, store both uri and cid.
Schema generation#
Hyperindex dynamically builds its public GraphQL schema from AT Protocol Lexicons. A collection NSID becomes a typed GraphQL query:
| Collection NSID | List query | Single-record query |
|---|---|---|
org.hypercerts.claim.activity | orgHypercertsClaimActivity | orgHypercertsClaimActivityByUri |
org.hypercerts.collection | orgHypercertsCollection | orgHypercertsCollectionByUri |
org.hypercerts.context.attachment | orgHypercertsContextAttachment | orgHypercertsContextAttachmentByUri |
app.certified.actor.profile | appCertifiedActorProfile | appCertifiedActorProfileByUri |
app.certified.link.evm | appCertifiedLinkEvm | appCertifiedLinkEvmByUri |
Use typed queries first. They provide typed fields, filters, sorting, and pagination. Typed collection queries only expose records that Indigo has validated against the startup Lexicon set used to generate the running schema. If an observed record is malformed for that saved schema, or if Hyperindex has no saved Lexicon for its collection, it is hidden from typed collection list queries, typed ByUri queries, typed counts, relationship hydration, and typed create/update subscriptions. Typed delete subscriptions emit only when the deleted row was valid before deletion.
Use the generic records(collection: ...) query or search(...) when you need raw JSON, debugging visibility, or access to records hidden from typed GraphQL. Hyperindex stores every observed record in the raw record table even when validation fails or no saved Lexicon is available. Generic record and search results include validation metadata:
| Field | Meaning |
|---|---|
validationStatus | valid, invalid, unknown_schema, or validation_error |
validationError | Explanation of why the record is hidden from typed GraphQL, when available |
validatedAt | Timestamp of the most recent local validation classification |
lexiconHash | SHA-256 validation fingerprint for the saved collection Lexicon and any transitive referenced Lexicons used for classification |
Validation is local-only. During normal ingestion Hyperindex uses Indigo to validate against the Lexicons loaded at startup and does not resolve _lexicon DNS records, DID documents, PDS-hosted schema records, or other remote schema sources.
Public typed GraphQL, record validation, startup record refresh, and default Jetstream collection filters use one fixed Lexicon set loaded at startup. Uploading, registering, or deleting a Lexicon changes only the saved configuration; restart or redeploy Hyperindex to apply the change to all of those runtime surfaces together. In a multi-replica deployment, coordinate a Lexicon-changing rollout so old-snapshot and new-snapshot backend replicas never serve concurrently against the shared validation metadata.
The generic recordEvents subscription receives all observed raw create/update/delete events, including events for invalid or unknown-schema records. Typed collection subscriptions filter that stream to valid create/update rows and deletes that were valid before removal.
All generated record types, generic record results, timeline nodes, and record subscription payloads expose:
author {
did
handle
}
author is non-null, while author.handle is nullable when no current verified handle is available. The direct record-level did field is deprecated but remains functional during the transition to author.did. This output-field deprecation does not affect where.did filters.
author is reserved metadata on generated record types. Lexicons that define their own top-level author property must rename it because that property and its generated filter are skipped in favor of ActorIdentity.
Relationships between records#
Hypercert records often reference other records with AT Protocol strong refs, usually { uri, cid }. Hyperindex exposes those refs, but it does not automatically join every referenced record.
For example:
- a hypercert can reference a rights record
- an attachment can reference one or more subject records
- a collection can reference activity claims or other collections
- a badge award can reference a badge definition and a subject
For arbitrary references, read the referenced uri and fetch it with the matching ByUri query, or use search when you need to find records that mention a nested AT-URI.
Consistency model#
Hyperindex is eventually consistent with the AT Protocol network. Indexing is generally fast, but relay or Tap hiccups can occasionally make indexing slower, so consumers should design for this by:
- using pagination instead of assuming fixed result sets
- storing
urifor stable record references - storing
cidwhen exact record versions matter - retrying recent writes before treating missing records as permanent
Core collections#
The hosted Hypercerts indexer exposes typed queries for these main collections.
| Concept | Collection | Query |
|---|---|---|
| Hypercert activity claims | org.hypercerts.claim.activity | orgHypercertsClaimActivity |
| Contribution details | org.hypercerts.claim.contribution | orgHypercertsClaimContribution |
| Contributor information | org.hypercerts.claim.contributorInformation | orgHypercertsClaimContributorInformation |
| Rights and transfer terms | org.hypercerts.claim.rights | orgHypercertsClaimRights |
| Collections of hypercerts | org.hypercerts.collection | orgHypercertsCollection |
| Acknowledgements | org.hypercerts.context.acknowledgement | orgHypercertsContextAcknowledgement |
| Attachments and evidence | org.hypercerts.context.attachment | orgHypercertsContextAttachment |
| Evaluations | org.hypercerts.context.evaluation | orgHypercertsContextEvaluation |
| Measurements | org.hypercerts.context.measurement | orgHypercertsContextMeasurement |
| Funding receipts | org.hypercerts.funding.receipt | orgHypercertsFundingReceipt |
| Work-scope tags | org.hypercerts.workscope.tag | orgHypercertsWorkscopeTag |
| Certified profiles | app.certified.actor.profile | appCertifiedActorProfile |
| Certified organizations | app.certified.actor.organization | appCertifiedActorOrganization |
| Certified follows | app.certified.graph.follow | appCertifiedGraphFollow |
| EVM wallet links | app.certified.link.evm | appCertifiedLinkEvm |
| Locations | app.certified.location | appCertifiedLocation |
| Badge definitions | app.certified.badge.definition | appCertifiedBadgeDefinition |
| Badge awards | app.certified.badge.award | appCertifiedBadgeAward |
| Badge responses | app.certified.badge.response | appCertifiedBadgeResponse |
Query model#
Most typed list queries accept:
firstandafterfor forward paginationlastandbeforefor backward paginationwherefor filteringsortByandsortDirectionfor ordering
Pagination defaults to 20 records. The maximum page size is 1000 records.
Common filters:
where: { uri: { eq: "at://did:plc:example/org.hypercerts.claim.activity/rkey" } }
where: { uri: { in: ["at://did:plc:example/org.hypercerts.claim.activity/rkey1", "at://did:plc:example/org.hypercerts.claim.activity/rkey2"] } }
where: { did: { eq: "did:plc:example" } }
where: { title: { contains: "reforestation" } }
where: { createdAt: { gte: "2026-01-01T00:00:00Z" } }
where: { image: { isNull: false } }
The generated uri filter is a record metadata filter for exact AT-URI lookup and batched hydration. It supports eq and in and does not search the JSON payload.
Any single in operator accepts up to 100 values. For larger DID, URI, label source/value, or scalar batches, split the values into multiple GraphQL requests and merge the paginated results client-side. This limit is separate from connection page size.
Scalar fields support value filters such as eq, neq, in, contains, startsWith, gt, lt, gte, lte, and isNull, depending on the scalar type.
Complex fields support presence checks with isNull. Some complex fields use the shared PresenceFilterInput; arrays, refs, and unions may instead expose generated nested filter inputs that also include isNull. Do not rely on the input type name for presence checks; introspect the field and use isNull. Nested scalar leaves support exact operators only: eq, in, and isNull. Use array any when at least one array item should match; multiple predicates inside the same any must match the same array item. Nested array fields inside an existing any scope expose presence checks only; Hyperindex does not advertise nested any within another any.
where: {
image: { isNull: false }
contributors: {
any: {
contributorIdentity: { identity: { eq: "did:plc:example" } }
}
}
}
Nested filters do not support substring operators (contains, startsWith), comparison operators (gt, lt, gte, lte), nested-array any filters inside another any, nested sorting, arbitrary JSON paths, or automatic strong-ref dereferencing. A small set of explicit collection filter extensions may perform product-specific cross-record lookups; uploaded lexicons do not get these fields automatically.
For Hypercerts activity contributors that may be inline, legacy bare DID strings, or org.hypercerts.claim.contributorInformation strong refs, use the compatibility filter:
where: { contributorDid: { eq: "did:plc:example" } }
For Certified badge awards, use badgeType to filter by the referenced app.certified.badge.definition.badgeType without joining badge definitions client-side:
where: { badgeType: { eq: "endorsement" } }
badgeType uses StringFilterInput, so it supports the same string operators exposed for badge definitions. Awards whose referenced badge definition is missing or has no badgeType do not match positive value filters.
Certified endorsement closure#
Use endorsementClosure(where, first, after) when a client needs a DID-rooted Certified endorsement network: accounts directly or indirectly reached through active endorsement badge awards.
query EndorsementClosure($did: String!) {
endorsementClosure(
where: { did: { eq: $did } }
first: 100
) {
truncated
totalCount
pageInfo { hasNextPage endCursor }
edges {
cursor
node {
did
degree
certifiedProfileData { did displayName avatar }
viaAccounts {
did
certifiedProfileData { did displayName avatar }
}
}
}
}
}
Variables:
{ "did": "did:plc:example" }
where.did.eq is required and selects the root DID. The endorsement closure DID filter exposes only eq, not in, because each request is rooted at one DID. Optional where.degree.eq returns only one hop distance; the value must be 1, 2, or 3. Omit where.degree to return all supported degrees. Results are sorted by degree then DID. certifiedProfileData resolves the reached account's Certified profile when one exists. viaAccounts lists up to 64 previous-ring accounts that led to an account, including each predecessor DID and optional Certified profile data; it is empty for degree-1 accounts. truncated is true when the server-side account cap is reached; clients should treat the response as a useful subset, not a complete network.
The resolver computes edges from current app.certified.badge.award, app.certified.badge.definition, and app.certified.badge.response records at request time. Only records whose validationStatus is valid participate, so invalid awards or definitions cannot create edges and invalid responses cannot suppress them. An active edge requires an endorsement-typed badge definition, an app.certified.defs#did account subject with a valid DID, an issuer allowed by allowedIssuers when that badge definition has an allowlist, no self-loop, and no rejection response authored by the subject for that award. Badge awards to record strongRefs do not create account endorsement edges.
External and author label filters#
Hyperindex can filter typed record connections by locally ingested external ATProto labels before pagination and totalCount are calculated.
Use where.externalLabels when the label subject is the record AT-URI:
where: {
externalLabels: {
has: {
src: { eq: "did:plc:labeler" }
val: { eq: "high-quality" }
activeOnly: true
}
}
}
Use where.authorLabels when the label subject is the record author's account DID:
where: {
authorLabels: {
none: {
src: { eq: "did:plc:pswneepkd5lesumj7ejmkbal" }
val: { eq: "likely-test" }
activeOnly: true
}
}
}
authorLabels only matches DID-subject labels such as uri: "did:plc:..." with no CID. It does not infer account labels from profile or organization records. Unlabeled authors pass none predicates and fail has predicates.
To require certified authors, use has with multiple values:
where: {
authorLabels: {
has: {
src: { eq: "did:plc:pswneepkd5lesumj7ejmkbal" }
val: { in: ["standard", "high-quality"] }
activeOnly: true
}
}
}
There is no node-level authorLabels field. To display account labels for known DIDs, use the root externalLabels(subjects: [...]) query with those DID subjects.
Quickstart#
Send a POST request with a GraphQL query and optional variables.
curl -s https://api.indexer.hypercerts.dev/graphql \
-H 'content-type: application/json' \
--data '{"query":"query { orgHypercertsClaimActivity(first: 1) { edges { node { uri title } } } }"}'
A minimal TypeScript helper:
const endpoint = "https://api.indexer.hypercerts.dev/graphql";
export async function queryHyperindex<T>(
query: string,
variables?: Record<string, unknown>,
): Promise<T> {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(`Hyperindex request failed: ${response.status} ${response.statusText}`);
}
const payload = await response.json();
if (payload.errors?.length) {
throw new Error(payload.errors.map((error: { message: string }) => error.message).join("\n"));
}
return payload.data as T;
}
Example: query recent hypercerts#
query RecentHypercerts($after: String) {
orgHypercertsClaimActivity(
first: 20
after: $after
sortBy: createdAt
sortDirection: DESC
) {
edges {
cursor
node {
uri
cid
did
title
shortDescription
createdAt
}
}
pageInfo { hasNextPage endCursor }
}
}
Variables:
{ "after": null }
Example: fetch a hypercert by AT-URI#
query HypercertByUri($uri: String!) {
orgHypercertsClaimActivityByUri(uri: $uri) {
uri
cid
did
title
shortDescription
createdAt
startDate
endDate
rights { uri cid }
image {
__typename
... on OrgHypercertsDefsUri { uri }
... on OrgHypercertsDefsSmallImage {
image { ref mimeType size }
}
}
}
}
Variables:
{
"uri": "at://did:plc:example/org.hypercerts.claim.activity/example-rkey"
}
Example: search record JSON#
Use search for simple discovery, substring matching, or nested AT-URI references that are not covered by generated exact nested filters.
query SearchAttachments($hypercertUri: String!, $after: String) {
search(
query: $hypercertUri
collection: "org.hypercerts.context.attachment"
first: 20
after: $after
) {
edges {
cursor
node {
uri
cid
did
collection
value
}
}
pageInfo { hasNextPage endCursor }
}
}
Variables:
{
"hypercertUri": "at://did:plc:example/org.hypercerts.claim.activity/example-rkey",
"after": null
}
Working with unions, refs, and blobs#
For unions, request __typename and use inline fragments:
image {
__typename
... on OrgHypercertsDefsUri { uri }
... on OrgHypercertsDefsSmallImage {
image { ref mimeType size }
}
}
For strong references, request both uri and cid:
rights { uri cid }
For blobs, request the blob reference and metadata:
image { ref mimeType size }
Service health endpoints#
In addition to GraphQL, hosted Hyperindex exposes lightweight status endpoints:
| Endpoint | Meaning |
|---|---|
/health | Liveness check. Use this to check whether the process is running. |
/ready | Readiness check. Use this to check whether the API is ready to serve traffic. |
/stats | Public operational stats and diagnostics for the indexer. |
Best practices#
- Prefer typed queries over generic JSON queries.
- Always paginate list queries.
- Keep selection sets small.
- Use
urifor stable record identity. - Use
uripluscidfor version-sensitive data. - Use generated nested filters for exact nested matches when available; use
searchor client-side filtering for substring matching or unsupported nested shapes. - Request
totalCountonly when your UI needs it.
Troubleshooting#
Cannot query field ...#
The selected endpoint's schema does not expose that field. Check that you are using the right environment and inspect the schema in GraphiQL.
A nested filter does not work#
Generated nested filters only cover arrays, refs, and unions up to three lexicon path segments deep, and nested scalar leaves only support eq, in, and isNull. Multiple predicates inside the same array any are evaluated against the same array item. Nested array fields inside an existing any scope expose presence checks only, not another any. They do not support substring operators (contains, startsWith), comparison operators (gt, lt, gte, lte), arbitrary JSON paths, nested sorting, or automatic strong-ref dereferencing. Introspect the target endpoint's WhereInput; if the nested input is absent, use search, follow a referenced uri, or filter client-side.
A recently written record is missing#
Hyperindex is generally fast, but it is still eventually consistent with AT Protocol repositories. Retry after a short delay and confirm the record belongs to an indexed collection.