# 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`, and `rkey`
- 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.

```text
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:

```graphql
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 `uri` for stable record references
- storing `cid` when 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:

- `first` and `after` for forward pagination
- `last` and `before` for backward pagination
- `where` for filtering
- `sortBy` and `sortDirection` for ordering

Pagination defaults to 20 records. The maximum page size is 1000 records.

Common filters:

```graphql
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`.

```graphql
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:

```graphql
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:

```graphql
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.

```graphql
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:

```json
{ "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:

```graphql
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:

```graphql
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:

```graphql
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.

```bash
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:

```ts
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

```graphql
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:

```json
{ "after": null }
```

## Example: fetch a hypercert by AT-URI

```graphql
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:

```json
{
  "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.

```graphql
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:

```json
{
  "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:

```graphql
image {
  __typename
  ... on OrgHypercertsDefsUri { uri }
  ... on OrgHypercertsDefsSmallImage {
    image { ref mimeType size }
  }
}
```

For strong references, request both `uri` and `cid`:

```graphql
rights { uri cid }
```

For blobs, request the blob reference and metadata:

```graphql
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 `uri` for stable record identity.
- Use `uri` plus `cid` for version-sensitive data.
- Use generated nested filters for exact nested matches when available; use `search` or client-side filtering for substring matching or unsupported nested shapes.
- Request `totalCount` only 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.
