Pinion Documentation

Quick Start Guide

Get started with Pinion in three simple steps:

1

Get Your API Token

Sign in, then go to Profile → API Tokens to generate a personal access token. Tokens are shown only once, so copy yours before navigating away, keep it secure!

2

Choose Your Tool

Use curl for quick tests, install an IPFS pinning service client library for your language of choice, reach for pinion-cli for a scriptable command-line workflow across every service, or if you already run kubo, add Pinion as a remote pinning service and keep using the commands you already know.

3

Make Your First Pin

Try pinning content with a quick API call:

curl -X POST https://helium.pinion.build/pinning/pat/v1/pins \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"cid": "bafybeigdyrzt...", "name": "my-first-pin"}'

Authentication

All Pinion API endpoints require authentication. Every service (Pinning, Upload, Gateway, Prover) exposes the same three authentication schemes at a predictable URL shape:

https://<environment>.pinion.build/<service>/<scheme>/<version>/...

<service> is one of pinning, upload, gateway, or prover. <scheme>selects which authentication method you're using, and is one of:

pat: Personal Access Token (simplest to get started)

Each service can self-issue its own bearer token, independent of any OAuth flow. This is what non-browser clients (curl, scripts, kubo) should use. Generate one per service from Profile → API Tokens and pass it as a Bearer token on every request:

Authorization: Bearer YOUR_TOKEN

PAT endpoints live under /pat/v1/ on each service, e.g. /pinning/pat/v1/, /upload/pat/v1/, /gateway/pat/v1/, /prover/pat/v1/. A token issued for one service is not accepted by another.

pinion-cli stores PATs for you, one per service, in a named context (similar to a kubectl context: a target cluster plus credentials for it). Generate a token per service from Profile → API Tokens the same way, then register each one once:

pinion context add mine --base-url https://helium.pinion.build
pinion context enable mine pinning YOUR_PINNING_TOKEN
pinion context enable mine upload YOUR_UPLOAD_TOKEN
pinion context enable mine gateway YOUR_GATEWAY_TOKEN
pinion context enable mine prover YOUR_PROVER_TOKEN
pinion context use mine

Every pinion command after that picks up the right token for whichever service it talks to, automatically, from the current context. Only enable the services you actually have a token for; a command against a service with no token enabled fails with a clear error rather than silently sending no Authorization header. Switch environments (e.g. hydrogen vs. helium, or a second account) by adding another context and running pinion context use <name>, or override per-command with --context <name>.

web: OAuth2 session (browser access)

Authenticates via OAuth 2.0 with PKCE and a browser session cookie. Signing in issues the session automatically; no manual token management required. Browser-facing endpoints are available under /web/ on each service and use this session. The pinion.build website itself uses a combination of this scheme and the api scheme below, whichever fits a given route. It is not limited to the Pinion dashboard: a third-party developer can use /web/too, by redirecting their user through the OAuth login flow so the user's browser session authenticates their subsequent requests.

api: OAuth2 resource server

