Developers

XnY lineage developer API

Read access to XnY Protocol lineage: datasets, contribution fragments (CFs), DIDs, frontiers, campaigns, tasks, and the Merkle proofs that back share claims.

Everything here is a projection of public Base chain state. There is no private data behind this API and no write path — you cannot create, modify, or claim anything through it.

Audience: developers reading lineage from outside XnY. If you are working on the Explorer UI itself, you want the Explorer's own data layer, not this document.

Status of this document. Every response body below was captured from staging.xnyscan.com while writing it. Every field claim is checked against the handler source rather than a design document. Where behaviour is surprising, this document says so instead of describing the intent.


Access

This is the one section that will change. The endpoint shapes below track on-chain primitives and are stable; the host, the auth story, and the limits are provisional. See the "Planned changes" section at the end.

Base URL

https://staging.xnyscan.com/api/v1

Staging only. There is no production host yet.

Authentication: none. Requests are anonymous. No API keys are issued and none are accepted.

CORS: Access-Control-Allow-Origin: *, methods GET, OPTIONS. Browser calls from any origin work.

Methods: GET and OPTIONS. Nothing else is routed.

Rate limit: 120 requests per minute, per client IP, fixed window. Exceeding it returns 429 with a Retry-After header.

The client is identified by the address the load balancer observed, taken from X-Forwarded-For counting from the right — not by the first entry in that header. Entries you send arrive to the left of it and do not change which bucket you are counted against, so there is no way to widen your own budget by setting the header, and no reason to try.

One honest caveat remains, and it can make your effective budget differ from 120: the counter lives in the serving process's memory, so it is per pod rather than per cluster. One replica is deployed today, but that is not a guarantee.

Treat 120/min as a fair-use ceiling to stay under, not as a quota you have been granted.


Quickstart

Five requests, no credentials, real ids from staging. Copy-paste in order.

1. List frontiers

A frontier is a data domain — the top of the hierarchy.

curl -s 'https://staging.xnyscan.com/api/v1/frontiers?limit=2'
{
  "items": [
    {
      "frontierId": "0x529a6e12b139b1d046d44d3e5141194bad2b3bf217809b9092c7b5f433a6733a",
      "ownerDidId": "dea37a60-2b2f-7fef-ad26-d77cfd3c89c0",
      "metadataHash": "0x754ff35f0182f08f8215e9a785339d3294e1e52ec1acda836d3ae0a3bbbb16f5",
      "metadataUri": "arweave://48M1OaQ0Myj3V8Q0x7GBLK7khkov9RcN4QB6U7tIkds",
      "blockNumber": 44373722,
      "name": "Crypto Address Annotation",
      "description": "Crypto Address Annotation"
    }
  ],
  "pagination": { "limit": 2, "hasMore": true, "nextCursor": "eyJiIjo0NDE2OTMyMSwibCI6ODB9" }
}

2. Read a dataset version

A dataset id is a version identifier: each assembled version has its own datasetId (keccak256 of assembler + manifest + version number). There is no separate version path segment.

curl -s 'https://staging.xnyscan.com/api/v1/datasets/0x0e48bb19ea1bfe53893ac1f705b8ba66b430945e122c6db0297e8e56492409db'
{
  "datasetId": "0x0e48bb19ea1bfe53893ac1f705b8ba66b430945e122c6db0297e8e56492409db",
  "frontierId": "0x1f5d0d21e8e8b1401596bf4d9e10ed26a80bc432d3b4731ac021f33b7ea069e4",
  "versionNumber": 1,
  "contributorsMerkleRoot": "0xb6d0720a27f6f41d97b39fb736f67cdc086ae8b252b808adf299c79a1c280e46",
  "totalShares": "1000",
  "metadataUri": "arweave://8c6kudVdIJTjGc5ATpKhwXVbg80U5JWMViUlpLE0ndw",
  "blockNumber": 43336338,
  "txHash": "0xc7f62ee5e811a4764d293325a7a689c4ae69a16cee952c282845ba3ee7dd3599",
  "previousDatasetId": null,
  "manifestUri": "https://storage.googleapis.com/humanbased-test/xny/dataset/manifest-0e48bb19ea1bfe53.json",
  "manifestHash": "0c74bedb9838d70167a6497004cc57f5c0f815e29cbf097e970ae9cd023097b4",
  "status": "ACTIVE",
  "metadataStatus": "DONE",
  "metadataFetchedAt": "2026-06-26T02:42:24.133217Z"
}

