Archive Node API
A GraphQL service over an archive node database — the supported way for zkApps, explorers, and indexers to read Mina's history.
An archive node stores Mina's history in a Postgres database, but it exposes no public API of its own. The Archive Node API closes that gap: it is a read-only GraphQL server that you point at an existing archive node database and query for blocks, events, actions, and transactions.
It does not run an archive node. You need one already syncing, or a snapshot of its database, before this service has anything to serve.
Choose a data path
Four ways to read Mina data. They are not interchangeable — pick by what you are building.
| You need | Use | Why |
|---|---|---|
| Current network state, recent blocks, sending transactions | Daemon GraphQL | The daemon keeps only the last k blocks (currently 290) |
| zkApp events and actions, block and transaction history | Archive Node API | Purpose-built for o1js and zkApp developers |
| Exchange integration — deposits, withdrawals, balances | Rosetta API | A standardized cross-chain interface exchanges already support |
| Arbitrary analytical queries over the full schema | The archive Postgres directly | No API constrains your query shape — and none protects the database |
The Archive Node API and Rosetta read the same archive database. Running both against one database is normal.
Architecture
| Component | Default port | Role |
|---|---|---|
| Mina daemon | 8302 (P2P), 3085 (GraphQL) | Syncs with the network |
mina-archive process | 3086 | Receives blocks from the daemon, writes them to Postgres |
| PostgreSQL | 5432 | Stores the history |
| Archive Node API | 8080 | Serves GraphQL over that Postgres |
The API holds a connection pool to Postgres and issues read-only queries. It has no write path, no keys, and no daemon connection of its own.
Quick start
- npm
- Docker
- Docker Compose
Lightest weight, for when you already have an archive database reachable.
npm install -g @o1-labs/mina-archive-node-graphql
PG_CONN='postgres://postgres:postgres@localhost:5432/archive' \
ENABLE_GRAPHIQL=true \
mina-archive-node-graphql
# → {"level":"info",...,"port":"8080","msg":"server started"}
Open http://localhost:8080 for the GraphiQL playground. The GraphQL endpoint is the root path: send POST requests to /, not to /graphql.
For when you have a database reachable but no Node toolchain.
docker pull ghcr.io/o1-labs/archive-node-api:2
docker run --rm -p 8080:8080 \
-e PG_CONN='postgres://postgres:postgres@host.docker.internal:5432/archive' \
-e ENABLE_GRAPHIQL=true \
ghcr.io/o1-labs/archive-node-api:2
Tags 2, 2.0 and 2.0.0 follow the major, the minor and one exact release. Use 2 or an exact tag in production, not latest.
For when you have no archive database at all — Compose stands one up from a published snapshot.
git clone https://github.com/o1-labs/Archive-Node-API.git
cd Archive-Node-API
cp .env.example.compose .env
./scripts/download_db.sh devnet # or: mainnet
docker compose up
Confirm that your instance is serving:
curl -s -X POST http://localhost:8080 \
-H 'Content-Type: application/json' \
--data '{"query":"{ networkState { maxBlockHeight { canonicalMaxBlockHeight pendingMaxBlockHeight } } }"}'
The query surface
Seven root fields. The authoritative definition is the schema reference, which also gives the schema as a file to download.
| Query | Returns |
|---|---|
events(input:) | Events emitted by a zkApp account, filtered by address, token, block range, and chain status |
actions(input:) | Actions dispatched from a zkApp account, optionally bounded by action state |
blocks(query:, limit:, sortBy:) | Blocks by height or date range, with transaction detail |
networkState | The archive's maximum canonical and pending block heights |
verificationKeyUpdates(input:) | Applied account updates that set a given verification key, over a required block range |
zkappCommands(input:) | zkApp commands and their account updates over a block range, optionally filtered by account and token. Off unless ENABLE_ZKAPP_COMMANDS_QUERY=true |
schemaVersion | The MAJOR.MINOR version of the schema this server speaks. Always served |
The examples below run against your own instance from Quick start. The addresses, heights and hashes are real Devnet values, so they return data on a Devnet archive, and the responses shown are what a Devnet archive returned. Block ranges are from inclusive and to exclusive on every query.
Handshake
The first query a client sends. schemaVersion tells you which contract the server speaks, and networkState tells you how far the archive has synced. A block becomes canonical when it is 290 blocks (k) below the tip, so the two heights differ by about 290:
query {
schemaVersion
networkState {
maxBlockHeight {
canonicalMaxBlockHeight
pendingMaxBlockHeight
}
}
}
Response
{
"data": {
"schemaVersion": "2.0",
"networkState": {
"maxBlockHeight": {
"canonicalMaxBlockHeight": 573702,
"pendingMaxBlockHeight": 573992
}
}
}
}
Blocks
The five most recent canonical blocks, newest first:
query {
blocks(query: { canonical: true }, limit: 5, sortBy: BLOCKHEIGHT_DESC) {
blockHeight
stateHash
creator
dateTime
}
}
A height range, with the transactions in each block. On Devnet, block 470002 holds two zkApp commands:
query {
blocks(
query: { blockHeight_gte: 470000, blockHeight_lt: 470003, canonical: true }
sortBy: BLOCKHEIGHT_ASC
) {
blockHeight
stateHash
dateTime
transactions {
coinbase
userCommands {
hash
kind
from
to
amount
fee
status
}
zkappCommands {
hash
feePayer
fee
status
failureReason
}
feeTransfer {
recipient
fee
type
}
}
}
}
Response (block 470002; blocks 470000 and 470001 hold only a coinbase)
{
"data": {
"blocks": [
"\u2026",
{
"blockHeight": 470002,
"stateHash": "3NK9zM3mXJsWbBiLfUqFewEgyovVNBDCaSCeRRnnS2oTg53KN7iq",
"dateTime": "2025-11-05T10:30:00.000Z",
"transactions": {
"coinbase": "720000000000",
"userCommands": [],
"zkappCommands": [
{
"hash": "5JtwjufZFKL6GYt1wx1c4JFoNRNs3DCCu5LAv4Hk88mYECRaMHan",
"feePayer": "B62qod2DugDjy9Jxhzd56gFS7npN8pWhanxxb36MLPzDDqtzzDyBy5z",
"fee": "400000000",
"status": "applied",
"failureReason": null
},
{
"hash": "5JuxTZXY9Z3fd1LSrPyvnmhq2ScCLaxmdfaEMmJk9bWHnCQv7VF1",
"feePayer": "B62qmGtQ7kn6zbw4tAYomBJJri1gZSThfQZJaMG6eR3tyNP3RiCcEQZ",
"fee": "100000000",
"status": "applied",
"failureReason": null
}
],
"feeTransfer": [
{
"recipient": "B62qpfgnUm7zVqi8MJHNB2m37rtgMNDbFNhC2DpMmmVpQt8x6gKv9Ww",
"fee": "500000000",
"type": "fee_transfer"
}
]
}
}
]
}
}
The same three blocks selected by time instead of height. DateTime values are ISO 8601 strings, and Devnet produces a block about every three minutes:
query {
blocks(
query: {
dateTime_gte: "2025-11-05T10:15:00Z"
dateTime_lt: "2025-11-05T10:31:00Z"
canonical: true
}
sortBy: BLOCKHEIGHT_ASC
) {
blockHeight
dateTime
transactions {
coinbase
}
}
}
Response
{
"data": {
"blocks": [
{
"blockHeight": 470000,
"dateTime": "2025-11-05T10:15:00.000Z",
"transactions": {
"coinbase": "720000000000"
}
},
{
"blockHeight": 470001,
"dateTime": "2025-11-05T10:21:00.000Z",
"transactions": {
"coinbase": "720000000000"
}
},
{
"blockHeight": 470002,
"dateTime": "2025-11-05T10:30:00.000Z",
"transactions": {
"coinbase": "720000000000"
}
}
]
}
}
userCommands, zkappCommands and feeTransfer on a block are served only when ENABLE_BLOCK_TRANSACTION_DETAILS=true. The other fields are always served.
Events
The events that a zkApp emitted in a block range. This address is a Lumina DEX pool on Devnet, and the range holds one event-emitting transaction, at block 470259:
query {
events(
input: {
address: "B62qjGGHziBe9brhAC4zkvQa2dyN7nisKnAhKC7rasGFtW31GiuTZoY"
status: CANONICAL
from: 470000
to: 470300
}
) {
blockInfo {
height
stateHash
parentHash
timestamp
chainStatus
distanceFromMaxBlockHeight
}
eventData {
accountUpdateId
data
transactionInfo {
hash
status
memo
authorizationKind
}
}
}
}
Response
{
"data": {
"events": [
{
"blockInfo": {
"height": 470259,
"stateHash": "3NLyMpkM1Gdgtt2jDs2qK3puuy4m1iFE3MXbtnmdg6WTYnBsu2d1",
"parentHash": "3NLJJEu12roWDC7CBmaRU8C74bJacoJwD9qZRUWbCz4qQVg8J9Ff",
"timestamp": "1762430760000",
"chainStatus": "canonical",
"distanceFromMaxBlockHeight": 103733
},
"eventData": [
{
"accountUpdateId": "192898",
"data": [
"3",
"24052708529394526800224616175202855196403836544833931471428478190495166298049",
"1",
"5000000000"
],
"transactionInfo": {
"hash": "5JuwhbYXvPK4u4Lv6dgdjrJHzSKj7rUKVY9FbAHUp66Frd94Mh8y",
"status": "applied",
"memo": "E4YM2vTHhWEg66xpj52JErHUBU4pZ1yageL4TVDDpTTSsv8mK6YaH",
"authorizationKind": "Proof"
}
}
]
}
]
}
}
timestamp is Unix time in milliseconds, as a string. data holds the event's field elements in the order that the contract emitted them. chainStatus is canonical or pending, and distanceFromMaxBlockHeight tells you how far the block is from the tip. Amounts, such as the 5000000000 above, are strings in nanomina.
Events of the same account under a custom token. tokenId defaults to MINA, wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf; pass the token's ID to read the account that the zkApp owns under that token:
query {
events(
input: {
address: "B62qjGGHziBe9brhAC4zkvQa2dyN7nisKnAhKC7rasGFtW31GiuTZoY"
tokenId: "wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf"
status: CANONICAL
from: 470000
to: 470300
}
) {
blockInfo {
height
}
eventData {
data
}
}
}
With the MINA token ID this returns the same event as the query above. With a custom token ID it returns the events of the account that the zkApp owns under that token.
Actions
Replace address with your zkApp's public key in these two examples.
Actions folded from a checkpoint, which is what an o1js reducer does. The fromActionState below is the initial action state of every account, so this request starts from the zkApp's first action. In a reducer loop, pass the last action state you processed instead:
query {
actions(
input: {
address: "B62q..."
status: CANONICAL
fromActionState: "25079927036070901246064867767436987657692091363973573142121686150614948079097"
}
) {
blockInfo {
height
stateHash
}
transactionInfo {
hash
status
}
actionState {
actionStateOne
actionStateTwo
actionStateThree
actionStateFour
actionStateFive
}
actionData {
accountUpdateId
data
}
}
}
Actions between two checkpoints, inside a block range. Both action-state bounds are inclusive. Use this when a reducer has already settled up to endActionState:
query {
actions(
input: {
address: "B62q..."
from: 470000
to: 480000
fromActionState: "25079927036070901246064867767436987657692091363973573142121686150614948079097"
endActionState: "<action state after the last processed action>"
}
) {
blockInfo {
height
}
actionState {
actionStateOne
}
actionData {
data
}
}
}
actionStateOne is the action state after the block's actions were applied. actionStateTwo to actionStateFive are the four earlier states that the account keeps, which a reducer uses to find its checkpoint again after a reorganization.
Verification key updates
Every account update that set one verification key, in a required block range. This finds every contract deployed from the same code. On Devnet, this hash is the Lumina pool contract, and the range holds four pool deployments:
query {
verificationKeyUpdates(
input: {
verificationKeyHash: "7480901441026468595703278519003423509807923435933557767337375565636818933806"
from: 430000
to: 440000
status: CANONICAL
}
) {
address
tokenId
blockInfo {
height
stateHash
}
transactionInfo {
hash
status
}
}
}
Response (first two of four; the four deployments are at blocks 432749, 434622, 435910, 436785)
{
"data": {
"verificationKeyUpdates": [
{
"address": "B62qjGGHziBe9brhAC4zkvQa2dyN7nisKnAhKC7rasGFtW31GiuTZoY",
"tokenId": "wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf",
"blockInfo": {
"height": 432749,
"stateHash": "3NLX5TfnXBYQVHqkQC9ywz3H6Yu22NaG5VQACouPnCCC6udM1mWd"
},
"transactionInfo": {
"hash": "5Jv2h9n2iRtnqMh4ZbYoFnwbdGD7Spi5noRLv1McyXK2rbkWC11z",
"status": "applied"
}
},
{
"address": "B62qjPYCxG2Z9k5XTCknCgq6N1W6EDEU4tJ4Dp2cUuzVjBHPSsJ7ptN",
"tokenId": "wSHV2S4qX9jFsLjQo8r1BsMLH2ZRKsZx6EJd1sbozGPieEC4Jf",
"blockInfo": {
"height": 434622,
"stateHash": "3NKYF3E6tGvEXCUcSbEXkrNGzfnj82cy4yX5QHXVgkYMJkSGnob9"
},
"transactionInfo": {
"hash": "5Ju2yoYtECLa513WMFnFAtiiDPSiimd64WUiLfhHhZwsJYVeyaaa",
"status": "applied"
}
}
]
}
}
The first result is at block 432749: the pool from the events example. To find the hash of a deployed contract, read verificationKey { hash } from the account on the daemon GraphQL API.
zkApp commands
Successful zkApp commands and their account updates in a block range. This serves an indexer that needs the whole transaction, not only one account's events. Both bounds are required, and accountPublicKey keeps only the commands that touch one account. This request returns the Lumina pool's transaction from the events example:
query {
zkappCommands(
input: {
from: 470259
to: 470260
blockStatus: CANONICAL
accountPublicKey: "B62qjGGHziBe9brhAC4zkvQa2dyN7nisKnAhKC7rasGFtW31GiuTZoY"
}
) {
hash
feePayer
fee
memo
blockInfo {
height
stateHash
}
accountUpdates {
publicKey
tokenId
authorizationKind
balanceChange
callDepth
events {
fields
}
actions {
fields
}
appState {
fields
}
}
}
}
balanceChange is a signed string in nanomina. appState, events and actions are the raw field arrays from the archive database. The query is off by default: set ENABLE_ZKAPP_COMMANDS_QUERY=true on the server, and expect a Cannot query field "zkappCommands" error from a server that has not.
Send a query with variables
Any GraphQL client works. With curl, send the query and its variables as JSON to /:
curl -s -X POST http://localhost:8080 \
-H 'Content-Type: application/json' \
--data @- <<'EOF'
{
"query": "query Events($input: EventFilterOptionsInput!) { events(input: $input) { blockInfo { height } eventData { data } } }",
"variables": {
"input": {
"address": "B62qjGGHziBe9brhAC4zkvQa2dyN7nisKnAhKC7rasGFtW31GiuTZoY",
"status": "CANONICAL",
"from": 470000,
"to": 470300
}
}
}
EOF
Response
{
"data": {
"events": [
{
"blockInfo": {
"height": 470259
},
"eventData": [
{
"data": [
"3",
"24052708529394526800224616175202855196403836544833931471428478190495166298049",
"1",
"5000000000"
]
}
]
}
]
}
}
Walk a long history
Range limits differ by query, so a client reads a long history one range at a time.
eventsandactionssearch at mostBLOCK_RANGE_SIZEblocks (10000 by default). The server shortens a wider range without an error: it keepsfromand movestotofrom + BLOCK_RANGE_SIZE. With onlyto, it searches theBLOCK_RANGE_SIZEblocks belowto. With neither, it searches the lastBLOCK_RANGE_SIZEblocks only, so an older event does not appear.verificationKeyUpdatesrejects a range wider thanBLOCK_RANGE_SIZE.zkappCommandsrejects a range wider thanZKAPP_COMMAND_RANGE_SIZE(1000 by default).
Always send both from and to, and step from by the range size. This TypeScript loop collects every canonical event of an address between two heights:
const RANGE = 10000; // the server's BLOCK_RANGE_SIZE
const query = `query Events($input: EventFilterOptionsInput!) {
events(input: $input) { blockInfo { height } eventData { data } }
}`;
async function allEvents(endpoint: string, address: string, fromHeight: number, toHeight: number) {
const events = [];
for (let from = fromHeight; from < toHeight; from += RANGE) {
const to = Math.min(from + RANGE, toHeight);
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
variables: { input: { address, status: 'CANONICAL', from, to } },
}),
});
const { data, errors } = await res.json();
if (errors) throw new Error(JSON.stringify(errors));
events.push(...data.events);
}
return events;
}
The client SDKs do the request and the retries for you, and the loop stays the same.
Using it from o1js
A zkApp reaches the API through the archive property of its network configuration. o1js then uses it for fetchEvents and fetchActions:
const Network = Mina.Network({
mina: 'https://api.minascan.io/node/devnet/v1/graphql',
archive: 'https://api.minascan.io/archive/devnet/v1/graphql',
});
Mina.setActiveInstance(Network);
Without an archive endpoint, fetching events or actions raises an error. For the full o1js workflow, see Fetch Events and Actions.
Client SDKs
Typed clients wrap the same endpoint with configurable retries and timeouts. Each has one method for each of events, actions, blocks, networkState and verificationKeyUpdates; reach zkappCommands and schemaVersion through the SDK's raw-query method.
Pass the server's base URL as-is. The Archive Node API serves GraphQL at /, and the SDKs do not append a path.
- TypeScript
- Rust
- Go
npm install @o1-labs/mina-archive-sdk
import { ArchiveClient } from '@o1-labs/mina-archive-sdk';
const client = new ArchiveClient('https://api.minascan.io/archive/devnet/v1/graphql');
const events = await client.getEvents({
address: 'B62q...',
status: 'CANONICAL',
from: 100,
to: 200,
});
cargo add mina-archive-sdk
use mina_archive_sdk::{ArchiveClient, EventFilterOptionsInput, BlockStatusFilter};
let client = ArchiveClient::new("https://api.minascan.io/archive/devnet/v1/graphql");
let events = client
.get_events(
EventFilterOptionsInput::for_address("B62q...")
.status(BlockStatusFilter::Canonical)
.from(100)
.to(200),
)
.await?;
go get github.com/o1-labs/mina-archive-sdk-go/v2
import archive "github.com/o1-labs/mina-archive-sdk-go/v2"
client := archive.NewClient(
archive.WithGraphQLURI("https://api.minascan.io/archive/devnet/v1/graphql"),
)
defer client.Close()
events, err := client.GetEvents(ctx, archive.EventFilterOptionsInput{
Address: "B62q...",
Status: archive.BlockStatusCanonical,
From: archive.IntPtr(100),
To: archive.IntPtr(200),
})
Sources: mina-archive-sdk-js, mina-archive-sdk-rust, mina-archive-sdk-go.
Configuration
PG_CONN is the only required variable. Configuration is validated at startup: a missing connection string, a non-numeric port, or a mistyped boolean stops the server with a clear message rather than booting into a broken state.
Boolean variables accept true/false, 1/0, yes/no, or on/off, case-insensitively.
Essentials
| Variable | Default | Description |
|---|---|---|
PG_CONN | (required) | Postgres connection string for the archive database |
PORT | 8080 | Port the GraphQL server listens on |
BLOCK_RANGE_SIZE | 10000 | Maximum block span a single query may cover |
ENABLED_QUERIES | (all) | Comma-separated subset of root fields to expose. schemaVersion is always served |
ENABLE_GRAPHIQL | false | Serve the GraphiQL playground at / |
ENABLE_INTROSPECTION | false | Allow schema introspection |
ENABLE_BLOCK_TRANSACTION_DETAILS | false | Include userCommands, zkappCommands, and feeTransfers on blocks |
ENABLE_ZKAPP_COMMANDS_QUERY | false | Expose the zkappCommands root query |
ZKAPP_COMMAND_RANGE_SIZE | 1000 | Maximum block span of one zkappCommands query |
ZKAPP_COMMAND_ACCOUNT_UPDATE_LIMIT | 5000 | Maximum account updates one zkappCommands query may expand |
Database
| Variable | Default | Description |
|---|---|---|
PG_MAX_CONNECTIONS | 10 | Maximum pooled connections, total rather than per host |
PG_IDLE_TIMEOUT | 30 | Seconds an idle connection is kept |
PG_CONNECT_TIMEOUT | 30 | Seconds to wait for a new connection |
PG_STATEMENT_TIMEOUT | 15000 | Server-side timeout in milliseconds per statement; 0 disables |
For redundancy, PG_CONN accepts several hosts — postgres://host1:5432,host2:5432/archive, the same syntax psql uses. The driver fails over between them; it does not spread reads across them.
Exposure controls
| Variable | Default | Description |
|---|---|---|
CORS_ORIGIN | (disabled) | Cross-origin access. Unset means same-origin only; * means any origin; or give a comma-separated allowlist |
RATE_LIMIT_MAX | 600 | Requests per client IP per window; 0 disables |
RATE_LIMIT_WINDOW_MS | 60000 | Rate-limit window length |
TRUST_PROXY | (unset) | Number of trusted proxy hops in front of the API |
GRAPHQL_MAX_DEPTH | 12 | Maximum selection-set nesting depth |
GRAPHQL_MAX_ALIASES | 15 | Maximum aliases in one operation |
GRAPHQL_MAX_TOKENS | 1000 | Maximum lexical tokens in a query document |
GRAPHQL_MAX_COST | 5000 | Maximum estimated query cost |
TRUST_PROXY has no default, and the rate limiter stays disabled until you set it. Neither reading is safe to assume: keying on the socket address behind a load balancer collapses every client into one bucket, while trusting X-Forwarded-For without a hop count lets any caller mint a fresh bucket per request. A bare GCP external Application Load Balancer is TRUST_PROXY=2; add one per additional proxy. Use TRUST_PROXY=0 only for a directly exposed server.
Browser clients need CORS_ORIGIN set. The default sends no permissive CORS headers, so a web front end on another origin cannot call the API until you allowlist its origin. Server-to-server callers and curl are unaffected.
The complete reference, including tracing variables, is in the repository's setup guide.
Running it in production
| Endpoint | Purpose |
|---|---|
/healthcheck | Liveness — the process is up and serving HTTP |
/readiness | Readiness — the database is reachable; returns 503 when it is not |
/metrics | Prometheus request-rate, error-rate, and duration metrics, behind ENABLE_METRICS |
Point your orchestrator's liveness probe at /healthcheck and its readiness probe at /readiness, so a pod with an unreachable database leaves the load-balancer pool instead of being restarted.
Two things to keep in mind when you deploy it:
/metricsis unauthenticated. Do not publish it on the same address as the API.- On
SIGTERMthe server drains in-flight requests before exiting, bounded bySHUTDOWN_TIMEOUT_MS(20000 by default). Keep the orchestrator's grace period above that value or it will kill the process mid-drain.
Reference Kubernetes manifests, a production Compose file, and an operations runbook covering SLOs, capacity, and failover are published in the repository under deploy/ and docs/.
Schema stability
The GraphQL schema follows semantic versioning. A new root field or a new optional argument is a minor release; removing or renaming anything a client can select is a major one, and never happens silently — a schema check gates every change to the repository.
The server, its schema and the client SDKs share one major version. The same major means compatible: a 2.x SDK works against any 2.x server, and a higher server minor only adds what the SDK does not wrap yet. Minor and patch versions move independently.
A client can ask the server which schema it speaks:
query {
schemaVersion
}
schemaVersion reports the contract, not the queries a given deployment exposes. ENABLED_QUERIES and ENABLE_ZKAPP_COMMANDS_QUERY can still switch data queries off, so detect a missing query from the Cannot query field error.
The full policy, including what counts as a breaking change, is in the repository's versioning guide. The 2.0 schema is on the schema reference page and at https://docs.minaprotocol.com/archive-node-api/schema.graphql as a file.