For registered OAuth2 clients (apps that are themselves an OAuth client, for example the pinion.build website presenting a signed-in user's access token) to call a service on the user's behalf. Endpoints live under /api/v1/ on each service. Most third-party integrations want pat instead, since this scheme requires registering an OAuth client and completing a full authorization-code flow.

For the rest of this document, we assume PAT authentication, since it's the simplest way to get started. Every base URL and code sample below uses the /pat/v1/ path for this reason. Swap in /api/v1/if you're building a registered OAuth2 client instead.

What is IPFS?

IPFS (InterPlanetary File System) is a peer-to-peer network for storing and sharing content. What makes it powerful is that every piece of content is identified by a Content Identifier (CID), a cryptographic hash derived directly from the content itself. There is no central authority assigning names; the data speaks for itself.

The ID comes from the content

A CID is computed by hashing the content. This means two files with identical bytes always produce the same CID; the same CID always refers to the same bytes, everywhere in the world, forever.

Tamper-evidence is built in

Any change to the content, even a single byte, produces a completely different CID. You never need to “trust” a server: if the CID matches what you expected, the data is exactly what it claims to be. Integrity verification is implicit, not bolted on.

CIDs already give you versioning

A question we hear often: “how do I version my files?” You already have a version history, without configuring anything. Because the CID is derived from the content, editing a file and uploading it again produces a brand new CID. The old CID still points to the old bytes, unchanged, for as long as that content is pinned. Keeping a record of the CIDs you receive over time, in the order you received them, is a complete version history on its own. If you want a stable name that always points to the latest version, store a mapping from that name to the current CID in your own database; that's the simplest way to layer it on top.

Block CIDs vs. Root CIDs

IPFS splits content into blocks, chunks of raw data, each with its own CID. Larger files are organized into a Merkle DAG (Directed Acyclic Graph): each node's CID is computed from the CIDs of its children, so the root CID is a cryptographic commitment to the entire tree. Changing any block anywhere in the tree produces a different root CID. When you pin or retrieve content you work with the root CID; IPFS fetches all underlying blocks automatically.

Root CID  (bafybeig...)
├── Block A  (bafybea...)   <- chunk of the file
│   └── Block C  (bafybec...)
└── Block B  (bafybeb...)   <- another chunk
    └── Block D  (bafybed...)

Each node is addressed by the hash of its own content plus its children's CIDs, so the root CID pins the whole structure.

What is a pinning service?

IPFS nodes only keep data as long as it is useful to them locally. A pinning service is a hosted IPFS node (or cluster) that commits to storing your content persistently and keeping it available on the network, so your content stays reachable even when your own machine is offline. Pinion.build is one example. Pinning services expose a standard HTTP API described in the IPFS Pinning Service API specification.

Want to go deeper? Read the official IPFS documentation →

Pinning Service

Overview

The Pinning Service API allows you to manage and persist content on IPFS by "pinning" CID objects. Pinning ensures that specific data remains available and is not garbage-collected by IPFS nodes.

This API is compatible with the IPFS Pinning Service API specification v1.0.0.

Who this API is for

Pinning a CID tells us which CID to keep. It does not send us any data. Our worker fetches the actual blocks from the IPFS network itself, the same way any other IPFS node would, which only works if something on the network is actually serving that content. In practice that means a kubo node, yours or someone else's, that already has the content and is announcing it. This API is designed to be driven by a kubo node or an equivalent IPFS-native client. See the kubo tab on the examples below.

Calling this API directly with curl will still create the pin record. If nothing else is hosting that CID, though, there is nothing for us to fetch, so the pin will sit unfulfilled instead of completing. If you want to hand us bytes over plain HTTP without running an IPFS node yourself, use the Upload Service instead.

Base URL

https://helium.pinion.build/pinning/pat/v1

Shown here using PAT authentication (see Authentication for the /web/ and /api/v1/ alternatives).

Pin Lifecycle

Every pin moves through a sequence of states. The happy path is queued pinning pinned. A failure during ingestion moves the pin to failed, which is terminal.

queued

The pin request has been created and published to the processing queue. A worker has not yet started on it. If delivery fails before a worker picks it up, the message is redelivered automatically and the pin stays in this state.

pinning

A worker has picked up the request and is ingesting the content. For pins created via the Pinning Service API (where you supply an existing CID), the worker must fetch every block in the DAG from the IPFS network via Bitswap. The pin stays in this state until all blocks have been downloaded. For pins created via the Upload Service, the content is already in the shared blockstore, so this state resolves almost immediately.

pinned

All blocks have been stored successfully. This is a terminal state. The content is durably pinned and immediately available via the Gateway.

failed

The worker could not download the content and will not retry. This is a terminal state. It typically means the blocks are not available on the IPFS network (no peers are serving them). To try again, delete this pin request and create a new one.

Endpoints

List Pins
GET/pins

Retrieve a list of pinned or pinning requests.

Query Parameters
NameTypeDescription
cidstringFilter by specific CID (exact match or comma-separated list)
namestringFilter by user-defined pin name
statusstringFilter by pin status (queued, pinning, pinned, failed)
limitintegerMaximum number of results to return
beforestringReturn results created before this ISO 8601 timestamp
afterstringReturn results created after this ISO 8601 timestamp

Results are always returned in ascending order by created (oldest first), regardless of how many pins match. This holds even when there are more matches than limit: the response is always the oldest matching page, never an arbitrary subset. That makes GET /pins?name=X a reliable way to read back a revision history for anything you upload repeatedly under the same name: reuse the name on every upload, then list by that name to get every CID back in the order it was created.

countis the total number of pins matching this request's filters, independent of limitspecifically, so it's not capped at the page size. It is still scoped to this request, though: once you page forward by advancing after, after itself is one of the filters, so countcorrectly shrinks on each subsequent page (it is “how many match from here on,” not a fixed grand total for the name). If count is larger than the length of results, there are more matches to read; pass after with the created timestamp of the last item in the current page to fetch the next one. An empty results array (count: 0) means you've read everything.

Examples

Pin a CID by name:

curl -X POST https://helium.pinion.build/pinning/pat/v1/pins \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"cid": "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", "name": "my-file"}'

List all pinned items:

curl https://helium.pinion.build/pinning/pat/v1/pins?status=pinned \
  -H "Authorization: Bearer YOUR_TOKEN"

Check the status of a specific pin:

curl https://helium.pinion.build/pinning/pat/v1/pins/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer YOUR_TOKEN"

Delete a pin:

curl -X DELETE https://helium.pinion.build/pinning/pat/v1/pins/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer YOUR_TOKEN"

Gateway API

Overview

The Pinion Gateway provides HTTP access to IPFS content by CID. It implements the IPFS HTTP Gateway specification, including full content-type negotiation via the Accept header.

Content uploaded through the Upload Service is immediately retrievable through the Gateway, even before the worker has finished processing the pin request.

Base URL

https://helium.pinion.build/gateway/pat/v1

Shown here using PAT authentication (see Authentication for the /web/ and /api/v1/ alternatives).

Endpoint

GET/ipfs/{cid}[/path]

Retrieve content by CID. Optionally traverse into a UnixFS directory using a path suffix.

ParameterTypeDescription
cidpath (required)IPFS Content Identifier (CIDv0 or CIDv1)
pathpath (optional)Sub-path within a UnixFS directory (e.g. /index.html)

Content-Type Negotiation

Use the Accept header to request a specific response format:

Accept headerResponse
(default)Deserialized content (file bytes, HTML for directories)
application/vnd.ipld.carCAR archive of the DAG rooted at the CID
application/vnd.ipld.rawRaw block bytes for the exact CID
application/jsonJSON representation (for IPLD JSON nodes)

Caching

Responses include standard HTTP caching headers. Because IPFS content is content-addressed and immutable, responses for a given CID are safe to cache indefinitely. Cache-Control: public, max-age=29030400, immutable is set by the gateway for resolved content.

Examples

Download a file by CID:

curl https://helium.pinion.build/gateway/pat/v1/ipfs/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -o myfile.jpg

Download a CAR archive of a DAG:

curl https://helium.pinion.build/gateway/pat/v1/ipfs/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Accept: application/vnd.ipld.car" \
  -o content.car

Fetch a file within a directory CID:

curl https://helium.pinion.build/gateway/pat/v1/ipfs/bafybeid.../index.html \
  -H "Authorization: Bearer YOUR_TOKEN"

Public Paths

Every endpoint above requires authentication, because retrieving content costs us egress bandwidth that has to be billed to someone. That's a problem for a perfectly normal use case: you want to hand someone a link and have it just work, no Pinion account required. Self-hosting a website, sharing a dataset with a collaborator, linking to a file from a README, publishing something via DNSLink.

A naive "make this pin public" toggle breaks down the moment two accounts pin the same content, which happens constantly since IPFS is content-addressed: whoever flipped the toggle first would end up billed for everyone's traffic. A Public Pathfixes this by making the exposure explicit and account-scoped instead of a property of the CID itself: it's a named, globally-unique allowlist of your own pins. Content attached to it is served with no authentication at all, billed to your account specifically, regardless of who else happens to have pinned the same CID. You can create as many paths as you like and attach the same pin to more than one.

Creating a path and attaching pins to it uses the same authenticated endpoints as everything else on this page (shown here under /pat/v1/). Fetching content through a path is the one exception in this entire document: it lives directly under /gateway/, with no /pat/v1/, /web/, or /api/v1/ prefix, and no Authorization header.

Managing paths

EndpointDescription
POST /pathsReserve a new path name: {"name": "..."}, 3-63 characters (letters, numbers, hyphens, underscores). Names are global across all Pinion accounts; 409 if already taken.
GET /pathsList your own paths, including their hit/byte counters.
DELETE/paths/{name}Delete a path you own. Content attached to it stops being publicly reachable immediately.
GET/paths/{name}/pinsList the pins currently attached to a path.
POST/paths/{name}/pinsAttach one of your own pins, by requestid (not a bare CID — see below). The pin must already be pinned.
DELETE/paths/{name}/pins/{requestid}Detach a pin from a path.

Why requestid, not CID?

The underlying blockstore is shared and content-addressed across every Pinion account. Attaching by CID alone would let anyone publish any CID that happens to already exist in it, including content someone else uploaded and never intended to make public. Requiring your own pin's requestid proves you actually own a pin for that content before it can be exposed.

Fetching through a path — no account needed

https://helium.pinion.build/gateway
GET/{path}/ipfs/{cid}[/path]
GET/{path}/ipns/{domain} (DNSLink)

The DNSLink form resolves domain's _dnslinkTXT record (a record you control, we don't manage DNS on your behalf) and serves the resolved CID the same way, as long as it's attached to that path. Either form 404s if the path doesn't exist, or if the resolved CID isn't attached to it.