3. List its contributors

curl -s 'https://staging.xnyscan.com/api/v1/datasets/0x0e48bb19ea1bfe53893ac1f705b8ba66b430945e122c6db0297e8e56492409db/contributors?limit=3'
{
  "items": [
    { "contributorDidId": "a4939d12-f60a-1446-5ae2-2d77eb91afde", "cfCount": 5, "shareBalance": "0" },
    { "contributorDidId": "cb1a9833-e02a-0abe-05e4-fa646061c33e", "cfCount": 2, "shareBalance": "0" },
    { "contributorDidId": "df57911d-6af4-affe-4b1e-fea5b403f877", "cfCount": 3, "shareBalance": "300",
      "dbaAddress": "0x927400f558e4b5538461719915d64b678383a693" }
  ],
  "pagination": { "limit": 3, "hasMore": false }
}

shareBalance is the ERC-1155 balance currently held. 0 with a positive cfCount means the contributor has earned shares but not claimed them yet.

4. Check whether a CF's shares can be claimed

curl -s 'https://staging.xnyscan.com/api/v1/datasets/0x0e48bb19ea1bfe53893ac1f705b8ba66b430945e122c6db0297e8e56492409db/cf/0x1149dbb8563f1c805cfaf41ae11ebc7a6d13ae55aee9c23bc3fa7c6f9e622f6c/claimability'
{
  "cfId": "0x1149dbb8563f1c805cfaf41ae11ebc7a6d13ae55aee9c23bc3fa7c6f9e622f6c",
  "datasetId": "0x0e48bb19ea1bfe53893ac1f705b8ba66b430945e122c6db0297e8e56492409db",
  "contributorDidId": "a4939d12-f60a-1446-5ae2-2d77eb91afde",
  "claimable": true,
  "reason": null,
  "shareAmount": "500",
  "cfMerkleRoot": "0x238784dbfd200d2600424e3b703b72b8d1ff75971a936ee9c85f46adb945b3a2",
  "proof": ["0xe2b30bb1...", "0x6fcd805f..."],
  "ownershipBalance": "0",
  "hasClaimed": false,
  "proofStatus": "ready"
}

When claimable is false, reason is one of cf_not_found, cf_not_validated, contributor_not_found, dataset_not_assembled, proof_pending, already_claimed. They mirror the protocol's claim dependency chain: anchored → validated → assembled → proof ready → claimable.

5. Fetch the full two-layer proof

curl -s 'https://staging.xnyscan.com/api/v1/datasets/0x0e48bb19ea1bfe53893ac1f705b8ba66b430945e122c6db0297e8e56492409db/cf/0x1149dbb8563f1c805cfaf41ae11ebc7a6d13ae55aee9c23bc3fa7c6f9e622f6c/proof'
{
  "datasetId": "0x0e48bb19ea1bfe53893ac1f705b8ba66b430945e122c6db0297e8e56492409db",
  "contributorsMerkleRoot": "0xb6d0720a27f6f41d97b39fb736f67cdc086ae8b252b808adf299c79a1c280e46",
  "chainId": "84532",
  "contract": "0xf994bb70f4f038227592efdc89046c0cfa381897",
  "contributorDidId": "218759844787491018806030599800457637854",
  "shareAmount": "500",
  "cfId": "0x1149dbb8563f1c805cfaf41ae11ebc7a6d13ae55aee9c23bc3fa7c6f9e622f6c",
  "cfMerkleRoot": "0x238784dbfd200d2600424e3b703b72b8d1ff75971a936ee9c85f46adb945b3a2",
  "cfLeafIndex": 0,
  "cfProof": [
    "0x5ad7d61ff74b2a261e0051d4680825b86b3e1f50b61f037257a4e9f32e9c0758",
    "0xd781322940482066baed71705ee4add685e6239c2108949c412d3740e976e221",
    "0x4bd4225c7992d9188cb42de937b4f89f7ebbbef0b4d06ece6011ef40792a542e"
  ],
  "contributorLeafIndex": 0,
  "claimProof": [
    "0xe2b30bb1f65000597d3cb79d11cb9df11b288ab179332a00b0f63f55a6a44f9f",
    "0x6fcd805f5d6976f56442bc24dbbdd81c7ca8831ca4f07fca1fbdf1584112808d"
  ],
  "cfIds": ["0x1149dbb8...", "0x5ad7d61f...", "0x5b378c23...", "0x887165c1...", "0xc9181996..."]
}

Compare step 4 and step 5: the same contributor is a4939d12-f60a-1446-5ae2-2d77eb91afde in one and 218759844787491018806030599800457637854 in the other. Those are the same 128-bit number in two renderings, and the reason is explained next.


Identifiers

bytes32 ids

datasetId, cfId, frontierId, campaignId, taskId, all Merkle roots and proof elements: 0x-prefixed, lowercase, exactly 64 hex digits. Sent and returned in that form. A missing 0x prefix is a 400, not a guess:

{"error":{"code":"BAD_REQUEST","message":"invalid datasetId \"notahex\": missing 0x prefix"}}

DID ids: one value, two renderings

An XnY DID id is an on-chain uint128. The canonical DID string is did:xny:<uuid>, where the UUID is that 128-bit number rendered as a UUID. Both forms are the same value and conversion is lossless.

In requests, any of these four forms is accepted wherever {did} appears:

a4939d12-f60a-1446-5ae2-2d77eb91afde          UUID
218759844787491018806030599800457637854       decimal uint128
did:xny:a4939d12-f60a-1446-5ae2-2d77eb91afde  DID with UUID
did:xny:218759844787491018806030599800457637854  DID with decimal

In responses, didId / ownerDidId / contributorDidId / verifiedByDidId render as UUID — with one deliberate exception.

The proof endpoint's decimal contributorDidId

GET /v1/datasets/{id}/cf/{cfId}/proof returns contributorDidId as a decimal string, not a UUID.

This is not an inconsistency to work around. The Merkle leaf is built over the numeric uint128, and this endpoint's entire purpose is to hand you the exact values that hash to the anchored root. Rendering it as a UUID here would mean every caller converting it back before hashing, with a silent wrong-proof failure for anyone who forgot. The decimal is the hashing input, so the endpoint returns the hashing input.

Converting between the two is a base change over the same 16 bytes:

uuid_str = "a4939d12-f60a-1446-5ae2-2d77eb91afde"
did_id = int(uuid_str.replace("-", ""), 16)
# 218759844787491018806030599800457637854

Large integers are strings

Share counts, token amounts, and any uint128 / uint256 are JSON strings:

{ "totalShares": "1000", "shareAmount": "500", "shareBalance": "300" }

They exceed IEEE-754's exact integer range, so a JSON parser that maps numbers to doubles corrupts them silently — no exception, just a wrong value in the low digits. This matters most for contributorDidId in decimal form, which is a full 128-bit number and will always be mangled if read as a number.

In JavaScript use BigInt(value). In Python int(value) is fine. Do not let a generic parser coerce these fields.

Block numbers, versionNumber, cfCount, limit, and leaf indices are ordinary JSON numbers — they are bounded and safe.


Enum values

Integer enums come straight from contracts/src/ProtocolTypes.sol. They are ordinals, not labels.

Field 0 1 2 3 4 5
kind (AtomicKind) SAMPLE LABEL AMENDS VALIDATION
verdict (Verdict) PENDING APPROVED REJECTED
qualityGrade (QualityGrade) NONE D C B A S
taskType (TaskType) SUBMISSION VALIDATION
execution (Execution) HUMAN AGENT

Note kind in particular: 0 is SAMPLE and 3 is VALIDATION. A CF with kind: 0, verdict: 1 is an approved sample, not an approved label.

Some fields arrive pre-labelled as strings instead, and they come from different vocabularies. Do not map any of these through the table above:

Field Values Origin
status on a dataset ACTIVE, REVOKED, ARCHIVED The off-chain metadata document, validated on ingest against docs/schemas/DatasetMetadata.schema.json. Passed through verbatim
status on a grant ACTIVE, REVOKED, UNKNOWN On-chain grant_anchor.status ordinal (0, 1), labelled server-side. UNKNOWN means an ordinal this build does not recognise
metadataStatus PENDING, IN_PROGRESS, DONE, FAILED Enrichment job state — see Eventual consistency
proofStatus on claimability pending, ready — lowercase, unlike every other status field Whether the proof columns are populated

A dataset's status is therefore a claim its publisher made off-chain, while a grant's status is on-chain fact. They are not interchangeable despite sharing two labels.


Pagination

Most list endpoints return an items array beside a pagination object:

{ "items": [ ... ], "pagination": { "limit": 3, "hasMore": true, "nextCursor": "eyJiIjo0NDg1MTA5MiwibCI6NDd9" } }

Four endpoints do not use that exact envelope, so do not assume items is always present:

Endpoint Shape
/v1/datasets/{id}/holders items and pagination, plus top-level datasetId and totalSupply
/v1/datasets/{id}/royalty-flow Two arrays — distributions and claims — plus pagination
/v1/datasets/{id}/versions versions array, plus assemblerDidId and manifestId. Not paginated
/v1/datasets/{id}/frontier Verification result. Not a list

The cursor rules below apply wherever pagination appears.

  • limit — default 50, maximum 200. A larger value is silently clamped to 200 rather than rejected, so check the limit echoed in pagination if it matters. A non-integer or a value below 1 is a 400.
  • nextCursor — present only when hasMore is true. Pass it back verbatim as ?cursor=....
  • The cursor is opaque. It happens to be base64url JSON today; that is an implementation detail, not a contract. Do not parse it, construct it, or persist it as a bookmark across deploys.

GET /v1/datasets/{id}/lineage and GET /v1/cfs/{cf_id}/lineage are graph reads rather than pages, and take different parameters:

  • depth — default 2, maximum 5.
  • directionup, down, or both.
  • nodeLimit — default 50, maximum 100.

Unlike limit, these are rejected with a 400 when over the cap rather than clamped. The response carries a truncated flag when the graph was cut off by nodeLimit.


Eventual consistency

Two independent lags apply. Neither is an error state, and both are visible in the response.

1. The indexer's cursor, not chain head. Responses reflect blocks the indexer has processed. A transaction confirmed seconds ago may not be visible yet. Every entity carries the blockNumber it was observed at, which is what you should use for ordering and for judging freshness.

2. Off-chain metadata enrichment lags further. Names and descriptions live off-chain (Arweave, GCS) and are fetched by a separate job after the on-chain record is indexed. metadataStatus tells you where that stands:

Value Meaning
PENDING Not fetched yet — including the case where no fetch job row exists
IN_PROGRESS Fetch running
DONE Fetched and stored
FAILED Fetch failed; will not resolve without intervention

The consequence that catches people: "name": null means "not known yet", not "has no name". Read it together with metadataStatus. Rendering "Untitled" for a PENDING record is wrong; it will have a name shortly.

metadataFetchedAt is the timestamp of the successful fetch, when there was one.


Endpoints

26 read endpoints. Everything is GET. {id} is a bytes32 hex string; {did} accepts any of the four DID forms.

Datasets

Endpoint Returns
/v1/datasets/{id} One dataset version: roots, shares, manifest, metadata status
/v1/datasets/{id}/versions Version chain this dataset belongs to
/v1/datasets/{id}/cfs CFs included in this version
/v1/datasets/{id}/contributors Contributors with cfCount and shareBalance
/v1/datasets/{id}/holders Current ERC-1155 share holders
/v1/datasets/{id}/grants Access grants (status: ACTIVE / REVOKED)
/v1/datasets/{id}/circulation ERC-1155 share movements (mint / transfer / burn)
/v1/datasets/{id}/royalty-flow Royalty events, split into distributions and claims. Filters: token, from, to
/v1/datasets/{id}/frontier Frontier verification: whether every CF in this version comes from the claimed frontier
/v1/datasets/{id}/lineage Version-to-version graph. depth, direction, nodeLimit
/v1/datasets/{id}/claimability Claimability for a contributor. Requires ?did=
/v1/datasets/{id}/cf/{cfId}/claimability Claimability for one CF
/v1/datasets/{id}/cf/{cfId}/proof Full two-layer Merkle proof