Create a path:

curl -X POST https://helium.pinion.build/gateway/pat/v1/paths \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "<YourPathName>"}'

Attach one of your pins to it:

curl -X POST https://helium.pinion.build/gateway/pat/v1/paths/<YourPathName>/pins \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"requestid": "550e8400-e29b-41d4-a716-446655440000"}'

Fetch it — no account needed:

# No Authorization header -- this is the whole point.
curl https://helium.pinion.build/gateway/<YourPathName>/ipfs/bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi \
  -o myfile.jpg

Upload Service

Overview

The Upload Service accepts files and CAR archives, writes them directly into the shared IPFS blockstore, and creates a pin request for asynchronous processing by the worker cluster. Content is immediately available via the Gateway upon upload, even before the pin is confirmed.

This is the writable side of the Gateway, a plain HTTP endpoint for handing us bytes directly without running an IPFS node. If you already run a kubo node or another IPFS-native client, you likely want the Pinning Service instead. It tells us which CID to keep, and we fetch it from the network ourselves.

Two upload formats are supported:

  • Regular file: any file, chunked and organized into a UnixFS DAG automatically. A multipart request can include more than one file field (e.g. a train/test split); when it does, all of them are wrapped into a single directory pin.
  • CAR archive: a pre-built IPFS DAG in Content Addressable aRchive format. Supports multiple roots; when there is more than one, they are wrapped into a single directory pin the same way multiple files are.