token accepts native or a 0x-prefixed 20-byte address. from / to bound a block range.

/frontier is a verification result, not a lookup — it returns status, claimedPrimaryFrontierId, derivedFrontierIds, checkedCount, and totalCount. For the frontier a dataset was published under, read frontierId from /v1/datasets/{id}.

Omitting did on /claimability is a 400, not a whole-dataset summary:

{"error":{"code":"BAD_REQUEST","message":"invalid did \"\": empty"}}

Contribution fragments

Endpoint Returns
/v1/cfs/{cf_id} One CF: kind, verdict, grade, verifier, dataset memberships
/v1/cfs/{cf_id}/lineage CF derivation graph. depth, direction, nodeLimit

datasetMemberships is an array of datasetIds. It is empty for a CF not yet assembled into any dataset version — which is normal, not an error, and means no proof exists for it yet.

DIDs

Endpoint Returns
/v1/dids/{did} Identity: current owner, DBA address, contribution and dataset counts, owner history
/v1/dids/{did}/contributions CFs authored by this DID
/v1/dids/{did}/holdings Non-zero dataset-share balances, with totalShares for percentage math
/v1/dids/{did}/royalty-history Royalty events for this DID. Filters: token, from, to

Frontiers, campaigns, tasks

Endpoint Returns
/v1/frontiers Frontier list
/v1/frontiers/{id} One frontier
/v1/campaigns Campaign list
/v1/campaigns/{id} One campaign
/v1/tasks Task list
/v1/tasks/{id} One task
/v1/tasks/{id}/cfs CFs submitted to this task

Finding a starting point

There is no dataset-list endpoint. To reach a dataset without already having its id, walk down:

/v1/frontiers → /v1/tasks → /v1/tasks/{id}/cfs → /v1/cfs/{cf_id} → datasetMemberships[]

or, if you have a DID, /v1/dids/{did}/holdings gives dataset ids directly.


Verifying a proof

The claim proof is two layers. Understanding both is the difference between "the API says 500 shares" and "500 shares is provably what the chain anchored".

Layer 1 — the CF is in the contributor's CF set

One tree per contributor per dataset version. Leaves are raw cfId values, used directly — a cfId is already a keccak256 hash and is not re-hashed. Leaves are sorted by cfId byte-ascending. The root is cfMerkleRoot.

Verify cfProof for cfId at cfLeafIndex against cfMerkleRoot.

Layer 2 — the contributor is in the dataset's contributor set

One leaf per contributor. The root, contributorsMerkleRoot, is anchored on-chain in the DatasetVersion record.

leaf = keccak256(keccak256(abi.encode(
    uint128 contributorDidId,   // decimal — the numeric id, not the UUID
    uint256 shareAmount,        // Σ weightShares for this contributor
    bytes32 cfMerkleRoot,       // this contributor's Layer-1 root
    bytes32 datasetId,
    uint256 chainId,            // prevents cross-chain replay
    address contract            // prevents cross-deployment replay
)))

abi.encode left-pads each field into a 32-byte slot — 192 bytes total. The hash is applied twice. Leaves are sorted by contributorDidId ascending.

Verify claimProof for that leaf at contributorLeafIndex against contributorsMerkleRoot.

Internal nodes, both layers

Sorted-pair hashing, matching OpenZeppelin's MerkleProof:

parent = keccak256(min(a, b) ++ max(a, b))

Comparison is as 256-bit big-endian unsigned integers. On an odd layer, the last node is duplicated before pairing.

Using the SDK instead

consumer-sdk implements Layer 2. Note that contributor_did_id and share_amount are ints, not strings — convert before calling:

from consumer_sdk.merkle import verify_claim