Whenever an upload produces more than one root, the response is always a single directory pin rather than one pin per file, see Response below for the exact shape.

Base URL

https://helium.pinion.build/upload/pat/v1

Shown here using PAT authentication (see Authentication for the /web/ and /api/v1/ alternatives).

(psst… there's also an interactive Swagger UI for this one, if you'd rather click around than read.)

Endpoint

POST/

Upload a file or CAR archive to IPFS.

Query Parameters
NameTypeDescription
formatstringSet to car to treat the body as a CAR archive. Alternatively, set Content-Type: application/vnd.ipld.car.
namestringHuman-readable label for the pin.
wrapstringSet to trueto wrap a single-file/single-root upload in a 1-entry directory. Ignored when the upload already produces more than one root: those are always wrapped. Mirrors kubo's -w / --wrap-with-directory.
Request Body
Content-TypeBody
multipart/form-dataForm field file containing the file bytes. Repeat the field to upload several files in one request; they are wrapped into a single directory pin.
application/octet-streamRaw file bytes in the request body
application/vnd.ipld.carCAR archive bytes in the request body

Response

Always returns a single PinStatus object, never an array. For a single file or single-root upload, pin.cidis that file's own CID:

// Single file / single root
{
  "requestid": "550e8400-e29b-41d4-a716-446655440000",
  "status": "pinning",
  "created": "2025-01-15T12:00:00Z",
  "pin": {
    "cid": "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi",
    "name": "my-file"
  }
}

When an upload produces more than one root (multiple file fields, or a multi-root CAR), or when wrap=true was set, pin.cid is instead the CID of a UnixFS directory wrapping every file, and pin.info carries a file:<name> manifest entry per file, so you never have to fetch a directory listing just to find where a file landed:

// Multiple files/roots, or wrap=true
{
  "requestid": "6f9619ff-8b86-d011-b42d-00cf4fc964ff",
  "status": "pinning",
  "created": "2025-01-15T12:00:00Z",
  "pin": {
    "cid": "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdd",
    "name": "dataset"
  },
  "info": {
    "file:train.csv": "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi",
    "file:test.csv":  "bafybeih6cq4nsdlz6hshoyv2ymhr5vhwjq7ke5m5rmglmvv2s3iuxqtxbi"
  }
}

File names inside the directory (and in the manifest) come from each part's own filename for multipart uploads, or root-0, root-1, … for CAR files, which don't carry filenames of their own.

Behavior

After upload, the pin enters pinning status. A worker node picks up the request from the pubsub queue, verifies the content is present in the blockstore, and transitions the pin to pinned. Because content is written directly to the shared blockstore on upload, it is retrievable via the Gateway immediately, not only after pinning completes.

Examples

Upload a regular file (multipart):

curl -X POST "https://helium.pinion.build/upload/pat/v1/?name=myfile" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -F "file=@./myfile.txt"

Upload several files in one request (multipart, e.g. a dataset split into parts):

curl -X POST "https://helium.pinion.build/upload/pat/v1/?name=dataset" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -F "file=@./train.csv" \
  -F "file=@./test.csv"

Upload raw bytes (octet-stream):

curl -X POST https://helium.pinion.build/upload/pat/v1/?name=myfile \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @./myfile.txt

Upload a CAR archive (via Content-Type):

curl -X POST https://helium.pinion.build/upload/pat/v1/?name=my-dag \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/vnd.ipld.car" \
  --data-binary @./content.car

Upload a CAR archive (via query param, useful when Content-Type cannot be set). No pinion-clitab here since it's the same pinion upload --car command shown above either way:

curl -X POST "https://helium.pinion.build/upload/pat/v1/?format=car&name=my-dag" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  --data-binary @./content.car

GitHub Integration

Overview

Pinion integrates with GitHub as a GitHub App. Once installed on your account or organization, Pinion monitors your repositories and automatically pins content to IPFS based on a configuration file you commit to each repository.

No CI pipeline changes are needed. Pinion responds directly to GitHub webhook events.

Green checkmarks on every commit

Pinion posts a commit status to GitHub under the pinion/ipfs context. When pinning succeeds, the status turns green and includes the IPFS CID. Clicking Details opens the pinned content directly in the Pinion gateway.

What Gets Pinned

  • Repository content: pin the full repository or specific subdirectories on every push to your configured branch.
  • Subdirectories: pin one or more paths within your repository (e.g. docs/, dist/) independently.
  • Release assets: pin the files attached to a GitHub release when it is published.
  • Filtered release assets: use glob patterns to pin only specific assets (e.g. *.tar.gz), and control whether auto-generated source code archives are included.

Installation

  1. Sign in to Pinion and go to your Profile page.
  2. Click Connect GitHub and install the Pinion GitHub App on your account or organization.
  3. Add a pinion.build.yaml file to the root of any repository you want Pinion to monitor.

Configuration: pinion.build.yaml

Place this file at the root of your repository. Sections you omit are ignored.

version: 1

# Release asset pinning - supports pattern matching
release_assets:
  enabled: true
  include_source_code: true
  patterns:
    - "*.txt"
    - "*.tar.gz"


# Directory monitoring - supports multiple paths and branch selection
subdirectories:
  enabled: true
  branch: "main"        # Git branch to monitor (default: "main")
  paths:
    - "docs/"           # Pin documentation directory
    - "static/"         # Pin static assets
    - "dist/"           # Pin build output
    - "data/"           # Pin data files
release_assets
FieldTypeDescription
enabledboolPin assets when a release is published
patternsstring[]Glob patterns to filter which assets are pinned. If omitted, all assets are pinned.
include_source_codeboolWhether to include the auto-generated source code archives (source.zip, source.tar.gz) that GitHub attaches to every release.
subdirectories
FieldTypeDescription
enabledboolPin the listed paths on each push
branchstringBranch to monitor. Defaults to main.
pathsstring[]Repository paths to pin. Each path is pinned independently and receives its own CID.

Monitoring

Each webhook event Pinion receives is recorded with a status and any processing messages. You can review these events from your GitHub Events page to diagnose configuration issues or confirm that pins were created successfully.

Prover API

The Prover API adds cryptographic proofs of storage to your pinned content. Rather than trusting that your data is safe, you can issue mathematical challenges to Pinion servers and verify the responses yourself, with no Pinion involvement in the verification step. See the Storage Proofs page for a full explanation of the protocols.

This API needs a client library, not just curl

Every other service in these docs (Pinning, Upload, Gateway) is plain REST: read the curl example, you're done. The Prover API is different. Building a challenge and verifying a proof is real cryptography (BN254 pairing math over elliptic curve points), not JSON you can hand-assemble. Curl can create keys, tag roots, and fetch setup documents, but it cannot generate a valid challenge or verify a response. For that you need pinion-prover-client (JavaScript or Go). Both ship a one-call audit() / Audit() that builds the challenge, calls /prove, and cryptographically verifies the result for you; see the Audit Cycle section below for the exact two lines of code. If you're only planning to script this with curl, stop here and pull in one of the clients first, everything past this point assumes you have.

Prover Keys