ok = verify_claim(
    contributors_merkle_root=bytes.fromhex(r["contributorsMerkleRoot"][2:]),
    contributor_did_id=int(r["contributorDidId"]),      # decimal string → int
    share_amount=int(r["shareAmount"]),                 # decimal string → int
    cf_merkle_root=bytes.fromhex(r["cfMerkleRoot"][2:]),
    proof=[bytes.fromhex(p[2:]) for p in r["claimProof"]],
    dataset_id=r["datasetId"],
    chain_id=int(r["chainId"]),
    contract_address=r["contract"],
)

Full specification: ../contracts/ownership-merkle-tree.md.

Trusting the result

The service rebuilds both trees from stored rows on every proof request and compares the result against the anchored roots. A mismatch is reported as a 500 with INTERNAL_ERROR rather than a proof you might act on. So a 200 from this endpoint means the served proof reproduces the on-chain root — but verify it yourself anyway if you are about to submit a claim; that is the point of the proof.


Errors

Non-2xx responses carry a code you can match on. Both the gateway and the upstream service emit a single shape:

curl -s 'https://staging.xnyscan.com/api/v1/datasets/0x0000000000000000000000000000000000000000000000000000000000000001'
{"error":{"code":"NOT_FOUND","message":"dataset not found"}}

Read body.error?.code. There is no flat shape to fall back to — describing one would teach a parser branch for a body the gateway never produced, and the cutover (IN-3019) retired it rather than leave it as a fallback.

Codes

From the gateway:

Code Status Meaning
NOT_FOUND 404 Path is not part of this API
INVALID_PATH 400 Percent-encoded path separator (%2F) in the path
RATE_LIMITED 429 Over 120/min. Honour Retry-After
UPSTREAM_UNAVAILABLE 502 Lineage service unreachable or slower than 10s

From the service:

Code Status Meaning
BAD_REQUEST 400 Malformed id, limit, cursor, or filter
NOT_FOUND 404 Entity does not exist at the indexer's cursor
PROOF_NOT_READY 409 Proof columns not yet populated. Retry later
UNAVAILABLE 503 Endpoint not configured (proof endpoint without chain id / contract address)
INTERNAL_ERROR 500 Server fault, including a failed proof integrity check

404 deserves care given eventual consistency: for an entity created very recently it may mean "not indexed yet" rather than "does not exist". PROOF_NOT_READY (409) is the explicit "exists but not ready" signal, and it applies only to the proof endpoint.

One code is declared in the service's error package but never emitted by any handler: INVALID_PARAMETER (422). Do not write branches for it. RATE_LIMITED does get emitted — by the gateway, with the same status as a service-side rate limit would carry.


What is deliberately not here

Two paths exist upstream and are withheld from this API: /v1/frontiers/{id}/activity and /v1/search.

Both are shaped for Explorer pages — a merged event timeline and an id lookup for the site's search box — so they follow the UI rather than the protocol, and building against them would mean building against a moving target. They are served to the Explorer and are not public.

Three more once existed and have been removed rather than withheld: /v1/datasets/{id}/overview, /v1/cfs/{cf_id}/overview, and /v1/datasets/{id}/activity. They were page rollups over granular reads, and no client ever adopted them — the pages fan out to the endpoints above instead. Nothing was lost with them: an overview was /datasets/{id} plus /contributors plus /holders, and the dataset activity feed was /circulation plus /royalty-flow.

There is also no rate-limit header, no cache header, no conditional-request support, no webhook, and no write path. If you have read otherwise in api.md, that document is a design record and describes several things that were never built.


Planned changes

The endpoint shapes above track on-chain primitives, and the v2 storage layout is frozen — so they are the stable part of this document. The Access section is not:

  • Host. staging.xnyscan.com is a staging deployment. The production host is undecided.
  • Auth. Anonymous today. API keys are under discussion; if introduced, anonymous access to this surface may be reduced.
  • Rate limits. The key is settled — the address the load balancer observed — but the counter is still per pod. A shared-store limiter would change the effective numbers.
  • Error envelope. The two shapes above should converge on one.

Track these against the lineage module split rather than assuming the current behaviour is settled.


Reference

Topic Source
Merkle tree specification ../contracts/ownership-merkle-tree.md
Protocol enums contracts/src/ProtocolTypes.sol
Field-level schema services/protocol-indexer/api/openapi.yaml
Encoding conventions ../schemas/CONVENTIONS.md