A prover key is a server-held cryptographic keypair generated for a specific proof protocol. Creating a key returns a client_setup blob containing the public material you need to generate challenges and verify proofs. The signing secret stays on the server. One key can cover many pinned CIDs simultaneously.

FieldTypeDescription
key_idstring (UUID)Unique identifier for this key. Pass it to /prove and tagging endpoints.
client_setupbytes (base64)Protocol-specific public material used to construct challenges and verify proofs.
protocolstringOne of sw-priv, sw-pub, ateniese, erway, bjo.
audit_countintNumber of successful proof rounds completed with this key.
blocks_auditedintCumulative number of blocks sampled across all proof rounds.

Supported protocols:

ProtocolTypeProof SizeChallengesPublic Verify
sw-privPORO(S) field elementsUnlimitedNo
sw-pubPORO(S) group elementsUnlimitedYes
ateniesePDPO(1)UnlimitedNo

Unsupported protocols:

The underlying storage-proofs library implements additional protocols that Pinion does not support for IPFS content:

ProtocolWhy not supported
erwayDesigned for mutable data via an authenticated skip list. The skip list structure is incompatible with our CID-based sparse-array implementation, and IPFS Merkle DAGs are intentionally immutable.
bjoRequires erasure-coded data before tagging. Standard IPFS blocks cannot be tagged without first transforming the content, which is impractical for a general pinning service.

Tags & Data

A tagis a small cryptographic authentication value computed from a block’s content and identity. When you call POST /tag, Pinion queues the work and returns a job_id immediately: it walks the full IPFS DAG of the given root CID in the background, virtualizes it into uniform fixed-size super-blocks, computes one tag per super-block, and stores the tags. Once the job is done, polling it returns a block_count, the number of super-blocks, which is all you need to construct challenges client-side: ids are rootCID || localIndex for localIndex in [0, block_count), so no per-block manifest is ever sent. This is the expected flow for most users.

The root must already be in the pinned lifecycle state for your account before you can tag it. Calling POST /tag for a CID that’s still queued or pinning returns 409 {"error": "pin_not_active", "cid": "..."}: wait for the pin to finish (or poll GET /pinning/pat/v1/pins/<requestid> until status is pinned) and retry.

Multiple roots can be tagged under one key. The prover merges all tagged roots into a single proof when challenged with an empty roots array. In the unusual case that you need to hold your own private key, you can generate tags client-side using the storage-proofs library and register them via POST /register. The challenge and verification flow is identical; only key custody changes.

Setup

Setup is done once per key. It creates the cryptographic material the server needs to answer challenges and the material you need to issue them. There are three steps.

Step 1, Create a challenge key

Choose a protocol. The server generates a keypair and returns your client_setup blob and a key_id. Save both. client_setup cannot be retrieved again.

Challenge strength isn’t chosen here. It’s picked fresh each time you build a challenge (see Audit Cycle below), so the same key can be audited at any strength without being fixed in advance. That value, c, controls how many blocks are randomly sampled from the combined pool of all tagged roots in each audit round. A higher value gives a greater probability of detecting data loss per round at the cost of a slightly larger proof. 20 is a reasonable default; the detection probability per round for a fraction f of lost blocks is approximately 1 - (1-f)^c.

curl -X POST https://helium.pinion.build/prover/pat/v1/challenge-key \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"protocol": "sw-pub"}'
{ "key_id": "550e8400-...", "client_setup": "<base64>" }

Step 2, Tag each pinned root

Call once per root CID you want to audit. Tagging is asynchronous: the call queues a job and returns a job_id immediately, while the server walks the full IPFS DAG in the background, virtualizes it into uniform super-blocks, computes a cryptographic tag per super-block, and stores the prover-side material; a large DAG can take minutes. Poll the job until it's done, then it carries block_count, the number of super-blocks. Save this alongside your client_setup.

# Tagging is asynchronous, so this queues the job and returns immediately:
curl -X POST https://helium.pinion.build/prover/pat/v1/tag \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"root": "bafybeigdyrzt...", "key_id": "550e8400-..."}'
# Returns JSON: {"job_id":"<id>"}. Poll GET /tag/<job_id> (same auth) until
# status is "tag-done" (then block_count is populated; see "Tags & Data"
# for why no per-block manifest is ever sent) or "tag-failed" (then error
# is). A large DAG can take minutes to walk.
curl https://helium.pinion.build/prover/pat/v1/tag/<job_id> -H "Authorization: Bearer YOUR_TOKEN"
{ "block_count": 42 }

Step 3, Export your client state

Once all roots are tagged, fetch your full client state in one call. This gives you everything needed to run audits from any machine, without making authenticated requests again.

curl "https://helium.pinion.build/prover/pat/v1/setup?key_id=550e8400-..." \
  -H "Authorization: Bearer YOUR_TOKEN"

The response is client_setup plus an array of tagged roots, one entry per root, each with its own block_count:

{
  "client_setup": "<base64>",
  "roots": [
    { "root": "bafybeigdyrzt...", "block_count": 42 }
  ]
}

You will not usually call this with raw curl since key_idisn’t in the response body; both client libraries already know it from the key they created. The Go client’s testclient CLI runs testclient setup to fetch and cache this locally; the JavaScript client calls getSetup() and keeps the parsed setup in memory.

Tag Signing

client_setup (from CreateKey) and block_count (from tagging) are both looked up fresh from Firestore on every GET /setupand every share-link resolve. Pinion signs both with an Ed25519 key the moment they’re created — client_setup at CreateKey time, block_countat tag-completion time — so a client can confirm this data really came from Pinion’s own prover, unmodified, before trusting it. Without that check, a compromise below the application layer (a Firestore injection bug, a bad migration, compromised database credentials, not necessarily a compromise of pinion-prover itself) could substitute fabricated key material, or shrink a block_countto make an unsatisfiable challenge trivially satisfiable, and a naive client would never notice: the pairing math would still “pass” against the tampered inputs. Verifying the signature protects the inputs to that math, which matters as much as the math itself.

Each environment signs with its own keypair

hydrogen and helium each run their own pinion-prover deployment with its own CHAL_KEY_SIGNING_KEY (server-side, an Ed25519 private seed — never published). The public half is what you configure client-side, and it must come from something published and reviewed out-of-band, like this table, not fetched from the prover deployment itself at request time: fetching the trust key from the same server whose claims you’re verifying would let whatever could tamper with client_setup/block_count also tamper with the key used to check them, defeating the entire point.

EnvironmentTrusted public key (hex, Ed25519)
hydrogen.pinion.build185c0993817d78c178f15162831b53d750c1c98b47d049d659d9e00aa0d87e4a
helium.pinion.buildthis environmente2ca7910acbe769ad9da4078e2999e13d0aaa5db08cf720f9e21f2e3c86dc17a

Configure the trusted key once, per client:

import proverclient "github.com/pinionengineering/pinion-prover-client/go"

pubKeyHex := "e2ca7910acbe769ad9da4078e2999e13d0aaa5db08cf720f9e21f2e3c86dc17a"
pubKey, _ := hex.DecodeString(pubKeyHex)

client := proverclient.NewClient("https://helium.pinion.build/prover",
	proverclient.WithAuthURL("https://helium.pinion.build/prover/pat/v1"),
	proverclient.WithToken(tokenFunc),
	proverclient.WithTrustedKey(ed25519.PublicKey(pubKey)),
)
// client.Audit(...) now verifies ClientSetup/BlockCount signatures before
// trusting anything in them; see the Go client's trustkey.go doc comments.

Both client libraries and pinion-cli refuse to run an audit without a configured trusted key — a clear error instead of silently skipping the check, which would defeat its purpose. This only gates audit/Audit(): the lower-level challenge/verify flow (see Audit Cycle below) doesn’t currently perform this check, so prefer audit when you want the authenticity guarantee.

Requires @pinionengineering/prover-client v0.12.0+ and pinion-prover-client/go with WithTrustedKey (both current as of this page). Earlier versions checked against a fixed placeholder key baked into the JS package and had no trust-key concept in the Go package at all; upgrade if audit/Audit()reports an untrusted-setup failure you can’t otherwise explain.

Audit Cycle

Once setup is complete, you can audit at any time from any machine using only your saved state file. No authenticated requests are needed. Two roles are involved:

Challenger (you)

Generates a random challenge locally using your client_setup and the ids derived from block_count. Also produces a one-time Validator that holds the secret randomness for this round. No network call.

Prover (Pinion)

Queues your challenge as a proof job and computes a compact cryptographic proof against the stored blocks in the background; poll the job until it's done. The /prove endpoint is not authenticated with a PAT or any bearer token, so anyone can challenge and anyone can verify. You do still need a valid key_id from a challenge key you created during setup to successfully perform a proof.

The cycle per round: generate challenge locally, POST to /prover/prove (queues a job and returns a job_id immediately), poll GET /prover/prove/<job_id> until it reaches a terminal state, verify locally. If verification fails, the data has been tampered with or lost.

Load state file and run the audit loop:

# curl cannot generate a valid challenge or verify the proof; those steps
# require client-side cryptography. Use the Go or JavaScript library instead.
#
# The /prove endpoint itself accepts a raw POST (no authentication required).
# Proving is asynchronous, so this queues the job and returns immediately:
curl -X POST https://helium.pinion.build/prover/prove \
  -H "Content-Type: application/json" \
  -d '{"key_id":"<uuid>","roots":[],"challenge":"<base64>","challenge_id":"<optional-id>"}'
# Returns JSON: {"job_id":"<id>"}. Poll GET /prove/<job_id> until status is
# "prove-done" (then proof is populated) or "prove-failed" (then error is).
# The challenge must be generated by the Go/JS client and the proof
# verified by the same client. See the Go and JavaScript tabs above.

The sw-pub protocol is recommended for IPFS workloads: it is publicly verifiable and supports cross-CID challenges. Both tabs above use pinion-prover-client, which ships a client for each language: the JavaScript tab uses @pinionengineering/prover-client, which implements the BN254 pairing math itself; the Go tab uses pinion-prover-client/go, a thin wrapper around storage-proofs/line/swpub and ipfs-storage-proofs, the same libraries pinion-prover itself runs on. Both clients speak the same wire protocol, so you can mix them, tag from one, audit from the other.

Endpoints

Key Management (requires Bearer JWT or OAuth session)
MethodPathBody / ParamsResponse
POST/prover/pat/v1/challenge-key{"protocol": "sw-priv"}{key_id, client_setup}
GET/prover/pat/v1/challenge-keys(none)Array of ChallengeKeyInfo
DELETE/prover/pat/v1/challenge-key/:id(none)204 No Content

Each entry in the GET /challenge-keys response includes:

FieldDescription
key_idUUID identifying this key pair.
protocolProtocol chosen at key creation (e.g. sw-pub).
audit_countNumber of successful proof rounds completed for this key.
blocks_auditedCumulative total of blocks sampled across all successful rounds (approximately audit_count × average challenge size, since challenge size is chosen fresh per round rather than fixed).
Tagging (requires Bearer JWT or OAuth session)
MethodPathDescriptionResponse
POST/prover/pat/v1/tagTag a pinned root CID under a key{job_id}
GET/prover/pat/v1/tag/:job_idPoll a tag job's status{status, progress?, block_count?, error?}
GET/prover/pat/v1/setupFetch client_setup + block_count per root for a key (?key_id=){client_setup, roots:[...]}
POST/prover/pat/v1/registerUpload tags generated client-side with your own private key (advanced use)204 No Content
DELETE/prover/pat/v1/register/:key_id/:rootRemove tags for one root CID204 No Content

Tagging is asynchronous, same as proving: POST /tag queues the walk and returns immediately; poll GET /tag/:job_id until status is "tag-done" (then block_countis populated; see “Tags & Data” above for why no per-block manifest is ever sent) or "tag-failed" (then erroris). A large DAG can take minutes to walk. The JS client's tag() only submits the job and returns immediately with a job handle; call waitForTag()to actually wait for a terminal state — there's no default deadline, since a large DAG can legitimately take longer than any fixed ceiling.

Proving (no authentication required)
MethodPathBodyResponse
POST/prover/prove{key_id, roots, challenge, challenge_id?}{job_id}
GET/prover/prove/:job_id(none){status, challenge_id?, proof?, error?}

Proving is asynchronous: POST /prove queues the challenge and returns immediately; poll GET /prove/:job_id until status is "prove-done" (then proof is populated) or "prove-failed" (then error is). In the JS client, prove()itself only submits and returns a job handle immediately — call waitForProve() to wait for a terminal state, with no default deadline. audit() is the one call that still does both for you (submit, wait, and cryptographically verify), and its wait phase also has no fixed ceiling unless you pass your own signal.

curl -X POST https://helium.pinion.build/prover/prove \
  -H "Content-Type: application/json" \
  -d '{"key_id":"<uuid>","roots":[],"challenge":"<base64>","challenge_id":"<optional-id>"}'
# Response: {"job_id":"<id>"}. Proving is asynchronous.
curl https://helium.pinion.build/prover/prove/<job_id>
# Response once done: {"status":"prove-done","challenge_id":"<echoed>","proof":"<base64>"}
# proof is base64-encoded raw bytes; pass to validator.Verify() after decoding